From 0f1bc0911e4c4eb9ab88772f4273a1c16009f53f Mon Sep 17 00:00:00 2001 From: royvardhan Date: Tue, 6 Oct 2026 21:05:50 +0530 Subject: [PATCH 01/12] [evm]: select a solver by userOpHash through EntryPoint v0.9 --- evm/src/apps/IntentGatewayV2.sol | 24 +- evm/src/apps/intentsv2/IntentsBase.sol | 34 +- evm/src/apps/intentsv2/SolverAccount.sol | 13 +- evm/tests/foundry/IntentGatewayV2Test.sol | 409 +++++++++--------- .../foundry/account/SolverAccountTest.sol | 283 +++++++++--- .../core/contracts/apps/IntentGatewayV2.sol | 20 +- 6 files changed, 486 insertions(+), 297 deletions(-) diff --git a/evm/src/apps/IntentGatewayV2.sol b/evm/src/apps/IntentGatewayV2.sol index f3367ec61a..cc61328da4 100644 --- a/evm/src/apps/IntentGatewayV2.sol +++ b/evm/src/apps/IntentGatewayV2.sol @@ -401,8 +401,9 @@ contract IntentGatewayV2 is } /** - * @dev Records a solver selection signed by the order's session key, for `fillOrder` in the - * same transaction. Returns the session key. Reverts `Filled` on a finalized order. + * @dev Records the session key's selection of one UserOperation, for the `fillOrder` that + * UserOperation runs through EntryPoint v0.9 in the same transaction. Returns the session key. + * Reverts `Filled` on a finalized order. */ function select(SelectOptions calldata options) public returns (address) { return _select(options); @@ -430,16 +431,19 @@ contract IntentGatewayV2 is if (_filled[commitment] != address(0)) revert Filled(); if (_params.solverSelection) { - // The caller's own selection slot, so a second selection on this order in the same - // bundle cannot clobber it. See `_select`. - bytes32 selectionSlot = keccak256(abi.encode(commitment, msg.sender)); - bytes32 storedSelectionHash; + // Only inside the UserOperation the session key selected. Zero means no UserOperation + // is executing, as for a direct call. + bytes32 userOpHash = ENTRYPOINT_V09.getCurrentUserOpHash(); + if (userOpHash == bytes32(0)) revert Unauthorized(); + + // Each (UserOperation, signer) pair has its own slot, so no other selection in the same + // bundle can clobber the session key's. See `_select`. + bytes32 selectionSlot = keccak256(abi.encode(commitment, userOpHash, order.session)); + uint256 selected; assembly { - storedSelectionHash := tload(selectionSlot) + selected := tload(selectionSlot) } - - bytes32 expectedSelectionHash = keccak256(abi.encode(order.session)); - if (storedSelectionHash != expectedSelectionHash) revert Unauthorized(); + if (selected == 0) revert Unauthorized(); } uint256 outputsLen = order.output.assets.length; diff --git a/evm/src/apps/intentsv2/IntentsBase.sol b/evm/src/apps/intentsv2/IntentsBase.sol index 70b7f364b8..11976cc10f 100644 --- a/evm/src/apps/intentsv2/IntentsBase.sol +++ b/evm/src/apps/intentsv2/IntentsBase.sol @@ -44,6 +44,14 @@ interface IArbSys { function arbBlockNumber() external view returns (uint256); } +/** + * @dev The one ERC-4337 EntryPoint v0.9 view the gateway reads: the hash of the UserOperation + * being executed, zero outside an execution. + */ +interface IEntryPointV09 { + function getCurrentUserOpHash() external view returns (bytes32); +} + /** * @title IntentsBase * @author Polytope Labs (hello@polytope.technology) @@ -56,9 +64,16 @@ abstract contract IntentsBase is EIP712 { /** * @dev EIP-712 typehash for solver selection signatures. - * Encodes the struct: SelectSolver(bytes32 commitment, address solver). + * Encodes the struct: SelectSolver(bytes32 commitment, bytes32 userOpHash). + */ + bytes32 public constant SELECT_SOLVER_TYPEHASH = keccak256("SelectSolver(bytes32 commitment,bytes32 userOpHash)"); + + /** + * @dev ERC-4337 EntryPoint v0.9, which reports the UserOperation a selected fill must run in. + * Fixed rather than taken from the caller: anyone may call the gateway, so a caller posing as + * an EntryPoint could report whatever hash a selection was signed for. */ - bytes32 public constant SELECT_SOLVER_TYPEHASH = keccak256("SelectSolver(bytes32 commitment,address solver)"); + IEntryPointV09 internal constant ENTRYPOINT_V09 = IEntryPointV09(0x433709009B8330FDa32311DF1C2AFA402eD8D009); /** * @dev Sentinel key under which the Hyperbridge relayer fees are held in `_orders`. The low @@ -777,22 +792,21 @@ abstract contract IntentsBase is EIP712 { } /** - * @dev Recovers the session key that signed the selection and stores `keccak256(sessionKey)` in - * transient storage under `keccak256(commitment, solver)`. + * @dev Recovers the session key that signed the selection and sets a non-zero marker in + * transient storage under `keccak256(commitment, userOpHash, sessionKey)`. */ function _select(SelectOptions calldata options) internal returns (address) { if (_filled[options.commitment] != address(0)) revert Filled(); - bytes32 structHash = keccak256(abi.encode(SELECT_SOLVER_TYPEHASH, options.commitment, options.solver)); + bytes32 structHash = keccak256(abi.encode(SELECT_SOLVER_TYPEHASH, options.commitment, options.userOpHash)); bytes32 digest = _hashTypedDataV4(structHash); address sessionKey = ECDSA.recover(digest, options.signature); - bytes32 slot = keccak256(abi.encode(options.commitment, options.solver)); - // Hashed, never the bare key: an untouched slot reads zero, which must not match an order - // whose `session` is the zero address. - bytes32 selectionHash = keccak256(abi.encode(sessionKey)); + // Keyed on the signer too: `select` is permissionless, so a selection signed by any other + // key lands in its own slot and cannot overwrite the session key's. + bytes32 slot = keccak256(abi.encode(options.commitment, options.userOpHash, sessionKey)); assembly { - tstore(slot, selectionHash) + tstore(slot, 1) } return sessionKey; diff --git a/evm/src/apps/intentsv2/SolverAccount.sol b/evm/src/apps/intentsv2/SolverAccount.sol index 244bf585e0..9337d7fd7f 100644 --- a/evm/src/apps/intentsv2/SolverAccount.sol +++ b/evm/src/apps/intentsv2/SolverAccount.sol @@ -116,7 +116,8 @@ contract SolverAccount is Account, ERC7821, IERC1271 { * nonce and the solver's gas. * * A 162-byte signature is `abi.encodePacked(commitment, solverSignature, sessionSignature)`. - * The gateway's `select` recovers the session key, and the nonce key must derive from the + * The session key signs the commitment and `userOpHash`, so its selection holds for this op + * alone. The gateway's `select` recovers that key, and the nonce key must derive from the * commitment, that key and the op's calldata, so none of them can be swapped after signing. * * The calldata is what makes each bid's key its own. A solver bidding several prices on one @@ -143,10 +144,11 @@ contract SolverAccount is Account, ERC7821, IERC1271 { bytes calldata solverSignature = op.signature[32:97]; bytes calldata sessionSignature = op.signature[97:162]; - // Recovers the session key and stages the selection that `fillOrder` checks. A bad session - // signature fails validation instead of reverting it, as ERC-4337 requires. + // Recovers the session key and stages the selection that `fillOrder` checks while this op + // executes. A bad session signature fails validation instead of reverting it, as ERC-4337 + // requires. SelectOptions memory selectOptions = - SelectOptions({commitment: commitment, solver: address(this), signature: sessionSignature}); + SelectOptions({commitment: commitment, userOpHash: userOpHash, signature: sessionSignature}); address sessionKey; try IIntentGatewayV2(_intentGateway).select(selectOptions) returns (address recovered) { sessionKey = recovered; @@ -184,8 +186,7 @@ contract SolverAccount is Account, ERC7821, IERC1271 { * @dev Whether `selector` is a `fillOrder` of this or an earlier gateway release. */ function _isFillOrder(bytes4 selector) private pure returns (bool) { - return selector == FILL_ORDER_SELECTOR - || selector == HISTORICAL_FILL_ORDER_SELECTOR + return selector == FILL_ORDER_SELECTOR || selector == HISTORICAL_FILL_ORDER_SELECTOR || selector == HISTORICAL_FILL_ORDER_NO_EXPIRY_SELECTOR; } diff --git a/evm/tests/foundry/IntentGatewayV2Test.sol b/evm/tests/foundry/IntentGatewayV2Test.sol index 247977ad9e..0fe8a2d9df 100644 --- a/evm/tests/foundry/IntentGatewayV2Test.sol +++ b/evm/tests/foundry/IntentGatewayV2Test.sol @@ -35,7 +35,7 @@ import { SelectOptions } from "@hyperbridge/core/apps/IntentGatewayV2.sol"; import {deployIntentGatewayImpl, deployIntentModules} from "./IntentGatewayDeploy.sol"; -import {IntentsBase} from "../../src/apps/intentsv2/IntentsBase.sol"; +import {IntentsBase, IEntryPointV09} from "../../src/apps/intentsv2/IntentsBase.sol"; import {ExtrinsicIntents} from "../../src/apps/intentsv2/ExtrinsicIntents.sol"; import {HyperApp} from "@hyperbridge/core/apps/HyperApp.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; @@ -82,6 +82,13 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { // EIP-1967 implementation slot: keccak256("eip1967.proxy.implementation") - 1. bytes32 internal constant ERC1967_IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + // ERC-4337 EntryPoint v0.9, live on the fork. Outside `handleOps` it reports no UserOperation. + address internal constant ENTRYPOINT_V09 = 0x433709009B8330FDa32311DF1C2AFA402eD8D009; + + // Stand-ins for the hashes of two solvers' bid UserOperations. + bytes32 internal constant BID_USER_OP_HASH = keccak256("bid user operation"); + bytes32 internal constant OTHER_USER_OP_HASH = keccak256("other user operation"); + function setUp() public override { super.setUp(); @@ -139,14 +146,14 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { } /// @dev Helper function to create EIP-712 signature for solver selection - function _createSelectSolverSignature(bytes32 commitment, address solver, uint256 privateKey, address gateway) + function _createSelectSolverSignature(bytes32 commitment, bytes32 userOpHash, uint256 privateKey, address gateway) internal view returns (bytes memory) { // Compute the EIP-712 digest using public constants IntentGatewayV2 gatewayContract = IntentGatewayV2(payable(gateway)); - bytes32 structHash = keccak256(abi.encode(gatewayContract.SELECT_SOLVER_TYPEHASH(), commitment, solver)); + bytes32 structHash = keccak256(abi.encode(gatewayContract.SELECT_SOLVER_TYPEHASH(), commitment, userOpHash)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", gatewayContract.DOMAIN_SEPARATOR(), structHash)); @@ -1590,17 +1597,19 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { bytes32 commitment = keccak256(abi.encode(order)); - // Create EIP-712 signature from session key + // Create EIP-712 signature from session key over the solver's UserOperation bytes memory sessionSignature = _createSelectSolverSignature( commitment, - filler, + BID_USER_OP_HASH, 1, // Session key private key address(intentGateway) ); - // Solver selects themselves vm.prank(filler); - intentGateway.select(SelectOptions({commitment: commitment, solver: filler, signature: sessionSignature})); + address sessionKey = intentGateway.select( + SelectOptions({commitment: commitment, userOpHash: BID_USER_OP_HASH, signature: sessionSignature}) + ); + assertEq(sessionKey, vm.addr(1), "select recovers the session key"); } function testSelectRevertsOnFinalizedOrder() public { @@ -1634,7 +1643,8 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { vm.stopPrank(); bytes32 commitment = keccak256(abi.encode(order)); - bytes memory sessionSignature = _createSelectSolverSignature(commitment, filler, 1, address(intentGateway)); + bytes memory sessionSignature = + _createSelectSolverSignature(commitment, BID_USER_OP_HASH, 1, address(intentGateway)); // The creator's same-chain cancel finalizes the order by writing `_filled`. vm.prank(user); @@ -1643,193 +1653,215 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { // The bid is now stale, so it is refused here rather than in `fillOrder`. vm.prank(filler); vm.expectRevert(IntentsBase.Filled.selector); - intentGateway.select(SelectOptions({commitment: commitment, solver: filler, signature: sessionSignature})); + intentGateway.select( + SelectOptions({commitment: commitment, userOpHash: BID_USER_OP_HASH, signature: sessionSignature}) + ); } function testFillOrderWithSolverSelection() public { - // Enable solver selection - Params memory newParams = Params({ - host: address(host), - dispatcher: address(dispatcher), - solverSelection: true, - surplusShareBps: 10000, - protocolFeeBps: 0, - priceOracle: address(0) - }); - - IntentGatewayV2 gatewayWithSelection = _deployGatewayProxy(); - gatewayWithSelection.initialize( - InitParams({params: newParams, peerChains: new bytes[](0), relayer: address(0), owner: address(this)}) - ); + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); + _selectUserOp(gatewayWithSelection, order, BID_USER_OP_HASH); - uint256 inputAmount = 1000 * 1e6; + _mockCurrentUserOp(BID_USER_OP_HASH); + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + gatewayWithSelection.fillOrder(order, options); - TokenInfo[] memory inputs = new TokenInfo[](1); - inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: inputAmount}); + assertEq(gatewayWithSelection._filled(keccak256(abi.encode(order))), filler, "selected op fills the order"); + } - TokenInfo[] memory outputAssets = new TokenInfo[](1); - outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + function testSecondSelectionDoesNotClobberTheFirst() public { + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); - PaymentInfo memory output = - PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}); + // Both bids hold a selection signed by the same session key. A 4337 bundle stages every + // validation before any execution, so both land before either fill runs. + _selectUserOp(gatewayWithSelection, order, BID_USER_OP_HASH); + _selectUserOp(gatewayWithSelection, order, OTHER_USER_OP_HASH); - Order memory order = Order({ - user: bytes32(uint256(uint160(user))), - source: host.host(), - destination: host.host(), - deadline: block.number + 1000, - nonce: 0, - fees: 0, - session: vm.addr(1), // Session key - predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), - inputs: inputs, - output: output - }); + // The later selection sits in its own slot, so the first bid's fill still authorises. + _mockCurrentUserOp(BID_USER_OP_HASH); + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + gatewayWithSelection.fillOrder(order, options); - vm.startPrank(user); - usdc.approve(address(gatewayWithSelection), inputAmount); - gatewayWithSelection.placeOrder(order, bytes32(0)); - vm.stopPrank(); + assertEq(gatewayWithSelection._filled(keccak256(abi.encode(order))), filler, "first bid fills the order"); + } + /// @dev `select` is permissionless, so another UserOperation in the bundle can re-select the + /// victim's op before it executes. Neither another key's signature nor a junk one recovering + /// some unrelated key displaces the session key's selection. + function testSelectionByAnotherKeyDoesNotClobberTheSessionKey() public { + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); bytes32 commitment = keccak256(abi.encode(order)); + _selectUserOp(gatewayWithSelection, order, BID_USER_OP_HASH); - // Create EIP-712 signature from session key - bytes memory sessionSignature = _createSelectSolverSignature( - commitment, - filler, - 1, // Session key private key - address(gatewayWithSelection) + address attacker = makeAddr("attacker"); + vm.prank(attacker); + address otherKey = gatewayWithSelection.select( + SelectOptions({ + commitment: commitment, + userOpHash: BID_USER_OP_HASH, + signature: _createSelectSolverSignature(commitment, BID_USER_OP_HASH, 2, address(gatewayWithSelection)) + }) ); + assertEq(otherKey, vm.addr(2), "another key's selection"); - // Solver selects themselves - vm.startPrank(filler); - gatewayWithSelection.select( - SelectOptions({commitment: commitment, solver: filler, signature: sessionSignature}) + vm.prank(attacker); + address junkKey = gatewayWithSelection.select( + SelectOptions({ + commitment: commitment, + userOpHash: BID_USER_OP_HASH, + signature: _createSelectSolverSignature( + commitment, OTHER_USER_OP_HASH, 1, address(gatewayWithSelection) + ) + }) ); + assertTrue(junkKey != order.session && junkKey != otherKey, "a junk key"); - // Filler fills order - dai.approve(address(gatewayWithSelection), type(uint256).max); - - TokenInfo[] memory solverOutputs = new TokenInfo[](1); - solverOutputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + _mockCurrentUserOp(BID_USER_OP_HASH); + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + gatewayWithSelection.fillOrder(order, options); - gatewayWithSelection.fillOrder( - order, - FillOptions({ - relayerFee: 0, - nativeDispatchFee: 0, - validUntil: 0, - outputs: solverOutputs, - inputs: IntentQuoteTestUtils.inputs(order, solverOutputs) - }) - ); - vm.stopPrank(); + assertEq(gatewayWithSelection._filled(commitment), filler, "selected op still fills the order"); } - function testSecondSelectionDoesNotClobberTheFirst() public { - Params memory newParams = Params({ - host: address(host), - dispatcher: address(dispatcher), - solverSelection: true, - surplusShareBps: 10000, - protocolFeeBps: 0, - priceOracle: address(0) - }); + /// @dev A direct call runs outside any UserOperation, so the EntryPoint reports zero. + function testFillOrderWithSolverSelectionOutsideUserOp() public { + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); + _selectUserOp(gatewayWithSelection, order, BID_USER_OP_HASH); - IntentGatewayV2 gatewayWithSelection = _deployGatewayProxy(); - gatewayWithSelection.initialize( - InitParams({params: newParams, peerChains: new bytes[](0), relayer: address(0), owner: address(this)}) - ); + assertEq(IEntryPointV09(ENTRYPOINT_V09).getCurrentUserOpHash(), bytes32(0)); - uint256 inputAmount = 1000 * 1e6; + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + vm.expectRevert(IntentsBase.Unauthorized.selector); + gatewayWithSelection.fillOrder(order, options); + } - TokenInfo[] memory inputs = new TokenInfo[](1); - inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: inputAmount}); + /// @dev The session key's selection of a zero hash sits in the very slot a direct call would + /// read, so only the gateway refusing a zero hash outright keeps the fill out. + function testFillOrderOutsideUserOpWithZeroHashSelection() public { + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); + bytes32 commitment = keccak256(abi.encode(order)); - TokenInfo[] memory outputAssets = new TokenInfo[](1); - outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + address recovered = gatewayWithSelection.select( + SelectOptions({ + commitment: commitment, + userOpHash: bytes32(0), + signature: _createSelectSolverSignature(commitment, bytes32(0), 1, address(gatewayWithSelection)) + }) + ); + assertEq(recovered, order.session, "the session key selects the zero hash"); - PaymentInfo memory output = - PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}); + assertEq(IEntryPointV09(ENTRYPOINT_V09).getCurrentUserOpHash(), bytes32(0)); - Order memory order = Order({ - user: bytes32(uint256(uint160(user))), - source: host.host(), - destination: host.host(), - deadline: block.number + 1000, - nonce: 0, - fees: 0, - session: vm.addr(1), - predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), - inputs: inputs, - output: output - }); + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + vm.expectRevert(IntentsBase.Unauthorized.selector); + gatewayWithSelection.fillOrder(order, options); + } - vm.startPrank(user); - usdc.approve(address(gatewayWithSelection), inputAmount); - gatewayWithSelection.placeOrder(order, bytes32(0)); - vm.stopPrank(); + function testFillOrderInUnselectedUserOp() public { + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); + _selectUserOp(gatewayWithSelection, order, BID_USER_OP_HASH); + + _mockCurrentUserOp(OTHER_USER_OP_HASH); + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + vm.expectRevert(IntentsBase.Unauthorized.selector); + gatewayWithSelection.fillOrder(order, options); + } + /// @dev A session signature over one UserOperation does not select another: the gateway + /// recovers some other key from it, which is not the order's session. + function testSelectionSignedForAnotherUserOpIsRejected() public { + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); bytes32 commitment = keccak256(abi.encode(order)); - address otherSolver = address(0xB0B); - // Both solvers hold a selection signed by the same session key. A 4337 bundle stages every - // validation before any execution, so both land before either fill runs. - vm.prank(filler); - gatewayWithSelection.select( + address recovered = gatewayWithSelection.select( SelectOptions({ commitment: commitment, - solver: filler, - signature: _createSelectSolverSignature(commitment, filler, 1, address(gatewayWithSelection)) + userOpHash: BID_USER_OP_HASH, + signature: _createSelectSolverSignature( + commitment, OTHER_USER_OP_HASH, 1, address(gatewayWithSelection) + ) }) ); + assertTrue(recovered != order.session, "not the session key"); - vm.prank(otherSolver); - gatewayWithSelection.select( - SelectOptions({ - commitment: commitment, - solver: otherSolver, - signature: _createSelectSolverSignature(commitment, otherSolver, 1, address(gatewayWithSelection)) - }) - ); + FillOptions memory options = _fullFillOptions(order); - // The later selection sits in its own slot, so the first solver's fill still authorises. - TokenInfo[] memory solverOutputs = new TokenInfo[](1); - solverOutputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); + _mockCurrentUserOp(BID_USER_OP_HASH); + vm.prank(filler); + vm.expectRevert(IntentsBase.Unauthorized.selector); + gatewayWithSelection.fillOrder(order, options); - vm.startPrank(filler); - dai.approve(address(gatewayWithSelection), type(uint256).max); - gatewayWithSelection.fillOrder( - order, - FillOptions({ - relayerFee: 0, - nativeDispatchFee: 0, - validUntil: 0, - outputs: solverOutputs, - inputs: IntentQuoteTestUtils.inputs(order, solverOutputs) - }) - ); - vm.stopPrank(); + _mockCurrentUserOp(OTHER_USER_OP_HASH); + vm.prank(filler); + vm.expectRevert(IntentsBase.Unauthorized.selector); + gatewayWithSelection.fillOrder(order, options); + } + + /// @dev With selection off the gateway never asks the EntryPoint, so a direct fill works even + /// where the EntryPoint would fail. + function testFillOrderWithoutSolverSelectionIgnoresEntryPoint() public { + Order memory order = _placeSelectionOrder(intentGateway); + vm.mockCallRevert(ENTRYPOINT_V09, abi.encodeCall(IEntryPointV09.getCurrentUserOpHash, ()), "no EntryPoint here"); - assertEq(gatewayWithSelection._filled(commitment), filler, "first solver fills the order"); + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + intentGateway.fillOrder(order, options); + + assertEq(intentGateway._filled(keccak256(abi.encode(order))), filler, "filled without a selection"); } - function testFillOrderWithWrongSolver() public { - // Enable solver selection - Params memory newParams = Params({ - host: address(host), - dispatcher: address(dispatcher), - solverSelection: true, - surplusShareBps: 10000, - protocolFeeBps: 0, - priceOracle: address(0) - }); + /// @dev On a chain without EntryPoint v0.9 nothing answers the gateway's query for the current + /// UserOperation, so with selection on no fill goes through there. + function testFillOrderWithSolverSelectionRevertsWithoutEntryPointV09() public { + IntentGatewayV2 gatewayWithSelection = _deploySelectionGateway(); + Order memory order = _placeSelectionOrder(gatewayWithSelection); + _selectUserOp(gatewayWithSelection, order, BID_USER_OP_HASH); - IntentGatewayV2 gatewayWithSelection = _deployGatewayProxy(); - gatewayWithSelection.initialize( - InitParams({params: newParams, peerChains: new bytes[](0), relayer: address(0), owner: address(this)}) + vm.etch(ENTRYPOINT_V09, ""); + + FillOptions memory options = _fullFillOptions(order); + vm.prank(filler); + // The revert carries no data, but forge fills in a diagnostic when tracing, so any revert. + vm.expectRevert(); + gatewayWithSelection.fillOrder(order, options); + } + + /// @dev A fresh gateway with solver selection on. + function _deploySelectionGateway() internal returns (IntentGatewayV2 gateway) { + gateway = _deployGatewayProxy(); + gateway.initialize( + InitParams({ + params: Params({ + host: address(host), + dispatcher: address(dispatcher), + solverSelection: true, + surplusShareBps: 10000, + protocolFeeBps: 0, + priceOracle: address(0) + }), + peerChains: new bytes[](0), + relayer: address(0), + owner: address(this) + }) ); + } + /// @dev Places a same-chain order selling 1,000 USDC for 1,000 DAI whose session is key 1, and + /// approves the filler's DAI to `gateway`. + function _placeSelectionOrder(IntentGatewayV2 gateway) internal returns (Order memory order) { uint256 inputAmount = 1000 * 1e6; TokenInfo[] memory inputs = new TokenInfo[](1); @@ -1838,10 +1870,7 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { TokenInfo[] memory outputAssets = new TokenInfo[](1); outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); - PaymentInfo memory output = - PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}); - - Order memory order = Order({ + order = Order({ user: bytes32(uint256(uint160(user))), source: host.host(), destination: host.host(), @@ -1851,53 +1880,46 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { session: vm.addr(1), predispatch: DispatchInfo({assets: new TokenInfo[](0), call: ""}), inputs: inputs, - output: output + output: PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""}) }); vm.startPrank(user); - usdc.approve(address(gatewayWithSelection), inputAmount); - gatewayWithSelection.placeOrder(order, bytes32(0)); + usdc.approve(address(gateway), inputAmount); + gateway.placeOrder(order, bytes32(0)); vm.stopPrank(); - bytes32 commitment = keccak256(abi.encode(order)); - - // Create EIP-712 signature from session key for filler - bytes memory sessionSignature = _createSelectSolverSignature( - commitment, - filler, - 1, // Session key private key - address(gatewayWithSelection) - ); - - // Solver selects filler vm.prank(filler); - gatewayWithSelection.select( - SelectOptions({commitment: commitment, solver: filler, signature: sessionSignature}) - ); + dai.approve(address(gateway), type(uint256).max); + } - // Different address tries to fill - should revert - address wrongSolver = address(0x9999); - deal(address(dai), wrongSolver, 10000 * 1e18); + /// @dev Session key 1 selects the UserOperation `userOpHash` to fill `order`. + function _selectUserOp(IntentGatewayV2 gateway, Order memory order, bytes32 userOpHash) internal { + bytes32 commitment = keccak256(abi.encode(order)); + gateway.select( + SelectOptions({ + commitment: commitment, + userOpHash: userOpHash, + signature: _createSelectSolverSignature(commitment, userOpHash, 1, address(gateway)) + }) + ); + } - vm.startPrank(wrongSolver); - dai.approve(address(gatewayWithSelection), 1000 * 1e18); - dai.approve(address(gatewayWithSelection), type(uint256).max); + /// @dev Has the EntryPoint report `userOpHash` as the UserOperation it is executing. + function _mockCurrentUserOp(bytes32 userOpHash) internal { + vm.mockCall(ENTRYPOINT_V09, abi.encodeCall(IEntryPointV09.getCurrentUserOpHash, ()), abi.encode(userOpHash)); + } + /// @dev A quote for all of a `_placeSelectionOrder` order at its own rate. + function _fullFillOptions(Order memory order) internal view returns (FillOptions memory) { TokenInfo[] memory solverOutputs = new TokenInfo[](1); solverOutputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18}); - - vm.expectRevert(IntentsBase.Unauthorized.selector); - gatewayWithSelection.fillOrder( - order, - FillOptions({ - relayerFee: 0, - nativeDispatchFee: 0, - validUntil: 0, - outputs: solverOutputs, - inputs: IntentQuoteTestUtils.inputs(order, solverOutputs) - }) - ); - vm.stopPrank(); + return FillOptions({ + relayerFee: 0, + nativeDispatchFee: 0, + validUntil: 0, + outputs: solverOutputs, + inputs: IntentQuoteTestUtils.inputs(order, solverOutputs) + }); } // ============================================ @@ -4565,7 +4587,6 @@ contract IntentGatewayV2Test is MainnetForkBaseTest { assertEq(gateway.version(), 3); } - function testMigrateRejectsEveryoneButHost() public { IntentGatewayV2 gateway = _legacyGateway(); diff --git a/evm/tests/foundry/account/SolverAccountTest.sol b/evm/tests/foundry/account/SolverAccountTest.sol index dfdf9d0380..ece7e06ba7 100644 --- a/evm/tests/foundry/account/SolverAccountTest.sol +++ b/evm/tests/foundry/account/SolverAccountTest.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.17; import "forge-std/Test.sol"; import {SolverAccount} from "../../../src/apps/intentsv2/SolverAccount.sol"; import {IntentGatewayV2} from "../../../src/apps/IntentGatewayV2.sol"; -import {IntentsBase} from "../../../src/apps/intentsv2/IntentsBase.sol"; +import {IntentsBase, IEntryPointV09} from "../../../src/apps/intentsv2/IntentsBase.sol"; import {deployIntentGatewayImpl, deployIntentModules} from "../IntentGatewayDeploy.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {IntentQuoteTestUtils} from "../IntentQuoteTestUtils.sol"; @@ -334,7 +334,7 @@ contract SolverAccountTest is Test { bytes32 userOpHash = keccak256("test_userop"); // Create session signature (EIP-712 signature by session key) - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); // Solver signs the plain userOpHash — the order/session binding is carried // by the nonce key. @@ -355,7 +355,7 @@ contract SolverAccountTest is Test { }); SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCall(address(intentGateway), selectCalldata, abi.encode(sessionKey)); @@ -370,7 +370,7 @@ contract SolverAccountTest is Test { function test_ValidateUserOp_IntentSelection_FillOrderCalldata_Success() public { bytes32 userOpHash = keccak256("test_userop"); - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); bytes memory solverSignature = _signUserOpHash(userOpHash); bytes memory signature = abi.encodePacked(testCommitment, solverSignature, sessionSignature); @@ -392,7 +392,7 @@ contract SolverAccountTest is Test { }); SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCall(address(intentGateway), selectCalldata, abi.encode(sessionKey)); @@ -410,7 +410,7 @@ contract SolverAccountTest is Test { function _assertHistoricalFillAcceptsIntentSignature(bytes4 selector) internal { bytes32 userOpHash = keccak256("test_userop"); - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); bytes memory solverSignature = _signUserOpHash(userOpHash); bytes memory signature = abi.encodePacked(testCommitment, solverSignature, sessionSignature); @@ -430,7 +430,7 @@ contract SolverAccountTest is Test { }); SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCall(address(intentGateway), selectCalldata, abi.encode(sessionKey)); @@ -443,7 +443,7 @@ contract SolverAccountTest is Test { function test_ValidateUserOp_IntentSelection_PlainUserOpHash_WrongNonceKey_Fails() public { bytes32 userOpHash = keccak256("test_userop"); - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); // Valid solver signature over the plain userOpHash... bytes memory solverSignature = _signUserOpHash(userOpHash); @@ -466,7 +466,7 @@ contract SolverAccountTest is Test { }); SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCall(address(intentGateway), selectCalldata, abi.encode(sessionKey)); @@ -480,15 +480,6 @@ contract SolverAccountTest is Test { /// calldata, so each is sequence 0 of its own key and validates on its own, in either /// order: neither waits on the other. function test_ValidateUserOp_IntentSelection_TwoBidsOnOneOrder_EachOnItsOwnKey() public { - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); - SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); - vm.mockCall( - address(intentGateway), - abi.encodeWithSelector(intentGateway.select.selector, expectedOptions), - abi.encode(sessionKey) - ); - bytes memory first = _fillCalldata(hex"01"); bytes memory second = _fillCalldata(hex"02"); uint256 firstNonce = _bidNonce(testCommitment, sessionKey, first); @@ -499,27 +490,16 @@ contract SolverAccountTest is Test { assertEq(secondNonce & type(uint64).max, 0); // The second bid validates first: nothing orders them. - assertEq(_validateBid(second, secondNonce, sessionSignature), ERC4337Utils.SIG_VALIDATION_SUCCESS); - assertEq(_validateBid(first, firstNonce, sessionSignature), ERC4337Utils.SIG_VALIDATION_SUCCESS); + assertEq(_validateBid(second, secondNonce), ERC4337Utils.SIG_VALIDATION_SUCCESS); + assertEq(_validateBid(first, firstNonce), ERC4337Utils.SIG_VALIDATION_SUCCESS); } /// @dev The key commits to the calldata, so an op carrying calldata other than the one its /// key was derived from is refused. function test_ValidateUserOp_IntentSelection_NonceKeyFromOtherCalldata_Fails() public { - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); - SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); - vm.mockCall( - address(intentGateway), - abi.encodeWithSelector(intentGateway.select.selector, expectedOptions), - abi.encode(sessionKey) - ); - uint256 keyedOnFirst = _bidNonce(testCommitment, sessionKey, _fillCalldata(hex"01")); - assertEq( - _validateBid(_fillCalldata(hex"02"), keyedOnFirst, sessionSignature), ERC4337Utils.SIG_VALIDATION_FAILED - ); + assertEq(_validateBid(_fillCalldata(hex"02"), keyedOnFirst), ERC4337Utils.SIG_VALIDATION_FAILED); } /// @dev The pre-upgrade composite format (EIP-191 over (userOpHash, commitment, @@ -529,7 +509,7 @@ contract SolverAccountTest is Test { bytes32 userOpHash = keccak256("test_userop"); // Create session signature (EIP-712 signature by session key) - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); // Legacy composite solver signature, with an otherwise-correct nonce binding bytes memory solverSignature = _createSolverSignature(userOpHash, testCommitment, sessionKey); @@ -551,7 +531,7 @@ contract SolverAccountTest is Test { // Mock the IntentGateway.select call to return the sessionKey SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCall(address(intentGateway), selectCalldata, abi.encode(sessionKey)); @@ -591,8 +571,7 @@ contract SolverAccountTest is Test { // Create invalid session signature (wrong signer) uint256 wrongPrivateKey = 0x9999999999999999; - bytes32 structHash = - keccak256(abi.encode(intentGateway.SELECT_SOLVER_TYPEHASH(), testCommitment, address(solverAccount))); + bytes32 structHash = keccak256(abi.encode(intentGateway.SELECT_SOLVER_TYPEHASH(), testCommitment, userOpHash)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", intentGateway.DOMAIN_SEPARATOR(), structHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPrivateKey, digest); bytes memory invalidSessionSignature = abi.encodePacked(r, s, v); @@ -616,9 +595,8 @@ contract SolverAccountTest is Test { }); // Mock IntentGateway.select to fail (return empty or revert) - SelectOptions memory expectedOptions = SelectOptions({ - commitment: testCommitment, solver: address(solverAccount), signature: invalidSessionSignature - }); + SelectOptions memory expectedOptions = + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: invalidSessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCallRevert(address(intentGateway), selectCalldata, "Invalid session signature"); @@ -633,7 +611,7 @@ contract SolverAccountTest is Test { bytes32 userOpHash = keccak256("test_userop"); // Create valid session signature - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); // Create INVALID solver signature (wrong signer over the plain userOpHash) uint256 wrongPrivateKey = 0x9999999999999999; @@ -657,7 +635,7 @@ contract SolverAccountTest is Test { // Mock the IntentGateway.select call to return the sessionKey SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCall(address(intentGateway), selectCalldata, abi.encode(sessionKey)); @@ -674,7 +652,7 @@ contract SolverAccountTest is Test { bytes32 wrongCommitment = keccak256("wrong_commitment"); // Create session signature for correct commitment - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); // Valid solver signature over the plain userOpHash bytes memory solverSignature = _signUserOpHash(userOpHash); @@ -696,7 +674,7 @@ contract SolverAccountTest is Test { // Mock the IntentGateway.select call - it will be called with wrongCommitment SelectOptions memory expectedOptions = - SelectOptions({commitment: wrongCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: wrongCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); // This should fail because session signature was for testCommitment, not wrongCommitment @@ -713,7 +691,7 @@ contract SolverAccountTest is Test { function test_ValidateUserOp_IntentSelection_GatewayReturnsWrongSessionKey_Fails() public { bytes32 userOpHash = keccak256("test_userop"); - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); bytes memory solverSignature = _signUserOpHash(userOpHash); bytes memory signature = abi.encodePacked(testCommitment, solverSignature, sessionSignature); @@ -730,7 +708,7 @@ contract SolverAccountTest is Test { signature: signature }); - _mockSelect(sessionSignature, abi.encode(address(0xdead))); + _mockSelect(userOpHash, sessionSignature, abi.encode(address(0xdead))); vm.prank(entryPoint); uint256 result = solverAccount.validateUserOp(op, userOpHash, 0); @@ -744,7 +722,7 @@ contract SolverAccountTest is Test { function test_ValidateUserOp_IntentSelection_GatewayReturnsTruncatedData_Reverts() public { bytes32 userOpHash = keccak256("test_userop"); - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); bytes memory solverSignature = _signUserOpHash(userOpHash); bytes memory signature = abi.encodePacked(testCommitment, solverSignature, sessionSignature); @@ -761,17 +739,17 @@ contract SolverAccountTest is Test { signature: signature }); - _mockSelect(sessionSignature, hex"1234"); + _mockSelect(userOpHash, sessionSignature, hex"1234"); vm.prank(entryPoint); vm.expectRevert(); solverAccount.validateUserOp(op, userOpHash, 0); } - /// @dev Mocks the gateway's `select` for this commitment and session signature. - function _mockSelect(bytes memory sessionSignature, bytes memory returnData) internal { + /// @dev Mocks the gateway's `select` for this commitment, op and session signature. + function _mockSelect(bytes32 userOpHash, bytes memory sessionSignature, bytes memory returnData) internal { SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); vm.mockCall( address(intentGateway), abi.encodeWithSelector(intentGateway.select.selector, expectedOptions), returnData ); @@ -785,7 +763,7 @@ contract SolverAccountTest is Test { bytes32 commitment2 = keccak256("commitment_2"); // First operation with commitment1 - bytes memory sessionSignature1 = _createSessionKeySignature(commitment1, address(solverAccount)); + bytes memory sessionSignature1 = _createSessionKeySignature(commitment1, userOpHash1); bytes memory solverSignature1 = _signUserOpHash(userOpHash1); bytes memory signature1 = abi.encodePacked(commitment1, solverSignature1, sessionSignature1); @@ -802,7 +780,7 @@ contract SolverAccountTest is Test { }); SelectOptions memory expectedOptions1 = - SelectOptions({commitment: commitment1, solver: address(solverAccount), signature: sessionSignature1}); + SelectOptions({commitment: commitment1, userOpHash: userOpHash1, signature: sessionSignature1}); bytes memory selectCalldata1 = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions1); vm.mockCall(address(intentGateway), selectCalldata1, abi.encode(sessionKey)); @@ -811,7 +789,7 @@ contract SolverAccountTest is Test { assertEq(result1, ERC4337Utils.SIG_VALIDATION_SUCCESS); // Second operation with commitment2 - bytes memory sessionSignature2 = _createSessionKeySignature(commitment2, address(solverAccount)); + bytes memory sessionSignature2 = _createSessionKeySignature(commitment2, userOpHash2); bytes memory solverSignature2 = _signUserOpHash(userOpHash2); bytes memory signature2 = abi.encodePacked(commitment2, solverSignature2, sessionSignature2); @@ -828,7 +806,7 @@ contract SolverAccountTest is Test { }); SelectOptions memory expectedOptions2 = - SelectOptions({commitment: commitment2, solver: address(solverAccount), signature: sessionSignature2}); + SelectOptions({commitment: commitment2, userOpHash: userOpHash2, signature: sessionSignature2}); bytes memory selectCalldata2 = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions2); vm.mockCall(address(intentGateway), selectCalldata2, abi.encode(sessionKey)); @@ -838,7 +816,7 @@ contract SolverAccountTest is Test { } /// @dev Anti-griefing: anyone can produce a valid `SelectSolver` signature for - /// (commitment, solver) with their own key. If that swapped session + /// (commitment, userOpHash) with their own key. If that swapped session /// signature were accepted, validation would pass and execution would /// revert at fillOrder's session check — consuming the bid's nonce and /// charging the solver. The nonce key binds the session key the solver @@ -851,7 +829,7 @@ contract SolverAccountTest is Test { address sessionKey2 = vm.addr(sessionKey2PrivateKey); // Create session signature with first session key - bytes memory sessionSignature = _createSessionKeySignature(testCommitment, address(solverAccount)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); // Valid solver signature over the plain userOpHash, with the nonce bound to // the session key the solver bid against @@ -874,7 +852,7 @@ contract SolverAccountTest is Test { // Mock IntentGateway to return DIFFERENT session key SelectOptions memory expectedOptions = - SelectOptions({commitment: testCommitment, solver: address(solverAccount), signature: sessionSignature}); + SelectOptions({commitment: testCommitment, userOpHash: userOpHash, signature: sessionSignature}); bytes memory selectCalldata = abi.encodeWithSelector(intentGateway.select.selector, expectedOptions); vm.mockCall(address(intentGateway), selectCalldata, abi.encode(sessionKey2)); @@ -885,6 +863,20 @@ contract SolverAccountTest is Test { assertEq(result, ERC4337Utils.SIG_VALIDATION_FAILED); } + /// @dev Against the real gateway: the session key signed another op, so `select` recovers some + /// other key from this op's hash and the nonce key no longer matches. + function test_ValidateUserOp_IntentSelection_SessionSignedOtherUserOp_Fails() public { + bytes32 userOpHash = keccak256("test_userop"); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, keccak256("other_userop")); + + PackedUserOperation memory op = + _standardOp("", abi.encodePacked(testCommitment, _signUserOpHash(userOpHash), sessionSignature)); + op.nonce = _bidNonce(testCommitment, sessionKey, ""); + + vm.prank(entryPoint); + assertEq(solverAccount.validateUserOp(op, userOpHash, 0), ERC4337Utils.SIG_VALIDATION_FAILED); + } + /// @dev Pinned against the same vector asserted in the SDK's /// packedUserOpTypedData.test.ts — guards TS/Solidity drift in the bid /// nonce-key derivation. @@ -1122,6 +1114,98 @@ contract SolverAccountTest is Test { assertEq(token.allowance(address(solverAccount), address(intentGateway)), 0); } + /// @dev The selection staged in validation holds only while its own op executes: the same batch + /// run while the EntryPoint reports another op, or none, is refused by the gateway. + function test_GatewayFill_OutsideSelectedUserOp_Reverts() public { + uint256 outputAmount = 900e18; + ERC20Token inputToken = new ERC20Token("Input", "IN", 18); + token.mint(address(solverAccount), outputAmount); + + Order memory order = _placeOrder(inputToken, 1000e18, outputAmount); + FillOptions memory options = _fillOptions(order, outputAmount, 0); + (bytes memory callData, bytes32 userOpHash) = + _validateSelectedBid(order, _gatewayBatch(order, options, outputAmount, outputAmount)); + + vm.expectRevert(IntentsBase.Unauthorized.selector); + _executeBid(callData, keccak256(abi.encode(userOpHash))); + + vm.expectRevert(IntentsBase.Unauthorized.selector); + _executeBid(callData, bytes32(0)); + + assertEq(intentGateway._filled(keccak256(abi.encode(order))), address(0)); + assertEq(token.balanceOf(beneficiary), 0); + } + + /// @dev A selected bid through the live EntryPoint v0.9's `handleOps`: validation stages the + /// session key's selection of this op, and the gateway takes the fill while it executes. + function test_HandleOps_SelectedBidFills() public { + string memory url = vm.envOr("MAINNET_FORK_URL", string("")); + if (bytes(url).length == 0) { + vm.skip(true); + return; + } + vm.selectFork(vm.createFork(url)); + setUp(); + + uint256 inputAmount = 1000e18; + uint256 outputAmount = 900e18; + ERC20Token inputToken = new ERC20Token("Input", "IN", 18); + token.mint(address(solverAccount), outputAmount); + + Order memory order = _placeOrder(inputToken, inputAmount, outputAmount); + bytes32 commitment = keccak256(abi.encode(order)); + bytes memory callData = + _executeCalldata(_gatewayBatch(order, _fillOptions(order, outputAmount, 0), outputAmount, outputAmount)); + + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + (ops[0],) = _handleOpsBid(commitment, callData, sessionKeyPrivateKey); + _handleOps(ops); + + assertEq(intentGateway._filled(commitment), address(solverAccount), "filled by the selected op"); + assertEq(token.balanceOf(beneficiary), outputAmount); + assertEq(inputToken.balanceOf(address(solverAccount)), inputAmount); + assertEq(solverAccount.spent(orderId), outputAmount); + } + + /// @dev Two bids on one order in one bundle through the live EntryPoint v0.9. The first is + /// selected by a key other than the order's session, so the gateway refuses its fill while it + /// executes; the session key's bid behind it still fills. + function test_HandleOps_BidSelectedByAnotherKeyDoesNotFill() public { + string memory url = vm.envOr("MAINNET_FORK_URL", string("")); + if (bytes(url).length == 0) { + vm.skip(true); + return; + } + vm.selectFork(vm.createFork(url)); + setUp(); + + uint256 inputAmount = 1000e18; + uint256 outputAmount = 900e18; + ERC20Token inputToken = new ERC20Token("Input", "IN", 18); + token.mint(address(solverAccount), outputAmount); + + Order memory order = _placeOrder(inputToken, inputAmount, outputAmount); + bytes32 commitment = keccak256(abi.encode(order)); + bytes memory callData = + _executeCalldata(_gatewayBatch(order, _fillOptions(order, outputAmount, 0), outputAmount, outputAmount)); + + PackedUserOperation[] memory ops = new PackedUserOperation[](2); + bytes32 otherHash; + (ops[0], otherHash) = _handleOpsBid(commitment, callData, 0xfedcba0987654321); + (ops[1],) = _handleOpsBid(commitment, callData, sessionKeyPrivateKey); + + vm.expectEmit(true, true, false, true, entryPoint); + emit IEntryPointHandleOps.UserOperationRevertReason( + otherHash, address(solverAccount), ops[0].nonce, abi.encodeWithSelector(IntentsBase.Unauthorized.selector) + ); + _handleOps(ops); + + assertEq(intentGateway._filled(commitment), address(solverAccount), "filled by the selected op"); + assertEq(token.balanceOf(beneficiary), outputAmount); + assertEq(inputToken.balanceOf(address(solverAccount)), inputAmount); + assertEq(solverAccount.spent(orderId), outputAmount); + } + // ============================================ // Helper Functions // ============================================ @@ -1269,12 +1353,22 @@ contract SolverAccountTest is Test { }); } - /// @dev Runs a selected bid on `order` as the EntryPoint does: validation, then the batch. + /// @dev Runs a selected bid on `order` as the EntryPoint does: validation, then the batch, during + /// which the EntryPoint reports the op as executing. function _runBid(Order memory order, Execution[] memory calls) internal { + (bytes memory callData, bytes32 userOpHash) = _validateSelectedBid(order, calls); + _executeBid(callData, userOpHash); + } + + /// @dev Validates a bid on `order` running `calls`, which stages the session key's selection of it. + function _validateSelectedBid(Order memory order, Execution[] memory calls) + internal + returns (bytes memory callData, bytes32 userOpHash) + { bytes32 commitment = keccak256(abi.encode(order)); - bytes memory callData = _executeCalldata(calls); - bytes32 userOpHash = keccak256(callData); - bytes memory sessionSignature = _createSessionKeySignature(commitment, address(solverAccount)); + callData = _executeCalldata(calls); + userOpHash = keccak256(callData); + bytes memory sessionSignature = _createSessionKeySignature(commitment, userOpHash); PackedUserOperation memory op = PackedUserOperation({ sender: address(solverAccount), @@ -1290,9 +1384,15 @@ contract SolverAccountTest is Test { vm.prank(entryPoint); assertEq(solverAccount.validateUserOp(op, userOpHash, 0), ERC4337Utils.SIG_VALIDATION_SUCCESS); + } + /// @dev Runs a bid's batch as the EntryPoint does while it reports `executing` as the current op. + function _executeBid(bytes memory callData, bytes32 executing) internal { + bytes memory currentUserOpHashCall = abi.encodeCall(IEntryPointV09.getCurrentUserOpHash, ()); + vm.mockCall(entryPoint, currentUserOpHashCall, abi.encode(executing)); vm.prank(entryPoint); (bool ok, bytes memory returned) = address(solverAccount).call(callData); + vm.mockCall(entryPoint, currentUserOpHashCall, abi.encode(bytes32(0))); if (!ok) { assembly { revert(add(returned, 0x20), mload(returned)) @@ -1300,6 +1400,31 @@ contract SolverAccountTest is Test { } } + /// @dev A bid on `commitment` for the live EntryPoint, signed by the solver and selected by the + /// key `selectorPrivateKey`, with its nonce bound to that key. + function _handleOpsBid(bytes32 commitment, bytes memory callData, uint256 selectorPrivateKey) + internal + view + returns (PackedUserOperation memory op, bytes32 userOpHash) + { + op = _standardOp(callData, ""); + op.nonce = _bidNonce(commitment, vm.addr(selectorPrivateKey), callData); + op.accountGasLimits = bytes32((uint256(300_000) << 128) | uint256(1_000_000)); + op.preVerificationGas = 60_000; + op.gasFees = bytes32((uint256(1 gwei) << 128) | (block.basefee + 1 gwei)); + userOpHash = IEntryPointHandleOps(entryPoint).getUserOpHash(op); + op.signature = abi.encodePacked( + commitment, _signUserOpHash(userOpHash), _signSelection(commitment, userOpHash, selectorPrivateKey) + ); + } + + /// @dev Submits `ops` to the live EntryPoint as a bundler does. + function _handleOps(PackedUserOperation[] memory ops) internal { + address bundler = makeAddr("bundler"); + vm.prank(bundler, bundler); + IEntryPointHandleOps(entryPoint).handleOps(ops, payable(bundler)); + } + /// @notice ERC-7821 execute(mode, executionData) calldata for a batch of calls function _executeCalldata(Execution[] memory calls) internal view returns (bytes memory) { bytes32 mode = bytes32(uint256(0x01) << 248); // CALLTYPE_BATCH, EXECTYPE_DEFAULT @@ -1326,10 +1451,19 @@ contract SolverAccountTest is Test { } /// @notice Creates an EIP-712 signature by session key for IntentGateway.select - function _createSessionKeySignature(bytes32 commitment, address solverAddr) internal view returns (bytes memory) { - bytes32 structHash = keccak256(abi.encode(intentGateway.SELECT_SOLVER_TYPEHASH(), commitment, solverAddr)); + function _createSessionKeySignature(bytes32 commitment, bytes32 userOpHash) internal view returns (bytes memory) { + return _signSelection(commitment, userOpHash, sessionKeyPrivateKey); + } + + /// @dev An EIP-712 selection of `userOpHash` for IntentGateway.select, signed by `privateKey`. + function _signSelection(bytes32 commitment, bytes32 userOpHash, uint256 privateKey) + internal + view + returns (bytes memory) + { + bytes32 structHash = keccak256(abi.encode(intentGateway.SELECT_SOLVER_TYPEHASH(), commitment, userOpHash)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", intentGateway.DOMAIN_SEPARATOR(), structHash)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(sessionKeyPrivateKey, digest); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); return abi.encodePacked(r, s, v); } @@ -1366,12 +1500,12 @@ contract SolverAccountTest is Test { return _executeCalldata(calls); } - /// @dev Validates a selected bid carrying `callData` and `nonce`, signed by the solver. - function _validateBid(bytes memory callData, uint256 nonce, bytes memory sessionSignature) - internal - returns (uint256) - { + /// @dev Validates a selected bid carrying `callData` and `nonce`, signed by the solver and + /// selected by the session key, with the gateway's `select` mocked to recover that key. + function _validateBid(bytes memory callData, uint256 nonce) internal returns (uint256) { bytes32 userOpHash = keccak256(abi.encode(callData, nonce)); + bytes memory sessionSignature = _createSessionKeySignature(testCommitment, userOpHash); + _mockSelect(userOpHash, sessionSignature, abi.encode(sessionKey)); bytes memory signature = abi.encodePacked(testCommitment, _signUserOpHash(userOpHash), sessionSignature); PackedUserOperation memory op = PackedUserOperation({ sender: address(solverAccount), @@ -1398,6 +1532,17 @@ contract SolverAccountTest is Test { } } +/// @dev The EntryPoint v0.9 calls a bundler makes, and the event an op that reverts in execution emits. +interface IEntryPointHandleOps { + event UserOperationRevertReason( + bytes32 indexed userOpHash, address indexed sender, uint256 nonce, bytes revertReason + ); + + function handleOps(PackedUserOperation[] calldata ops, address payable beneficiary) external; + + function getUserOpHash(PackedUserOperation calldata userOp) external view returns (bytes32); +} + contract MockContract { fallback() external payable {} } diff --git a/sdk/packages/core/contracts/apps/IntentGatewayV2.sol b/sdk/packages/core/contracts/apps/IntentGatewayV2.sol index 2a38e6d743..bcb65d14e5 100644 --- a/sdk/packages/core/contracts/apps/IntentGatewayV2.sol +++ b/sdk/packages/core/contracts/apps/IntentGatewayV2.sol @@ -189,15 +189,16 @@ struct FillOptions { /** * @notice A struct representing the options for selecting a solver - * @dev This struct is used to specify various parameters and options - * when selecting a solver. + * @dev The session key selects one ERC-4337 UserOperation, the solver's bid, rather than a solver + * address. With solver selection on, `fillOrder` succeeds only while EntryPoint v0.9 executes + * that UserOperation. */ struct SelectOptions { /// @dev The commitment hash of the order. bytes32 commitment; - /// @dev The solver address to select. - address solver; - /// @dev The EIP-712 signature from the session key that signed SelectSolver(commitment, solver) + /// @dev The EntryPoint v0.9 hash of the selected UserOperation. + bytes32 userOpHash; + /// @dev The EIP-712 signature from the session key that signed SelectSolver(commitment, userOpHash) bytes signature; } @@ -449,7 +450,8 @@ interface IIntentGatewayV2 { // ============================================ /** - * @notice EIP-712 type hash for SelectSolver message + * @notice EIP-712 type hash for the message + * SelectSolver(bytes32 commitment,bytes32 userOpHash) */ function SELECT_SOLVER_TYPEHASH() external view returns (bytes32); @@ -576,8 +578,10 @@ interface IIntentGatewayV2 { function placeOrder(Order memory order, bytes32 graffiti) external payable; /** - * @notice Selects a solver for an order (when solver selection is enabled). Reverts `Filled` - * if the order has already been filled, refunded or cancelled. + * @notice Selects a solver's UserOperation for an order (when solver selection is enabled). + * The selection lasts for the transaction, and `fillOrder` accepts it only while + * EntryPoint v0.9 executes that UserOperation. Reverts `Filled` if the order has + * already been filled, refunded or cancelled. * @param options The options for selecting a solver * @return sessionKey The recovered session key address */ From 10a1c3b077f12cf68140549af8ddf3fccb4d0d98 Mon Sep 17 00:00:00 2001 From: royvardhan Date: Tue, 6 Oct 2026 21:05:50 +0530 Subject: [PATCH 02/12] [evm, simplex]: testnet rollout scripts and local rundler for EntryPoint v0.9 --- evm/config.testnet.toml | 6 + evm/script/deploy.sh | 8 +- evm/script/testnet/DeployFixedPriceFeed.s.sol | 33 + evm/script/testnet/FixedPriceAggregator.sol | 21 + evm/script/testnet/FundSimplexPaymaster.s.sol | 43 ++ evm/script/testnet/README.md | 51 ++ evm/script/testnet/entrypoint-v09.sh | 441 ++++++++++++++ .../SimplexPaymasterBundlerRolloutTest.t.sol | 115 ++++ scripts/docker/rundler/.env.example | 8 + scripts/docker/rundler/README.md | 57 ++ scripts/docker/rundler/docker-compose.yml | 51 ++ .../simplex/e2e/entrypoint-v09-sudo.mjs | 569 ++++++++++++++++++ 12 files changed, 1400 insertions(+), 3 deletions(-) create mode 100644 evm/script/testnet/DeployFixedPriceFeed.s.sol create mode 100644 evm/script/testnet/FixedPriceAggregator.sol create mode 100644 evm/script/testnet/FundSimplexPaymaster.s.sol create mode 100644 evm/script/testnet/README.md create mode 100755 evm/script/testnet/entrypoint-v09.sh create mode 100644 evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol create mode 100644 scripts/docker/rundler/.env.example create mode 100644 scripts/docker/rundler/README.md create mode 100644 scripts/docker/rundler/docker-compose.yml create mode 100644 sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs diff --git a/evm/config.testnet.toml b/evm/config.testnet.toml index 308e887265..5055bd2316 100644 --- a/evm/config.testnet.toml +++ b/evm/config.testnet.toml @@ -99,6 +99,9 @@ HOST_MANAGER = "0xa7273Cb04E99978CF5c0A935e92EC95F09e9D44a" INTENT_GATEWAY_V2_IMPL = "0x5E03fe27c7372E044296B4BA38B2cF9f760984CA" INTENT_GATEWAY_V2_INTRINSIC_MODULE = "0x19587e181634Fd118258e2Af37e5c6531e6cFf43" INTENT_GATEWAY_V2_EXTRINSIC_MODULE = "0x0205338774146975D86E3164FfB3882b26b6d742" +NATIVE_ORACLE = "0x0000000000000000000000000000000000000000" +USDC_TOKEN = "0xA801da100bF16D07F668F4A49E1f71fc54D05177" +USDC_ORACLE = "0x0000000000000000000000000000000000000000" [97.bool] is_mainnet = false @@ -135,6 +138,9 @@ HOST_MANAGER = "0xa7273Cb04E99978CF5c0A935e92EC95F09e9D44a" INTENT_GATEWAY_V2_IMPL = "0x5E03fe27c7372E044296B4BA38B2cF9f760984CA" INTENT_GATEWAY_V2_INTRINSIC_MODULE = "0x19587e181634Fd118258e2Af37e5c6531e6cFf43" INTENT_GATEWAY_V2_EXTRINSIC_MODULE = "0x0205338774146975D86E3164FfB3882b26b6d742" +NATIVE_ORACLE = "0x0000000000000000000000000000000000000000" +USDC_TOKEN = "0xBE97E73126D66188d72fbF99029126D0340a7f18" +USDC_ORACLE = "0x0000000000000000000000000000000000000000" [80002.bool] is_mainnet = false diff --git a/evm/script/deploy.sh b/evm/script/deploy.sh index 07e09232ce..21ed37dc65 100755 --- a/evm/script/deploy.sh +++ b/evm/script/deploy.sh @@ -159,6 +159,8 @@ if [[ ! "$SCRIPT_NAME" =~ \.s\.sol$ ]]; then fi SCRIPT_PATH="script/${SCRIPT_NAME}" +# forge keys broadcast artifacts by file name, so testnet/X.s.sol lands under broadcast/X.s.sol +BROADCAST_DIR="broadcast/$(basename "$SCRIPT_NAME")" if [ ! -f "$SCRIPT_PATH" ]; then echo -e "${RED}Error: Script not found: $SCRIPT_PATH${NC}" @@ -306,7 +308,7 @@ for chain in "${CHAIN_ARRAY[@]}"; do continue fi - BROADCAST_FILE="broadcast/${SCRIPT_NAME}/${CHAIN_ID}/run-latest.json" + BROADCAST_FILE="${BROADCAST_DIR}/${CHAIN_ID}/run-latest.json" if [ ! -f "$BROADCAST_FILE" ]; then echo -e "${RED}No broadcast artifacts found: $BROADCAST_FILE${NC}" FAILED_CHAINS+=("$chain") @@ -421,11 +423,11 @@ if [ ${#FAILED_CHAINS[@]} -eq 0 ]; then elif [ "$MODE" = "verify" ]; then echo "" echo -e "${YELLOW}Verification completed using artifacts from:${NC}" - echo -e " broadcast/${SCRIPT_NAME}//" + echo -e " ${BROADCAST_DIR}//" else echo "" echo -e "${YELLOW}Deployment artifacts saved in:${NC}" - echo -e " broadcast/${SCRIPT_NAME}//" + echo -e " ${BROADCAST_DIR}//" fi exit 0 else diff --git a/evm/script/testnet/DeployFixedPriceFeed.s.sol b/evm/script/testnet/DeployFixedPriceFeed.s.sol new file mode 100644 index 0000000000..95362f556c --- /dev/null +++ b/evm/script/testnet/DeployFixedPriceFeed.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import "forge-std/Script.sol"; + +import {BaseScript} from "../BaseScript.sol"; +import {FixedPriceAggregator} from "./FixedPriceAggregator.sol"; + +/// @notice Deploys fixed-price native/USD and USDC/USD feeds for the testnet SimplexPaymaster +/// and writes them to NATIVE_ORACLE and USDC_ORACLE. +contract DeployScript is BaseScript { + uint8 internal constant FEED_DECIMALS = 8; + int256 internal constant USDC_USD = 1e8; + + function deploy() internal override { + FixedPriceAggregator nativeFeed = new FixedPriceAggregator{salt: salt}(nativeUsd(), FEED_DECIMALS); + FixedPriceAggregator usdcFeed = new FixedPriceAggregator{salt: salt}(USDC_USD, FEED_DECIMALS); + + vm.stopBroadcast(); + + console.log("Native/USD feed deployed at:", address(nativeFeed)); + console.log("USDC/USD feed deployed at:", address(usdcFeed)); + + config.set("NATIVE_ORACLE", address(nativeFeed)); + config.set("USDC_ORACLE", address(usdcFeed)); + } + + function nativeUsd() internal view returns (int256) { + if (block.chainid == 97) return 600e8; // BNB + if (block.chainid == 80002) return 0.25e8; // POL + revert("no fixed native price for this chain"); + } +} diff --git a/evm/script/testnet/FixedPriceAggregator.sol b/evm/script/testnet/FixedPriceAggregator.sol new file mode 100644 index 0000000000..6ff08f0fcc --- /dev/null +++ b/evm/script/testnet/FixedPriceAggregator.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +/// @notice Testnet only: a Chainlink AggregatorV3 stand-in that reports a fixed price which never goes stale. +contract FixedPriceAggregator { + int256 public immutable answer; + uint8 public immutable decimals; + + constructor(int256 answer_, uint8 decimals_) { + answer = answer_; + decimals = decimals_; + } + + function latestRoundData() + external + view + returns (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + return (1, answer, block.timestamp, block.timestamp, 1); + } +} diff --git a/evm/script/testnet/FundSimplexPaymaster.s.sol b/evm/script/testnet/FundSimplexPaymaster.s.sol new file mode 100644 index 0000000000..c102ff2041 --- /dev/null +++ b/evm/script/testnet/FundSimplexPaymaster.s.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import "forge-std/Script.sol"; +import "stringutils/strings.sol"; + +import {SimplexPaymaster, IStakeManager} from "../../src/utils/SimplexPaymaster.sol"; +import {BaseScript} from "../BaseScript.sol"; + +/// @notice Tops the SIMPLEX_PAYMASTER EntryPoint deposit and stake up to PAYMASTER_DEPOSIT and +/// PAYMASTER_STAKE (wei). Sends nothing for a balance already at its target, so a re-run +/// is a no-op. The stake goes through `addStake`, which only the treasury may call. +contract DeployScript is BaseScript { + using strings for *; + + uint32 internal constant UNSTAKE_DELAY = 1 days; + + function deploy() internal override { + SimplexPaymaster paymaster = SimplexPaymaster(payable(config.get("SIMPLEX_PAYMASTER").toAddress())); + uint256 depositTarget = vm.envUint("PAYMASTER_DEPOSIT"); + uint256 stakeTarget = vm.envUint("PAYMASTER_STAKE"); + IStakeManager entryPoint = IStakeManager(address(paymaster.entryPoint())); + + IStakeManager.DepositInfo memory info = entryPoint.getDepositInfo(address(paymaster)); + if (info.deposit < depositTarget) { + paymaster.deposit{value: depositTarget - info.deposit}(); + } + if (info.stake < stakeTarget || info.unstakeDelaySec < UNSTAKE_DELAY) { + require(paymaster.treasury() == vm.addr(uint256(privateKey)), "PRIVATE_KEY is not the paymaster treasury"); + uint256 topUp = info.stake < stakeTarget ? stakeTarget - info.stake : 0; + paymaster.addStake{value: topUp}(UNSTAKE_DELAY); + } + + vm.stopBroadcast(); + + info = entryPoint.getDepositInfo(address(paymaster)); + console.log("SimplexPaymaster:", address(paymaster)); + console.log(" EntryPoint:", address(entryPoint)); + console.log(" deposit:", info.deposit); + console.log(" stake:", info.stake); + console.log(" unstakeDelaySec:", info.unstakeDelaySec); + } +} diff --git a/evm/script/testnet/README.md b/evm/script/testnet/README.md new file mode 100644 index 0000000000..d537519f0c --- /dev/null +++ b/evm/script/testnet/README.md @@ -0,0 +1,51 @@ +# Testnet scripts + +## EntryPoint v0.9 rollout + +Moves BSC Chapel (`bsc-testnet`, 97) and Polygon Amoy (`polygon-amoy`, 80002) to EntryPoint v0.9, +governed from Gargantua. The EVM steps run with `entrypoint-v09.sh` from `evm/`. The sudo steps run +with `e2e/entrypoint-v09-sudo.mjs` from `sdk/packages/simplex`. + +Both scripts read `PRIVATE_KEY`, `SECRET_PHRASE`, `BSC_CHAPEL`, `POLYGON_AMOY` and +`HYPERBRIDGE_GARGANTUA` from `sdk/.env.local`, and skip a step that already holds on a chain. +With `--dry-run`: + +- the EVM steps run on local anvil forks against a temporary copy of the config; +- the sudo steps print each decoded call and its hex without signing. + +Every EVM broadcast goes through `script/deploy.sh`, which asks for confirmation and verifies the +contracts. Live runs write the new addresses into `config.testnet.toml`. Keep `VERSION` (default +`entrypoint-v09`) the same for every EVM step, because it seeds every CREATE2 salt. + +| # | Command | Verified by the script | +|---|---------|------------------------| +| 1 | `script/testnet/entrypoint-v09.sh solver-account` | `entryPoint()` is v0.9, and both chains have the same address | +| 2 | `script/testnet/entrypoint-v09.sh gateway-impl` | the implementation has the new `SELECT_SOLVER_TYPEHASH`, and the `execute_on_gateway` calldata is printed | +| 3 | `script/testnet/entrypoint-v09.sh paymaster` | feeds deployed, and the proxy is at version 3 with relayer `0xc8809D…2757` and the deployer as treasury | +| 4 | `node e2e/entrypoint-v09-sudo.mjs register-paymaster` | `Paymasters(Evm(id))` holds the proxy | +| 5 | `node e2e/entrypoint-v09-sudo.mjs upgrade-gateway` | the gateway implementation slot is the new one, and `SELECT_SOLVER_TYPEHASH()` is `0xe706…e09e` | +| 6 | `script/testnet/entrypoint-v09.sh fund` | the EntryPoint deposit and stake are at target, with an unstake delay of 86400 | +| 7 | Start the bundlers with [`scripts/docker/rundler`](../../../scripts/docker/rundler/README.md) | `eth_supportedEntryPoints` returns only v0.9 | +| 8 | `node e2e/entrypoint-v09-sudo.mjs allow-bundlers` | `getBundlers()` lists the rundler signer and its simulation origin | +| 9 | `node e2e/entrypoint-v09-sudo.mjs clear-bundlers`, once acceptance passes | `getBundlers()` is empty | + +Step details: + +- **Step 5** waits for the relayer, which can take minutes. Steps 8 and 9 wait for it too. Use + `--timeout-min` to change how long they wait. +- **Step 6** tops up to 0.3 tBNB deposit and 0.05 stake on Chapel, and 1 POL deposit and 0.1 stake + on Amoy. Use `--deposit` and `--stake` (in ether) to change the targets. +- **Steps 8 and 9.** The runtime has no `set_paymaster_bundlers`, so the allowlist rides on + `upgrade_paymaster`: + - The proxy is pointed at its current implementation. + - The init data is an `onAccept` call carrying a nested `SetBundlers` request: source + `KUSAMA-4009`, from `pallet-intents`, relayer `0xc8809D…2757`. + - The proxy delegatecalls this init data with the host still the caller. + - `node e2e/entrypoint-v09-sudo.mjs init-data` prints the bytes, and + `tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol` pins them. + +To recheck state: + +- `script/testnet/entrypoint-v09.sh status` re-reads every EVM check. +- `node e2e/entrypoint-v09-sudo.mjs metadata` prints the live call shapes, the sudo key and what the + pallet stores. diff --git a/evm/script/testnet/entrypoint-v09.sh b/evm/script/testnet/entrypoint-v09.sh new file mode 100755 index 0000000000..b9631579dc --- /dev/null +++ b/evm/script/testnet/entrypoint-v09.sh @@ -0,0 +1,441 @@ +#!/bin/bash + +# EntryPoint v0.9 testnet rollout, EVM side, on BSC Chapel and Polygon Amoy. +# Usage: script/testnet/entrypoint-v09.sh [OPTIONS] [step ...] +# The Hyperbridge governance steps run from sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs. +# README.md next to this script has the full order. + +set -eo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +EVM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPO_DIR="$(cd "$EVM_DIR/.." && pwd)" +ENV_FILE="${ENV_FILE:-$REPO_DIR/sdk/.env.local}" +CONFIG="${CONFIG:-config.testnet.toml}" + +ENTRYPOINT_V09=0x433709009B8330FDa32311DF1C2AFA402eD8D009 +SELECT_SOLVER_TYPEHASH=0xe706bdab7d945360dcd9d81d355f856754dd1cfa461edfc0a7502e2583b4e09e +IMPLEMENTATION_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc +UNSTAKE_DELAY=86400 + +DRY_RUN=false +CHAINS="bsc-testnet,polygon-amoy" +DEPOSIT="" +STAKE="" +STEPS=() + +print_usage() { + echo -e "${BLUE}EntryPoint v0.9 testnet rollout (EVM steps)${NC}" + echo "" + echo "Usage: $0 [OPTIONS] [step ...]" + echo "" + echo "Steps, each skipped on a chain where it already holds:" + echo " solver-account 1. CREATE2 SolverAccount; checks entryPoint() is v0.9 and one address on every chain" + echo " gateway-impl 2. IntentGatewayV2 implementation; prints the execute_on_gateway calldata" + echo " paymaster 3. fixed-price feeds, then a SimplexPaymaster proxy" + echo " fund 6. tops the paymaster's EntryPoint deposit and stake up to the targets" + echo " status read-only report of every check above" + echo "" + echo "Options:" + echo " --dry-run Run on local anvil forks with a temporary config copy; nothing leaves the machine" + echo " -c, --chains LIST bsc-testnet,polygon-amoy (default: both)" + echo " --deposit ETHER Deposit target (default: 0.3 on bsc-testnet, 1 on polygon-amoy)" + echo " --stake ETHER Stake target (default: 0.05 on bsc-testnet, 0.1 on polygon-amoy)" + echo " -h, --help Show this help message" + echo "" + echo "Environment (each falls back as listed; secrets are read from $ENV_FILE with grep, never sourced):" + echo " PRIVATE_KEY deployer key, else PRIVATE_KEY in the env file" + echo " BSC_TESTNET_RPC_URL else BSC_CHAPEL in the env file" + echo " POLYGON_AMOY_RPC_URL else POLYGON_AMOY in the env file" + echo " VERSION CREATE2 salt seed (default: entrypoint-v09)" + echo " GOVERNANCE_RELAYER paymaster relayer (default: 0xc8809DD0b00370be097382d741A43347Ad582757)" + echo " ADMIN, TREASURY default: the PRIVATE_KEY address" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) DRY_RUN=true; shift ;; + -c|--chains) CHAINS="$2"; shift 2 ;; + --deposit) DEPOSIT="$2"; shift 2 ;; + --stake) STAKE="$2"; shift 2 ;; + -h|--help) print_usage; exit 0 ;; + -*) echo -e "${RED}Error: Unknown option $1${NC}"; print_usage; exit 1 ;; + *) STEPS+=("$1"); shift ;; + esac +done + +if [ ${#STEPS[@]} -eq 0 ]; then + echo -e "${RED}Error: Missing step${NC}\n" + print_usage + exit 1 +fi + +die() { echo -e "${RED}Error: $*${NC}" >&2; exit 1; } +ok() { echo -e " ${GREEN}✓${NC} $*"; } +info() { echo -e " $*"; } +warn() { echo -e " ${YELLOW}$*${NC}"; } +step() { echo ""; echo -e "${BLUE}━━ $* ━━${NC}"; } + +lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } +same() { [ "$(lower "$1")" = "$(lower "$2")" ]; } + +# Decimal strings, so values past 2^63 wei still compare. +at_least() { + local a=$1 b=$2 + [ ${#a} -gt ${#b} ] && return 0 + [ ${#a} -lt ${#b} ] && return 1 + [[ "$a" > "$b" || "$a" == "$b" ]] +} + +# One line of the env file; it is not valid shell, so never source it. +env_get() { + local value + value=$(grep -m1 "^$1=" "$ENV_FILE" 2>/dev/null | cut -d= -f2-) || true + value=${value%$'\r'} + value="${value%"${value##*[![:space:]]}"}" + value=${value#\"}; value=${value%\"} + value=${value#\'}; value=${value%\'} + printf '%s' "$value" +} + +chain_id() { + case $1 in + bsc-testnet) echo 97 ;; + polygon-amoy) echo 80002 ;; + *) die "Unsupported chain '$1': use bsc-testnet or polygon-amoy" ;; + esac +} + +rpc_var() { + case $1 in + bsc-testnet) echo BSC_TESTNET_RPC_URL ;; + polygon-amoy) echo POLYGON_AMOY_RPC_URL ;; + esac +} + +rpc_fallback() { + case $1 in + bsc-testnet) echo BSC_CHAPEL ;; + polygon-amoy) echo POLYGON_AMOY ;; + esac +} + +default_deposit() { + case $1 in + bsc-testnet) echo 0.3 ;; + polygon-amoy) echo 1 ;; + esac +} + +default_stake() { + case $1 in + bsc-testnet) echo 0.05 ;; + polygon-amoy) echo 0.1 ;; + esac +} + +rpc_url() { local var; var=$(rpc_var "$1"); printf '%s' "${!var}"; } + +# Reads `[.address] KEY` from the active config. +config_get() { + awk -v section="[$1.address]" -v key="$2" ' + /^\[/ { found = ($0 == section); next } + found && $1 == key { gsub(/"/, "", $3); print $3; exit } + ' "$EVM_DIR/$CONFIG" +} + +# The RPC rides in ETH_RPC_URL so it never shows up in argv or output. +rpc_call() { + local chain=$1; shift + ETH_RPC_URL="$(rpc_url "$chain")" cast call "$@" 2>/dev/null | awk '{print $1}' || true +} + +has_code() { + [ -n "$2" ] && [ "$(lower "$2")" != "0x0000000000000000000000000000000000000000" ] || return 1 + local code + code=$(ETH_RPC_URL="$(rpc_url "$1")" cast code "$2" 2>/dev/null) || return 1 + [ -n "$code" ] && [ "$code" != "0x" ] +} + +implementation_of() { + local word + word=$(ETH_RPC_URL="$(rpc_url "$1")" cast storage "$2" "$IMPLEMENTATION_SLOT" 2>/dev/null) || return 0 + cast parse-bytes32-address "$word" +} + +# Runs one forge script on the chains given: straight onto the anvil forks in a dry run, +# through deploy.sh (confirmation prompt, broadcast, verification) otherwise. +run_script() { + local script=$1; shift + [ $# -gt 0 ] || return 0 + if $DRY_RUN; then + local chain rpc + for chain in "$@"; do + rpc=$(rpc_url "$chain") + [[ "$rpc" == http://127.0.0.1:* ]] || die "Dry run refuses a non-local RPC for $chain" + info "${YELLOW}forge script $script on the $chain fork${NC}" + (cd "$EVM_DIR" && forge script "script/$script.s.sol" --sig "run()" --rpc-url "$rpc" -g 150 \ + --broadcast --sender "$ADMIN") || die "$script failed on the $chain fork" + done + else + local list + list=$(IFS=,; echo "$*") + (cd "$EVM_DIR" && bash script/deploy.sh --mode full -c "$CONFIG" "$script" "$list") || + warn "deploy.sh reported a failure (verification included); checking on-chain state" + fi +} + +# ── Environment ───────────────────────────────────────────────────── + +IFS=',' read -ra CHAIN_ARRAY <<< "$CHAINS" +for chain in "${CHAIN_ARRAY[@]}"; do chain_id "$chain" > /dev/null; done + +[ -f "$ENV_FILE" ] || die "Env file not found: $ENV_FILE" + +PRIVATE_KEY="${PRIVATE_KEY:-$(env_get PRIVATE_KEY)}" +[ -n "$PRIVATE_KEY" ] || die "PRIVATE_KEY is unset and missing from $ENV_FILE" +[[ "$PRIVATE_KEY" == 0x* ]] || PRIVATE_KEY="0x$PRIVATE_KEY" +export PRIVATE_KEY +export ADMIN="${ADMIN:-$(cast wallet address --private-key "$PRIVATE_KEY")}" +export TREASURY="${TREASURY:-$ADMIN}" +export VERSION="${VERSION:-entrypoint-v09}" +export GOVERNANCE_RELAYER="${GOVERNANCE_RELAYER:-0xc8809DD0b00370be097382d741A43347Ad582757}" +# BaseScript reads both at construction; no step here uses them. +export CONSENSUS_STATE="${CONSENSUS_STATE:-0x}" +export SP1_VERIFICATION_KEY="${SP1_VERIFICATION_KEY:-0x0000000000000000000000000000000000000000000000000000000000000000}" +ETHEREUM_ETHERSCAN_API_KEY="${ETHEREUM_ETHERSCAN_API_KEY:-$(env_get ETHERSCAN_API)}" +export ETHEREUM_ETHERSCAN_API_KEY + +for chain in "${CHAIN_ARRAY[@]}"; do + var=$(rpc_var "$chain") + [ -n "${!var}" ] || export "$var=$(env_get "$(rpc_fallback "$chain")")" + [ -n "${!var}" ] || die "$var is unset and $(rpc_fallback "$chain") is missing from $ENV_FILE" +done + +# The forge config loader resolves every ${VAR} in the TOML; chains outside this rollout get a stub. +for var in $(grep -o '\${[A-Za-z0-9_]*}' "$EVM_DIR/$CONFIG" | tr -d '${}' | sort -u); do + [ -n "${!var}" ] || export "$var=http://127.0.0.1:1" +done + +echo -e "${BLUE}EntryPoint v0.9 rollout${NC}" +echo -e " Chains: ${YELLOW}${CHAINS}${NC}" +echo -e " Steps: ${YELLOW}${STEPS[*]}${NC}" +echo -e " Deployer: ${YELLOW}${ADMIN}${NC}" +echo -e " Treasury: ${YELLOW}${TREASURY}${NC}" +echo -e " Relayer: ${YELLOW}${GOVERNANCE_RELAYER}${NC}" +echo -e " VERSION: ${YELLOW}${VERSION}${NC}" +echo -e " Dry run: ${YELLOW}${DRY_RUN}${NC}" + +if $DRY_RUN; then + TMP_DIR=$(mktemp -d) + ANVIL_PIDS=() + # forge only writes inside the project, so the copy lives next to the original. + DRY_CONFIG="config.testnet.dryrun.$$.toml" + cp "$EVM_DIR/$CONFIG" "$EVM_DIR/$DRY_CONFIG" + CONFIG="$DRY_CONFIG" + export FOUNDRY_BROADCAST="$TMP_DIR/broadcast" + cleanup() { + for pid in "${ANVIL_PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done + rm -f "$EVM_DIR/$DRY_CONFIG" + rm -rf "$TMP_DIR" + } + trap cleanup EXIT + + step "Compiling before the forks start" + (cd "$EVM_DIR" && forge build) || die "forge build failed" + + port=${ANVIL_PORT_BASE:-18545} + for chain in "${CHAIN_ARRAY[@]}"; do + var=$(rpc_var "$chain") + anvil --fork-url "${!var}" --port "$port" --silent > /dev/null 2>&1 & + ANVIL_PIDS+=($!) + export "$var=http://127.0.0.1:$port" + for _ in $(seq 1 60); do + cast chain-id --rpc-url "http://127.0.0.1:$port" > /dev/null 2>&1 && break + sleep 1 + done + [ "$(cast chain-id --rpc-url "http://127.0.0.1:$port" 2>/dev/null)" = "$(chain_id "$chain")" ] || + die "The $chain fork did not come up on port $port" + info "$chain forked on 127.0.0.1:$port" + port=$((port + 1)) + done +fi +export CONFIG + +# ── Steps ─────────────────────────────────────────────────────────── + +step_solver_account() { + step "1. SolverAccount" + local chain id account pending=() + for chain in "${CHAIN_ARRAY[@]}"; do + id=$(chain_id "$chain") + account=$(config_get "$id" SOLVER_ACCOUNT) + if has_code "$chain" "$account" && same "$(rpc_call "$chain" "$account" "entryPoint()(address)")" "$ENTRYPOINT_V09"; then + ok "$chain: already on v0.9 at $account" + else + pending+=("$chain") + fi + done + run_script DeploySolverAccount "${pending[@]}" + + local first="" + for chain in "${CHAIN_ARRAY[@]}"; do + account=$(config_get "$(chain_id "$chain")" SOLVER_ACCOUNT) + same "$(rpc_call "$chain" "$account" "entryPoint()(address)")" "$ENTRYPOINT_V09" || + die "$chain: SolverAccount $account does not report EntryPoint v0.9" + ok "$chain: SolverAccount $account, entryPoint() v0.9" + [ -z "$first" ] && first=$account + same "$first" "$account" || die "SolverAccount differs across chains: $first vs $account" + done + [ ${#CHAIN_ARRAY[@]} -gt 1 ] && ok "Same SolverAccount on every chain" + return 0 +} + +step_gateway_impl() { + step "2. IntentGatewayV2 implementation" + local chain id gateway version impl pending=() + for chain in "${CHAIN_ARRAY[@]}"; do + id=$(chain_id "$chain") + gateway=$(config_get "$id" INTENT_GATEWAY_V2) + version=$(rpc_call "$chain" "$gateway" "version()(uint64)") + [ "$version" = 3 ] || die "$chain: gateway $gateway is at version '$version'; the upgrade calldata assumes 3" + impl=$(config_get "$id" INTENT_GATEWAY_V2_IMPL) + if has_code "$chain" "$impl" && same "$(rpc_call "$chain" "$impl" "SELECT_SOLVER_TYPEHASH()(bytes32)")" "$SELECT_SOLVER_TYPEHASH"; then + ok "$chain: implementation already deployed at $impl" + else + pending+=("$chain") + fi + done + run_script DeployIntentGatewayImpl "${pending[@]}" + + for chain in "${CHAIN_ARRAY[@]}"; do + impl=$(config_get "$(chain_id "$chain")" INTENT_GATEWAY_V2_IMPL) + same "$(rpc_call "$chain" "$impl" "SELECT_SOLVER_TYPEHASH()(bytes32)")" "$SELECT_SOLVER_TYPEHASH" || + die "$chain: implementation $impl has the wrong SELECT_SOLVER_TYPEHASH" + ok "$chain: implementation $impl" + info "execute_on_gateway data: $(cast calldata "upgradeToAndCall(address,bytes)" "$impl" 0x)" + done +} + +paymaster_ready() { + local paymaster + paymaster=$(config_get "$(chain_id "$1")" SIMPLEX_PAYMASTER) + has_code "$1" "$paymaster" && + [ "$(rpc_call "$1" "$paymaster" "version()(uint64)")" = 3 ] && + same "$(rpc_call "$1" "$paymaster" "entryPoint()(address)")" "$ENTRYPOINT_V09" +} + +step_paymaster() { + step "3. Fixed-price feeds and SimplexPaymaster" + local chain id feeds=() paymasters=() + for chain in "${CHAIN_ARRAY[@]}"; do + id=$(chain_id "$chain") + if paymaster_ready "$chain"; then + ok "$chain: paymaster already deployed at $(config_get "$id" SIMPLEX_PAYMASTER)" + continue + fi + paymasters+=("$chain") + if has_code "$chain" "$(config_get "$id" NATIVE_ORACLE)" && has_code "$chain" "$(config_get "$id" USDC_ORACLE)"; then + ok "$chain: feeds already deployed" + else + feeds+=("$chain") + fi + done + run_script testnet/DeployFixedPriceFeed "${feeds[@]}" + run_script DeploySimplexPaymaster "${paymasters[@]}" + + local paymaster host + for chain in "${CHAIN_ARRAY[@]}"; do + id=$(chain_id "$chain") + paymaster=$(config_get "$id" SIMPLEX_PAYMASTER) + paymaster_ready "$chain" || die "$chain: no v0.9 SimplexPaymaster at '$paymaster'" + host=$(config_get "$id" HOST) + same "$(rpc_call "$chain" "$paymaster" "host()(address)")" "$host" || die "$chain: paymaster host is not $host" + same "$(rpc_call "$chain" "$paymaster" "relayer()(address)")" "$GOVERNANCE_RELAYER" || + die "$chain: paymaster relayer is not $GOVERNANCE_RELAYER" + same "$(rpc_call "$chain" "$paymaster" "treasury()(address)")" "$TREASURY" || + die "$chain: paymaster treasury is not $TREASURY" + ok "$chain: SimplexPaymaster $paymaster (implementation $(implementation_of "$chain" "$paymaster"))" + done +} + +# deposit, stake and unstake delay, one per line +deposit_info() { + ETH_RPC_URL="$(rpc_url "$1")" cast call "$ENTRYPOINT_V09" \ + "getDepositInfo(address)(uint256,bool,uint112,uint32,uint48)" "$2" 2>/dev/null | + awk 'NR == 1 || NR == 3 || NR == 4 {print $1}' || true +} + +step_fund() { + step "6. Paymaster deposit and stake" + local chain paymaster deposit stake funds have_deposit have_stake have_delay + for chain in "${CHAIN_ARRAY[@]}"; do + paymaster=$(config_get "$(chain_id "$chain")" SIMPLEX_PAYMASTER) + paymaster_ready "$chain" || die "$chain: deploy the paymaster first" + deposit=$(cast to-wei "${DEPOSIT:-$(default_deposit "$chain")}") + stake=$(cast to-wei "${STAKE:-$(default_stake "$chain")}") + + funds=$(deposit_info "$chain" "$paymaster") + have_deposit=$(sed -n 1p <<< "$funds"); have_stake=$(sed -n 2p <<< "$funds"); have_delay=$(sed -n 3p <<< "$funds") + if at_least "$have_deposit" "$deposit" && at_least "$have_stake" "$stake" && at_least "$have_delay" "$UNSTAKE_DELAY"; then + ok "$chain: already funded" + else + export PAYMASTER_DEPOSIT=$deposit PAYMASTER_STAKE=$stake + run_script testnet/FundSimplexPaymaster "$chain" + fi + + funds=$(deposit_info "$chain" "$paymaster") + have_deposit=$(sed -n 1p <<< "$funds"); have_stake=$(sed -n 2p <<< "$funds"); have_delay=$(sed -n 3p <<< "$funds") + at_least "$have_deposit" "$deposit" || die "$chain: deposit $have_deposit is below $deposit" + at_least "$have_stake" "$stake" || die "$chain: stake $have_stake is below $stake" + at_least "$have_delay" "$UNSTAKE_DELAY" || die "$chain: unstake delay $have_delay is below $UNSTAKE_DELAY" + ok "$chain: deposit $(cast from-wei "$have_deposit"), stake $(cast from-wei "$have_stake"), unstake delay ${have_delay}s" + done +} + +step_status() { + step "Status" + local chain id account gateway impl paymaster funds + for chain in "${CHAIN_ARRAY[@]}"; do + id=$(chain_id "$chain") + echo -e " ${YELLOW}$chain ($id)${NC}" + account=$(config_get "$id" SOLVER_ACCOUNT) + info "SolverAccount $account entryPoint() $(rpc_call "$chain" "$account" "entryPoint()(address)")" + gateway=$(config_get "$id" INTENT_GATEWAY_V2) + impl=$(implementation_of "$chain" "$gateway") + info "Gateway $gateway version() $(rpc_call "$chain" "$gateway" "version()(uint64)")" + info " implementation $impl (config: $(config_get "$id" INTENT_GATEWAY_V2_IMPL))" + info " typehash ok $(same "$(rpc_call "$chain" "$gateway" "SELECT_SOLVER_TYPEHASH()(bytes32)")" "$SELECT_SOLVER_TYPEHASH" && echo yes || echo no)" + paymaster=$(config_get "$id" SIMPLEX_PAYMASTER) + if has_code "$chain" "$paymaster"; then + info "SimplexPaymaster $paymaster version() $(rpc_call "$chain" "$paymaster" "version()(uint64)")" + info " implementation $(implementation_of "$chain" "$paymaster")" + info " relayer $(rpc_call "$chain" "$paymaster" "relayer()(address)")" + info " treasury $(rpc_call "$chain" "$paymaster" "treasury()(address)")" + info " bundlers $(ETH_RPC_URL="$(rpc_url "$chain")" cast call "$paymaster" "getBundlers()(address[])" 2>/dev/null)" + funds=$(deposit_info "$chain" "$paymaster") + info " deposit / stake $(sed -n 1p <<< "$funds") / $(sed -n 2p <<< "$funds") wei, delay $(sed -n 3p <<< "$funds")s" + else + info "SimplexPaymaster not deployed" + fi + done +} + +for name in "${STEPS[@]}"; do + case $name in + solver-account) step_solver_account ;; + gateway-impl) step_gateway_impl ;; + paymaster) step_paymaster ;; + fund) step_fund ;; + status) step_status ;; + *) die "Unknown step '$name'" ;; + esac +done + +echo "" +echo -e "${GREEN}✓ Done${NC}" diff --git a/evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol b/evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol new file mode 100644 index 0000000000..9bee207c4d --- /dev/null +++ b/evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import {IncomingPostRequest} from "@hyperbridge/core/interfaces/IApp.sol"; +import {PostRequest} from "@hyperbridge/core/libraries/Message.sol"; + +import {SimplexPaymaster, AggregatorV3Interface} from "../../src/utils/SimplexPaymaster.sol"; +import {FixedPriceAggregator} from "../../script/testnet/FixedPriceAggregator.sol"; + +contract KusamaHost { + function hyperbridge() external pure returns (bytes memory) { + return bytes("KUSAMA-4009"); + } +} + +/// @notice Pins the testnet `upgrade_paymaster` init_data that sets the bundler allowlist on a +/// runtime without `set_paymaster_bundlers`. The outer `UpgradeContract` points the proxy +/// at its current implementation and delegatecalls this init_data, a nested `onAccept` +/// carrying `SetBundlers`, with the host still `msg.sender`. +contract SimplexPaymasterBundlerRolloutTest is Test { + address constant RELAYER = 0xc8809DD0b00370be097382d741A43347Ad582757; + address constant RUNDLER_SIGNER = 0x2E0E23636E27826193EA4A585a05CD1808d3c96c; + address constant RUNDLER_SIMULATION_ORIGIN = 0x0643866dA50efE0b055Cd15aF95191968c8411b5; + + // `node e2e/entrypoint-v09-sudo.mjs init-data` in sdk/packages/simplex prints both. + bytes constant ALLOW_INIT_DATA = + hex"0fee32ce00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c8809dd0b00370be097382d741a43347ad58275700000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000b4b5553414d412d343030390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e70616c6c65742d696e74656e7473000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1080000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000002e0e23636e27826193ea4a585a05cd1808d3c96c0000000000000000000000000643866da50efe0b055cd15af95191968c8411b500000000000000000000000000000000000000000000000000000000000000"; + bytes constant CLEAR_INIT_DATA = + hex"0fee32ce00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c8809dd0b00370be097382d741a43347ad58275700000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000b4b5553414d412d343030390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e70616c6c65742d696e74656e7473000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1080000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000002e0e23636e27826193ea4a585a05cd1808d3c96c0000000000000000000000000643866da50efe0b055cd15af95191968c8411b500000000000000000000000000000000000000000000000000000000000000"; + + KusamaHost host; + SimplexPaymaster paymaster; + address implementation; + + function setUp() public { + host = new KusamaHost(); + implementation = address(new SimplexPaymaster()); + SimplexPaymaster.Params memory params = SimplexPaymaster.Params({ + nativeOracle: AggregatorV3Interface(address(new FixedPriceAggregator(600e8, 8))), + markupBps: 200, + treasury: address(this), + maxOracleAge: 90_000, + swapSlippageBps: 200 + }); + bytes memory init = abi.encodeCall( + SimplexPaymaster.initialize, + (address(host), params, new address[](0), new AggregatorV3Interface[](0), RELAYER) + ); + paymaster = SimplexPaymaster(payable(address(new ERC1967Proxy(implementation, init)))); + } + + function testInitDataIsTheNestedSetBundlersRequest() public pure { + assertEq(ALLOW_INIT_DATA, _initData(_defaultBundlers(), true, RELAYER)); + assertEq(CLEAR_INIT_DATA, _initData(_defaultBundlers(), false, RELAYER)); + } + + function testUpgradeToCurrentImplementationSetsThenClearsBundlers() public { + _upgradePaymaster(ALLOW_INIT_DATA); + assertEq(paymaster.getBundlers(), _defaultBundlers()); + assertEq(_implementation(), implementation); + assertEq(paymaster.relayer(), RELAYER); + + _upgradePaymaster(CLEAR_INIT_DATA); + assertEq(paymaster.getBundlers().length, 0); + assertEq(_implementation(), implementation); + } + + function testNestedRequestFromAnotherRelayerReverts() public { + vm.expectRevert(SimplexPaymaster.UnauthorizedRelayer.selector); + _upgradePaymaster(_initData(_defaultBundlers(), true, makeAddr("other"))); + } + + /// @dev What the relayer delivers for `upgrade_paymaster(Evm(_), implementation, initData)`. + function _upgradePaymaster(bytes memory initData) internal { + PostRequest memory request = PostRequest({ + source: bytes("KUSAMA-4009"), + dest: bytes("EVM-97"), + nonce: 0, + from: bytes("pallet-intents"), + to: abi.encodePacked(address(paymaster)), + timeoutTimestamp: 0, + body: bytes.concat( + bytes1(uint8(SimplexPaymaster.RequestKind.UpgradeContract)), abi.encode(implementation, initData) + ) + }); + vm.prank(address(host)); + paymaster.onAccept(IncomingPostRequest({request: request, relayer: RELAYER})); + } + + function _initData(address[] memory bundlers, bool allowed, address relayer) internal pure returns (bytes memory) { + PostRequest memory request = PostRequest({ + source: bytes("KUSAMA-4009"), + dest: "", + nonce: 0, + from: bytes("pallet-intents"), + to: "", + timeoutTimestamp: 0, + body: bytes.concat(bytes1(uint8(SimplexPaymaster.RequestKind.SetBundlers)), abi.encode(bundlers, allowed)) + }); + return abi.encodeCall(SimplexPaymaster.onAccept, (IncomingPostRequest({request: request, relayer: relayer}))); + } + + function _defaultBundlers() internal pure returns (address[] memory bundlers) { + bundlers = new address[](2); + bundlers[0] = RUNDLER_SIGNER; + bundlers[1] = RUNDLER_SIMULATION_ORIGIN; + } + + function _implementation() internal view returns (address) { + return address(uint160(uint256(vm.load(address(paymaster), ERC1967Utils.IMPLEMENTATION_SLOT)))); + } +} diff --git a/scripts/docker/rundler/.env.example b/scripts/docker/rundler/.env.example new file mode 100644 index 0000000000..486f1ed2ff --- /dev/null +++ b/scripts/docker/rundler/.env.example @@ -0,0 +1,8 @@ +# Path to the rundler fork checkout. Defaults to ../../../../rundler, a sibling of this repo. +RUNDLER_DIR= +# Plain EOA private key, shared by both bundlers. +RUNDLER_SIGNER_PRIVATE_KEY= +CHAPEL_RPC_URL= +AMOY_RPC_URL= +# Defaults to info. +RUST_LOG= diff --git a/scripts/docker/rundler/README.md b/scripts/docker/rundler/README.md new file mode 100644 index 0000000000..d259e44389 --- /dev/null +++ b/scripts/docker/rundler/README.md @@ -0,0 +1,57 @@ +# Rundler testnet bundlers + +Runs the rundler fork as the ERC-4337 bundler for BSC Chapel and Polygon Amoy. Both serve +EntryPoint v0.9 (`0x433709009B8330FDa32311DF1C2AFA402eD8D009`) only and run in unsafe mode, +because the paymaster reads `tx.origin` during validation. + +| Chain | Chain ID | Network spec | RPC | Metrics | +|-------|----------|--------------|-----|---------| +| BSC Chapel | 97 | `bsc_testnet` (base `bsc`) | `http://127.0.0.1:4337` | `9337` | +| Polygon Amoy | 80002 | `polygon_amoy` (base `polygon`), EIP-7702 turned on | `http://127.0.0.1:4338` | `9338` | + +The signer (`RUNDLER_SIGNER_PRIVATE_KEY`) must be a plain EOA funded on both chains. + +## Start + +The image builds from the fork checkout at `RUNDLER_DIR`, which needs its submodules +(`git submodule update --init --recursive` in the fork). + +```sh +cd scripts/docker/rundler +cp .env.example .env # then fill it in +docker compose up -d --build +docker compose logs -f +``` + +## Native binary + +With the variables from `.env` exported, run these from the fork checkout: + +```sh +NODE_HTTP="$CHAPEL_RPC_URL" SIGNER_PRIVATE_KEYS="$RUNDLER_SIGNER_PRIVATE_KEY" RUST_LOG=info \ + cargo run --bin rundler -- node --network bsc_testnet --enabled_entry_points v0.9 --unsafe \ + --rpc.port 4337 --metrics.port 9337 + +CHAIN_EIP7702_ENABLED=true NODE_HTTP="$AMOY_RPC_URL" SIGNER_PRIVATE_KEYS="$RUNDLER_SIGNER_PRIVATE_KEY" RUST_LOG=info \ + cargo run --bin rundler -- node --network polygon_amoy --enabled_entry_points v0.9 --unsafe \ + --rpc.port 4338 --metrics.port 9338 +``` + +## Check + +Each bundler should return only the v0.9 EntryPoint: + +```sh +for port in 4337 4338; do + curl -s -X POST -H 'content-type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"eth_supportedEntryPoints","params":[]}' \ + "http://127.0.0.1:$port" + echo +done +``` + +## Paymaster bundler list + +When `SimplexPaymaster.getBundlers()` is non-empty on a chain, both the signer address and +rundler's simulation origin `0x0643866dA50efE0b055Cd15aF95191968c8411b5` must be on it. +Otherwise validation reverts with `UnauthorizedBundler`. diff --git a/scripts/docker/rundler/docker-compose.yml b/scripts/docker/rundler/docker-compose.yml new file mode 100644 index 0000000000..978c8e3e67 --- /dev/null +++ b/scripts/docker/rundler/docker-compose.yml @@ -0,0 +1,51 @@ +# ERC-4337 bundlers from the rundler fork for BSC Chapel and Polygon Amoy, EntryPoint v0.9 only. +# Chapel: RPC 4337, metrics 9337. Amoy: RPC 4338, metrics 9338. +x-rundler: &rundler + build: + context: ${RUNDLER_DIR:-../../../../rundler} + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +services: + rundler-chapel: + <<: *rundler + container_name: rundler-chapel + command: + - node + - --network=bsc_testnet + - --enabled_entry_points=v0.9 + # No traced validation: the paymaster reads tx.origin during validation. + - --unsafe + - --rpc.port=4337 + - --metrics.port=9337 + ports: + - "127.0.0.1:4337:4337" + - "127.0.0.1:9337:9337" + environment: + NODE_HTTP: ${CHAPEL_RPC_URL} + SIGNER_PRIVATE_KEYS: ${RUNDLER_SIGNER_PRIVATE_KEY} + RUST_LOG: ${RUST_LOG:-info} + + rundler-amoy: + <<: *rundler + container_name: rundler-amoy + command: + - node + - --network=polygon_amoy + - --enabled_entry_points=v0.9 + - --unsafe + - --rpc.port=4338 + - --metrics.port=9338 + ports: + - "127.0.0.1:4338:4338" + - "127.0.0.1:9338:9338" + environment: + NODE_HTTP: ${AMOY_RPC_URL} + SIGNER_PRIVATE_KEYS: ${RUNDLER_SIGNER_PRIVATE_KEY} + # The polygon base spec has EIP-7702 off; the solver wallets are 7702-delegated. + CHAIN_EIP7702_ENABLED: "true" + RUST_LOG: ${RUST_LOG:-info} diff --git a/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs b/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs new file mode 100644 index 0000000000..019fa5bb6c --- /dev/null +++ b/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs @@ -0,0 +1,569 @@ +// EntryPoint v0.9 testnet rollout, governance side: the Gargantua sudo calls that register the +// SimplexPaymaster, upgrade the IntentGatewayV2 proxies and set or clear the paymaster's bundler +// allowlist. Every step that reaches an EVM chain polls it until the relayer has delivered. +// +// Usage: node e2e/entrypoint-v09-sudo.mjs [--dry-run] [options] (run with --help) +// The EVM steps and the full order: evm/script/testnet/entrypoint-v09.sh and the README beside it. +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { parseArgs } from "node:util" +import { ApiPromise, Keyring, WsProvider } from "@polkadot/api" +import { u8aToHex } from "@polkadot/util" +import { cryptoWaitReady, keccakAsU8a } from "@polkadot/util-crypto" +import { + concat, + createPublicClient, + encodeAbiParameters, + encodeFunctionData, + getAddress, + hexToString, + http, + isAddress, + toHex, +} from "viem" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const REPO = path.resolve(HERE, "../../../..") + +const HYPERBRIDGE = "KUSAMA-4009" +const RELAYER = "0xc8809DD0b00370be097382d741A43347Ad582757" +// The rundler signer and the origin rundler simulates validation from. +const BUNDLERS = ["0x2E0E23636E27826193EA4A585a05CD1808d3c96c", "0x0643866dA50efE0b055Cd15aF95191968c8411b5"] +const ENTRYPOINT_V09 = "0x433709009B8330FDa32311DF1C2AFA402eD8D009" +const SELECT_SOLVER_TYPEHASH = "0xe706bdab7d945360dcd9d81d355f856754dd1cfa461edfc0a7502e2583b4e09e" +const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" +// `SimplexPaymaster.RequestKind.SetBundlers` +const SET_BUNDLERS = 8 +const POLL_MS = 15_000 + +const CHAINS = { + 97: { name: "BSC Chapel", rpc: "BSC_TESTNET_RPC_URL", fallback: "BSC_CHAPEL" }, + 80002: { name: "Polygon Amoy", rpc: "POLYGON_AMOY_RPC_URL", fallback: "POLYGON_AMOY" }, +} + +const CALLS = [ + "addPaymasterDeployment", + "executeOnGateway", + "upgradePaymaster", + "upgradeGateway", + "setPaymasterBundlers", +] + +const postRequest = { + name: "request", + type: "tuple", + components: [ + { name: "source", type: "bytes" }, + { name: "dest", type: "bytes" }, + { name: "nonce", type: "uint64" }, + { name: "from", type: "bytes" }, + { name: "to", type: "bytes" }, + { name: "timeoutTimestamp", type: "uint64" }, + { name: "body", type: "bytes" }, + ], +} + +const view = (name, output) => ({ + type: "function", + name, + stateMutability: "view", + inputs: [], + outputs: [{ type: output }], +}) + +const paymasterAbi = [ + { + type: "function", + name: "onAccept", + stateMutability: "nonpayable", + inputs: [{ name: "incoming", type: "tuple", components: [postRequest, { name: "relayer", type: "address" }] }], + outputs: [], + }, + view("getBundlers", "address[]"), + view("version", "uint64"), + view("relayer", "address"), + view("entryPoint", "address"), + view("host", "address"), +] + +const gatewayAbi = [ + { + type: "function", + name: "upgradeToAndCall", + stateMutability: "nonpayable", + inputs: [ + { name: "newImplementation", type: "address" }, + { name: "data", type: "bytes" }, + ], + outputs: [], + }, + view("version", "uint64"), + view("SELECT_SOLVER_TYPEHASH", "bytes32"), +] + +const hostAbi = [view("hyperbridge", "bytes")] + +const USAGE = `Usage: node e2e/entrypoint-v09-sudo.mjs [options] + +Commands: + metadata Arg shapes of the intentsCoprocessor calls on the live runtime, and what it stores + init-data The nested onAccept init_data for the bundler allowlist (no network) + register-paymaster 4. addPaymasterDeployment(Evm(chain), SIMPLEX_PAYMASTER) + upgrade-gateway 5. executeOnGateway(Evm(chain), upgradeToAndCall(INTENT_GATEWAY_V2_IMPL, "")), + then polls the gateway's implementation slot and SELECT_SOLVER_TYPEHASH() + allow-bundlers 8. upgradePaymaster(Evm(chain), current implementation, onAccept(SetBundlers(list, true))) + clear-bundlers 9. the same with allowed = false, then expects an empty getBundlers() + +Options: + --dry-run Print each decoded call and its hex; sign and send nothing + --chains 97,80002 Chain ids (default: both) + --paymaster 0x.. Paymaster proxy (default: SIMPLEX_PAYMASTER in the config) + --paymaster-impl 0x.. Implementation to re-point the proxy at (default: read from the proxy) + --impl 0x.. Gateway implementation (default: INTENT_GATEWAY_V2_IMPL in the config) + --bundlers 0x..,0x.. Allowlist (default: ${BUNDLERS.join(",")}) + --relayer 0x.. Relayer named in the nested request (default: ${RELAYER}) + --timeout-min N How long to poll an EVM chain for the relayer (default: 30) + --env-file PATH Default: sdk/.env.local, read line by line, never sourced + --config PATH Default: evm/config.testnet.toml + +Environment: HYPERBRIDGE_GARGANTUA, SECRET_PHRASE (the sudo key; live runs only), +BSC_TESTNET_RPC_URL or BSC_CHAPEL, POLYGON_AMOY_RPC_URL or POLYGON_AMOY. The process environment wins +over the env file.` + +const { values: options, positionals } = parseArgs({ + allowPositionals: true, + options: { + "dry-run": { type: "boolean", default: false }, + chains: { type: "string", default: "97,80002" }, + paymaster: { type: "string" }, + "paymaster-impl": { type: "string" }, + impl: { type: "string" }, + bundlers: { type: "string" }, + relayer: { type: "string", default: RELAYER }, + "timeout-min": { type: "string", default: "30" }, + "env-file": { type: "string", default: path.join(REPO, "sdk/.env.local") }, + config: { type: "string", default: path.join(REPO, "evm/config.testnet.toml") }, + help: { type: "boolean", short: "h", default: false }, + }, +}) + +// Keys and endpoints never reach the output, whatever an error message quotes. Longest first, so +// a value is never half-redacted by one it contains. +const SECRETS = [] +const secret = (value) => { + if (value && value.length > 8 && !SECRETS.includes(value)) { + SECRETS.push(value) + SECRETS.sort((a, b) => b.length - a.length) + } + return value +} +const redact = (text) => SECRETS.reduce((out, value) => out.split(value).join("***"), String(text)) +for (const method of ["log", "info", "warn", "error"]) { + const original = console[method].bind(console) + console[method] = (...args) => + original( + ...args.map((arg) => + typeof arg === "string" || arg instanceof Error + ? redact(arg instanceof Error ? (arg.stack ?? arg.message) : arg) + : arg, + ), + ) +} +const log = (line) => console.log(`${new Date().toISOString()} ${line}`) +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const same = (a, b) => Boolean(a) && Boolean(b) && a.toLowerCase() === b.toLowerCase() +const dryRun = options["dry-run"] + +/** `KEY=value` lines by exact key; the file holds lines that are not valid shell. */ +function readEnvFile(file) { + const entries = {} + if (!fs.existsSync(file)) return entries + for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line) + if (!match || match[1] in entries) continue + entries[match[1]] = match[2].trim().replace(/^(["'])(.*)\1$/, "$2") + } + return entries +} + +const fileEnv = readEnvFile(options["env-file"]) +const setting = (name, fallback = name) => process.env[name] || fileEnv[fallback] + +/** `[.address]` tables of the forge config; a zero address counts as unset. */ +function readConfig(file) { + const tables = {} + let table + for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + const header = /^\[(.+)\]\s*$/.exec(line) + if (header) { + table = tables[header[1]] = {} + continue + } + const entry = /^\s*([A-Za-z0-9_]+)\s*=\s*"?([^"]*)"?\s*$/.exec(line) + if (entry && table) table[entry[1]] = entry[2] + } + return (id, key) => { + const value = tables[`${id}.address`]?.[key] + return value && !/^0x0{40}$/i.test(value) ? getAddress(value) : undefined + } +} + +function address(value, label) { + if (!value || !isAddress(value)) throw new Error(`${label} is not an address: ${value}`) + return getAddress(value) +} + +function evm(id) { + const chain = CHAINS[id] + const url = secret(setting(chain.rpc, chain.fallback)) + if (!url) throw new Error(`${chain.rpc} is unset and ${chain.fallback} is missing from the env file`) + return createPublicClient({ transport: http(url) }) +} + +const read = (client, address, abi, functionName) => client.readContract({ address, abi, functionName }) + +async function implementationOf(client, proxy) { + const word = await client.getStorageAt({ address: proxy, slot: IMPLEMENTATION_SLOT }) + return word && BigInt(word) !== 0n ? getAddress(`0x${word.slice(-40)}`) : undefined +} + +/** `abi.encodeCall(SimplexPaymaster.onAccept, (IncomingPostRequest))` carrying a `SetBundlers` body. */ +function bundlerInitData(bundlers, allowed, relayer) { + const body = concat([ + toHex(SET_BUNDLERS, { size: 1 }), + encodeAbiParameters([{ type: "address[]" }, { type: "bool" }], [bundlers, allowed]), + ]) + const request = { + source: toHex(HYPERBRIDGE), + dest: "0x", + nonce: 0n, + from: toHex("pallet-intents"), + to: "0x", + timeoutTimestamp: 0n, + body, + } + return encodeFunctionData({ abi: paymasterAbi, functionName: "onAccept", args: [{ request, relayer }] }) +} + +async function connect() { + const url = secret(setting("HYPERBRIDGE_GARGANTUA")) + if (!url) throw new Error("HYPERBRIDGE_GARGANTUA is unset and missing from the env file") + await cryptoWaitReady() + const api = await ApiPromise.create({ + provider: new WsProvider(url), + noInitWarn: true, + // Hyperbridge hashes with keccak; the default blake2 registry signs and reads wrongly. + typesBundle: { spec: { nexus: { hasher: keccakAsU8a }, gargantua: { hasher: keccakAsU8a } } }, + }) + const version = api.runtimeVersion + log(`Gargantua ${version.specName} spec ${version.specVersion}`) + return api +} + +/** The sudo key, required to match SECRET_PHRASE before anything is signed. */ +async function sudoSigner(api) { + const phrase = secret(setting("SECRET_PHRASE")) + if (!phrase) throw new Error("SECRET_PHRASE is unset and missing from the env file") + const signer = new Keyring({ type: "sr25519" }).addFromUri(phrase) + const key = await api.query.sudo.key() + if (key.isNone || key.unwrap().toHex() !== u8aToHex(signer.publicKey)) { + throw new Error(`SECRET_PHRASE (${signer.address}) is not the sudo key ${key.toString()}`) + } + return signer +} + +function describe(api, error) { + if (error.isModule) { + const { section, name, docs } = api.registry.findMetaError(error.asModule) + return `${section}.${name}: ${docs.join(" ")}` + } + return error.toString() +} + +/** Prints the call in a dry run; otherwise sends it through `sudo.sudo` and waits for inclusion. */ +async function dispatch(api, signer, label, call) { + const wrapped = api.tx.sudo.sudo(call) + log(`${label}: ${call.method.section}.${call.method.method}`) + console.log(` decoded: ${JSON.stringify(call.method.toHuman())}`) + console.log(` call: ${call.method.toHex()}`) + console.log(` sudo: ${wrapped.method.toHex()}`) + if (dryRun) return false + await new Promise((resolve, reject) => { + let unsubscribe + const stop = () => unsubscribe?.() + wrapped + .signAndSend(signer, { nonce: -1 }, ({ status, events, dispatchError }) => { + if (dispatchError) { + stop() + reject(new Error(`${label}: ${describe(api, dispatchError)}`)) + return + } + if (!status.isInBlock) return + stop() + const sudid = events.find(({ event }) => api.events.sudo.Sudid.is(event)) + if (!sudid) return reject(new Error(`${label}: no sudo.Sudid event in ${status.asInBlock.toHex()}`)) + const result = sudid.event.data[0] + if (result.isErr) return reject(new Error(`${label}: sudo call failed: ${describe(api, result.asErr)}`)) + const emitted = events.map(({ event }) => `${event.section}.${event.method}`).join(", ") + log(`${label}: included in ${status.asInBlock.toHex()} (${emitted})`) + resolve() + }) + .then((unsub) => { + unsubscribe = unsub + }) + .catch(reject) + }) + return true +} + +/** Re-reads `check` until it reports done, printing progress, or fails after `--timeout-min`. */ +async function poll(label, check) { + const limit = Number(options["timeout-min"]) * 60_000 + const start = Date.now() + for (;;) { + const { done, progress } = await check().catch((error) => ({ + done: false, + progress: `read failed: ${error.shortMessage ?? error.message}`, + })) + const elapsed = `${Math.round((Date.now() - start) / 1000)}s` + if (done) return log(`${label}: landed after ${elapsed} (${progress})`) + if (Date.now() - start > limit) + throw new Error(`${label}: nothing after ${options["timeout-min"]} min (${progress})`) + log(`${label}: waiting for the relayer, ${elapsed}: ${progress}`) + await pause(POLL_MS) + } +} + +/** A failed precondition stops a live run; a dry run reports it and still prints the call. */ +function precondition(condition, message) { + if (condition) return + if (!dryRun) throw new Error(message) + log(`warning: ${message}`) +} + +function selectedChains() { + return options.chains.split(",").map((id) => { + const chain = Number(id.trim()) + if (!CHAINS[chain]) throw new Error(`Unsupported chain ${id}: use ${Object.keys(CHAINS).join(" or ")}`) + return chain + }) +} + +function bundlerList() { + const list = options.bundlers ? options.bundlers.split(",") : BUNDLERS + return list.map((bundler) => address(bundler.trim(), "bundler")) +} + +async function paymasterOf(api, configured, id) { + const registered = (await api.query.intentsCoprocessor.paymasters({ Evm: id })).unwrapOr(undefined)?.toHex() + const paymaster = options.paymaster ?? configured(id, "SIMPLEX_PAYMASTER") ?? registered + if (!paymaster) throw new Error(`EVM-${id}: no paymaster in --paymaster, the config or the pallet`) + return { paymaster: address(paymaster, "paymaster"), registered: registered && getAddress(registered) } +} + +/** The checks the paymaster runs on a governance delivery, read from the chain. */ +async function checkPaymaster(client, id, paymaster) { + const [version, entryPoint, relayer, host] = await Promise.all( + ["version", "entryPoint", "relayer", "host"].map((name) => read(client, paymaster, paymasterAbi, name)), + ).catch(() => []) + precondition(version === 3n, `EVM-${id}: paymaster ${paymaster} reports version ${version}, expected 3`) + precondition(same(entryPoint, ENTRYPOINT_V09), `EVM-${id}: paymaster entryPoint() is ${entryPoint}, expected v0.9`) + precondition( + same(relayer, options.relayer), + `EVM-${id}: paymaster relayer() is ${relayer}, the nested request names ${options.relayer}`, + ) + if (host) { + const source = hexToString(await read(client, host, hostAbi, "hyperbridge")) + precondition( + source === HYPERBRIDGE, + `EVM-${id}: host hyperbridge() is ${source}, the nested request names ${HYPERBRIDGE}`, + ) + } +} + +// ── Commands ───────────────────────────────────────────────────────── + +async function metadata(api) { + const pallet = api.tx.intentsCoprocessor + for (const name of CALLS) { + const call = pallet[name] + if (!call) { + console.log(`intentsCoprocessor.${name}: not in this runtime`) + continue + } + const args = call.meta.args.map( + (arg) => `${arg.name}: ${arg.typeName.toString() || arg.type.toString()} (${arg.type.toString()})`, + ) + console.log(`intentsCoprocessor.${name} [call index ${u8aToHex(call.callIndex)}]`) + for (const arg of args) console.log(` ${arg}`) + } + console.log( + `StateMachine Evm(97) encodes as ${api.createType(pallet.addPaymasterDeployment.meta.args[0].type.toString(), { Evm: 97 }).toHex()}`, + ) + console.log(`sudo.key: ${(await api.query.sudo.key()).toString()}`) + for (const id of selectedChains()) { + const gateway = (await api.query.intentsCoprocessor.gateways({ Evm: id })).unwrapOr(undefined) + const paymaster = (await api.query.intentsCoprocessor.paymasters({ Evm: id })).unwrapOr(undefined) + console.log( + `EVM-${id}: gateway ${gateway ? gateway.gateway.toHex() : "unset"}, paymaster ${paymaster ? paymaster.toHex() : "unset"}`, + ) + } +} + +function initData() { + const bundlers = bundlerList() + console.log(`bundlers: ${bundlers.join(", ")}`) + console.log(`relayer: ${getAddress(options.relayer)}`) + console.log(`allow: ${bundlerInitData(bundlers, true, getAddress(options.relayer))}`) + console.log(`clear: ${bundlerInitData(bundlers, false, getAddress(options.relayer))}`) +} + +async function registerPaymaster(api, signer, configured) { + for (const id of selectedChains()) { + const label = `EVM-${id} register-paymaster` + const { paymaster, registered } = await paymasterOf(api, configured, id) + await checkPaymaster(evm(id), id, paymaster) + if (same(registered, paymaster)) { + log(`${label}: ${paymaster} already registered`) + continue + } + if (registered) log(`${label}: replaces ${registered}`) + const sent = await dispatch( + api, + signer, + label, + api.tx.intentsCoprocessor.addPaymasterDeployment({ Evm: id }, paymaster), + ) + if (!sent) continue + const stored = (await api.query.intentsCoprocessor.paymasters({ Evm: id })).unwrapOr(undefined)?.toHex() + if (!same(stored, paymaster)) throw new Error(`${label}: the pallet stores ${stored}, not ${paymaster}`) + log(`${label}: Paymasters(Evm(${id})) = ${paymaster}`) + } +} + +async function upgradeGateway(api, signer, configured) { + const pending = [] + for (const id of selectedChains()) { + const label = `EVM-${id} upgrade-gateway` + const client = evm(id) + const impl = address(options.impl ?? configured(id, "INTENT_GATEWAY_V2_IMPL"), "gateway implementation") + const info = (await api.query.intentsCoprocessor.gateways({ Evm: id })).unwrapOr(undefined) + if (!info) throw new Error(`${label}: no gateway registered for Evm(${id})`) + const gateway = getAddress(info.gateway.toHex()) + + const typehash = await read(client, impl, gatewayAbi, "SELECT_SOLVER_TYPEHASH").catch(() => undefined) + precondition( + typehash === SELECT_SOLVER_TYPEHASH, + `${label}: ${impl} reports SELECT_SOLVER_TYPEHASH ${typehash}`, + ) + const version = await read(client, gateway, gatewayAbi, "version").catch(() => undefined) + precondition( + version === 3n, + `${label}: gateway ${gateway} is at version ${version}; the empty init data assumes 3`, + ) + + const landed = async () => { + const current = await implementationOf(client, gateway) + const live = await read(client, gateway, gatewayAbi, "SELECT_SOLVER_TYPEHASH").catch(() => undefined) + return { + done: same(current, impl) && live === SELECT_SOLVER_TYPEHASH, + progress: `implementation ${current}, typehash ${live}`, + } + } + const before = await landed() + if (before.done) { + log(`${label}: gateway ${gateway} already on ${impl}`) + continue + } + const data = encodeFunctionData({ abi: gatewayAbi, functionName: "upgradeToAndCall", args: [impl, "0x"] }) + console.log(` gateway ${gateway}: ${before.progress}, target ${impl}`) + if (await dispatch(api, signer, label, api.tx.intentsCoprocessor.executeOnGateway({ Evm: id }, data))) { + pending.push(poll(label, landed)) + } + } + await Promise.all(pending) +} + +async function setBundlers(api, signer, configured, allowed) { + const bundlers = bundlerList() + const relayer = getAddress(options.relayer) + const pending = [] + for (const id of selectedChains()) { + const label = `EVM-${id} ${allowed ? "allow" : "clear"}-bundlers` + const client = evm(id) + const { paymaster, registered } = await paymasterOf(api, configured, id) + precondition( + same(registered, paymaster), + `${label}: Paymasters(Evm(${id})) is ${registered}, not ${paymaster}; run register-paymaster first`, + ) + await checkPaymaster(client, id, paymaster) + + const fromProxy = await implementationOf(client, paymaster).catch(() => undefined) + const impl = options["paymaster-impl"] + ? address(options["paymaster-impl"], "paymaster implementation") + : fromProxy + if (!impl) throw new Error(`${label}: cannot read the implementation of ${paymaster}; pass --paymaster-impl`) + precondition(!fromProxy || same(fromProxy, impl), `${label}: ${paymaster} runs ${fromProxy}, not ${impl}`) + + const landed = async () => { + const listed = await read(client, paymaster, paymasterAbi, "getBundlers") + const done = allowed + ? bundlers.every((bundler) => listed.some((entry) => same(entry, bundler))) + : listed.length === 0 + return { done, progress: `getBundlers() [${listed.join(", ")}]` } + } + const before = await landed().catch((error) => ({ + done: false, + progress: `unread: ${error.shortMessage ?? error.message}`, + })) + if (before.done) { + log(`${label}: already ${before.progress}`) + continue + } + const init = bundlerInitData(bundlers, allowed, relayer) + console.log(` paymaster ${paymaster} on ${impl}, ${before.progress}`) + console.log(` init_data ${init}`) + if (await dispatch(api, signer, label, api.tx.intentsCoprocessor.upgradePaymaster({ Evm: id }, impl, init))) { + pending.push(poll(label, landed)) + } + } + await Promise.all(pending) +} + +async function main() { + const [command] = positionals + if (options.help || !command) { + console.log(USAGE) + return + } + options.relayer = address(options.relayer, "--relayer") + if (command === "init-data") return initData() + + const commands = { + metadata: (api) => metadata(api), + "register-paymaster": (api, signer, configured) => registerPaymaster(api, signer, configured), + "upgrade-gateway": (api, signer, configured) => upgradeGateway(api, signer, configured), + "allow-bundlers": (api, signer, configured) => setBundlers(api, signer, configured, true), + "clear-bundlers": (api, signer, configured) => setBundlers(api, signer, configured, false), + } + if (!commands[command]) throw new Error(`Unknown command ${command}\n\n${USAGE}`) + + const configured = readConfig(options.config) + const api = await connect() + try { + const signer = command === "metadata" || dryRun ? undefined : await sudoSigner(api) + if (dryRun) log("dry run: nothing is signed or sent") + await commands[command](api, signer, configured) + } finally { + await api.disconnect().catch(() => {}) + } +} + +main().then( + () => process.exit(0), + (error) => { + console.error(error) + process.exit(1) + }, +) From 1b12387a5d98d3e9529922e8e90a19d54337e68f Mon Sep 17 00:00:00 2001 From: royvardhan Date: Tue, 6 Oct 2026 22:46:31 +0530 Subject: [PATCH 03/12] [sdk, simplex, indexer]: sign, route and index bids for EntryPoint v0.9 --- sdk/packages/core/package.json | 2 +- .../src/configs/abis/IntentGatewayV3.abi.json | 30 +- .../indexer/src/configs/config-local.json | 15 +- .../indexer/src/configs/config-testnet.json | 6 +- .../__tests__/fillEnrichment.handlers.test.ts | 25 + .../utils/__tests__/userOp.helpers.test.ts | 14 +- .../indexer/src/utils/userOp.helpers.ts | 3 +- .../flows/how-a-solver-s-bid-gets-signed.md | 2 +- sdk/packages/sdk/package.json | 2 +- sdk/packages/sdk/src/abis/IntentGatewayV2.ts | 6 +- .../sdk/src/configs/ChainConfigService.ts | 4 +- sdk/packages/sdk/src/configs/chain.ts | 52 +- sdk/packages/sdk/src/protocols/intents/Bid.ts | 191 +++-- .../sdk/src/protocols/intents/BidManager.ts | 20 +- .../sdk/src/protocols/intents/CryptoUtils.ts | 88 +- .../sdk/src/protocols/intents/GasEstimator.ts | 110 ++- .../src/protocols/intents/OrderExecutor.ts | 15 +- .../sdk/src/protocols/intents/index.ts | 10 +- .../sdk/src/protocols/intents/rundlerFees.ts | 78 ++ .../sdk/src/protocols/intents/selection.ts | 104 +++ .../sdk/src/tests/bidEntryPoint.test.ts | 283 +++++++ .../sdk/src/tests/bidSelection.test.ts | 2 +- .../sdk/src/tests/bidSimulate.test.ts | 128 +++ .../tests/fillEstimateBundlerFailure.test.ts | 128 ++- .../sdk/src/tests/fillEstimateEscrow.test.ts | 50 +- .../src/tests/fillEstimateGasPrice.test.ts | 145 ++++ .../sdk/src/tests/rundlerFees.test.ts | 76 ++ .../sdk/src/tests/solverSelection.test.ts | 130 +++ .../sdk/src/tests/userOperationLogs.test.ts | 24 +- sdk/packages/sdk/src/types/index.ts | 18 +- sdk/packages/simplex-desktop/package.json | 2 +- ...master-selection-for-a-sponsored-userop.md | 2 +- sdk/packages/simplex/e2e/budget.mjs | 13 +- .../e2e/entrypoint-v09-local-indexer.cjs | 13 + .../simplex/e2e/entrypoint-v09-local.mjs | 769 ++++++++++++++++++ .../simplex/e2e/entrypoint-v09-sudo.mjs | 41 +- sdk/packages/simplex/e2e/env.mjs | 12 +- sdk/packages/simplex/e2e/refusal.mjs | 11 +- sdk/packages/simplex/package.json | 4 +- .../src/config/abis/IntentGatewayV2.ts | 6 +- sdk/packages/simplex/src/core/boot.ts | 3 + sdk/packages/simplex/src/data/memory.ts | 1 + .../simplex/src/data/sqlite/limit-orders.ts | 12 +- sdk/packages/simplex/src/data/types.ts | 7 + .../simplex/src/orderbook/limit-orders.ts | 177 +++- .../src/services/FillerConfigService.ts | 2 +- .../simplex/src/services/UserOpSender.ts | 37 +- .../simplex/src/services/bundler-preflight.ts | 112 +++ .../simplex/src/services/server/setup-api.ts | 26 + sdk/packages/simplex/src/simplex.ts | 12 +- .../tests/core/boot-bundler-preflight.test.ts | 48 ++ .../src/tests/core/chain-lifecycle.test.ts | 149 +++- .../src/tests/data/limit-order-store.test.ts | 108 +++ .../orderbook/entry-point-cutover.test.ts | 304 +++++++ .../src/tests/orderbook/limit-orders.test.ts | 48 ++ .../src/tests/orderbook/matching.test.ts | 1 + .../tests/orderbook/orderbook.live.test.ts | 10 +- .../src/tests/orderbook/posted-userop.test.ts | 25 +- .../simplex/src/tests/orderbook/usd.test.ts | 1 + .../simplex/src/tests/pimlicoBundler.ts | 8 +- .../DelegationService.ordering.test.ts | 44 + .../src/tests/services/UserOpSender.test.ts | 97 ++- .../tests/services/bundler-preflight.test.ts | 225 +++++ .../simplex/src/tests/setup-api.test.ts | 38 + 64 files changed, 3776 insertions(+), 353 deletions(-) create mode 100644 sdk/packages/sdk/src/protocols/intents/rundlerFees.ts create mode 100644 sdk/packages/sdk/src/protocols/intents/selection.ts create mode 100644 sdk/packages/sdk/src/tests/bidEntryPoint.test.ts create mode 100644 sdk/packages/sdk/src/tests/bidSimulate.test.ts create mode 100644 sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts create mode 100644 sdk/packages/sdk/src/tests/rundlerFees.test.ts create mode 100644 sdk/packages/sdk/src/tests/solverSelection.test.ts create mode 100644 sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs create mode 100644 sdk/packages/simplex/e2e/entrypoint-v09-local.mjs create mode 100644 sdk/packages/simplex/src/services/bundler-preflight.ts create mode 100644 sdk/packages/simplex/src/tests/core/boot-bundler-preflight.test.ts create mode 100644 sdk/packages/simplex/src/tests/orderbook/entry-point-cutover.test.ts create mode 100644 sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts diff --git a/sdk/packages/core/package.json b/sdk/packages/core/package.json index 0a69f7c3db..1ff205bd08 100644 --- a/sdk/packages/core/package.json +++ b/sdk/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbridge/core", - "version": "2.3.5", + "version": "2.4.0", "description": "Hyperbridge Solidity SDK for dispatching & receiving cross-chain messages", "author": "Polytope Labs ", "license": "Apache-2.0", diff --git a/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json b/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json index 92f249e82f..3aa83103b6 100644 --- a/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json +++ b/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json @@ -180,25 +180,6 @@ "outputs": [], "stateMutability": "nonpayable" }, - { - "type": "function", - "name": "calculateCommitmentSlotHash", - "inputs": [ - { - "name": "commitment", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bytes", - "internalType": "bytes" - } - ], - "stateMutability": "pure" - }, { "type": "function", "name": "cancelOrder", @@ -1256,9 +1237,9 @@ "internalType": "bytes32" }, { - "name": "solver", - "type": "address", - "internalType": "address" + "name": "userOpHash", + "type": "bytes32", + "internalType": "bytes32" }, { "name": "signature", @@ -1926,11 +1907,6 @@ ], "anonymous": false }, - { - "type": "error", - "name": "Cancelled", - "inputs": [] - }, { "type": "error", "name": "ECDSAInvalidSignature", diff --git a/sdk/packages/indexer/src/configs/config-local.json b/sdk/packages/indexer/src/configs/config-local.json index 3b34d5a814..2dfb066e99 100644 --- a/sdk/packages/indexer/src/configs/config-local.json +++ b/sdk/packages/indexer/src/configs/config-local.json @@ -15,7 +15,12 @@ "ethereumHost": "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1", "handlerV2": "0x53184b424971d4b5944fdf6f52f18c3fcc4d0925", "erc6160ext20": "0xBe97E73126d66188d72FBf99029126d0340a7f18", - "intentGatewayV3": "0xFbF50B2b32768127603cC9eF4b871574b881b8eD" + "intentGatewayV3": "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA", + "solverAccount": [ + "0x153DB990FE3b761B54ad71D0f1A0987dF11740DB", + "0xf98E484858F59C30e31D2695ac18F92bc7C7b799", + "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2" + ] } }, "cere-disabled": { @@ -44,7 +49,13 @@ "contracts": { "ethereumHost": "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1", "handlerV2": "0x53184b424971d4b5944fdf6f52f18c3fcc4d0925", - "erc6160ext20": "0xBe97E73126d66188d72FBf99029126d0340a7f18" + "erc6160ext20": "0xBe97E73126d66188d72FBf99029126d0340a7f18", + "intentGatewayV3": "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA", + "solverAccount": [ + "0x153DB990FE3b761B54ad71D0f1A0987dF11740DB", + "0xf98E484858F59C30e31D2695ac18F92bc7C7b799", + "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2" + ] } } } diff --git a/sdk/packages/indexer/src/configs/config-testnet.json b/sdk/packages/indexer/src/configs/config-testnet.json index 291dc75151..9485661a2a 100644 --- a/sdk/packages/indexer/src/configs/config-testnet.json +++ b/sdk/packages/indexer/src/configs/config-testnet.json @@ -76,7 +76,8 @@ "intentGatewayV3": "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA", "solverAccount": [ "0x153DB990FE3b761B54ad71D0f1A0987dF11740DB", - "0xf98E484858F59C30e31D2695ac18F92bc7C7b799" + "0xf98E484858F59C30e31D2695ac18F92bc7C7b799", + "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2" ], "yieldVaults": { "0xa801da100bf16d07f668f4a49e1f71fc54d05177": { @@ -107,7 +108,8 @@ "intentGatewayV3": "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA", "solverAccount": [ "0x153DB990FE3b761B54ad71D0f1A0987dF11740DB", - "0xf98E484858F59C30e31D2695ac18F92bc7C7b799" + "0xf98E484858F59C30e31D2695ac18F92bc7C7b799", + "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2" ], "yieldVaults": { "0xbe97e73126d66188d72fbf99029126d0340a7f18": { diff --git a/sdk/packages/indexer/src/services/__tests__/fillEnrichment.handlers.test.ts b/sdk/packages/indexer/src/services/__tests__/fillEnrichment.handlers.test.ts index 1afe1c103c..46267541de 100644 --- a/sdk/packages/indexer/src/services/__tests__/fillEnrichment.handlers.test.ts +++ b/sdk/packages/indexer/src/services/__tests__/fillEnrichment.handlers.test.ts @@ -148,6 +148,31 @@ it.each([ } }) +it.each([ + ["OrderFilled", handleOrderFilledEventV3, "IOrderV3Fill"], + ["PartialFill", handlePartialFilledEventV3, "IOrderV3PartialFill"], +] as const)("persists the userOpHash of a %s executed through EntryPoint v0.9", async (name, handle, entity) => { + const encoded = abi.encodeEventLog(abi.getEvent(name), [ + commitment, + filler, + [{ token: pad(token), amount: 100n }], + [], + ]) + await handle({ + address: gateway, + transactionHash: hash, + blockHash: hash, + blockNumber: 10, + logIndex: 5, + args: abi.decodeEventLog(name, encoded.data, encoded.topics), + transaction: { + input: "0x", + receipt: async () => ({ logs: [{ ...logs[1], address: "0x433709009b8330fda32311df1c2afa402ed8d009" }] }), + }, + } as any) + expect(records.get(`${entity}:${hash}.5`)).toMatchObject({ userOpHash: hash, orderId: commitment }) +}) + it("records plain EOA fills without receipt enrichment when no transaction is supplied", async () => { await IntentGatewayV3Service.recordFill(commitment, filler, [{ token: pad(token) as any, amount: 100n }], [], { transactionHash: hash, diff --git a/sdk/packages/indexer/src/utils/__tests__/userOp.helpers.test.ts b/sdk/packages/indexer/src/utils/__tests__/userOp.helpers.test.ts index 5eb14968c2..f2465934c7 100644 --- a/sdk/packages/indexer/src/utils/__tests__/userOp.helpers.test.ts +++ b/sdk/packages/indexer/src/utils/__tests__/userOp.helpers.test.ts @@ -30,12 +30,14 @@ beforeEach(() => { ;(global as any).logger = { warn: jest.fn() } }) -it.each(["0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "0x0000000071727de22e5e9d8baf0edac6f37da032", entryPoint])( - "matches a placement from canonical EntryPoint %s", - async (address) => { - expect(await resolve([start(0, address), boundary(6, account, hash, address)])).toBe(hash) - }, -) +it.each([ + "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", + "0x0000000071727de22e5e9d8baf0edac6f37da032", + entryPoint, + "0x433709009b8330fda32311df1c2afa402ed8d009", +])("matches a placement from canonical EntryPoint %s", async (address) => { + expect(await resolve([start(0, address), boundary(6, account, hash, address)])).toBe(hash) +}) it("selects each operation for repeated senders in an unordered bundled receipt", async () => { const logs = [boundary(12, account, laterHash), boundary(6), start()] diff --git a/sdk/packages/indexer/src/utils/userOp.helpers.ts b/sdk/packages/indexer/src/utils/userOp.helpers.ts index dffee21fc2..f43f73150d 100644 --- a/sdk/packages/indexer/src/utils/userOp.helpers.ts +++ b/sdk/packages/indexer/src/utils/userOp.helpers.ts @@ -2,7 +2,7 @@ import { Interface } from "@ethersproject/abi" import type { EthereumLog, EthereumTransaction } from "@subql/types-ethereum" import { extractAddressFromTopic } from "./transfer.helpers" -// These events have the same ABI in canonical EntryPoint v0.6/v0.7/v0.8. +// These events have the same ABI in canonical EntryPoint v0.6/v0.7/v0.8/v0.9. const entryPoint = new Interface([ "event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed)", "event BeforeExecution()", @@ -13,6 +13,7 @@ const ENTRY_POINTS = new Set([ "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "0x0000000071727de22e5e9d8baf0edac6f37da032", "0x4337084d9e255ff0702461cf8895ce9e3b5ff108", + "0x433709009b8330fda32311df1c2afa402ed8d009", ]) function findUserOpEvent(logs: EthereumLog[], sender: string, logIndex: number): EthereumLog | undefined { diff --git a/sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md b/sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md index 6ea7ae84d9..542509d1de 100644 --- a/sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md +++ b/sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md @@ -4,7 +4,7 @@ The bid carries one take per leg in `options.inputs`, paired with its output; every `fillOrder` call in the calldata must decode with a quote for each leg. Before signing, the destination gateway must report release 3 (`supportsRateFills`), for ordinary and phantom bids alike. `SolverAccount` carries no `version()`, so the account itself is not version-checked. `previewRateFill` shows what a take actually releases after rounding. -1. The caller assembles the bid and calls `prepareSubmitBid` (`src/protocols/intents/BidManager.ts`), passing the solver account, nonce, entry point, gas limits, pre-built ERC-7821 `callData`, and any `paymasterAndData`. +1. The caller assembles the bid and calls `prepareSubmitBid` (`src/protocols/intents/BidManager.ts`), passing the solver account, nonce, entry point, gas limits, pre-built ERC-7821 `callData`, and any `paymasterAndData`. The entry point must be the one the solver's `SolverAccount` validates against (`addresses.EntryPoint`, read through `ChainConfigService.getEntryPointAddress`): the user's `BidImpl` recovers it from the solver signature and executes the bid there. 2. `BidManager` builds the v0.7-packed `PackedUserOperation` with an empty signature, then checks the nonce key binds the order commitment and session key (`CryptoUtils.bidNonceKey`) — a mismatch is warned about, not thrown, and fails on-chain validation later. 3. It signs `CryptoUtils.packedUserOpTypedData(userOp, entryPointAddress, chainId)` with **`solverSigner.signTypedData`** — the only `SigningAccount` member this package calls, and it passes the payload alone: the chain id the backend might need is already in `domain.chainId`. Signing the typed data rather than the digest produces the same signature `SolverAccount._rawSignatureValidation` recovers, while leaving the payload legible to a custody backend's policy engine. 4. The returned signature is prefixed with the order id (`concat([order.id, solverSignature])`) — that concatenation, not the bare signature, is what goes on the UserOperation. diff --git a/sdk/packages/sdk/package.json b/sdk/packages/sdk/package.json index ba330e368e..5fb270762a 100644 --- a/sdk/packages/sdk/package.json +++ b/sdk/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbridge/sdk", - "version": "2.8.24", + "version": "2.9.0", "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/abis/IntentGatewayV2.ts b/sdk/packages/sdk/src/abis/IntentGatewayV2.ts index 2271251a23..bfc6a848da 100644 --- a/sdk/packages/sdk/src/abis/IntentGatewayV2.ts +++ b/sdk/packages/sdk/src/abis/IntentGatewayV2.ts @@ -1363,9 +1363,9 @@ export const ABI = [ internalType: "bytes32", }, { - name: "solver", - type: "address", - internalType: "address", + name: "userOpHash", + type: "bytes32", + internalType: "bytes32", }, { name: "signature", diff --git a/sdk/packages/sdk/src/configs/ChainConfigService.ts b/sdk/packages/sdk/src/configs/ChainConfigService.ts index f022927343..0452968569 100644 --- a/sdk/packages/sdk/src/configs/ChainConfigService.ts +++ b/sdk/packages/sdk/src/configs/ChainConfigService.ts @@ -257,8 +257,8 @@ export class ChainConfigService { return this.getConfig(chain)?.popularTokens ?? [] } - getEntryPointV08Address(chain: string): HexString { - return this.getConfig(chain)?.addresses.EntryPointV08! + getEntryPointAddress(chain: string): HexString | undefined { + return this.getConfig(chain)?.addresses.EntryPoint as HexString | undefined } getCirclePaymasterAddress(chain: string): HexString | undefined { diff --git a/sdk/packages/sdk/src/configs/chain.ts b/sdk/packages/sdk/src/configs/chain.ts index 750553597c..99b6f78c6f 100644 --- a/sdk/packages/sdk/src/configs/chain.ts +++ b/sdk/packages/sdk/src/configs/chain.ts @@ -128,6 +128,15 @@ export const tronNile = defineChain({ // Known Tron chain IDs (mainnet + Nile testnet) export const tronChainIds = new Set([728126428, 3448148188]) +/** ERC-4337 v0.8 EntryPoint (canonical CREATE2 address) */ +export const ENTRY_POINT_V08: HexString = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" + +/** ERC-4337 v0.9 EntryPoint (canonical CREATE2 address) */ +export const ENTRY_POINT_V09: HexString = "0x433709009B8330FDa32311DF1C2AFA402eD8D009" + +/** Every EntryPoint version the SDK supports, newest first */ +export const KNOWN_ENTRY_POINTS: readonly HexString[] = [ENTRY_POINT_V09, ENTRY_POINT_V08] + export type ConfiguredAssetSymbol = | "WETH" | "DAI" @@ -213,8 +222,8 @@ export interface ChainConfigData { UniswapV4Quoter?: `0x${string}` Calldispatcher?: `0x${string}` Permit2?: `0x${string}` - /** ERC-4337 v0.8 EntryPoint address (canonical across all EVM chains) */ - EntryPointV08?: `0x${string}` + /** ERC-4337 EntryPoint the chain's `SolverAccount` validates against; it changes together with `SolverAccount` */ + EntryPoint?: `0x${string}` /** USDT0 OFT contract address (OFT Adapter on Ethereum, OFT on other chains) */ Usdt0Oft?: `0x${string}` /** SolverAccount contract address used for EIP-7702 delegation */ @@ -276,8 +285,10 @@ export const chainConfigs: Record = { UniversalRouter: "0xcc6d5ece3d4a57245bf5a2f64f3ed9179b81f714", UniswapV3Quoter: "0x0000000000000000000000000000000000000000", UniswapV4Quoter: "0x0000000000000000000000000000000000000000", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - SolverAccount: "0xf98E484858F59C30e31D2695ac18F92bc7C7b799", + Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", + EntryPoint: "0x433709009B8330FDa32311DF1C2AFA402eD8D009", + SolverAccount: "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2", + SimplexPaymaster: "0x9f4a6F1254f27373C2aca5bDd0e48FbA45EA3aeC", }, rpcEnvKey: "BSC_CHAPEL", defaultRpcUrl: "https://bnb-testnet.api.onfinality.io/public", @@ -312,7 +323,7 @@ export const chainConfigs: Record = { UniswapRouter02: "0x0000000000000000000000000000000000000000", UniswapV2Factory: "0x0000000000000000000000000000000000000000", UniswapV3Factory: "0x0000000000000000000000000000000000000000", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", }, rpcEnvKey: "GNOSIS_CHIADO", defaultRpcUrl: "https://gnosis-chiado-rpc.publicnode.com", @@ -352,7 +363,7 @@ export const chainConfigs: Record = { UniswapV2Factory: "0x0000000000000000000000000000000000000000", UniswapV3Factory: "0x0000000000000000000000000000000000000000", Calldispatcher: "0xC7f13b6D03A0A7F3239d38897503E90553ABe155", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", }, rpcEnvKey: "SEPOLIA", defaultRpcUrl: "https://1rpc.io/sepolia", @@ -415,7 +426,7 @@ export const chainConfigs: Record = { UniswapV4StateView: "0x7ffe42c4a5deea5b0fec41c94c136cf115597227", Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", SimplexPaymaster: "0xD4340d7466e040626383cb9cda9307ba8E081149", Usdt0Oft: "0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee", @@ -481,7 +492,7 @@ export const chainConfigs: Record = { UniswapV4StateView: "0xd13dd3d6e93f276fafc9db9e6bb47c1180aee0c4", Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", SimplexPaymaster: "0xeD02f9f0df8F562B89cC5b25867Ad3C2d61252A9", // "Usdt0Oft": Not available on BSC }, @@ -542,7 +553,7 @@ export const chainConfigs: Record = { UniswapV4StateView: "0x76fd297e2d437cd7f76d50f01afe6160f86e9990", Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", SimplexPaymaster: "0x7281Bccb4f0BCE44F3B8542d1fC5e51c2F5fC08C", Usdt0Oft: "0x14E4A1B13bf7F943c8ff7C51fb60FA964A298D92", @@ -611,7 +622,7 @@ export const chainConfigs: Record = { UniswapV4Quoter: "0x0d5e0f971ed27fbff6c2837bf31316121532048d", Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", SimplexPaymaster: "0x15b3B03C870c7ef252029c35A12d3b339F5c8d7f", AerodromeRouter: "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", @@ -687,7 +698,7 @@ export const chainConfigs: Record = { UniswapV4StateView: "0x5ea1bd7974c8a611cbab0bdcafcb1d9cc9b3ba5a", Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", SimplexPaymaster: "0xe99acFe0f5fC4C8ea54A187D8D3b05f136150095", Usdt0Oft: "0x6BA10300f0DC58B7a1e4c0e41f5daBb7D7829e13", @@ -765,8 +776,9 @@ export const chainConfigs: Record = { Host: "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1", Calldispatcher: "0x876F1891982E260026630c233A4897160A281Fb8", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - SolverAccount: "0xf98E484858F59C30e31D2695ac18F92bc7C7b799", + EntryPoint: "0x433709009B8330FDa32311DF1C2AFA402eD8D009", + SolverAccount: "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2", + SimplexPaymaster: "0x6085a14078B7d26259145a894140f7C8361e02ae", }, rpcEnvKey: "POLYGON_AMOY", defaultRpcUrl: "https://rpc-amoy.polygon.technology", @@ -798,7 +810,7 @@ export const chainConfigs: Record = { UniswapV3Factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984", Calldispatcher: "0xC71251c8b3e7B02697A84363Eef6DcE8DfBdF333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", UniswapV4PositionManager: "0x3c3ea4b57a46241e54610e5f022e5c45859a1017", UniswapV4PoolManager: "0x9a13f98cb987694c9f086b1f5eb990eea8264ec3", @@ -830,7 +842,7 @@ export const chainConfigs: Record = { UniswapRouter02: "0xB2e26652e4BAd1e56055A051f922E06760cA0BFE", // Mocked UniswapV2Factory: "0x0000000000000000000000000000000000000000", Calldispatcher: "0xC71251c8b3e7B02697A84363Eef6DcE8DfBdF333", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", }, defaultRpcUrl: "https://rpc.gnosischain.com", consensusStateId: "GNO0", @@ -854,7 +866,7 @@ export const chainConfigs: Record = { addresses: { TokenGateway: "0xCe304770236f39F9911BfCC51afBdfF3b8635718", Host: "0x620128E2B19193d6Bd244a3AC8D3bBa0541B19c3", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", UniswapV4PositionManager: "0x1b35d13a2e2528f192637f14b05f0dc0e7deb566", UniswapV4PoolManager: "0x360e68faccca8ca495c1b759fd9eee466db9fb32", }, @@ -880,7 +892,7 @@ export const chainConfigs: Record = { addresses: { TokenGateway: "0xFcDa26cA021d5535C3059547390E6cCd8De7acA6", Host: "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", }, defaultRpcUrl: "https://sepolia-rollup.arbitrum.io/rpc", consensusStateId: "ETH0", @@ -904,7 +916,7 @@ export const chainConfigs: Record = { addresses: { TokenGateway: "0xFcDa26cA021d5535C3059547390E6cCd8De7acA6", Host: "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", }, defaultRpcUrl: "https://sepolia.optimism.io", consensusStateId: "ETH0", @@ -928,7 +940,7 @@ export const chainConfigs: Record = { addresses: { TokenGateway: "0xFcDa26cA021d5535C3059547390E6cCd8De7acA6", Host: "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", }, defaultRpcUrl: "https://sepolia.base.org", consensusStateId: "ETH0", @@ -953,7 +965,7 @@ export const chainConfigs: Record = { IntentGateway: "0x606ba811aa6cb424ce2108e8977c5284686f0d1f", TokenGateway: "0x1c1e5be83df4a54c7a2230c337e4a3e8b7354b1c", Host: "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1", - EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", + EntryPoint: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", }, defaultRpcUrl: "https://testnet-asset-hub-eth-rpc.polkadot.io", consensusStateId: "PAS0", diff --git a/sdk/packages/sdk/src/protocols/intents/Bid.ts b/sdk/packages/sdk/src/protocols/intents/Bid.ts index 09ea333d7f..3b0a577379 100644 --- a/sdk/packages/sdk/src/protocols/intents/Bid.ts +++ b/sdk/packages/sdk/src/protocols/intents/Bid.ts @@ -1,24 +1,43 @@ import { ABI as IntentGatewayV2ABI } from "@/abis/IntentGatewayV2" +import { ENTRY_POINT_V08, ENTRY_POINT_V09, KNOWN_ENTRY_POINTS } from "@/configs/chain" import type { Bid, - ERC7821Call, FillOptions, FillerBid, HexString, Order, PackedUserOperation, SelectBidResult, - SelectOptions, TokenInfo, } from "@/types" import { ADDRESS_ZERO, bytes32ToBytes20, normalizeStateMachineId, retryPromise } from "@/utils" import type Decimal from "decimal.js" -import { concat, decodeEventLog, encodeFunctionData, parseEventLogs, toEventSelector } from "viem" +import { concat, decodeEventLog, parseEventLogs, recoverAddress, slice, toEventSelector } from "viem" import type { Hex, Log } from "viem" import { CryptoUtils } from "./CryptoUtils" +import { readSelectionFormat, refreshSelectionFormat, selectionOffStateDiff, type SelectionFormat } from "./selection" import type { IntentGatewayContext } from "./types" import { BundlerMethod } from "./types" +/** + * The selection format the SolverAccount for each EntryPoint calls `select` with, and so the only + * format a bid signed for that EntryPoint can be selected under. + */ +const SELECTION_FORMAT_BY_ENTRY_POINT = new Map([ + [ENTRY_POINT_V09.toLowerCase(), "userOpHash"], + [ENTRY_POINT_V08.toLowerCase(), "address"], +]) + +/** + * Where a bid executes: the EntryPoint its solver signed it for, its userOpHash there, and the + * destination gateway's selection format. + */ +interface BidRoute { + entryPoint: HexString + userOpHash: HexString + format: SelectionFormat +} + /** The EntryPoint events that delimit one operation's logs inside a bundle transaction. */ const ENTRY_POINT_EVENT_ABI = [ { type: "event", name: "BeforeExecution", inputs: [] }, @@ -90,9 +109,18 @@ export interface BidParams { * * Holds everything needed to simulate and execute one solver bid: the shared * IntentsV2 context, crypto utilities, the order, the solver's UserOperation, and - * the decoded fill options. The session-key signature over the `SelectSolver` - * message is resolved lazily and cached, so it is signed at most once whether the - * consumer calls {@link simulate}, {@link execute}, or both. + * the decoded fill options. + * + * The bid runs on the EntryPoint its solver signed it for, found by recovering the + * solver's signature under each of {@link KNOWN_ENTRY_POINTS}. A v0.9 bid can only be + * selected on a gateway that selects by userOpHash and a v0.8 bid only on one that + * selects by address, since each SolverAccount calls its own form of `select`. A bid + * signed for no known EntryPoint, or for one the destination gateway does not pair + * with, fails {@link simulate} and {@link execute}. + * + * The EntryPoint and the session-key signature over the `SelectSolver` message are + * resolved lazily and cached, so each is worked out at most once whether the consumer + * calls {@link simulate}, {@link execute}, or both. */ export class BidImpl implements Bid { readonly solverAddress: HexString @@ -115,6 +143,9 @@ export class BidImpl implements Bid { /** Cached session-key signature over the `SelectSolver` message. */ private cachedSignature?: HexString + /** Cached result of {@link route}. */ + private cachedRoute?: BidRoute + constructor(params: BidParams) { this.ctx = params.ctx this.crypto = params.crypto @@ -149,14 +180,75 @@ export class BidImpl implements Bid { } /** - * Resolves the session key, signs the `SelectSolver` message for this bid's - * solver, and caches the signature. Signs at most once per bid. + * Finds the EntryPoint this bid's solver signed it for and checks that the + * destination gateway's selection format pairs with it, reading the format + * again before refusing a bid in case the gateway was upgraded since it was + * cached. Caches the result. + * + * @throws If the solver's signature recovers under no known EntryPoint, the + * EntryPoint does not pair with the gateway's selection format, or the + * format cannot be read. + */ + private async route(): Promise { + if (this.cachedRoute) return this.cachedRoute + + const chainId = this.chainId() + let signed: { entryPoint: HexString; userOpHash: HexString } | undefined + for (const entryPoint of KNOWN_ENTRY_POINTS) { + const userOpHash = CryptoUtils.computeUserOpHash(this.userOp, entryPoint, chainId) + if (await this.signedBySolver(userOpHash)) { + signed = { entryPoint, userOpHash } + break + } + } + if (!signed) { + throw new Error(`Bid from solver=${this.solverAddress} is not signed for a known EntryPoint`) + } + + const pairsWith = SELECTION_FORMAT_BY_ENTRY_POINT.get(signed.entryPoint.toLowerCase()) + const cached = readSelectionFormat(this.ctx.dest.client, chainId, this.intentGatewayV2Address) + let format = await cached + if (pairsWith !== format) { + format = await refreshSelectionFormat(this.ctx.dest.client, chainId, this.intentGatewayV2Address, cached) + } + if (pairsWith !== format) { + throw new Error( + `Bid from solver=${this.solverAddress} is signed for EntryPoint ${signed.entryPoint}, ` + + `which the destination gateway's ${format} selection does not pair with`, + ) + } + + this.cachedRoute = { ...signed, format } + return this.cachedRoute + } + + /** + * Whether the solver's signature in the bid, `signature[32:97]` after the order + * commitment, recovers to the solver over `userOpHash`. + */ + private async signedBySolver(userOpHash: HexString): Promise { + try { + const recovered = await recoverAddress({ + hash: userOpHash, + signature: slice(this.userOp.signature, 32, 97), + }) + return recovered.toLowerCase() === this.solverAddress.toLowerCase() + } catch { + return false + } + } + + /** + * Resolves the session key, signs the `SelectSolver` message for this bid in + * the destination gateway's selection format, and caches the signature. Signs + * at most once per bid. * - * @throws If the session key is missing or signing fails. + * @throws If the bid cannot be routed, or the session key is missing. */ private async signSelection(): Promise { if (this.cachedSignature) return this.cachedSignature + const { format, userOpHash } = await this.route() const commitment = this.order.id as HexString const sessionKeyAddress = this.order.session as HexString @@ -167,38 +259,40 @@ export class BidImpl implements Bid { throw new Error(`SessionKey not found for commitment: ${commitment}`) } - const signature = await CryptoUtils.signSolverSelection( - commitment, - this.solverAddress, - this.domainSeparator, - sessionKeyData.privateKey, - ) - if (!signature) { - throw new Error("Failed to sign solver selection") - } - - this.cachedSignature = signature - return signature + this.cachedSignature = + format === "userOpHash" + ? await CryptoUtils.signUserOpHashSelection( + commitment, + userOpHash, + this.domainSeparator, + sessionKeyData.privateKey, + ) + : await CryptoUtils.signLegacySolverSelection( + commitment, + this.solverAddress, + this.domainSeparator, + sessionKeyData.privateKey, + ) + return this.cachedSignature } /** - * Simulates this bid on-chain by batching the `select` and `fillOrder` calls - * via `eth_call` from the solver's account, using the IntentGatewayV2 ERC-7821 - * batch-execute pattern. + * Simulates this bid on-chain with an `eth_call` of its calldata from the + * solver's account, after checking that the bid can be routed. + * + * The gateway's solver selection is turned off for the call with + * {@link selectionOffStateDiff} and no `select` is made: a gateway that selects + * by userOpHash checks the selection against the EntryPoint's current + * operation, which only exists inside a bundle. The session's selection is + * therefore not part of the simulation. * * The native value forwarded to the simulation is the sum of any native-token * (`address(0)`) output amounts plus the Hyperbridge dispatch fee. * - * @throws If the `eth_call` simulation reverts or errors. + * @throws If the bid cannot be routed, or the `eth_call` simulation reverts or errors. */ async simulate(): Promise { - const signature = await this.signSelection() - - const selectOptions: SelectOptions = { - commitment: this.order.id as HexString, - solver: this.solverAddress, - signature, - } + await this.route() // Compute the native ETH the fillOrder call requires: // native token outputs (address(0)) + Hyperbridge dispatch fee @@ -208,24 +302,14 @@ export class BidImpl implements Bid { ) const simulationValue = nativeOutputs + this.fillOptions.nativeDispatchFee - const selectCalldata = encodeFunctionData({ - abi: IntentGatewayV2ABI, - functionName: "select", - args: [selectOptions], - }) as HexString - - const calls: ERC7821Call[] = [ - { target: this.intentGatewayV2Address, value: 0n, data: selectCalldata }, - { target: this.solverAddress, value: simulationValue, data: this.userOp.callData }, - ] - const batchedCalldata = this.crypto.encodeERC7821Execute(calls) - try { + const selectionOff = await selectionOffStateDiff(this.ctx.dest.client, this.intentGatewayV2Address) await this.ctx.dest.client.call({ account: this.solverAddress, to: this.solverAddress, - data: batchedCalldata, + data: this.userOp.callData, value: simulationValue, + stateOverride: [{ address: this.intentGatewayV2Address, stateDiff: [selectionOff] }], }) } catch (e: unknown) { throw new Error(`Simulation failed: ${e instanceof Error ? e.message : String(e)}`) @@ -235,13 +319,13 @@ export class BidImpl implements Bid { /** * Signs the `SelectSolver` message with the session key, appends it to the * solver's existing UserOp signature, and submits the UserOperation to the - * bundler. Waits for the receipt and reads `OrderFilled` / `PartialFill` - * logs to determine fill status. + * bundler on the EntryPoint the bid was signed for. Waits for the receipt and + * reads `OrderFilled` / `PartialFill` logs to determine fill status. * * @returns A {@link SelectBidResult} with the submitted UserOperation, its hash, * the solver address, transaction hash, and fill status. - * @throws If the bundler is not configured, the session key is missing, or the - * bundler rejects the UserOperation. + * @throws If the bundler is not configured, the bid cannot be routed, the + * session key is missing, or the bundler rejects the UserOperation. */ async execute(): Promise { const commitment = this.order.id as HexString @@ -250,6 +334,7 @@ export class BidImpl implements Bid { throw new Error("Bundler URL not configured") } + const { entryPoint } = await this.route() const sessionSignature = await this.signSelection() const finalSignature = concat([this.userOp.signature as Hex, sessionSignature as Hex]) as HexString @@ -258,13 +343,9 @@ export class BidImpl implements Bid { signature: finalSignature, } - const entryPointAddress = this.ctx.dest.configService.getEntryPointV08Address( - normalizeStateMachineId(this.order.destination), - ) - const userOpHash = await this.crypto.sendBundler(BundlerMethod.ETH_SEND_USER_OPERATION, [ CryptoUtils.prepareBundlerCall(signedUserOp), - entryPointAddress, + entryPoint, ]) let txnHash: HexString | undefined @@ -294,7 +375,7 @@ export class BidImpl implements Bid { ) const fills = parseEventLogs({ abi: IntentGatewayV2ABI, - logs: userOperationLogs(chainReceipt.logs, entryPointAddress, userOpHash), + logs: userOperationLogs(chainReceipt.logs, entryPoint, userOpHash), eventName: ["OrderFilled", "PartialFill"], }).filter( (event) => diff --git a/sdk/packages/sdk/src/protocols/intents/BidManager.ts b/sdk/packages/sdk/src/protocols/intents/BidManager.ts index cb66732d9c..d663473180 100644 --- a/sdk/packages/sdk/src/protocols/intents/BidManager.ts +++ b/sdk/packages/sdk/src/protocols/intents/BidManager.ts @@ -48,12 +48,15 @@ export class BidManager { * Constructs a signed `PackedUserOperation` that a solver can submit to the * Hyperbridge coprocessor as a bid to fill an order. * - * The solver signs the operation as EntryPoint v0.8 EIP-712 typed data, - * whose digest is the plain userOpHash. The binding to the order lives in - * the operation itself: the 4337 nonce key must be the lower 192 bits of - * the order commitment (`SolverAccount` enforces this during validation), - * and the callData carries the order. This keeps the signed payload fully - * transparent to signing infrastructure instead of an opaque digest. + * The solver signs the operation as EIP-712 typed data for + * `entryPointAddress`, whose digest is that EntryPoint's userOpHash. It must + * be the EntryPoint the solver's `SolverAccount` accepts: the user's SDK + * recovers it from this signature and executes the bid there. The binding + * to the order lives in the operation itself: the 4337 nonce key must derive + * from the order commitment, the session key and the calldata + * (`SolverAccount` enforces this during validation), and the callData + * carries the order. This keeps the signed payload fully transparent to + * signing infrastructure instead of an opaque digest. * * @param options - Parameters describing the solver account, gas limits, fee * market values, and pre-built `callData` for the fill operation. @@ -137,6 +140,11 @@ export class BidManager { * silently dropped with a warning. The returned `Bid` instances are ready to * be ranked, simulated, and executed by the consumer. * + * A bid signed for no known EntryPoint, or for one the destination gateway's + * selection format does not pair with, is kept here and fails + * {@link Bid.simulate} and {@link Bid.execute}, so {@link selectAndExecuteBest} + * skips it. + * * @param order - The placed order the bids are competing to fill. * @param bids - Raw filler bids fetched from the coprocessor. * @param sessionPrivateKey - Optional session-key override; looked up from diff --git a/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts b/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts index 5f30962eef..8fa206cd37 100644 --- a/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts +++ b/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts @@ -20,13 +20,20 @@ import { ERC7821_BATCH_MODE } from "./types" import type { BundlerMethod } from "./types" /** - * EIP-712 type hash for the `SelectSolver` struct. + * EIP-712 type hash of `SelectSolver(bytes32 commitment,bytes32 userOpHash)`. * - * Computed as `keccak256("SelectSolver(bytes32 commitment,address solver)")`. - * Used when the session key signs a solver-selection message so that the - * IntentGatewayV2 contract can verify the choice on-chain. + * The order's session key signs this struct to select one bid UserOperation + * for the order, on gateways that select solvers by userOpHash. */ -export const SELECT_SOLVER_TYPEHASH = keccak256(toHex("SelectSolver(bytes32 commitment,address solver)")) +export const SELECT_SOLVER_TYPEHASH = keccak256(toHex("SelectSolver(bytes32 commitment,bytes32 userOpHash)")) + +/** + * EIP-712 type hash of `SelectSolver(bytes32 commitment,address solver)`. + * + * The order's session key signs this struct to select a solver account for the + * order, on gateways that select solvers by address. + */ +export const LEGACY_SELECT_SOLVER_TYPEHASH = keccak256(toHex("SelectSolver(bytes32 commitment,address solver)")) /** * EIP-712 type hash for the `PackedUserOperation` struct. @@ -90,37 +97,61 @@ export class CryptoUtils { } /** - * Signs a `SelectSolver` EIP-712 message with a session key. + * Signs `SelectSolver(bytes32 commitment,bytes32 userOpHash)` with the + * order's session key, for gateways whose `SELECT_SOLVER_TYPEHASH` is + * {@link SELECT_SOLVER_TYPEHASH}. * - * The session key authorises the selection of a specific solver for the - * given order commitment. The resulting signature is appended to the - * solver's UserOperation signature before bundle submission. + * The signature selects one bid UserOperation for the order and is appended + * to that operation's signature before it is sent to the bundler. * - * @param commitment - The order commitment (bytes32) being fulfilled. - * @param solverAddress - Address of the solver account selected to fill the order. - * @param domainSeparator - EIP-712 domain separator for the IntentGatewayV2 contract. - * @param privateKey - Hex-encoded private key of the session key that signs the message. - * @returns The ECDSA signature as a hex string, or `null` if signing fails. + * @param commitment - The order commitment. + * @param userOpHash - EntryPoint userOpHash of the selected bid UserOperation. + * @param domainSeparator - EIP-712 domain separator of the IntentGatewayV2 contract. + * @param privateKey - Private key of the order's session key. + * @returns The 65-byte ECDSA signature. */ - static async signSolverSelection( + static async signUserOpHashSelection( commitment: HexString, - solverAddress: HexString, + userOpHash: HexString, domainSeparator: HexString, privateKey: HexString, - ): Promise { - const account = privateKeyToAccount(privateKey as Hex) + ): Promise { + const structHash = keccak256( + encodeAbiParameters( + [{ type: "bytes32" }, { type: "bytes32" }, { type: "bytes32" }], + [SELECT_SOLVER_TYPEHASH, commitment, userOpHash], + ), + ) + return signTypedDigest(domainSeparator, structHash, privateKey) + } + /** + * Signs `SelectSolver(bytes32 commitment,address solver)` with the order's + * session key, for gateways whose `SELECT_SOLVER_TYPEHASH` is + * {@link LEGACY_SELECT_SOLVER_TYPEHASH}. + * + * The signature selects a solver account for the order and is appended to + * that solver's bid UserOperation signature before it is sent to the bundler. + * + * @param commitment - The order commitment. + * @param solverAddress - Address of the selected solver account. + * @param domainSeparator - EIP-712 domain separator of the IntentGatewayV2 contract. + * @param privateKey - Private key of the order's session key. + * @returns The 65-byte ECDSA signature. + */ + static async signLegacySolverSelection( + commitment: HexString, + solverAddress: HexString, + domainSeparator: HexString, + privateKey: HexString, + ): Promise { const structHash = keccak256( encodeAbiParameters( [{ type: "bytes32" }, { type: "bytes32" }, { type: "address" }], - [SELECT_SOLVER_TYPEHASH, commitment, solverAddress], + [LEGACY_SELECT_SOLVER_TYPEHASH, commitment, solverAddress], ), ) - - const digest = keccak256(concat(["0x1901" as Hex, domainSeparator as Hex, structHash])) - const signature = await account.sign({ hash: digest }) - - return signature as HexString + return signTypedDigest(domainSeparator, structHash, privateKey) } /** @@ -517,3 +548,12 @@ export class CryptoUtils { } } } + +async function signTypedDigest( + domainSeparator: HexString, + structHash: HexString, + privateKey: HexString, +): Promise { + const digest = keccak256(concat(["0x1901", domainSeparator, structHash])) + return privateKeyToAccount(privateKey as Hex).sign({ hash: digest }) +} diff --git a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts index ea27afef29..349de107f9 100644 --- a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts +++ b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts @@ -1,17 +1,5 @@ -import { - encodeAbiParameters, - encodeFunctionData, - isAddress, - toHex, - pad, - maxUint256, - concat, - keccak256, - isHex, - hexToString, -} from "viem" +import { encodeAbiParameters, encodeFunctionData, toHex, maxUint256, concat, keccak256, isHex, hexToString } from "viem" import { generatePrivateKey, privateKeyToAccount, privateKeyToAddress } from "viem/accounts" -import { ABI as IntentGatewayV2ABI } from "@/abis/IntentGatewayV2" import { encodeFillOrder, assertGatewayRelease } from "./fillOrderCodec" import { ADDRESS_ZERO, @@ -38,6 +26,8 @@ import { BundlerMethod } from "./types" import type { BundlerGasEstimate, PimlicoGasPriceEstimate } from "./types" import { getFeeToken, transformOrderForContract, convertGasToFeeToken, convertFeeTokenToWei } from "./utils" import { CryptoUtils } from "./CryptoUtils" +import { readSelectionFormat, selectionOffStateDiff } from "./selection" +import { applyRundlerPriorityFee, fetchRundlerPriorityFee } from "./rundlerFees" /** * Estimates the gas cost for filling an IntentGatewayV2 order and converts it @@ -52,7 +42,9 @@ import { CryptoUtils } from "./CryptoUtils" * 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`. + * 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. */ /** * Gas budget assumed for delivering and executing the cross-chain RedeemEscrow @@ -140,9 +132,6 @@ interface GasEstimationPricingOptions { } export class GasEstimator { - /** Call dispatchers read from a gateway, for chains whose config carries none. */ - private readonly dispatchers = new Map() - /** * @param ctx - Shared IntentsV2 context providing the source and destination * chain clients, config service, bundler URL, and solver-code cache. @@ -172,7 +161,10 @@ export class GasEstimator { * 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`. If the bundler rejects the estimate, + * `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, * fixed gas limits are returned in its place, or the call throws when * `params.requireBundlerEstimate` is set. * @@ -211,7 +203,7 @@ export class GasEstimator { const souceStateMachineId = isHex(order.source) ? hexToString(order.source) : order.source const destStateMachineId = isHex(order.destination) ? hexToString(order.destination) : order.destination const intentGatewayV2Address = this.ctx.dest.configService.getIntentGatewayAddress(destStateMachineId) - const entryPointAddress = this.ctx.dest.configService.getEntryPointV08Address(destStateMachineId) + const entryPointAddress = this.ctx.dest.configService.getEntryPointAddress(destStateMachineId) const chainId = BigInt(Number.parseInt(destStateMachineId.split("-")[1])) const totalEthValue = quotedOutputs @@ -309,6 +301,8 @@ export class GasEstimator { if (this.ctx.bundlerUrl) { try { + if (!entryPointAddress) throw new Error(`No EntryPoint configured for ${destStateMachineId}`) + const callData = this.crypto.encodeERC7821Execute([ ...(params.prependCalls ?? []), { target: intentGatewayV2Address, value: totalNativeValue, data: fillOrderCalldata }, @@ -347,12 +341,21 @@ export class GasEstimator { chainId, intentGatewayV2Address, ) - const sessionSignature = await CryptoUtils.signSolverSelection( - commitment as HexString, - solverAccountAddress, - domainSeparator, - solverPrivateKey, - ) + const selectionFormat = await readSelectionFormat(this.ctx.dest.client, chainId, intentGatewayV2Address) + const sessionSignature = + selectionFormat === "userOpHash" + ? await CryptoUtils.signUserOpHashSelection( + commitment as HexString, + userOpHash, + domainSeparator, + solverPrivateKey, + ) + : await CryptoUtils.signLegacySolverSelection( + commitment as HexString, + solverAccountAddress, + domainSeparator, + solverPrivateKey, + ) preliminaryUserOp.signature = concat([ solverSig as import("viem").Hex, @@ -363,6 +366,8 @@ export class GasEstimator { 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[] }[] = [ { @@ -437,6 +442,21 @@ export class GasEstimator { 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 + } } catch (e) { if (params.requireBundlerEstimate) { throw new Error(`Bundler gas estimation failed: ${e instanceof Error ? e.message : String(e)}`, { @@ -492,30 +512,6 @@ export class GasEstimator { } } - /** - * The call dispatcher the gateway on `chain` uses: the configured address, or the one - * the gateway reports when the chain's config carries none. - * - * {@link buildStateOverride} writes it back into the gateway's params slot. Without an - * address that value is 12 bytes instead of 32, and the bundler rejects the whole - * estimate as `Invalid params`. - */ - private async callDispatcher(chain: string, gateway: HexString): Promise { - const configured = this.ctx.dest.configService.getCalldispatcherAddress(chain) - if (isAddress(configured)) return configured - - const known = this.dispatchers.get(chain) - if (known) return known - - const params = await this.ctx.dest.client.readContract({ - abi: IntentGatewayV2ABI, - address: gateway, - functionName: "params", - }) - this.dispatchers.set(chain, params.dispatcher as HexString) - return params.dispatcher as HexString - } - /** * Asks the bundler for the `preVerificationGas` of the bid UserOperation that will * actually be signed, with every funding call, approval and signature byte in it. @@ -624,9 +620,8 @@ export class GasEstimator { * slots should be overridden. * @param params.spenderAddress - Address that needs allowance from the solver * account (i.e. the IntentGatewayV2 contract). - * @param params.intentGatewayV2Address - If provided, overrides slot 5 of - * IntentGatewayV2 with the call-dispatcher address so dispatch calls - * succeed during estimation. + * @param params.intentGatewayV2Address - If provided, turns the gateway's + * solver selection off with {@link selectionOffStateDiff}. * @param params.entryPointAddress - If provided, overrides the EntryPoint * deposit mapping to give the solver account a large deposit. * @returns An object with `viem` and `bundler` state-override collections. @@ -673,14 +668,9 @@ export class GasEstimator { > = {} if (intentGatewayV2Address) { - // Params slot 5 packs the call dispatcher with `solverSelection` in the byte above it. - // Written back with that byte cleared, the simulated fill skips the selection check, - // which is what lets `estimateFillOrder` simulate the user's real order. - const paramsSlot5 = pad(toHex(5n), { size: 32 }) as HexString - const dispatcherAddress = await this.callDispatcher(chain, intentGatewayV2Address) - const newSlot5Value = ("0x" + "0".repeat(22) + "00" + dispatcherAddress.slice(2).toLowerCase()) as HexString - - const gatewayDiffs = [{ slot: paramsSlot5, value: newSlot5Value }] + // With solver selection off the simulated fill skips the selection check, which is what + // lets `estimateFillOrder` simulate the user's real order. + const gatewayDiffs = [await selectionOffStateDiff(this.ctx.dest.client, intentGatewayV2Address)] for (const [leg, input] of (escrow?.inputs ?? []).entries()) { gatewayDiffs.push({ slot: escrowSlot(escrow!.commitment, leg), diff --git a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts index 9e74cf8899..2646a50069 100644 --- a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts +++ b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts @@ -5,6 +5,7 @@ import type { BidManager } from "./BidManager" import { CryptoUtils } from "./CryptoUtils" import type { IntentGatewayContext } from "./types" import { ABI as IntentGatewayV2ABI } from "@/abis/IntentGatewayV2" +import { ENTRY_POINT_V08 } from "@/configs/chain" import { readLegPartialFill } from "./escrowReads" const USED_USEROPS_STORAGE_KEY = (commitment: HexString) => `used-userops:${commitment.toLowerCase()}` @@ -138,18 +139,14 @@ export class OrderExecutor { /** * Creates a closure that computes the deduplication hash key for a - * UserOperation, pre-bound to the order's destination chain and entry point. + * UserOperation, pre-bound to the destination chain. */ - private createUserOpHasher(order: { - destination: string - }): (userOp: SelectBidResult["userOp"] | FillerBid["userOp"]) => string { - const entryPointAddress = this.ctx.dest.configService.getEntryPointV08Address( - normalizeStateMachineId(order.destination), - ) + private createUserOpHasher(): (userOp: SelectBidResult["userOp"] | FillerBid["userOp"]) => string { const chainId = BigInt( this.ctx.dest.client.chain?.id ?? Number.parseInt(this.ctx.dest.config.stateMachineId.split("-")[1]), ) - return (userOp) => CryptoUtils.computeUserOpHash(userOp, entryPointAddress, chainId) + // Pinned so keys persisted per order stay valid when the chain's EntryPoint changes + return (userOp) => CryptoUtils.computeUserOpHash(userOp, ENTRY_POINT_V08, chainId) } /** @@ -305,7 +302,7 @@ export class OrderExecutor { } const usedUserOps = await this.loadUsedUserOps(commitment) - const userOpHashKey = this.createUserOpHasher(order) + const userOpHashKey = this.createUserOpHasher() const targetAssets = order.output.assets.map((a) => ({ token: a.token, amount: a.amount })) let initial: Awaited> diff --git a/sdk/packages/sdk/src/protocols/intents/index.ts b/sdk/packages/sdk/src/protocols/intents/index.ts index 3f058df911..028f1efe30 100644 --- a/sdk/packages/sdk/src/protocols/intents/index.ts +++ b/sdk/packages/sdk/src/protocols/intents/index.ts @@ -38,7 +38,15 @@ export { fetchSourceProof, orderCommitment, } from "./utils" -export { CryptoUtils, SELECT_SOLVER_TYPEHASH, PACKED_USEROP_TYPEHASH, DOMAIN_TYPEHASH } from "./CryptoUtils" +export { + CryptoUtils, + SELECT_SOLVER_TYPEHASH, + LEGACY_SELECT_SOLVER_TYPEHASH, + PACKED_USEROP_TYPEHASH, + DOMAIN_TYPEHASH, +} from "./CryptoUtils" +export { readSelectionFormat, type SelectionFormat } from "./selection" +export { fetchRundlerPriorityFee, applyRundlerPriorityFee } 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 new file mode 100644 index 0000000000..3399059777 --- /dev/null +++ b/sdk/packages/sdk/src/protocols/intents/rundlerFees.ts @@ -0,0 +1,78 @@ +import { BundlerMethod } from "./types" + +/** + * JSON-RPC codes for a request the server will never answer differently: invalid request, + * method not found and invalid params. Bundlers that do not serve + * `rundler_maxPriorityFeePerGas` reply with one of these. + */ +const UNSUPPORTED_REQUEST_CODES = new Set([-32600, -32601, -32602]) + +/** Bundler URLs that answered `rundler_maxPriorityFeePerGas` as unsupported. */ +const bundlersWithoutRundlerFees = 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. + * + * @param bundlerUrl - The bundler's JSON-RPC endpoint. + * @returns The required priority fee in wei, or `null`. + */ +export async function fetchRundlerPriorityFee(bundlerUrl: string): Promise { + if (bundlersWithoutRundlerFees.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: [], + }), + }) + const { result, error } = (await response.json()) as { result?: string; error?: { code?: number } } + if (error) { + if (error.code !== undefined && UNSUPPORTED_REQUEST_CODES.has(error.code)) { + bundlersWithoutRundlerFees.add(bundlerUrl) + } + return null + } + return result == null ? null : BigInt(result) + } catch { + return null + } +} + +/** + * Raises UserOperation fees to what a rundler bundler accepts and bundles. The priority fee + * becomes at least `rundlerPriorityFee` raised by `priorityFeeBumpPercent`. The max fee + * becomes at least the base fee raised by `maxFeeBumpPercent` plus that priority fee floor. + * Fees that already clear both are returned unchanged. + * + * The max fee is floored on the rundler figure rather than on the final priority fee: a + * priority fee estimated from the chain's gas price already includes the base fee, and + * adding the base fee to it again would make the op pay about twice the base fee. + */ +export function applyRundlerPriorityFee( + fees: { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }, + params: { + rundlerPriorityFee: bigint + baseFeePerGas: bigint + priorityFeeBumpPercent: bigint + maxFeeBumpPercent: bigint + }, +): { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } { + const { rundlerPriorityFee, baseFeePerGas, priorityFeeBumpPercent, maxFeeBumpPercent } = params + const priorityFloor = rundlerPriorityFee + (rundlerPriorityFee * priorityFeeBumpPercent) / 100n + const maxFeeFloor = baseFeePerGas + (baseFeePerGas * maxFeeBumpPercent) / 100n + priorityFloor + return { + maxPriorityFeePerGas: fees.maxPriorityFeePerGas > priorityFloor ? fees.maxPriorityFeePerGas : priorityFloor, + maxFeePerGas: fees.maxFeePerGas > maxFeeFloor ? fees.maxFeePerGas : maxFeeFloor, + } +} diff --git a/sdk/packages/sdk/src/protocols/intents/selection.ts b/sdk/packages/sdk/src/protocols/intents/selection.ts new file mode 100644 index 0000000000..159d982347 --- /dev/null +++ b/sdk/packages/sdk/src/protocols/intents/selection.ts @@ -0,0 +1,104 @@ +import { hexToBigInt, maxUint256, pad, toHex, type PublicClient } from "viem" +import { ABI as IntentGatewayV2ABI } from "@/abis/IntentGatewayV2" +import type { HexString } from "@/types" +import { LEGACY_SELECT_SOLVER_TYPEHASH, SELECT_SOLVER_TYPEHASH } from "./CryptoUtils" + +/** + * What the order's session key signs to select a solver: the bid's userOpHash + * (`SelectSolver(bytes32 commitment,bytes32 userOpHash)`) or the solver account's + * address (`SelectSolver(bytes32 commitment,address solver)`). + */ +export type SelectionFormat = "userOpHash" | "address" + +type ReadClient = Pick + +const formats = new Map>() + +/** + * The selection format an IntentGatewayV2 verifies, from its `SELECT_SOLVER_TYPEHASH()`. Read once + * per chain and gateway and kept until {@link refreshSelectionFormat} drops it; a failed read is + * not cached. + * + * @param client - Public client of the chain the gateway is deployed on. + * @param chainId - Chain id of that chain. + * @param gateway - IntentGatewayV2 address. + * @throws If the read fails or the gateway reports a typehash of neither format. + */ +export function readSelectionFormat( + client: ReadClient, + chainId: bigint | number, + gateway: HexString, +): Promise { + const key = `${chainId}:${gateway.toLowerCase()}` + const cached = formats.get(key) + if (cached) return cached + + const format = client + .readContract({ address: gateway, abi: IntentGatewayV2ABI, functionName: "SELECT_SOLVER_TYPEHASH" }) + .then((typehash) => { + switch (typehash.toLowerCase()) { + case SELECT_SOLVER_TYPEHASH: + return "userOpHash" as const + case LEGACY_SELECT_SOLVER_TYPEHASH: + return "address" as const + default: + throw new Error( + `IntentGatewayV2 ${gateway} on chain ${chainId} has unknown SELECT_SOLVER_TYPEHASH ${typehash}`, + ) + } + }) + formats.set(key, format) + format.catch(() => { + if (formats.get(key) === format) formats.delete(key) + }) + return format +} + +/** + * Drops `stale`, a selection format {@link readSelectionFormat} returned, and reads the format + * again. A gateway upgraded in place can change its format, so a bid that does not pair with the + * cached one is checked against a fresh read. When another caller has already replaced `stale`, + * its read is reused. + * + * @param client - Public client of the chain the gateway is deployed on. + * @param chainId - Chain id of that chain. + * @param gateway - IntentGatewayV2 address. + * @param stale - The cached read being replaced. + * @throws If the read fails or the gateway reports a typehash of neither format. + */ +export function refreshSelectionFormat( + client: ReadClient, + chainId: bigint | number, + gateway: HexString, + stale: Promise, +): Promise { + const key = `${chainId}:${gateway.toLowerCase()}` + if (formats.get(key) === stale) formats.delete(key) + return readSelectionFormat(client, chainId, gateway) +} + +/** The IntentGatewayV2 params slot that packs the call dispatcher with `solverSelection` in the byte above it. */ +const SELECTION_PARAMS_SLOT = pad(toHex(5n), { size: 32 }) as HexString + +const SOLVER_SELECTION_CLEARED = maxUint256 ^ (0xffn << 160n) + +/** + * The storage write that turns an IntentGatewayV2's solver selection off: its params slot as the + * gateway holds it, with only the `solverSelection` byte cleared. A fill simulated under it skips + * the selection check, so it needs neither the order's session key nor a `select` call, and runs + * outside a bundle, where a gateway that selects by userOpHash has no operation to check. + * + * @param client - Public client of the gateway's chain. + * @param gateway - IntentGatewayV2 address. + * @throws If the slot cannot be read. + */ +export async function selectionOffStateDiff( + client: Pick, + gateway: HexString, +): Promise<{ slot: HexString; value: HexString }> { + const word = await client.getStorageAt({ address: gateway, slot: SELECTION_PARAMS_SLOT }) + return { + slot: SELECTION_PARAMS_SLOT, + value: toHex(hexToBigInt(word ?? "0x0") & SOLVER_SELECTION_CLEARED, { size: 32 }), + } +} diff --git a/sdk/packages/sdk/src/tests/bidEntryPoint.test.ts b/sdk/packages/sdk/src/tests/bidEntryPoint.test.ts new file mode 100644 index 0000000000..77fd149b31 --- /dev/null +++ b/sdk/packages/sdk/src/tests/bidEntryPoint.test.ts @@ -0,0 +1,283 @@ +import { ENTRY_POINT_V08, ENTRY_POINT_V09 } from "@/configs/chain" +import { BidImpl } from "@/protocols/intents/Bid" +import { CryptoUtils, LEGACY_SELECT_SOLVER_TYPEHASH, SELECT_SOLVER_TYPEHASH } from "@/protocols/intents/CryptoUtils" +import type { HexString, Order, PackedUserOperation } from "@/types" +import { + concat, + encodeAbiParameters, + hashTypedData, + pad, + recoverAddress, + slice, + toEventSelector, + type Log, + type TypedDataDomain, +} from "viem" +import { privateKeyToAccount } from "viem/accounts" +import { describe, expect, it, vi } from "vitest" + +const CHAIN_ID = 8453 +const COMMITMENT = `0x${"66".repeat(32)}` as HexString +const TOKEN = pad("0x55", { size: 32 }) as HexString +const solver = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") +const SESSION_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" as HexString +const session = privateKeyToAccount(SESSION_KEY) +const UNKNOWN_ENTRY_POINT = "0x0000000000000000000000000000000000004337" as HexString + +const BY_USER_OP_HASH = { + address: "0x0000000000000000000000000000000000000a01" as HexString, + typehash: SELECT_SOLVER_TYPEHASH, +} +const BY_ADDRESS = { + address: "0x0000000000000000000000000000000000000a02" as HexString, + typehash: LEGACY_SELECT_SOLVER_TYPEHASH, +} +type Gateway = typeof BY_USER_OP_HASH + +const order: Order = { + id: COMMITMENT, + user: TOKEN, + source: "EVM-1", + destination: `EVM-${CHAIN_ID}`, + deadline: 100n, + nonce: 1n, + fees: 0n, + session: session.address, + predispatch: { assets: [], call: "0x" }, + inputs: [{ token: TOKEN, amount: 100n }], + output: { beneficiary: TOKEN, assets: [{ token: TOKEN, amount: 100n }], call: "0x" }, +} + +/** A bid as `BidManager.prepareSubmitBid` builds it: the commitment, then the solver's signature for `entryPoint`. */ +async function signedFor(entryPoint: HexString): Promise { + const userOp: PackedUserOperation = { + sender: solver.address, + nonce: 1n, + initCode: "0x", + callData: "0x1234", + accountGasLimits: `0x${"00".repeat(32)}`, + preVerificationGas: 1n, + gasFees: `0x${"00".repeat(32)}`, + paymasterAndData: "0x", + signature: "0x", + } + const solverSignature = await solver.signTypedData( + CryptoUtils.packedUserOpTypedData(userOp, entryPoint, BigInt(CHAIN_ID)), + ) + return { ...userOp, signature: concat([COMMITMENT, solverSignature]) } +} + +/** The bundle the bundler lands: only this operation, emitted by the EntryPoint it was sent to. */ +function bundle(entryPoint: HexString, userOpHash: HexString): Log[] { + const operationEvent = toEventSelector("UserOperationEvent(bytes32,address,address,uint256,bool,uint256,uint256)") + const data = encodeAbiParameters( + [{ type: "uint256" }, { type: "bool" }, { type: "uint256" }, { type: "uint256" }], + [1n, true, 0n, 0n], + ) + return [ + { address: entryPoint, topics: [toEventSelector("BeforeExecution()")], data: "0x" }, + { + address: entryPoint, + topics: [operationEvent, userOpHash, pad(solver.address, { size: 32 }), pad("0x", { size: 32 })], + data, + }, + ] as unknown as Log[] +} + +function bidOn(gateway: Gateway, userOp: PackedUserOperation) { + const sent = { entryPoint: "0x" as HexString, userOpHash: "0x" as HexString } + const client = { + chain: { id: CHAIN_ID }, + readContract: vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName !== "SELECT_SOLVER_TYPEHASH") throw new Error(`unexpected read ${functionName}`) + return gateway.typehash + }), + getStorageAt: vi.fn(async () => pad("0x01", { size: 32 })), + call: vi.fn(async () => ({ data: "0x" })), + waitForTransactionReceipt: async () => ({ logs: bundle(sent.entryPoint, sent.userOpHash) }), + } + const ctx = { + bundlerUrl: "https://bundler.invalid", + dest: { + config: { stateMachineId: `EVM-${CHAIN_ID}` }, + configService: { getIntentGatewayAddress: () => gateway.address }, + client, + }, + } as never + const crypto = new CryptoUtils(ctx) + const sendBundler = vi.spyOn(crypto, "sendBundler").mockImplementation(async (method, params) => { + if (method !== "eth_sendUserOperation") return { receipt: { transactionHash: `0x${"77".repeat(32)}` } } + const [, entryPoint] = params as [unknown, HexString] + sent.entryPoint = entryPoint + sent.userOpHash = CryptoUtils.computeUserOpHash(userOp, entryPoint, BigInt(CHAIN_ID)) + return sent.userOpHash as never + }) + const bid = new BidImpl({ + ctx, + crypto, + order, + fillerBid: { filler: "solver", bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n }, + fillOptions: { + outputs: [{ token: TOKEN, amount: 100n }], + inputs: [{ token: TOKEN, amount: 100n }], + relayerFee: 0n, + nativeDispatchFee: 0n, + validUntil: 0n, + }, + priceOutputs: async () => null, + sessionPrivateKey: SESSION_KEY, + }) + return { bid, client, sendBundler } +} + +/** The session's selection signature, the last 65 bytes of the signature sent to the bundler. */ +function sentSelection(sendBundler: ReturnType["sendBundler"]): HexString { + const [, [sentOp]] = sendBundler.mock.calls[0] as [string, [{ signature: HexString }]] + return slice(sentOp.signature, 97) +} + +function gatewayDomain(gateway: Gateway): TypedDataDomain { + return { name: "IntentGateway", version: "2", chainId: CHAIN_ID, verifyingContract: gateway.address } +} + +/** The digest a session signs to select the v0.9 bid `userOp` on `gateway`. */ +function userOpHashSelectionDigest(gateway: Gateway, userOp: PackedUserOperation): HexString { + return hashTypedData({ + domain: gatewayDomain(gateway), + types: { + SelectSolver: [ + { name: "commitment", type: "bytes32" }, + { name: "userOpHash", type: "bytes32" }, + ], + }, + primaryType: "SelectSolver", + message: { + commitment: COMMITMENT, + userOpHash: CryptoUtils.computeUserOpHash(userOp, ENTRY_POINT_V09, BigInt(CHAIN_ID)), + }, + }) +} + +describe("BidImpl EntryPoint routing", () => { + it("executes a bid signed for EntryPoint v0.9 there, selected by its userOpHash", async () => { + const userOp = await signedFor(ENTRY_POINT_V09) + const { bid, sendBundler } = bidOn(BY_USER_OP_HASH, userOp) + + await bid.simulate() + await bid.execute() + + expect(sendBundler.mock.calls[0][0]).toBe("eth_sendUserOperation") + expect((sendBundler.mock.calls[0][1] as unknown[])[1]).toBe(ENTRY_POINT_V09) + + const digest = userOpHashSelectionDigest(BY_USER_OP_HASH, userOp) + expect(await recoverAddress({ hash: digest, signature: sentSelection(sendBundler) })).toBe(session.address) + }) + + it("executes a bid signed for EntryPoint v0.8 there, selected by its solver's address", async () => { + const { bid, sendBundler } = bidOn(BY_ADDRESS, await signedFor(ENTRY_POINT_V08)) + + await bid.simulate() + await bid.execute() + + expect((sendBundler.mock.calls[0][1] as unknown[])[1]).toBe(ENTRY_POINT_V08) + + const digest = hashTypedData({ + domain: gatewayDomain(BY_ADDRESS), + types: { + SelectSolver: [ + { name: "commitment", type: "bytes32" }, + { name: "solver", type: "address" }, + ], + }, + primaryType: "SelectSolver", + message: { commitment: COMMITMENT, solver: solver.address }, + }) + expect(await recoverAddress({ hash: digest, signature: sentSelection(sendBundler) })).toBe(session.address) + }) + + it("drops a bid signed for neither EntryPoint", async () => { + const { bid, client, sendBundler } = bidOn(BY_USER_OP_HASH, await signedFor(UNKNOWN_ENTRY_POINT)) + + await expect(bid.simulate()).rejects.toThrow("is not signed for a known EntryPoint") + await expect(bid.execute()).rejects.toThrow("is not signed for a known EntryPoint") + expect(client.call).not.toHaveBeenCalled() + expect(sendBundler).not.toHaveBeenCalled() + }) + + it("drops a v0.8 bid on a gateway that selects by userOpHash", async () => { + const { bid, client, sendBundler } = bidOn(BY_USER_OP_HASH, await signedFor(ENTRY_POINT_V08)) + + await expect(bid.simulate()).rejects.toThrow("userOpHash selection does not pair with") + await expect(bid.execute()).rejects.toThrow("userOpHash selection does not pair with") + expect(client.call).not.toHaveBeenCalled() + expect(sendBundler).not.toHaveBeenCalled() + }) + + it("drops a v0.9 bid on a gateway that selects by address", async () => { + const { bid, client, sendBundler } = bidOn(BY_ADDRESS, await signedFor(ENTRY_POINT_V09)) + + await expect(bid.simulate()).rejects.toThrow("address selection does not pair with") + await expect(bid.execute()).rejects.toThrow("address selection does not pair with") + expect(client.call).not.toHaveBeenCalled() + expect(sendBundler).not.toHaveBeenCalled() + }) +}) + +describe("BidImpl selection format after a gateway upgrade", () => { + it("reads the format again when a v0.9 bid meets a cached address selection", async () => { + const gateway: Gateway = { + address: "0x0000000000000000000000000000000000000a03", + typehash: LEGACY_SELECT_SOLVER_TYPEHASH, + } + const before = bidOn(gateway, await signedFor(ENTRY_POINT_V08)) + await before.bid.simulate() + expect(before.client.readContract).toHaveBeenCalledOnce() + + // The proxy is upgraded in place to an implementation that selects by userOpHash. + gateway.typehash = SELECT_SOLVER_TYPEHASH + const userOp = await signedFor(ENTRY_POINT_V09) + const { bid, client, sendBundler } = bidOn(gateway, userOp) + + await bid.simulate() + await bid.execute() + + expect(client.readContract).toHaveBeenCalledOnce() + expect((sendBundler.mock.calls[0][1] as unknown[])[1]).toBe(ENTRY_POINT_V09) + const digest = userOpHashSelectionDigest(gateway, userOp) + expect(await recoverAddress({ hash: digest, signature: sentSelection(sendBundler) })).toBe(session.address) + + const stale = bidOn(gateway, await signedFor(ENTRY_POINT_V08)) + await expect(stale.bid.simulate()).rejects.toThrow("userOpHash selection does not pair with") + }) + + it("reads the format once for concurrent bids that miss the cached one", async () => { + const gateway: Gateway = { + address: "0x0000000000000000000000000000000000000a04", + typehash: LEGACY_SELECT_SOLVER_TYPEHASH, + } + await bidOn(gateway, await signedFor(ENTRY_POINT_V08)).bid.simulate() + + gateway.typehash = SELECT_SOLVER_TYPEHASH + const userOp = await signedFor(ENTRY_POINT_V09) + const bids = [bidOn(gateway, userOp), bidOn(gateway, userOp), bidOn(gateway, userOp)] + await Promise.all(bids.map(({ bid }) => bid.simulate())) + + const reads = bids.reduce((total, { client }) => total + client.readContract.mock.calls.length, 0) + expect(reads).toBe(1) + }) + + it("does not read the format again when the bid pairs with the cached one", async () => { + const gateway: Gateway = { + address: "0x0000000000000000000000000000000000000a05", + typehash: SELECT_SOLVER_TYPEHASH, + } + const first = bidOn(gateway, await signedFor(ENTRY_POINT_V09)) + await first.bid.simulate() + const second = bidOn(gateway, await signedFor(ENTRY_POINT_V09)) + await second.bid.simulate() + await second.bid.execute() + + expect(first.client.readContract).toHaveBeenCalledOnce() + expect(second.client.readContract).not.toHaveBeenCalled() + }) +}) diff --git a/sdk/packages/sdk/src/tests/bidSelection.test.ts b/sdk/packages/sdk/src/tests/bidSelection.test.ts index 2f96f3c011..02716853ab 100644 --- a/sdk/packages/sdk/src/tests/bidSelection.test.ts +++ b/sdk/packages/sdk/src/tests/bidSelection.test.ts @@ -41,7 +41,7 @@ function makeContext(params: { intentsCoprocessor: { getBidsForOrder: params.getBidsForOrder }, dest: { config: { stateMachineId: "EVM-8453" }, - configService: { getEntryPointV08Address: () => ENTRY_POINT, getIntentGatewayAddress: () => GATEWAY }, + configService: { getEntryPointAddress: () => ENTRY_POINT, getIntentGatewayAddress: () => GATEWAY }, client: { chain: { id: 8453, blockTime: 1 }, getBlockNumber: vi.fn(params.getBlockNumber), diff --git a/sdk/packages/sdk/src/tests/bidSimulate.test.ts b/sdk/packages/sdk/src/tests/bidSimulate.test.ts new file mode 100644 index 0000000000..9099685036 --- /dev/null +++ b/sdk/packages/sdk/src/tests/bidSimulate.test.ts @@ -0,0 +1,128 @@ +import { ENTRY_POINT_V08, ENTRY_POINT_V09 } from "@/configs/chain" +import { BidImpl } from "@/protocols/intents/Bid" +import { CryptoUtils, LEGACY_SELECT_SOLVER_TYPEHASH, SELECT_SOLVER_TYPEHASH } from "@/protocols/intents/CryptoUtils" +import type { HexString, Order, PackedUserOperation } from "@/types" +import { concat, pad } from "viem" +import { privateKeyToAccount } from "viem/accounts" +import { describe, expect, it, vi } from "vitest" + +const CHAIN_ID = 8453 +const COMMITMENT = `0x${"66".repeat(32)}` as HexString +const TOKEN = pad("0x55", { size: 32 }) as HexString +const NATIVE = pad("0x", { size: 32 }) as HexString +const DISPATCHER = `0x${"d1".repeat(20)}` as HexString +const PARAMS_SLOT = `0x${"0".repeat(63)}5` +// The gateway's params slot 5: `solverSelection` on in byte 20, above the dispatcher. +const PARAMS_WORD = `0x${"00".repeat(11)}01${DISPATCHER.slice(2)}` as HexString +const solver = privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + +const order: Order = { + id: COMMITMENT, + user: TOKEN, + source: "EVM-1", + destination: `EVM-${CHAIN_ID}`, + deadline: 100n, + nonce: 1n, + fees: 0n, + session: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + predispatch: { assets: [], call: "0x" }, + inputs: [{ token: TOKEN, amount: 100n }], + output: { beneficiary: TOKEN, assets: [{ token: NATIVE, amount: 7n }], call: "0x" }, +} + +/** A bid for `entryPoint` on the gateway at `gateway`, which selects in the format `typehash` names. */ +async function bidOn(entryPoint: HexString, gateway: HexString, typehash: HexString) { + const unsigned: PackedUserOperation = { + sender: solver.address, + nonce: 1n, + initCode: "0x", + callData: "0x1234", + accountGasLimits: `0x${"00".repeat(32)}`, + preVerificationGas: 1n, + gasFees: `0x${"00".repeat(32)}`, + paymasterAndData: "0x", + signature: "0x", + } + const solverSignature = await solver.signTypedData( + CryptoUtils.packedUserOpTypedData(unsigned, entryPoint, BigInt(CHAIN_ID)), + ) + const userOp = { ...unsigned, signature: concat([COMMITMENT, solverSignature]) } + + const client = { + chain: { id: CHAIN_ID }, + readContract: vi.fn(async () => typehash), + getStorageAt: vi.fn(async (_request: Record) => PARAMS_WORD), + call: vi.fn(async (_request: Record) => ({ data: "0x" })), + } + // No session key anywhere: simulating a bid does not sign a selection. + const ctx = { + dest: { + config: { stateMachineId: `EVM-${CHAIN_ID}` }, + configService: { getIntentGatewayAddress: () => gateway }, + client, + }, + } as never + const bid = new BidImpl({ + ctx, + crypto: new CryptoUtils(ctx), + order, + fillerBid: { filler: "solver", bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n }, + fillOptions: { + outputs: [{ token: NATIVE, amount: 7n }], + inputs: [{ token: TOKEN, amount: 100n }], + relayerFee: 0n, + nativeDispatchFee: 3n, + validUntil: 0n, + }, + priceOutputs: async () => null, + }) + return { bid, client, userOp } +} + +describe("BidImpl.simulate", () => { + it.each([ + ["v0.9", "userOpHash", ENTRY_POINT_V09, SELECT_SOLVER_TYPEHASH, "0x0000000000000000000000000000000000000b01"], + [ + "v0.8", + "address", + ENTRY_POINT_V08, + LEGACY_SELECT_SOLVER_TYPEHASH, + "0x0000000000000000000000000000000000000b02", + ], + ] as const)( + "runs a %s bid's calldata with %s selection turned off and no select", + async (_version, _format, entryPoint, typehash, gateway) => { + const { bid, client, userOp } = await bidOn(entryPoint, gateway, typehash) + + await bid.simulate() + + expect(client.getStorageAt).toHaveBeenCalledWith({ address: gateway, slot: PARAMS_SLOT }) + // One call of the bid's own calldata: no batch that runs `select` first. + expect(client.call).toHaveBeenCalledOnce() + expect(client.call.mock.calls[0][0]).toEqual({ + account: solver.address, + to: solver.address, + data: userOp.callData, + value: 10n, + // The gateway's own dispatcher kept, the `solverSelection` byte above it cleared. + stateOverride: [ + { + address: gateway, + stateDiff: [{ slot: PARAMS_SLOT, value: `0x${"0".repeat(24)}${DISPATCHER.slice(2)}` }], + }, + ], + }) + }, + ) + + it("fails when the call reverts", async () => { + const { bid, client } = await bidOn( + ENTRY_POINT_V09, + "0x0000000000000000000000000000000000000b03", + SELECT_SOLVER_TYPEHASH, + ) + client.call.mockRejectedValue(new Error("execution reverted: Unauthorized()")) + + await expect(bid.simulate()).rejects.toThrow("Simulation failed: execution reverted: Unauthorized()") + }) +}) diff --git a/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts b/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts index 373b2d7ef9..0079c76ef7 100644 --- a/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts +++ b/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts @@ -1,6 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" -import type { CryptoUtils } from "@/protocols/intents/CryptoUtils" +import { hashTypedData, recoverAddress, slice } from "viem" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { CryptoUtils, LEGACY_SELECT_SOLVER_TYPEHASH, SELECT_SOLVER_TYPEHASH } from "@/protocols/intents/CryptoUtils" import type { IntentGatewayContext } from "@/protocols/intents/types" +import { orderCommitment } from "@/protocols/intents/utils" import type { HexString, Order } from "@/types" // The gateway's release is read from a live node; here it is taken as supported. @@ -11,6 +13,17 @@ vi.mock("@/protocols/intents/fillOrderCodec", async (importOriginal) => ({ const { GasEstimator } = await import("@/protocols/intents/GasEstimator") +// The rundler priority fee is fetched outside the mocked bundler calls; this bundler serves none. +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + json: async () => ({ jsonrpc: "2.0", id: 1, error: { code: -32601, message: "Method not found" } }), + })), + ) +}) +afterEach(() => vi.unstubAllGlobals()) + // A testnet chain, so gas is not priced through a swap quote. const CHAIN = "EVM-97" const GATEWAY = "0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716" as HexString @@ -31,16 +44,23 @@ const ORDER: Order = { output: { beneficiary: USER, assets: [{ token: TOKEN, amount: 990n }], call: "0x" }, } -function estimatorWith(bundler: { batch: ReturnType; single: ReturnType }) { +function estimatorWith( + bundler: { batch: ReturnType; single: ReturnType }, + { + gateway = { address: GATEWAY, typehash: SELECT_SOLVER_TYPEHASH }, + entryPoint = ENTRY_POINT, + }: { gateway?: { address: HexString; typehash: HexString }; entryPoint?: HexString | null } = {}, +) { const chain = { config: { stateMachineId: CHAIN }, client: { getGasPrice: vi.fn().mockResolvedValue(1_000_000n), getBlock: vi.fn().mockResolvedValue({ baseFeePerGas: 900_000n }), + readContract: vi.fn().mockResolvedValue(gateway.typehash), }, configService: { - getIntentGatewayAddress: () => GATEWAY, - getEntryPointV08Address: () => ENTRY_POINT, + getIntentGatewayAddress: () => gateway.address, + getEntryPointAddress: () => entryPoint ?? undefined, }, getFeeTokenWithDecimals: vi.fn().mockResolvedValue({ address: `0x${TOKEN.slice(26)}`, decimals: 6 }), } @@ -106,4 +126,102 @@ describe("GasEstimator.estimateFillOrder when the bundler fails to estimate", () expect(estimate.verificationGasLimit).toBe(105_000n) expect(estimate.preVerificationGas).toBe(105_000n) }) + + it("throws when the chain has no EntryPoint configured and a bundler estimate is required", async () => { + const bundler = rejecting() + const estimator = estimatorWith(bundler, { entryPoint: null }) + + 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() + }) +}) + +describe("GasEstimator.estimateFillOrder session signature", () => { + const succeeding = () => ({ + batch: vi + .fn() + .mockResolvedValue([{ callGasLimit: "0x1", verificationGasLimit: "0x1", preVerificationGas: "0x1" }]), + single: vi.fn(), + }) + + /** 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] + return { + sender: op.sender, + selection: slice(op.signature, 97), + userOpHash: CryptoUtils.computeUserOpHash( + { + sender: op.sender, + nonce: BigInt(op.nonce), + initCode: "0x", + callData: op.callData, + accountGasLimits: CryptoUtils.packGasLimits( + BigInt(op.verificationGasLimit), + BigInt(op.callGasLimit), + ), + preVerificationGas: BigInt(op.preVerificationGas), + gasFees: CryptoUtils.packGasFees(BigInt(op.maxPriorityFeePerGas), BigInt(op.maxFeePerGas)), + paymasterAndData: "0x", + signature: "0x", + }, + ENTRY_POINT, + 97n, + ), + } + } + + const domain = (verifyingContract: HexString) => + ({ name: "IntentGateway", version: "2", chainId: 97, verifyingContract }) as const + + it("selects by userOpHash on a gateway that selects by userOpHash", async () => { + const gateway = { + address: "0x0000000000000000000000000000000000000c01" as HexString, + typehash: SELECT_SOLVER_TYPEHASH, + } + const bundler = succeeding() + + await estimatorWith(bundler, { gateway }).estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) + + const { sender, selection, userOpHash } = sentOp(bundler.batch) + const digest = hashTypedData({ + domain: domain(gateway.address), + types: { + SelectSolver: [ + { name: "commitment", type: "bytes32" }, + { name: "userOpHash", type: "bytes32" }, + ], + }, + primaryType: "SelectSolver", + message: { commitment: orderCommitment(ORDER) as HexString, userOpHash }, + }) + expect(await recoverAddress({ hash: digest, signature: selection })).toBe(sender) + }) + + it("selects by the solver's address on a gateway that selects by address", async () => { + const gateway = { + address: "0x0000000000000000000000000000000000000c02" as HexString, + typehash: LEGACY_SELECT_SOLVER_TYPEHASH, + } + const bundler = succeeding() + + await estimatorWith(bundler, { gateway }).estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) + + const { sender, selection } = sentOp(bundler.batch) + const digest = hashTypedData({ + domain: domain(gateway.address), + types: { + SelectSolver: [ + { name: "commitment", type: "bytes32" }, + { name: "solver", type: "address" }, + ], + }, + primaryType: "SelectSolver", + message: { commitment: orderCommitment(ORDER) as HexString, solver: sender }, + }) + expect(await recoverAddress({ hash: digest, signature: selection })).toBe(sender) + }) }) diff --git a/sdk/packages/sdk/src/tests/fillEstimateEscrow.test.ts b/sdk/packages/sdk/src/tests/fillEstimateEscrow.test.ts index a380b27e01..04a2406ed8 100644 --- a/sdk/packages/sdk/src/tests/fillEstimateEscrow.test.ts +++ b/sdk/packages/sdk/src/tests/fillEstimateEscrow.test.ts @@ -17,15 +17,17 @@ const SOLVER = "0x9C7054b429f6b1dd35FD03e4fDC4f875Bc19931f" as HexString const COMMITMENT = "0x0cfe2884884e8fd6752b0eab75b100f4111a2cc19018efb02891f16575c4d844" as HexString const LEG0_ESCROW_SLOT = "0x7eae9e13a21d616d177f4e2573da33a918bfa4f615c8d57173b436ebd9797750" -const DISPATCHER = "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333" as HexString const PARAMS_SLOT = `0x${"0".repeat(63)}5` -function estimator(options: { dispatcher?: HexString; client?: unknown } = {}) { +/** The gateway's params slot 5 with `solverSelection` set in byte 20, above `dispatcher`. */ +const paramsWord = (dispatcher: HexString) => `0x${"00".repeat(11)}01${dispatcher.slice(2)}` as HexString + +function estimator(options: { client?: unknown } = {}) { const ctx = { dest: { - client: options.client ?? {}, + client: options.client ?? { getStorageAt: async () => paramsWord(`0x${"d1".repeat(20)}`) }, configService: { - getCalldispatcherAddress: () => options.dispatcher ?? DISPATCHER, + getCalldispatcherAddress: () => "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", getSolverAccountAddress: () => undefined, }, }, @@ -72,7 +74,7 @@ describe("GasEstimator.buildStateOverride escrow", () => { }) }) -describe("GasEstimator.buildStateOverride call dispatcher", () => { +describe("GasEstimator.buildStateOverride solver selection", () => { const override = (gasEstimator: InstanceType) => gasEstimator.buildStateOverride({ accountAddress: SOLVER, @@ -82,27 +84,29 @@ describe("GasEstimator.buildStateOverride call dispatcher", () => { intentGatewayV2Address: GATEWAY, }) - it("writes the configured dispatcher into params slot 5 without reading the gateway", async () => { - const readContract = vi.fn() + it("clears only the solverSelection byte of the gateway's params slot 5", async () => { + // Bytes 21-31 are unused by the layout; they are set here to show they are kept as read. + const word = `0x${"ee".repeat(11)}01${"d1".repeat(20)}` as HexString + const getStorageAt = vi.fn(async (_request: unknown) => word) - const { bundler } = await override(estimator({ client: { readContract } })) + const { bundler } = await override(estimator({ client: { getStorageAt } })) - expect(bundler[GATEWAY].stateDiff?.[PARAMS_SLOT]).toBe(`0x${"0".repeat(24)}${DISPATCHER.slice(2)}`) - expect(readContract).not.toHaveBeenCalled() + expect(getStorageAt).toHaveBeenCalledWith({ address: GATEWAY, slot: PARAMS_SLOT }) + expect(bundler[GATEWAY].stateDiff?.[PARAMS_SLOT]).toBe(`0x${"ee".repeat(11)}00${"d1".repeat(20)}`) }) - it("reads the dispatcher from the gateway, once, when the chain's config has none", async () => { - // A chain with no `Calldispatcher` answers "0x". Written as it stands, the slot's value - // is 12 bytes and the bundler rejects the estimate as `Invalid params`. - const onChain = "0x876F1891982E260026630c233A4897160A281Fb8" as HexString - const readContract = vi.fn(async (_call: unknown) => ({ dispatcher: onChain })) - const gasEstimator = estimator({ dispatcher: "0x" as HexString, client: { readContract } }) - - const { bundler } = await override(gasEstimator) - await override(gasEstimator) - - expect(bundler[GATEWAY].stateDiff?.[PARAMS_SLOT]).toBe(`0x${"0".repeat(24)}${onChain.slice(2).toLowerCase()}`) - expect(readContract).toHaveBeenCalledTimes(1) - expect(readContract.mock.calls[0][0]).toMatchObject({ address: GATEWAY, functionName: "params" }) + it("keeps the gateway's own dispatcher, read on every estimate", async () => { + const dispatchers = [`0x${"e2".repeat(20)}`, `0x${"2b".repeat(20)}`] as HexString[] + const getStorageAt = vi + .fn() + .mockResolvedValueOnce(paramsWord(dispatchers[0])) + .mockResolvedValueOnce(paramsWord(dispatchers[1])) + const gasEstimator = estimator({ client: { getStorageAt } }) + + for (const dispatcher of dispatchers) { + const { bundler } = await override(gasEstimator) + expect(bundler[GATEWAY].stateDiff?.[PARAMS_SLOT]).toBe(`0x${"00".repeat(12)}${dispatcher.slice(2)}`) + } + expect(getStorageAt).toHaveBeenCalledTimes(2) }) }) diff --git a/sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts b/sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts new file mode 100644 index 0000000000..c5d641b408 --- /dev/null +++ b/sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { SELECT_SOLVER_TYPEHASH, type CryptoUtils } from "@/protocols/intents/CryptoUtils" +import type { IntentGatewayContext } from "@/protocols/intents/types" +import type { HexString, Order } from "@/types" + +// The gateway's release is read from a live node; here it is taken as supported. +vi.mock("@/protocols/intents/fillOrderCodec", async (importOriginal) => ({ + ...(await importOriginal()), + assertGatewayRelease: vi.fn(async () => {}), +})) + +vi.mock("@/protocols/intents/rundlerFees", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, fetchRundlerPriorityFee: vi.fn(actual.fetchRundlerPriorityFee) } +}) + +const { GasEstimator } = await import("@/protocols/intents/GasEstimator") +const { fetchRundlerPriorityFee } = await import("@/protocols/intents/rundlerFees") + +// A testnet chain, so gas is not priced through a swap quote. +const CHAIN = "EVM-97" +const GATEWAY = "0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716" as HexString +const ENTRY_POINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" as HexString +const USER = "0x9C7054b429f6b1dd35FD03e4fDC4f875Bc19931f" as HexString +const TOKEN = `0x000000000000000000000000${"46c85152bfe9f96829aa94755d9f915f9b10ef5f"}` as HexString + +const GAS_PRICE = 1_000_000n +const BASE_FEE = 900_000n +// The chain estimate with the default 8% priority and 10% max fee bumps. +const CHAIN_FEES = { maxPriorityFeePerGas: 1_080_000n, maxFeePerGas: 1_100_000n } + +const ORDER: Order = { + user: USER, + source: CHAIN, + destination: CHAIN, + deadline: 65337297000n, + nonce: 0n, + fees: 0n, + session: USER, + predispatch: { assets: [], call: "0x" }, + inputs: [{ token: TOKEN, amount: 1_000n }], + output: { beneficiary: USER, assets: [{ token: TOKEN, amount: 990n }], call: "0x" }, +} + +const GAS_ESTIMATE = { callGasLimit: "0x1", verificationGasLimit: "0x1", preVerificationGas: "0x1" } + +function estimatorWith(bundlerUrl: string, batch: ReturnType) { + const chain = { + config: { stateMachineId: CHAIN }, + client: { + getGasPrice: vi.fn().mockResolvedValue(GAS_PRICE), + getBlock: vi.fn().mockResolvedValue({ baseFeePerGas: BASE_FEE }), + readContract: vi.fn().mockResolvedValue(SELECT_SOLVER_TYPEHASH), + }, + configService: { + getIntentGatewayAddress: () => GATEWAY, + getEntryPointAddress: () => ENTRY_POINT, + }, + getFeeTokenWithDecimals: vi.fn().mockResolvedValue({ address: `0x${TOKEN.slice(26)}`, decimals: 6 }), + } + 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(), + } as unknown as CryptoUtils + + const estimator = new GasEstimator(ctx, crypto) + // Storage-slot discovery traces calls on a live node. + vi.spyOn(estimator, "buildStateOverride").mockResolvedValue({ viem: [], bundler: {} }) + 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 }) })) + vi.stubGlobal("fetch", fetch) + return fetch +} + +beforeEach(() => vi.mocked(fetchRundlerPriorityFee).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])) + + 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") + }) + + 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 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() + }) +}) diff --git a/sdk/packages/sdk/src/tests/rundlerFees.test.ts b/sdk/packages/sdk/src/tests/rundlerFees.test.ts new file mode 100644 index 0000000000..627dbe64b1 --- /dev/null +++ b/sdk/packages/sdk/src/tests/rundlerFees.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { applyRundlerPriorityFee, fetchRundlerPriorityFee } from "@/protocols/intents/rundlerFees" + +function stubBundler(reply: () => Promise) { + const fetch = vi.fn(async () => ({ json: reply })) + vi.stubGlobal("fetch", fetch) + return fetch +} + +afterEach(() => vi.unstubAllGlobals()) + +describe("fetchRundlerPriorityFee", () => { + it("returns the priority fee rundler requires", async () => { + const fetch = stubBundler(async () => ({ jsonrpc: "2.0", id: 1, result: "0x6fc23ac00" })) + + expect(await fetchRundlerPriorityFee("https://rundler-a.example")).toBe(30_000_000_000n) + const [url, init] = fetch.mock.calls[0] as unknown as [string, { body: string }] + expect(url).toBe("https://rundler-a.example") + expect(JSON.parse(init.body)).toMatchObject({ method: "rundler_maxPriorityFeePerGas", 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 fetchRundlerPriorityFee("https://other-a.example")).toBeNull() + expect(await fetchRundlerPriorityFee("https://other-a.example")).toBeNull() + expect(fetch).toHaveBeenCalledOnce() + }) + + it("asks again after an internal error", async () => { + const fetch = stubBundler(async () => ({ + jsonrpc: "2.0", + id: 1, + error: { code: -32603, message: "should get required fees" }, + })) + + expect(await fetchRundlerPriorityFee("https://rundler-b.example")).toBeNull() + expect(await fetchRundlerPriorityFee("https://rundler-b.example")).toBeNull() + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it("asks again after a network failure", async () => { + const fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) + vi.stubGlobal("fetch", fetch) + + expect(await fetchRundlerPriorityFee("https://rundler-c.example")).toBeNull() + expect(await fetchRundlerPriorityFee("https://rundler-c.example")).toBeNull() + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) + +describe("applyRundlerPriorityFee", () => { + const bumps = { priorityFeeBumpPercent: 8n, maxFeeBumpPercent: 10n } + + it("raises both fees to the rundler floor when it is above the estimate", () => { + const fees = applyRundlerPriorityFee( + { maxFeePerGas: 1_100_000n, maxPriorityFeePerGas: 1_080_000n }, + { rundlerPriorityFee: 100_000_000n, baseFeePerGas: 900_000n, ...bumps }, + ) + + expect(fees).toEqual({ maxPriorityFeePerGas: 108_000_000n, maxFeePerGas: 990_000n + 108_000_000n }) + }) + + it("keeps fees that already clear the floor", () => { + const fees = applyRundlerPriorityFee( + { maxFeePerGas: 1_100_000n, maxPriorityFeePerGas: 1_080_000n }, + { rundlerPriorityFee: 1_000n, baseFeePerGas: 900_000n, ...bumps }, + ) + + expect(fees).toEqual({ maxPriorityFeePerGas: 1_080_000n, maxFeePerGas: 1_100_000n }) + }) +}) diff --git a/sdk/packages/sdk/src/tests/solverSelection.test.ts b/sdk/packages/sdk/src/tests/solverSelection.test.ts new file mode 100644 index 0000000000..dbf48fa646 --- /dev/null +++ b/sdk/packages/sdk/src/tests/solverSelection.test.ts @@ -0,0 +1,130 @@ +import { createPublicClient, custom, encodeAbiParameters, recoverAddress, toFunctionSelector } from "viem" +import { describe, expect, it } from "vitest" +import { CryptoUtils, LEGACY_SELECT_SOLVER_TYPEHASH, SELECT_SOLVER_TYPEHASH } from "@/protocols/intents/CryptoUtils" +import { readSelectionFormat } from "@/protocols/intents/selection" +import type { HexString } from "@/types" + +// Vectors derived with foundry (`cast abi-encode` + `cast keccak`, cross-checked with +// `cast wallet sign --data` over the EIP-712 JSON), independent of the SDK. +const CHAIN_ID = 8453n +const GATEWAY = "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA" as HexString +const SESSION_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" as HexString +const SESSION_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" +const COMMITMENT = "0x5193b7b26e003ba73618491c243b3e2aa9ec144ce99506d13c38d523b298fa82" as HexString +const USER_OP_HASH = "0xab9c42a6333902fb3a76ad9afe551c6136a717d399208978e99f0c3a437cebe4" as HexString +const SOLVER = "0xEa4f68301aCec0dc9Bbe10F15730c59FB79d237E" as HexString + +const DOMAIN_SEPARATOR = "0x0f99e3135cb4975ba4730000fb380174ac0c44e7ff4a028af7cc448bad9230be" +const USER_OP_HASH_TYPEHASH = "0xe706bdab7d945360dcd9d81d355f856754dd1cfa461edfc0a7502e2583b4e09e" +const ADDRESS_TYPEHASH = "0x4e7a964fde3ec316fdcf2e27971047ec3048eaa33a854c4c0b363e05c61b68d0" +const USER_OP_HASH_DIGEST = "0x7dcb5f1729deab3f4e5522520839b3e5ba2f9e7f0ce3454597cace7ed37f13ed" +const ADDRESS_DIGEST = "0xdb1462cadfba4250c00830d787f8c777f8a18dbc9ad0160de16e7f34ece56b9c" +const USER_OP_HASH_SIGNATURE = + "0x4252ea55528ee8b8787f76949c5e7741c0ae7ad604c88047e9bacd3a2bcfdda02e181448672502e9d83110fa095ddf8c07c3f889faaa02531bb180543f4fef2a1b" +const ADDRESS_SIGNATURE = + "0x23bbf20996acba6d2a6c53e35d8e69812f04be01430837104f68ffd8898e4a762b4260f9d0bb1fe43077ac70eb84e936c5bd73bac89f30f9c9a9fd56d5c6c64e1b" + +const TYPEHASH_SELECTOR = toFunctionSelector("SELECT_SOLVER_TYPEHASH()") + +/** A client over a gateway that answers `SELECT_SOLVER_TYPEHASH()` with `typehash`, or fails while `down`. */ +function gatewayClient(state: { typehash: HexString; down?: boolean; calls: { to: string; data: string }[] }) { + return createPublicClient({ + transport: custom( + { + async request({ method, params }) { + if (method !== "eth_call") throw new Error(`unexpected ${method}`) + const { to, data } = (params as [{ to: string; data: string }])[0] + state.calls.push({ to, data }) + if (state.down) throw new Error("fetch failed") + if (data !== TYPEHASH_SELECTOR) throw new Error(`unexpected call ${data}`) + return encodeAbiParameters([{ type: "bytes32" }], [state.typehash]) + }, + }, + { retryCount: 0 }, + ), + }) +} + +describe("solver selection signatures", () => { + it("typehashes and domain separator match the foundry vectors", () => { + expect(SELECT_SOLVER_TYPEHASH).toBe(USER_OP_HASH_TYPEHASH) + expect(LEGACY_SELECT_SOLVER_TYPEHASH).toBe(ADDRESS_TYPEHASH) + expect(CryptoUtils.getDomainSeparator("IntentGateway", "2", CHAIN_ID, GATEWAY)).toBe(DOMAIN_SEPARATOR) + }) + + it("signs SelectSolver(bytes32 commitment,bytes32 userOpHash)", async () => { + const signature = await CryptoUtils.signUserOpHashSelection( + COMMITMENT, + USER_OP_HASH, + DOMAIN_SEPARATOR, + SESSION_KEY, + ) + expect(signature).toBe(USER_OP_HASH_SIGNATURE) + expect(await recoverAddress({ hash: USER_OP_HASH_DIGEST, signature })).toBe(SESSION_ADDRESS) + }) + + it("signs SelectSolver(bytes32 commitment,address solver)", async () => { + const signature = await CryptoUtils.signLegacySolverSelection(COMMITMENT, SOLVER, DOMAIN_SEPARATOR, SESSION_KEY) + expect(signature).toBe(ADDRESS_SIGNATURE) + expect(await recoverAddress({ hash: ADDRESS_DIGEST, signature })).toBe(SESSION_ADDRESS) + }) +}) + +describe("readSelectionFormat", () => { + it("reads userOpHash from an upgraded gateway", async () => { + const state = { typehash: USER_OP_HASH_TYPEHASH as HexString, calls: [] as { to: string; data: string }[] } + const gateway = "0x0000000000000000000000000000000000000a01" as HexString + + expect(await readSelectionFormat(gatewayClient(state), CHAIN_ID, gateway)).toBe("userOpHash") + expect(state.calls).toEqual([{ to: gateway, data: TYPEHASH_SELECTOR }]) + }) + + it("reads address from a gateway that selects by address", async () => { + const state = { typehash: ADDRESS_TYPEHASH as HexString, calls: [] as { to: string; data: string }[] } + + expect( + await readSelectionFormat(gatewayClient(state), CHAIN_ID, "0x0000000000000000000000000000000000000a02"), + ).toBe("address") + }) + + it("throws on an unknown typehash", async () => { + const state = { typehash: `0x${"11".repeat(32)}` as HexString, calls: [] as { to: string; data: string }[] } + + await expect( + readSelectionFormat(gatewayClient(state), CHAIN_ID, "0x0000000000000000000000000000000000000a03"), + ).rejects.toThrow(/unknown SELECT_SOLVER_TYPEHASH/) + }) + + it("reads once per chain and gateway", async () => { + const state = { typehash: USER_OP_HASH_TYPEHASH as HexString, calls: [] as { to: string; data: string }[] } + const client = gatewayClient(state) + const gateway = GATEWAY + + await Promise.all([ + readSelectionFormat(client, CHAIN_ID, gateway), + readSelectionFormat(client, CHAIN_ID, gateway.toLowerCase() as HexString), + ]) + await readSelectionFormat(client, Number(CHAIN_ID), gateway) + expect(state.calls).toHaveLength(1) + + state.typehash = ADDRESS_TYPEHASH + expect(await readSelectionFormat(client, 1n, gateway)).toBe("address") + expect(await readSelectionFormat(client, CHAIN_ID, gateway)).toBe("userOpHash") + expect(state.calls).toHaveLength(2) + }) + + it("reads again after a failed read", async () => { + const state = { + typehash: USER_OP_HASH_TYPEHASH as HexString, + down: true, + calls: [] as { to: string; data: string }[], + } + const client = gatewayClient(state) + const gateway = "0x0000000000000000000000000000000000000a05" as HexString + + await expect(readSelectionFormat(client, CHAIN_ID, gateway)).rejects.toThrow() + state.down = false + expect(await readSelectionFormat(client, CHAIN_ID, gateway)).toBe("userOpHash") + expect(state.calls).toHaveLength(2) + }) +}) diff --git a/sdk/packages/sdk/src/tests/userOperationLogs.test.ts b/sdk/packages/sdk/src/tests/userOperationLogs.test.ts index b20b2ba4e6..b99e084388 100644 --- a/sdk/packages/sdk/src/tests/userOperationLogs.test.ts +++ b/sdk/packages/sdk/src/tests/userOperationLogs.test.ts @@ -1,11 +1,13 @@ import { ABI } from "@/abis/IntentGatewayV2" +import { ENTRY_POINT_V09 } from "@/configs/chain" import { BidImpl, userOperationLogs } from "@/protocols/intents/Bid" -import { CryptoUtils } from "@/protocols/intents/CryptoUtils" +import { CryptoUtils, SELECT_SOLVER_TYPEHASH } from "@/protocols/intents/CryptoUtils" import type { HexString, Order, PackedUserOperation } from "@/types" -import { encodeAbiParameters, encodeEventTopics, pad, toEventSelector, type Log } from "viem" +import { concat, encodeAbiParameters, encodeEventTopics, pad, toEventSelector, type Log } from "viem" +import { privateKeyToAccount } from "viem/accounts" import { describe, expect, it, vi } from "vitest" -const ENTRY_POINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" as HexString +const ENTRY_POINT = ENTRY_POINT_V09 const GATEWAY = "0x6666666666666666666666666666666666666666" as HexString const OURS = `0x${"aa".repeat(32)}` as HexString const THEIRS = `0x${"bb".repeat(32)}` as HexString @@ -66,7 +68,8 @@ describe("userOperationLogs", () => { }) describe("BidImpl.execute fill attribution", () => { - const SOLVER = "0x2222222222222222222222222222222222222222" as HexString + const solver = privateKeyToAccount(`0x${"02".repeat(32)}`) + const SOLVER = solver.address const TOKEN = pad("0x55", { size: 32 }) as HexString const COMMITMENT = `0x${"66".repeat(32)}` as HexString const userOp: PackedUserOperation = { @@ -78,7 +81,7 @@ describe("BidImpl.execute fill attribution", () => { preVerificationGas: 1n, gasFees: `0x${"00".repeat(32)}`, paymasterAndData: "0x", - signature: "0x12", + signature: "0x", } const order: Order = { id: COMMITMENT, @@ -104,6 +107,10 @@ describe("BidImpl.execute fill attribution", () => { } it("credits only the fills its own operation made in the gateway", async () => { + userOp.signature = concat([ + COMMITMENT, + await solver.signTypedData(CryptoUtils.packedUserOpTypedData(userOp, ENTRY_POINT, 8453n)), + ]) let userOpHash = "0x" as HexString // Another solver fills the same order earlier in the bundle, and a foreign contract imitates // the gateway's event inside our operation. @@ -121,9 +128,10 @@ describe("BidImpl.execute fill attribution", () => { sessionKeyStorage: { getSessionKeyByAddress: async () => ({ privateKey: `0x${"01".repeat(32)}` }) }, dest: { config: { stateMachineId: "EVM-8453" }, - configService: { getEntryPointV08Address: () => ENTRY_POINT, getIntentGatewayAddress: () => GATEWAY }, + configService: { getIntentGatewayAddress: () => GATEWAY }, client: { chain: { id: 8453 }, + readContract: async () => SELECT_SOLVER_TYPEHASH, waitForTransactionReceipt: async () => ({ logs: bundle() }), }, }, @@ -145,8 +153,8 @@ describe("BidImpl.execute fill attribution", () => { }) vi.spyOn(crypto, "sendBundler").mockImplementation(async (method, params) => { if (method !== "eth_sendUserOperation") return { receipt: { transactionHash: `0x${"77".repeat(32)}` } } - const sent = (params as [Record])[0] - userOpHash = CryptoUtils.computeUserOpHash({ ...userOp, signature: sent.signature }, ENTRY_POINT, 8453n) + const [sent, entryPoint] = params as [Record, HexString] + userOpHash = CryptoUtils.computeUserOpHash({ ...userOp, signature: sent.signature }, entryPoint, 8453n) return userOpHash as never }) diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index e19f767a83..92300aaa48 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1288,7 +1288,7 @@ export interface SubmitBidOptions { /** Pre-built ERC-7821 calldata encoding the UserOp execution (approvals + fillOrder). */ callData: HexString /** - * Optional packed paymasterAndData for EntryPoint v0.8. + * Optional packed paymasterAndData (v0.7+ layout). * Must be built BEFORE calling prepareSubmitBid so the hash covers paymaster bytes. * Defaults to "0x" (EntryPoint deposit pays gas). */ @@ -1475,14 +1475,15 @@ export interface FillerBid { } /** - * Options for selecting a solver in IntentGatewayV2 + * Arguments of `select` on an IntentGatewayV2 that selects solvers by userOpHash, + * i.e. whose `SELECT_SOLVER_TYPEHASH` is `SelectSolver(bytes32 commitment,bytes32 userOpHash)`. */ export interface SelectOptions { /** The order commitment hash (bytes32) */ commitment: HexString - /** The solver address to select */ - solver: HexString - /** The EIP-712 signature from the session key */ + /** EntryPoint userOpHash of the selected bid UserOperation */ + userOpHash: HexString + /** The session key's EIP-712 signature over `SelectSolver(commitment, userOpHash)` */ signature: HexString } @@ -1514,10 +1515,11 @@ export interface Bid { /** Raw UserOperation for advanced consumers (custom batching, paymaster flows). */ readonly userOp: PackedUserOperation /** - * Runs the batched `select` + `fillOrder` `eth_call` simulation so a bid can - * be pre-checked before it is shown to a user or submitted. + * Runs the bid's `fillOrder` as an `eth_call`, with the gateway's solver + * selection turned off, so a bid can be pre-checked before it is shown to a + * user or submitted. * - * @throws If the simulation reverts. + * @throws If the bid cannot execute on the destination, or the simulation reverts. */ simulate(): Promise /** diff --git a/sdk/packages/simplex-desktop/package.json b/sdk/packages/simplex-desktop/package.json index c41b225927..c80c2c6268 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.16.13", + "version": "0.17.0", "private": true, "description": "Native desktop shell for the Simplex intent solver", "author": "Polytope Labs ", diff --git a/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md b/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md index 03ee949430..2cd643c89b 100644 --- a/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md +++ b/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md @@ -13,7 +13,7 @@ Entry points: `UserOpSender.trySendSponsored` (delegation, vault sweeps/redeems, Step 1's deposit gate prices against `VERIFICATION_GAS_LIMIT_PERMIT` (250k) when `permitBootstrap` is set and `VERIFICATION_GAS_LIMIT_PERMIT2` (200k) otherwise, since the bootstrap op does not know which mode it will land in until the allowance is read. -3. Back in `UserOpSender.trySendSponsored`, the bundler gas price is fetched *before* paymaster selection so the deposit gate can price the op's max prefund (from the caller's fixed limits, or the generous fallbacks when estimating later). The EIP-7702 authorization thunk is still resolved only after paymaster data is built, because the bootstrap approve is a tx from the same EOA and an authorization signed earlier would carry a stale nonce (bundler reject). Then `EntryPoint.getNonce`, estimation (or the caller's fixed limits), signing of the packed userOp typed data, `eth_sendUserOperation`, and receipt polling. +3. Back in `UserOpSender.trySendSponsored`, the bundler gas price is fetched *before* paymaster selection so the deposit gate can price the op's max prefund (from the caller's fixed limits, or the generous fallbacks when estimating later). `getGasPrice` uses Pimlico's and Alchemy's own pricing; for any other bundler it bumps the chain gas price and, when the bundler answers `rundler_maxPriorityFeePerGas`, raises both fees to that floor through the SDK's `applyRundlerPriorityFee`, the same rule `GasEstimator` applies to bids. The EIP-7702 authorization thunk is still resolved only after paymaster data is built, because the bootstrap approve is a tx from the same EOA and an authorization signed earlier would carry a stale nonce (bundler reject). Then `EntryPoint.getNonce`, estimation (or the caller's fixed limits), signing of the packed userOp typed data, `eth_sendUserOperation`, and receipt polling. 4. On chain (`evm/src/utils/SimplexPaymaster.sol`): `_validatePaymasterUserOp` first reverts `UnauthorizedBundler(origin)` when the governance bundler list is non-empty and `tx.origin` is not on it (an empty list skips the check without reading ORIGIN; see `docs/ai/changelog/2026-10-05-simplex-paymaster-entrypoint-v09-and-bundler-allowlist.md`), then executes the permit for mode 0 after checking the token is registered and active; `_fetchDetails` accepts mode bytes `0x00` and `0x02` (`InvalidMode` otherwise, so a mode `0x01` payload with a standing allowance behind it is refused before any transfer) and, for mode 2, returns the permit deadline as `validUntil`; `_prefund` pulls mode 0 through the base `transferFrom` on the permit's allowance and mode 2 through `Permit2.permitTransferFrom` (owner = `userOp.sender`, to = paymaster, requestedAmount = oracle-derived prefund, capped by the signed amount); postOp refunds the unused part to the sender. diff --git a/sdk/packages/simplex/e2e/budget.mjs b/sdk/packages/simplex/e2e/budget.mjs index 38a335ceb2..5a8846ed8e 100644 --- a/sdk/packages/simplex/e2e/budget.mjs +++ b/sdk/packages/simplex/e2e/budget.mjs @@ -18,7 +18,7 @@ import { stringToBytes, zeroAddress, } from "viem" -import { ENTRY_POINT, GATEWAY, TOKENS } from "./env.mjs" +import { GATEWAY, TOKENS, entryPointOf } from "./env.mjs" // Simplex exports neither of these from its package root, so they are restated here. export const ACCOUNT_ABI = parseAbi([ @@ -28,7 +28,7 @@ export const ACCOUNT_ABI = parseAbi([ "error LimitOrderExceeded(bytes32 orderId, uint256 total, uint256 cap)", ]) -/** The EntryPoint v0.8 events that say whether an operation ran and, if not, why. */ +/** The EntryPoint events, the same in v0.8 and v0.9, that say whether an operation ran and, if not, why. */ export const ENTRY_POINT_ABI = parseAbi([ "struct PackedUserOperation { address sender; uint256 nonce; bytes initCode; bytes callData; bytes32 accountGasLimits; uint256 preVerificationGas; bytes32 gasFees; bytes paymasterAndData; bytes signature; }", "function getUserOpHash(PackedUserOperation userOp) view returns (bytes32)", @@ -58,15 +58,16 @@ export function debitOf(userOp) { } /** - * What the EntryPoint logged for `userOpHash` in a bundle's receipt: its `UserOperationEvent`, its + * What `chain`'s EntryPoint logged for `userOpHash` in a bundle's receipt: its `UserOperationEvent`, its * revert reason, and the logs its execution left. A bundle can carry other operations, so the * execution's logs are the ones between the previous operation's event, or `BeforeExecution`, and * this one's. Undefined when the operation is not in the receipt. */ -export function operationIn(receipt, userOpHash) { +export function operationIn(chain, receipt, userOpHash) { + const entryPoint = entryPointOf(chain).toLowerCase() const logs = receipt.logs const events = parseEventLogs({ abi: ENTRY_POINT_ABI, logs, strict: false }).filter( - (event) => event.address.toLowerCase() === ENTRY_POINT.toLowerCase(), + (event) => event.address.toLowerCase() === entryPoint, ) const ours = (event) => event.args.userOpHash?.toLowerCase() === userOpHash.toLowerCase() const done = events.find((event) => event.eventName === "UserOperationEvent" && ours(event)) @@ -367,7 +368,7 @@ async function refusalChecks(ctx, result, orders, tallies) { return `${label(order)}: the bid sent past the simulation never reached the chain: ${r.executeResult ?? r.executeError}` } const receipt = await client.getTransactionReceipt({ hash: r.transactionHash }) - const op = operationIn(receipt, r.userOpHash) + const op = operationIn(order.fillChain, receipt, r.userOpHash) if (!op) return `${r.transactionHash} does not carry the operation ${r.userOpHash}` if (op.success) return `${label(order)}: a fill past its size went through in ${r.transactionHash}` const refused = op.revertReason ? decodeRefusal(op.revertReason) : undefined diff --git a/sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs b/sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs new file mode 100644 index 0000000000..fbb41ddcc7 --- /dev/null +++ b/sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs @@ -0,0 +1,13 @@ +// The indexer's own userOpHash extraction, run on a bundle receipt for e2e/entrypoint-v09-local.mjs. +// It needs the indexer's tsconfig paths, so it runs as its own tsx process from that package. +// Reads `{ logs, sender, logIndex }` as JSON on stdin and prints `{ userOpHash }`. +const { findUserOpHash } = require("../../indexer/src/utils/userOp.helpers.ts") + +let input = "" +process.stdin.on("data", (chunk) => { + input += chunk +}) +process.stdin.on("end", () => { + const { logs, sender, logIndex } = JSON.parse(input) + process.stdout.write(JSON.stringify({ userOpHash: findUserOpHash(logs, sender, logIndex) ?? null })) +}) diff --git a/sdk/packages/simplex/e2e/entrypoint-v09-local.mjs b/sdk/packages/simplex/e2e/entrypoint-v09-local.mjs new file mode 100644 index 0000000000..f5a2e99040 --- /dev/null +++ b/sdk/packages/simplex/e2e/entrypoint-v09-local.mjs @@ -0,0 +1,769 @@ +// EntryPoint v0.9 end to end on a local anvil fork of BSC Chapel, in place of a live testnet run. +// +// 1. Governance, delivered as the host: the IntentGatewayV2 proxy moves to the userOpHash +// implementation through `Execute`, and the SimplexPaymaster bundler allowlist is set through +// the nested `UpgradeContract(sameImpl, onAccept(SetBundlers))` route of the sudo script. +// 2. A fresh solver EOA delegates to the v0.9 SolverAccount through EIP-7702. +// 3. A user places a same-chain order with the SDK. The solver prices it with the SDK's gas +// estimate, takes `paymasterAndData` from simplex's paymaster builder and signs it with +// `prepareSubmitBid`. +// 4. The user runs the SDK's `buildBids`, `sortBids`, `simulate` and `execute`, which sends the +// bid to a local rundler that bundles it on the fork. +// 5. The rundler signer is taken off the allowlist, and a second sponsored bid must be refused. +// +// From sdk/, after `pnpm -C packages/sdk build`: +// FORK_URL= pnpm -C packages/simplex exec tsx e2e/entrypoint-v09-local.mjs +// +// It runs under tsx because simplex does not export its paymaster builder. Environment: +// FORK_URL Chapel RPC to fork (else BSC_TESTNET_RPC_URL, else BSC_CHAPEL in the env file) +// FORK_BLOCK Block to fork at (default: latest) +// RUNDLER_SIGNER_PRIVATE_KEY Rundler's signer (else read by exact key from the env file) +// RUNDLER_BIN Default: ../rundler/target/debug/rundler beside this repository +// E2E_ENV_FILE Default: sdk/.env.local, read line by line, never sourced +// GATEWAY_IMPL Default: INTENT_GATEWAY_V2_IMPL of [97.address] in evm/config.testnet.toml +import { execFileSync, spawn } from "node:child_process" +import fs from "node:fs" +import net from "node:net" +import os from "node:os" +import path from "node:path" +import readline from "node:readline" +import { fileURLToPath } from "node:url" +import { + ChainConfigService, + CryptoUtils, + DEFAULT_GRAFFITI, + EvmChain, + FILL_ORDER_SELECTOR, + IntentGateway, + bytes20ToBytes32, + encodeERC7821ExecuteBatch, + encodeFillOrder, + transformOrderForContract, +} from "@hyperbridge/sdk" +import { + concat, + createPublicClient, + createWalletClient, + decodeErrorResult, + encodeAbiParameters, + encodeFunctionData, + erc20Abi, + getAddress, + http, + isAddressEqual, + keccak256, + parseAbi, + parseEther, + parseEventLogs, + parseUnits, + toFunctionSelector, + toHex, + zeroAddress, +} from "viem" +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts" +import { bscTestnet } from "viem/chains" +import { FillerConfigService } from "../src/services/FillerConfigService.ts" +import { buildPaymasterAndData } from "../src/services/paymaster/index.ts" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const SDK_DIR = path.resolve(HERE, "../../..") +const REPO = path.resolve(SDK_DIR, "..") +const INDEXER = path.join(SDK_DIR, "packages/indexer") +const ENV_FILE = process.env.E2E_ENV_FILE || path.join(SDK_DIR, ".env.local") +const RUNDLER_BIN = process.env.RUNDLER_BIN || path.resolve(REPO, "../rundler/target/debug/rundler") + +const CHAIN = "EVM-97" +const SIMULATION_ORIGIN = "0x0643866dA50efE0b055Cd15aF95191968c8411b5" +const SELECT_SOLVER_TYPEHASH = "0xe706bdab7d945360dcd9d81d355f856754dd1cfa461edfc0a7502e2583b4e09e" +const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" +// `IntentsBase.RequestKind.Execute` and `SimplexPaymaster.RequestKind.UpgradeContract`. +const GATEWAY_EXECUTE = 5 +const PAYMASTER_UPGRADE = 0 +const ORDER_IN = parseUnits("5", 18) +const ORDER_OUT = parseUnits("7500", 6) +const UNAUTHORIZED_BUNDLER = toFunctionSelector("UnauthorizedBundler(address)") +const SELECT = toFunctionSelector("select((bytes32,bytes32,bytes))") +const GET_CURRENT_USER_OP_HASH = toFunctionSelector("getCurrentUserOpHash()") +const VALIDATE_PAYMASTER = toFunctionSelector( + "validatePaymasterUserOp((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes),bytes32,uint256)", +) + +const config = new ChainConfigService() +const HOST = config.getHostAddress(CHAIN) +const GATEWAY = config.getIntentGatewayAddress(CHAIN) +const ENTRY_POINT = config.getEntryPointAddress(CHAIN) +const SOLVER_ACCOUNT = config.getSolverAccountAddress(CHAIN) +const PAYMASTER = config.getSimplexPaymasterAddress(CHAIN) +const USDH = config.getUsdcAsset(CHAIN) +const CNGN = config.getCNgnAsset(CHAIN) + +const abi = parseAbi([ + "struct PostRequest { bytes source; bytes dest; uint64 nonce; bytes from; bytes to; uint64 timeoutTimestamp; bytes body; }", + "struct IncomingPostRequest { PostRequest request; address relayer; }", + "function onAccept(IncomingPostRequest incoming)", + "function upgradeToAndCall(address newImplementation, bytes data)", + "function hyperbridge() view returns (bytes)", + "function host() view returns (bytes)", + "function version() view returns (uint64)", + "function SELECT_SOLVER_TYPEHASH() view returns (bytes32)", + "function params() view returns ((address host, address dispatcher, bool solverSelection, uint256 surplusShareBps, uint256 protocolFeeBps, address priceOracle))", + "function _filled(bytes32) view returns (address)", + "function _orders(bytes32, uint256) view returns (uint256)", + "function getBundlers() view returns (address[])", + "function relayer() view returns (address)", + "function treasury() view returns (address)", + "function deposit() payable", + "function addStake(uint32 unstakeDelaySec) payable", + "function getNonce(address sender, uint192 key) view returns (uint256)", + "function balanceOf(address account) view returns (uint256)", + "function getDepositInfo(address account) view returns ((uint256 deposit, bool staked, uint112 stake, uint32 unstakeDelaySec, uint48 withdrawTime))", + "event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed)", + "event OrderFilled(bytes32 indexed commitment, address filler, (bytes32 token, uint256 amount)[] outputs, (bytes32 token, uint256 amount)[] inputs)", + "error FailedOp(uint256 opIndex, string reason)", + "error FailedOpWithRevert(uint256 opIndex, string reason, bytes inner)", + "error UnauthorizedBundler(address origin)", +]) + +// ── Output ─────────────────────────────────────────────────────────── + +// Keys and endpoints never reach the output, whatever an error message quotes. Longest first. +const SECRETS = [] +const secret = (value) => { + if (value && value.length > 8) { + SECRETS.push(value) + SECRETS.sort((a, b) => b.length - a.length) + } + return value +} +const redact = (text) => SECRETS.reduce((out, value) => out.split(value).join("***"), String(text)) +for (const method of ["log", "info", "warn", "error"]) { + const original = console[method].bind(console) + console[method] = (...args) => + original( + ...args.map((arg) => (typeof arg === "string" || arg instanceof Error ? redact(arg.stack ?? arg) : arg)), + ) +} +const json = (value) => JSON.stringify(value, (_key, v) => (typeof v === "bigint" ? v.toString() : v)) +const log = (line) => console.log(`${new Date().toISOString()} ${line}`) +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const checks = [] +function check(name, ok, observed) { + checks.push({ name, ok: Boolean(ok) }) + log(`${ok ? "PASS" : "FAIL"} ${name}: ${typeof observed === "string" ? observed : json(observed)}`) +} + +function ensure(value, message) { + if (!value) throw new Error(message) + return value +} + +/** One `KEY=value` line of the env file, by exact key; nothing else is kept. */ +function envFileValue(key) { + if (!fs.existsSync(ENV_FILE)) return undefined + const line = fs + .readFileSync(ENV_FILE, "utf8") + .split(/\r?\n/) + .find((entry) => entry.startsWith(`${key}=`)) + return line + ?.slice(key.length + 1) + .trim() + .replace(/^(["'])(.*)\1$/, "$2") +} + +function gatewayImplementation() { + if (process.env.GATEWAY_IMPL) return getAddress(process.env.GATEWAY_IMPL) + const toml = fs.readFileSync(path.join(REPO, "evm/config.testnet.toml"), "utf8") + const table = /^\[97\.address\]$([\s\S]*?)(?=^\[)/m.exec(toml)?.[1] ?? "" + const impl = /^INTENT_GATEWAY_V2_IMPL\s*=\s*"(0x[0-9a-fA-F]{40})"/m.exec(table)?.[1] + return getAddress(ensure(impl, "INTENT_GATEWAY_V2_IMPL is missing from [97.address] in evm/config.testnet.toml")) +} + +// ── Processes ──────────────────────────────────────────────────────── + +const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "entrypoint-v09-local-")) +const children = [] +const running = (child) => child.exitCode === null && child.signalCode === null + +function start(name, command, args, env = {}) { + const logFile = path.join(workdir, `${name}.log`) + const out = fs.openSync(logFile, "w") + const child = spawn(command, args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }) + // Anvil's errors quote the fork URL from its argv, so lines are redacted before they reach the file. + for (const input of [child.stdout, child.stderr]) + readline.createInterface({ input }).on("line", (line) => fs.writeSync(out, `${redact(line)}\n`)) + // Unlike "exit", "close" fires only once every line has been written. + Object.assign(child, { label: name, logFile, exited: new Promise((resolve) => child.on("close", resolve)) }) + child.on("error", (error) => log(`${name}: ${error.message}`)) + children.push(child) + log(`${name} started, pid ${child.pid}, log ${logFile}`) + return child +} + +/** Polls `probe` until it answers, failing as soon as `child` exits. */ +async function ready(child, probe) { + const deadline = Date.now() + 60_000 + for (;;) { + if (!running(child)) throw new Error(`${child.label} exited; see ${child.logFile}`) + const value = await probe().catch(() => undefined) + if (value) return value + if (Date.now() > deadline) throw new Error(`${child.label} not ready after 60s; see ${child.logFile}`) + await pause(500) + } +} + +async function stopAll() { + await Promise.all( + children.filter(running).map(async (child) => { + child.kill("SIGTERM") + const timer = setTimeout(() => child.kill("SIGKILL"), 5_000) + await child.exited + clearTimeout(timer) + }), + ) + await Promise.all(children.map((child) => child.exited)) + for (const child of children) + log(`${child.label} pid ${child.pid}: ${running(child) ? "STILL RUNNING" : "stopped"}`) +} + +for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => stopAll().finally(() => process.exit(130))) + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() + server.close(() => resolve(port)) + }) + }) +} + +async function jsonRpc(url, method, params = []) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }) + const body = await response.json() + if (body.error) throw new Error(`${method}: ${body.error.message ?? json(body.error)}`) + return body.result +} + +// ── Chain helpers ──────────────────────────────────────────────────── + +let client +let transport + +const rpc = (method, params) => client.request({ method, params }) +const read = (address, functionName, args = []) => client.readContract({ address, abi, functionName, args }) +const balanceOf = (token, account) => + client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account] }) +const fund = (address) => rpc("anvil_setBalance", [address, toHex(parseEther("100"))]) + +async function confirmed(hash, label) { + const receipt = await client.waitForTransactionReceipt({ hash }) + if (receipt.status !== "success") throw new Error(`${label}: ${hash} reverted`) + return receipt +} + +/** Sends `request` as `from`, which anvil impersonates. */ +async function impersonated(from, request, label) { + await rpc("anvil_impersonateAccount", [from]) + await fund(from) + const wallet = createWalletClient({ account: from, chain: bscTestnet, transport }) + const receipt = await confirmed(await wallet.sendTransaction(request), label) + await rpc("anvil_stopImpersonatingAccount", [from]) + return receipt +} + +/** Writes `account`'s balance of `token` into the first storage slot that `balanceOf` reads it from. */ +async function deal(token, account, amount) { + for (let slot = 0n; slot < 16n; slot++) { + const key = keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, slot])) + const previous = await client.getStorageAt({ address: token, slot: key }) + await rpc("anvil_setStorageAt", [token, key, toHex(amount, { size: 32 })]) + if ((await balanceOf(token, account)) === amount) return + await rpc("anvil_setStorageAt", [token, key, previous ?? toHex(0n, { size: 32 })]) + } + throw new Error(`No balance slot found for ${token}`) +} + +async function approve(wallet, token, spender) { + const args = [spender, 2n ** 255n] + await confirmed( + await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args }), + "approve", + ) +} + +async function implementationOf(proxy) { + const word = await client.getStorageAt({ address: proxy, slot: IMPLEMENTATION_SLOT }) + return getAddress(`0x${word.slice(-40)}`) +} + +/** Every call `hash` made, depth first, from anvil's call tracer. */ +async function callsOf(hash) { + const flatten = (call) => [call, ...(call.calls ?? []).flatMap(flatten)] + return flatten(await rpc("debug_traceTransaction", [hash, { tracer: "callTracer" }])) +} + +/** The origin a handleOps revert names in `FailedOpWithRevert(_, _, UnauthorizedBundler(origin))`. */ +function unauthorizedOrigin(data) { + try { + const failed = decodeErrorResult({ abi, data }) + const inner = + failed.errorName === "FailedOpWithRevert" ? decodeErrorResult({ abi, data: failed.args[2] }) : failed + return inner.errorName === "UnauthorizedBundler" ? inner.args[0] : undefined + } catch { + return undefined + } +} + +/** The origin of an `UnauthorizedBundler(address)` revert quoted anywhere in `text`. */ +function unauthorizedIn(text) { + const word = new RegExp(`${UNAUTHORIZED_BUNDLER.slice(2)}(0{24}[0-9a-f]{40})`, "i").exec(text ?? "")?.[1] + return word && getAddress(`0x${word.slice(24)}`) +} + +/** The indexer's own `findUserOpHash`, run in the indexer package (see entrypoint-v09-local-indexer.cjs). */ +function indexerUserOpHash(logs, sender, logIndex) { + const input = json({ + logs: logs.map(({ address, topics, data, logIndex }) => ({ address, topics, data, logIndex })), + sender, + logIndex, + }) + const helper = path.join(HERE, "entrypoint-v09-local-indexer.cjs") + const tsx = path.join(INDEXER, "node_modules/.bin/tsx") + const tsconfig = path.join(INDEXER, "tsconfig.json") + const out = execFileSync(tsx, ["--tsconfig", tsconfig, helper], { cwd: INDEXER, encoding: "utf8", input }) + return JSON.parse(out).userOpHash +} + +// ── Governance ─────────────────────────────────────────────────────── + +let governanceNonce = 0n + +/** `onAccept` on `to` as the host delivers a `pallet-intents` request from Hyperbridge. */ +async function deliver(to, body, relayer, label) { + const request = { + source: await read(HOST, "hyperbridge"), + dest: await read(HOST, "host"), + nonce: governanceNonce++, + from: toHex("pallet-intents"), + to, + timeoutTimestamp: 0n, + body, + } + const data = encodeFunctionData({ abi, functionName: "onAccept", args: [{ request, relayer }] }) + const receipt = await impersonated(HOST, { to, data }, label) + log(`${label}: delivered in ${receipt.transactionHash}`) + return receipt +} + +/** `allow-bundlers` or `clear-bundlers`, with the init data the sudo script prints for it. */ +async function setBundlers(bundlers, allowed, relayer) { + const args = ["init-data", "--bundlers", bundlers.join(","), "--relayer", relayer, "--env-file", os.devNull] + const out = execFileSync(process.execPath, [path.join(HERE, "entrypoint-v09-sudo.mjs"), ...args], { + encoding: "utf8", + }) + const line = allowed ? "allow" : "clear" + const init = ensure(new RegExp(`^${line}:\\s+(0x[0-9a-fA-F]+)$`, "m").exec(out)?.[1], `no ${line} init data`) + const upgrade = encodeAbiParameters( + [{ type: "address" }, { type: "bytes" }], + [await implementationOf(PAYMASTER), init], + ) + await deliver(PAYMASTER, concat([toHex(PAYMASTER_UPGRADE, { size: 1 }), upgrade]), relayer, `${line}-bundlers`) + return read(PAYMASTER, "getBundlers") +} + +// ── Main ───────────────────────────────────────────────────────────── + +async function main() { + const forkUrl = secret(process.env.FORK_URL || process.env.BSC_TESTNET_RPC_URL || envFileValue("BSC_CHAPEL")) + ensure(forkUrl, `Set FORK_URL, or BSC_CHAPEL in ${ENV_FILE}`) + const signerKey = secret(process.env.RUNDLER_SIGNER_PRIVATE_KEY || envFileValue("RUNDLER_SIGNER_PRIVATE_KEY")) + ensure(signerKey, `Set RUNDLER_SIGNER_PRIVATE_KEY, or add it to ${ENV_FILE}`) + ensure(fs.existsSync(RUNDLER_BIN), `No rundler at ${RUNDLER_BIN}; run \`cargo build --bin rundler\` in the fork`) + const bundler = privateKeyToAccount(signerKey).address + const gatewayImpl = gatewayImplementation() + + const [anvilPort, rundlerPort, metricsPort] = [await freePort(), await freePort(), await freePort()] + const anvilUrl = `http://127.0.0.1:${anvilPort}` + const rundlerUrl = `http://127.0.0.1:${rundlerPort}` + const forkBlock = process.env.FORK_BLOCK ? ["--fork-block-number", process.env.FORK_BLOCK] : [] + const anvil = start("anvil", "anvil", [ + ...["--fork-url", forkUrl, ...forkBlock, "--port", String(anvilPort)], + ..."--chain-id 97 --hardfork prague --block-time 1 --silent".split(" "), + ]) + transport = http(anvilUrl) + client = createPublicClient({ chain: bscTestnet, transport }) + log(`anvil forked Chapel at block ${await ready(anvil, () => client.getBlockNumber())} on ${anvilUrl}`) + + // ── 1. Governance ── + // The one relayer Hyperbridge delivers through, which both contracts accept. + const relayer = await read(PAYMASTER, "relayer") + const gatewayBefore = await implementationOf(GATEWAY) + const upgrade = encodeFunctionData({ abi, functionName: "upgradeToAndCall", args: [gatewayImpl, "0x"] }) + const upgraded = await deliver( + GATEWAY, + concat([toHex(GATEWAY_EXECUTE, { size: 1 }), upgrade]), + relayer, + "upgrade-gateway", + ) + const gatewayAfter = await implementationOf(GATEWAY) + const typehash = await read(GATEWAY, "SELECT_SOLVER_TYPEHASH") + check( + "gateway implementation upgraded through Execute", + isAddressEqual(gatewayAfter, gatewayImpl) && typehash === SELECT_SOLVER_TYPEHASH, + { tx: upgraded.transactionHash, before: gatewayBefore, after: gatewayAfter, typehash }, + ) + const { solverSelection } = await read(GATEWAY, "params") + check("gateway solver selection is on", solverSelection, { solverSelection }) + + const treasury = await read(PAYMASTER, "treasury") + for (const [functionName, args] of [ + ["deposit", []], + ["addStake", [86_400]], + ]) { + const data = encodeFunctionData({ abi, functionName, args }) + await impersonated(treasury, { to: PAYMASTER, data, value: parseEther("1") }, functionName) + } + log(`paymaster funded by ${treasury}: ${json(await read(ENTRY_POINT, "getDepositInfo", [PAYMASTER]))}`) + + const allowed = await setBundlers([bundler, SIMULATION_ORIGIN], true, relayer) + check( + "paymaster allowlist holds the rundler signer and simulation origin", + [bundler, SIMULATION_ORIGIN].every((entry) => allowed.some((listed) => isAddressEqual(listed, entry))), + allowed, + ) + + // ── Bundler ── + await fund(bundler) + const rundler = start( + "rundler", + RUNDLER_BIN, + [ + ..."node --network bsc_testnet --enabled_entry_points v0.9 --unsafe".split(" "), + ...["--rpc.port", String(rundlerPort), "--metrics.port", String(metricsPort)], + ], + { NODE_HTTP: anvilUrl, SIGNER_PRIVATE_KEYS: signerKey, RUST_LOG: "info", NO_COLOR: "1" }, + ) + const entryPoints = await ready(rundler, () => jsonRpc(rundlerUrl, "eth_supportedEntryPoints")) + check( + "rundler serves only EntryPoint v0.9", + entryPoints.length === 1 && isAddressEqual(entryPoints[0], ENTRY_POINT), + entryPoints, + ) + + // ── 2. Accounts and delegation ── + const user = privateKeyToAccount(generatePrivateKey()) + const solver = privateKeyToAccount(generatePrivateKey()) + const userWallet = createWalletClient({ account: user, chain: bscTestnet, transport }) + const solverWallet = createWalletClient({ account: solver, chain: bscTestnet, transport }) + await Promise.all([fund(user.address), fund(solver.address)]) + + const authorization = await solverWallet.signAuthorization({ contractAddress: SOLVER_ACCOUNT, executor: "self" }) + const delegation = await confirmed( + await solverWallet.sendTransaction({ authorizationList: [authorization], to: solver.address, data: "0x" }), + "delegation", + ) + const code = await client.getCode({ address: solver.address }) + check( + "solver EOA delegated to the v0.9 SolverAccount", + code?.toLowerCase() === concat(["0xef0100", SOLVER_ACCOUNT]).toLowerCase(), + { solver: solver.address, tx: delegation.transactionHash, code }, + ) + + const sdkChain = EvmChain.fromParams({ chainId: 97, host: HOST, rpcUrl: anvilUrl, bundlerUrl: rundlerUrl }) + // The SDK keeps session keys under ./.hyperbridge-cache. + process.chdir(workdir) + const gateway = await IntentGateway.create(sdkChain, sdkChain) + const feeToken = await sdkChain.getFeeTokenWithDecimals() + + await deal(USDH, user.address, parseUnits("100", 18)) + await deal(feeToken.address, user.address, parseUnits("10", feeToken.decimals)) + await deal(USDH, solver.address, parseUnits("50", 18)) + await deal(CNGN, solver.address, parseUnits("100000", 6)) + await approve(userWallet, USDH, GATEWAY) + await approve(userWallet, feeToken.address, GATEWAY) + + /** Places a same-chain USD.h for cNGN order through the SDK's `execute`, stopping once it is placed. */ + async function placeOrder(label) { + const beneficiary = bytes20ToBytes32(user.address) + const order = { + user: beneficiary, + source: toHex(CHAIN), + destination: toHex(CHAIN), + deadline: (await client.getBlockNumber()) + 10_000n, + nonce: 0n, + // Set, so placement skips the fee quote. + fees: parseUnits("0.01", feeToken.decimals), + session: zeroAddress, + predispatch: { assets: [], call: "0x" }, + inputs: [{ token: bytes20ToBytes32(USDH), amount: ORDER_IN }], + output: { beneficiary, assets: [{ token: bytes20ToBytes32(CNGN), amount: ORDER_OUT }], call: "0x" }, + } + const stream = gateway.execute(order, DEFAULT_GRAFFITI, { auctionTimeMs: 0 }) + try { + const awaiting = (await stream.next()).value + ensure(awaiting?.status === "AWAITING_PLACE_ORDER", `${label}: SDK yielded ${awaiting?.status}`) + const { to, data, value } = awaiting + const request = await userWallet.prepareTransactionRequest({ to, data, value }) + const placed = (await stream.next(await userWallet.signTransaction(request))).value + ensure(placed?.status === "ORDER_PLACED", `${label}: SDK yielded ${placed?.status}`) + log(`${label}: order ${placed.order.id} placed in ${placed.receipt.transactionHash}`) + return { order: placed.order, sessionPrivateKey: awaiting.sessionPrivateKey } + } finally { + await stream.return(undefined) + } + } + + /** The solver's sponsored bid, built the way simplex's `prepareBidUserOp` builds one. */ + async function buildBid(order, label) { + const approveOutput = { + target: CNGN, + value: 0n, + data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [GATEWAY, ORDER_OUT] }), + } + const estimate = await gateway.estimateFillOrder({ + order, + prependCalls: [approveOutput], + requireBundlerEstimate: true, + }) + const fillOptions = { + relayerFee: estimate.fillOptions.relayerFee, + nativeDispatchFee: 0n, + validUntil: (await client.getBlockNumber()) + 600n, + outputs: order.output.assets, + inputs: order.inputs, + } + const callData = encodeERC7821ExecuteBatch([ + approveOutput, + { target: GATEWAY, value: 0n, data: encodeFillOrder(transformOrderForContract(order), fillOptions) }, + ]) + const nonceKey = CryptoUtils.bidNonceKey(order.id, order.session, callData) + const paymaster = await buildPaymasterAndData({ + chain: CHAIN, + solverAccount: solver.address, + publicClient: client, + walletClient: solverWallet, + signer: solver, + configService: new FillerConfigService([]), + prefund: { + baseGas: estimate.callGasLimit + estimate.verificationGasLimit + estimate.preVerificationGas, + maxFeePerGas: estimate.maxFeePerGas, + }, + }) + ensure(paymaster.type === "simplex", `${label}: simplex chose no paymaster: ${paymaster.reason}`) + const bidGas = { + solverAccount: solver.address, + nonce: await read(ENTRY_POINT, "getNonce", [solver.address, nonceKey]), + entryPointAddress: ENTRY_POINT, + callGasLimit: estimate.callGasLimit, + verificationGasLimit: estimate.verificationGasLimit, + maxFeePerGas: estimate.maxFeePerGas, + maxPriorityFeePerGas: estimate.maxPriorityFeePerGas, + callData, + paymasterAndData: paymaster.paymasterAndData, + } + const preVerificationGas = await gateway.estimateBidPreVerificationGas(bidGas) + const solverSigner = solver + const userOp = await gateway.prepareSubmitBid({ + ...bidGas, + order, + fillOptions, + solverSigner, + preVerificationGas, + }) + const { callGasLimit, verificationGasLimit, maxFeePerGas, maxPriorityFeePerGas } = bidGas + const mode = paymaster.paymasterAndData.slice(106, 108) + log( + `${label}: bid signed ${json({ callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas, maxPriorityFeePerGas })}, paymaster mode 0x${mode}`, + ) + return userOp + } + + /** The user's side, as the SDK runs it on the bids of an auction. */ + async function executeBid(order, userOp, sessionPrivateKey) { + const fillerBid = { filler: "local", bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n } + const bids = gateway.buildBids(order, [fillerBid], sessionPrivateKey) + ensure((await gateway.sortBids(order, bids)).length === 1, "sortBids dropped the bid") + await bids[0].simulate() + return bids[0].execute() + } + + const snapshot = async () => ({ + userCngn: await balanceOf(CNGN, user.address), + solverCngn: await balanceOf(CNGN, solver.address), + solverUsdh: await balanceOf(USDH, solver.address), + paymasterUsdh: await balanceOf(USDH, PAYMASTER), + paymasterDeposit: await read(ENTRY_POINT, "balanceOf", [PAYMASTER]), + }) + + // ── 3-6. The sponsored fill ── + const first = await placeOrder("fill") + const firstOp = await buildBid(first.order, "fill") + const before = await snapshot() + const result = await executeBid(first.order, firstOp, first.sessionPrivateKey) + const after = await snapshot() + + check( + "SDK execute filled the order with the v0.9 userOpHash the solver signed", + result.fillStatus === "full" && result.userOpHash === CryptoUtils.computeUserOpHash(firstOp, ENTRY_POINT, 97n), + { userOpHash: result.userOpHash, txnHash: result.txnHash, fillStatus: result.fillStatus }, + ) + + const receipt = await client.getTransactionReceipt({ hash: result.txnHash }) + const bundle = await client.getTransaction({ hash: result.txnHash }) + check( + "bundle sent by the rundler signer to EntryPoint v0.9", + isAddressEqual(bundle.from, bundler) && isAddressEqual(bundle.to, ENTRY_POINT), + { tx: result.txnHash, from: bundle.from, to: bundle.to, gasUsed: receipt.gasUsed, block: receipt.blockNumber }, + ) + + const calls = await callsOf(result.txnHash) + const at = (from, to, selector) => + calls.findIndex( + (call) => isAddressEqual(call.from, from) && isAddressEqual(call.to, to) && call.input.startsWith(selector), + ) + const steps = { + select: at(solver.address, GATEWAY, SELECT), + validatePaymasterUserOp: at(ENTRY_POINT, PAYMASTER, VALIDATE_PAYMASTER), + fillOrder: at(solver.address, GATEWAY, FILL_ORDER_SELECTOR), + getCurrentUserOpHash: at(GATEWAY, ENTRY_POINT, GET_CURRENT_USER_OP_HASH), + } + check( + "trace: SolverAccount selects and the paymaster validates, then fillOrder reads getCurrentUserOpHash", + Object.values(steps).every((index) => index >= 0) && + Math.max(steps.select, steps.validatePaymasterUserOp) < steps.fillOrder && + steps.fillOrder < steps.getCurrentUserOpHash, + { callIndex: steps, txOrigin: bundle.from }, + ) + + const [operation] = parseEventLogs({ abi, logs: receipt.logs, eventName: "UserOperationEvent" }).filter( + (event) => isAddressEqual(event.address, ENTRY_POINT) && event.args.userOpHash === result.userOpHash, + ) + check( + "UserOperationEvent from EntryPoint v0.9, sponsored by the paymaster proxy, successful", + operation?.args.success && + isAddressEqual(operation.args.paymaster, PAYMASTER) && + isAddressEqual(operation.args.sender, solver.address), + operation && { address: operation.address, ...operation.args }, + ) + + const [filled] = parseEventLogs({ abi, logs: receipt.logs, eventName: "OrderFilled" }).filter( + (event) => isAddressEqual(event.address, GATEWAY) && event.args.commitment === first.order.id, + ) + const filledBy = await read(GATEWAY, "_filled", [first.order.id]) + const escrowLeft = await read(GATEWAY, "_orders", [first.order.id, 0n]) + check( + "OrderFilled by the solver, escrow released and order closed", + filled && isAddressEqual(filled.args.filler, solver.address) && isAddressEqual(filledBy, solver.address), + { + commitment: first.order.id, + filler: filled?.args.filler, + filledBy, + escrowLeft, + released: filled?.args.inputs, + }, + ) + const escrow = first.order.inputs[0].amount + const moved = { + userCngn: after.userCngn - before.userCngn, + solverCngn: after.solverCngn - before.solverCngn, + solverUsdh: after.solverUsdh - before.solverUsdh, + paymasterUsdhCharged: after.paymasterUsdh - before.paymasterUsdh, + escrowReleased: escrow, + paymasterDepositSpent: before.paymasterDeposit - after.paymasterDeposit, + actualGasCost: operation?.args.actualGasCost, + } + check( + "balances moved as the fill and its sponsorship say", + moved.userCngn === ORDER_OUT && + moved.solverCngn === -ORDER_OUT && + moved.paymasterUsdhCharged > 0n && + moved.solverUsdh + moved.paymasterUsdhCharged === escrow && + moved.paymasterDepositSpent === moved.actualGasCost, + moved, + ) + + const indexed = indexerUserOpHash(receipt.logs, filled?.args.filler, filled?.logIndex) + check("indexer findUserOpHash resolves the fill's userOpHash", indexed === result.userOpHash, indexed) + + // ── 7. The rundler signer off the allowlist ── + const remaining = await setBundlers([bundler], false, relayer) + check( + "allowlist cleared of the rundler signer only", + remaining.length === 1 && isAddressEqual(remaining[0], SIMULATION_ORIGIN), + remaining, + ) + + const second = await placeOrder("refused") + const secondOp = await buildBid(second.order, "refused") + const secondHash = CryptoUtils.computeUserOpHash(secondOp, ENTRY_POINT, 97n) + const logOffset = fs.statSync(rundler.logFile).size + const fromBlock = await client.getBlockNumber() + const refusal = await executeBid(second.order, secondOp, second.sessionPrivateKey).then( + (executed) => ({ executed }), + (error) => ({ error: error.message }), + ) + // Leave rundler a few blocks to report what it did with the op. + await pause(3_000) + + const bundles = [] + const toBlock = await client.getBlockNumber() + for (let number = fromBlock; number <= toBlock; number++) { + const block = await client.getBlock({ blockNumber: number, includeTransactions: true }) + for (const tx of block.transactions.filter((tx) => isAddressEqual(tx.from, bundler))) { + const { status, gasUsed } = await client.getTransactionReceipt({ hash: tx.hash }) + const [top] = status === "reverted" ? await callsOf(tx.hash) : [] + bundles.push({ tx: tx.hash, block: number, status, gasUsed, origin: top && unauthorizedOrigin(top.output) }) + } + } + const logged = fs.readFileSync(rundler.logFile, "utf8").slice(logOffset).split("\n").filter(unauthorizedIn) + const { where, origin } = [ + ...bundles.map(({ tx, origin }) => ({ where: `bundle ${tx} reverted on chain`, origin })), + { where: "rundler refused eth_sendUserOperation", origin: unauthorizedIn(refusal.error) }, + { where: "rundler dropped it before bundling", origin: logged.map(unauthorizedIn).find(Boolean) }, + ].find((entry) => entry.origin) ?? { where: "nowhere" } + const opReceipt = await jsonRpc(rundlerUrl, "eth_getUserOperationReceipt", [secondHash]).catch(() => null) + const secondFilledBy = await read(GATEWAY, "_filled", [second.order.id]) + check( + "second sponsored op refused with UnauthorizedBundler(rundler signer)", + refusal.error && + origin && + isAddressEqual(origin, bundler) && + !opReceipt && + isAddressEqual(secondFilledBy, zeroAddress), + { + where, + origin, + userOpHash: secondHash, + sdk: refusal.error ?? refusal.executed, + bundles, + rundlerLog: logged.map((line) => line.trim().slice(0, 300)), + opReceipt, + orderFilledBy: secondFilledBy, + }, + ) +} + +let exitCode = 0 +try { + await main() +} catch (error) { + exitCode = 1 + console.error(error) + const rundler = children.find((child) => child.label === "rundler") + if (rundler) console.error(redact(fs.readFileSync(rundler.logFile, "utf8").split("\n").slice(-40).join("\n"))) +} finally { + await stopAll() +} +const failed = checks.filter((entry) => !entry.ok).map((entry) => entry.name) +if (failed.length > 0) exitCode = 1 +log( + `${checks.length - failed.length}/${checks.length} checks passed${failed.length ? `; failed: ${failed.join("; ")}` : ""}`, +) +log(`logs in ${workdir}`) +process.exit(exitCode) diff --git a/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs b/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs index 019fa5bb6c..01d75422d1 100644 --- a/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs +++ b/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs @@ -127,7 +127,7 @@ Options: --env-file PATH Default: sdk/.env.local, read line by line, never sourced --config PATH Default: evm/config.testnet.toml -Environment: HYPERBRIDGE_GARGANTUA, SECRET_PHRASE (the sudo key; live runs only), +Environment: HYPERBRIDGE_GARGANTUA, GARGANTUA_SUDO_SEED (seed of sudo.key(); live runs only), BSC_TESTNET_RPC_URL or BSC_CHAPEL, POLYGON_AMOY_RPC_URL or POLYGON_AMOY. The process environment wins over the env file.` @@ -261,16 +261,35 @@ async function connect() { return api } -/** The sudo key, required to match SECRET_PHRASE before anything is signed. */ -async function sudoSigner(api) { - const phrase = secret(setting("SECRET_PHRASE")) - if (!phrase) throw new Error("SECRET_PHRASE is unset and missing from the env file") - const signer = new Keyring({ type: "sr25519" }).addFromUri(phrase) +/** Read before anything connects, so a live run without it stops at once. */ +function sudoSeed() { + const seed = secret(setting("GARGANTUA_SUDO_SEED")) + if (!seed) { + throw new Error( + `GARGANTUA_SUDO_SEED is unset and missing from ${options["env-file"]}; live runs sign with the Gargantua sudo key`, + ) + } + return seed +} + +/** The pair GARGANTUA_SUDO_SEED derives that is `sudo.key()`, sr25519 first. */ +async function sudoSigner(api, seed) { const key = await api.query.sudo.key() - if (key.isNone || key.unwrap().toHex() !== u8aToHex(signer.publicKey)) { - throw new Error(`SECRET_PHRASE (${signer.address}) is not the sudo key ${key.toString()}`) + if (key.isNone) throw new Error("Gargantua has no sudo key") + const sudo = key.unwrap().toHex() + const tried = [] + for (const type of ["sr25519", "ed25519", "ecdsa"]) { + let pair + try { + pair = new Keyring({ type }).addFromUri(seed) + } catch (error) { + tried.push(`${type}: ${error.message}`) + continue + } + if (u8aToHex(pair.addressRaw) === sudo) return pair + tried.push(`${type} ${pair.address}`) } - return signer + throw new Error(`GARGANTUA_SUDO_SEED is not the sudo key ${key.toString()} (${tried.join(", ")})`) } function describe(api, error) { @@ -549,10 +568,12 @@ async function main() { } if (!commands[command]) throw new Error(`Unknown command ${command}\n\n${USAGE}`) + const live = command !== "metadata" && !dryRun + const seed = live ? sudoSeed() : undefined const configured = readConfig(options.config) const api = await connect() try { - const signer = command === "metadata" || dryRun ? undefined : await sudoSigner(api) + const signer = live ? await sudoSigner(api, seed) : undefined if (dryRun) log("dry run: nothing is signed or sent") await commands[command](api, signer, configured) } finally { diff --git a/sdk/packages/simplex/e2e/env.mjs b/sdk/packages/simplex/e2e/env.mjs index ad081aa5bb..c5de387c38 100644 --- a/sdk/packages/simplex/e2e/env.mjs +++ b/sdk/packages/simplex/e2e/env.mjs @@ -1,5 +1,6 @@ // Settings for the testnet swap end-to-end run. Every key and every endpoint comes from the // environment; only public contract addresses live here. +import { ChainConfigService } from "@hyperbridge/sdk" import { bscTestnet, polygonAmoy } from "viem/chains" /** Reads a required variable, naming every missing one at once. */ @@ -36,8 +37,15 @@ export function readEnv() { export const GATEWAY = "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA" export const HOST = "0x9AA003594d59C62EE17A73A569Fd7B1DbdBd71E1" export const FEE_TOKEN = "0xBE97E73126D66188d72fbF99029126D0340a7f18" -/** ERC-4337 EntryPoint v0.8, the one the SDK submits bids through. */ -export const ENTRY_POINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" + +const chainConfig = new ChainConfigService() + +/** The ERC-4337 EntryPoint the SDK submits bids through on `chain`, a state machine id such as `EVM-97`. */ +export function entryPointOf(chain) { + const address = chainConfig.getEntryPointAddress(chain) + if (!address) throw new Error(`The SDK configures no EntryPoint for ${chain}`) + return address +} export const TOKENS = { "EVM-97": { diff --git a/sdk/packages/simplex/e2e/refusal.mjs b/sdk/packages/simplex/e2e/refusal.mjs index d7c824e82e..6f2c69c4ec 100644 --- a/sdk/packages/simplex/e2e/refusal.mjs +++ b/sdk/packages/simplex/e2e/refusal.mjs @@ -5,17 +5,17 @@ import { createWalletClient, encodeFunctionData, erc20Abi, http } from "viem" import { privateKeyToAccount } from "viem/accounts" import { ACCOUNT_ABI, ENTRY_POINT_ABI, brief, debitOf, readSpent } from "./budget.mjs" -import { ENTRY_POINT, GATEWAY } from "./env.mjs" +import { GATEWAY, entryPointOf } from "./env.mjs" const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) /** The first `UserOperationEvent` for `userOpHash` from `fromBlock` on, polled while the bundle lands. */ -async function bundleOf(client, userOpHash, fromBlock) { +async function bundleOf(client, entryPoint, userOpHash, fromBlock) { const until = Date.now() + 120_000 for (;;) { const [event] = await client .getContractEvents({ - address: ENTRY_POINT, + address: entryPoint, abi: ENTRY_POINT_ABI, eventName: "UserOperationEvent", args: { userOpHash }, @@ -119,8 +119,9 @@ export async function refuseBest({ bids, orderInputs, client, chain, rpc, solver log("simulated", { refused: record.simulationError !== undefined, error: record.simulationError }) // Sent past the simulation, as a bundler takes any operation that validates. + const entryPoint = entryPointOf(`EVM-${chain.id}`) const userOpHash = await client.readContract({ - address: ENTRY_POINT, + address: entryPoint, abi: ENTRY_POINT_ABI, functionName: "getUserOpHash", args: [bid.userOp], @@ -133,7 +134,7 @@ export async function refuseBest({ bids, orderInputs, client, chain, rpc, solver } catch (error) { record.executeError = brief(error) } - record.transactionHash = await bundleOf(client, userOpHash, fromBlock) + record.transactionHash = await bundleOf(client, entryPoint, userOpHash, fromBlock) log("forced", { userOpHash, transactionHash: record.transactionHash, executeError: record.executeError }) return record } diff --git a/sdk/packages/simplex/package.json b/sdk/packages/simplex/package.json index 7aac2e29e4..00908ba330 100644 --- a/sdk/packages/simplex/package.json +++ b/sdk/packages/simplex/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbridge/simplex", - "version": "0.16.13", + "version": "0.17.0", "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", @@ -53,7 +53,7 @@ "test:data": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 src/tests/data", "test:unit": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 src/tests/cli src/tests/logger.test.ts src/tests/pairs.test.ts ui/src", "test:filler": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=1000000 src/tests/strategies/fx.payout.test.ts src/tests/quorum-public-client.test.ts", - "test:basic:testnet": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=1000000 src/tests/strategies/basic.testnet.test.ts", + "test:stable-testnet": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=1000000 src/tests/strategies/stable.testnet.test.ts", "test:fx": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=1000000 src/tests/strategies/fx.mainnet.test.ts", "test:fx-testnet": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=1000000 src/tests/strategies/fx.testnet.test.ts", "test:mpcvault": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=120000 src/tests/wallet/mpcvault.integration.test.ts", diff --git a/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts b/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts index 46394811d3..28408c7cde 100644 --- a/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts +++ b/sdk/packages/simplex/src/config/abis/IntentGatewayV2.ts @@ -1256,9 +1256,9 @@ export const INTENT_GATEWAY_V2_ABI = [ internalType: "bytes32", }, { - name: "solver", - type: "address", - internalType: "address", + name: "userOpHash", + type: "bytes32", + internalType: "bytes32", }, { name: "signature", diff --git a/sdk/packages/simplex/src/core/boot.ts b/sdk/packages/simplex/src/core/boot.ts index ec3cbad637..9a45f6095b 100644 --- a/sdk/packages/simplex/src/core/boot.ts +++ b/sdk/packages/simplex/src/core/boot.ts @@ -23,6 +23,7 @@ import { DEFAULT_RECONCILE_INTERVAL_SECS, } from "@/config/defaults" import { ChainClientManager } from "@/services/ChainClientManager" +import { assertBundlersServeEntryPoint } from "@/services/bundler-preflight" import { ContractInteractionService } from "@/services/ContractInteractionService" import { DelegationService } from "@/services/DelegationService" import { OrderbookClient } from "@/orderbook/client" @@ -277,6 +278,8 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions) "Running the binary? Configure it under [simplex.signer].", ) } + logger.info("Checking each bundler serves its chain's EntryPoint...") + await assertBundlersServeEntryPoint(resolvedChains, configService, { watchOnly: watchOnlyConfig }) const chainClientManager = new ChainClientManager(configService, options.signer) const runtimeSigner: Signer = chainClientManager.getSigner() if (options.signer) { diff --git a/sdk/packages/simplex/src/data/memory.ts b/sdk/packages/simplex/src/data/memory.ts index d5c0a9074d..20c70410c2 100644 --- a/sdk/packages/simplex/src/data/memory.ts +++ b/sdk/packages/simplex/src/data/memory.ts @@ -390,6 +390,7 @@ class MemoryLimitOrderStore implements LimitOrderStore { status: "open", commitment: null, orderNonce: order.orderNonce ?? "0", + entryPoint: null, bookExpiresAt: null, bookPrice: null, lastError: null, diff --git a/sdk/packages/simplex/src/data/sqlite/limit-orders.ts b/sdk/packages/simplex/src/data/sqlite/limit-orders.ts index b687203537..04a90c58e3 100644 --- a/sdk/packages/simplex/src/data/sqlite/limit-orders.ts +++ b/sdk/packages/simplex/src/data/sqlite/limit-orders.ts @@ -31,6 +31,7 @@ const LIMIT_ORDER_COLUMNS = ` status, commitment, order_nonce as orderNonce, + entry_point as entryPoint, book_expires_at as bookExpiresAt, book_price as bookPrice, last_error as lastError, @@ -89,6 +90,7 @@ export class SqliteLimitOrderStore implements LimitOrderStore { status TEXT NOT NULL, commitment TEXT, order_nonce TEXT NOT NULL DEFAULT '0', + entry_point TEXT, book_expires_at TEXT, book_price TEXT, last_error TEXT, @@ -114,6 +116,13 @@ export class SqliteLimitOrderStore implements LimitOrderStore { CREATE INDEX IF NOT EXISTS idx_limit_order_fills_order ON limit_order_fills(limit_order_id); `) + // A database written before postings recorded their EntryPoint gets the + // column in place. Its rows read null, which is v0.8. + if (!columnNames(this.db, "limit_orders").has("entry_point")) { + this.db.exec("ALTER TABLE limit_orders ADD COLUMN entry_point TEXT") + this.logger.info({ column: "entry_point" }, "Migrated limit order storage schema") + } + // A database created before fills kept what they took in needs the column added in place. // Its existing rows stay null, and are priced at their order's rate when read. The column // is nullable with no default, so a solver still on the earlier version can go on writing @@ -198,7 +207,7 @@ export class SqliteLimitOrderStore implements LimitOrderStore { .prepare(` UPDATE limit_orders SET commitment = ?, book_expires_at = ?, book_price = ?, order_nonce = ?, - status = ?, last_error = ?, updated_at = datetime('now') + entry_point = ?, status = ?, last_error = ?, updated_at = datetime('now') WHERE id = ?${statusGuard(only)} `) .run( @@ -206,6 +215,7 @@ export class SqliteLimitOrderStore implements LimitOrderStore { posting.bookExpiresAt, posting.bookPrice, posting.orderNonce, + posting.entryPoint, posting.status, posting.lastError, id, diff --git a/sdk/packages/simplex/src/data/types.ts b/sdk/packages/simplex/src/data/types.ts index 3114d72930..a0dc7cc7a9 100644 --- a/sdk/packages/simplex/src/data/types.ts +++ b/sdk/packages/simplex/src/data/types.ts @@ -368,6 +368,12 @@ export interface LimitOrder { commitment: string | null /** Bumped on every repost, so each posting hashes differently. */ orderNonce: string + /** + * The EntryPoint the current posting's op was signed for. Null while nothing is + * posted, and on a posting stored before this was recorded, which was signed + * for v0.8. + */ + entryPoint: string | null /** When the current posting expires, as the orderbook reported it. */ bookExpiresAt: string | null /** `Order.price` from the orderbook, which shades `price` by the protocol fee. */ @@ -408,6 +414,7 @@ export interface LimitOrderPosting { bookExpiresAt: string | null bookPrice: string | null orderNonce: string + entryPoint: string | null status: LimitOrderStatus lastError: string | null } diff --git a/sdk/packages/simplex/src/orderbook/limit-orders.ts b/sdk/packages/simplex/src/orderbook/limit-orders.ts index c501c73491..1e56d06494 100644 --- a/sdk/packages/simplex/src/orderbook/limit-orders.ts +++ b/sdk/packages/simplex/src/orderbook/limit-orders.ts @@ -1,5 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto" -import { getChainId, MAX_DECLARED_ENTRIES, type HexString } from "@hyperbridge/sdk" +import { ENTRY_POINT_V08, getChainId, MAX_DECLARED_ENTRIES, type HexString } from "@hyperbridge/sdk" import { type AssetRegistry, normalizeSymbol } from "@/config/asset-registry" import type { LimitOrder, LimitOrderFilter, LimitOrderInsert, LimitOrderStore } from "@/data/types" import type { ContractInteractionService } from "@/services/ContractInteractionService" @@ -143,7 +143,10 @@ export interface CancelledLimitOrder { export interface ReconcileReport { /** Orderbook entries no limit order here owns, now withdrawn. */ cancelled: number - /** Limit orders whose entry had gone, now posted again. */ + /** + * Limit orders posted again: their entry had gone, or it was signed for an + * EntryPoint the fill chain no longer uses. + */ reposted: number /** Postings the orderbook is not backing in full, left alone and surfaced. */ underFunded: number @@ -169,6 +172,8 @@ export class LimitOrderService { private logger: Logger private cachedLimits?: { limits: OrderbookLimits; readAt: number } private onEvent?: (event: LimitOrderEvent) => void + /** Re-signs in flight, by order id. A fill's resize waits one out rather than reposting alongside it. */ + private readonly resigning = new Map>() constructor( private readonly store: LimitOrderStore, @@ -304,6 +309,7 @@ export class LimitOrderService { bookExpiresAt: null, bookPrice: null, orderNonce: order.orderNonce, + entryPoint: null, status: "cancelled", lastError: null, }))!, @@ -621,25 +627,40 @@ export class LimitOrderService { async resize(remaining: LimitOrder, delivered: bigint): Promise { const id = remaining.id const floor = await this.dustFloor(remaining) - if (BigInt(remaining.remaining) < floor) { + // A re-sign in flight posts from a read taken before this fill, and run alongside + // it each would post its own entry, so this waits and replaces its posting + // instead. Nothing is awaited between the last check and the claim below, and + // both branches take it before touching the orderbook, so none can start once + // this is under way. + for (let resigning = this.resigning.get(id); resigning; resigning = this.resigning.get(id)) await resigning + // A row that has already closed stays closed, whatever fill lands on it later. + const order = await this.store.setStatus(id, "resizing", null, POSTABLE) + if (!order) return null + + if (BigInt(order.remaining) < floor) { this.logger.info( - { id, remaining: remaining.remaining, floor: floor.toString() }, + { id, remaining: order.remaining, floor: floor.toString() }, "Limit order worked down past the dust floor; closing it", ) - if (remaining.commitment) await this.withdraw(remaining.commitment as HexString) - const closed = await this.store.setPosting(id, { - commitment: null, - bookExpiresAt: null, - bookPrice: null, - orderNonce: remaining.orderNonce, - status: "filled", - lastError: null, - }) + if (order.commitment) await this.withdraw(order.commitment as HexString) + const closed = await this.store.setPosting( + id, + { + commitment: null, + bookExpiresAt: null, + bookPrice: null, + orderNonce: order.orderNonce, + entryPoint: null, + status: "filled", + lastError: null, + }, + POSTABLE, + ) if (closed) this.report({ kind: "filled", order: closed }) return closed } - const resized = await this.repost(await this.store.setStatus(id, "resizing")) + const resized = await this.repost(order) if (resized) this.report({ kind: "resized", order: resized, delivered }) return resized } @@ -654,7 +675,7 @@ export class LimitOrderService { async repost(order: LimitOrder | null): Promise { if (!order) return null // A same-asset order was never on the book, so there is nothing to put back. - if (isLocal(order)) return this.store.setStatus(order.id, "open") + if (isLocal(order)) return this.store.setStatus(order.id, "open", null, POSTABLE) // Expiry belongs here rather than in each caller. The matcher refuses an // expired order, so posting one advertises depth that is quoted to swappers, @@ -676,7 +697,7 @@ export class LimitOrderService { { id: order.id, commitment: order.commitment, err: message }, "Could not clear the old entry; leaving the posting alone rather than adding a second", ) - return this.store.setStatus(order.id, "open", message) + return this.store.setStatus(order.id, "open", message, POSTABLE) } } @@ -684,8 +705,12 @@ export class LimitOrderService { // the row is live with no entry on the book, which is exactly what // reconciliation would otherwise read as one to put back. The status is what // `POSTABLE` lets the posting write over, and the write refreshes - // `updatedAt`, which is what reconciliation actually leaves alone. - await this.store.setStatus(order.id, "resizing") + // `updatedAt`, which is what reconciliation actually leaves alone. Guarded, + // because a fill can have closed the row while the old entry was coming down. + if (!(await this.store.setStatus(order.id, "resizing", null, POSTABLE))) { + this.logger.info({ id: order.id }, "Limit order closed while it was being reposted; leaving it closed") + return null + } // A fresh nonce, because the orderbook remembers every op hash it has taken // and a signed op cannot be posted twice. @@ -739,7 +764,9 @@ export class LimitOrderService { * They drift apart when a request never got an answer or the process died * between two of them: an entry the orderbook still lists that nothing here * owns, an order here whose entry has gone, or an entry the orderbook has cut - * down because the solver cannot cover what it quoted. + * down because the solver cannot cover what it quoted. A posted op is also + * signed over one EntryPoint, so when the fill chain moves to another, every + * entry signed before the move is withdrawn and posted again. */ async reconcile(now: Date = new Date()): Promise { const [entries, live] = await Promise.all([this.postedOrders(), this.live()]) @@ -768,7 +795,17 @@ export class LimitOrderService { for (const order of live) { if (isLocal(order)) continue - if (order.commitment && found.has(order.commitment.toLowerCase())) continue + if (order.commitment && found.has(order.commitment.toLowerCase())) { + try { + if (await this.resign(order)) report.reposted++ + } catch (err) { + this.logger.error( + { id: order.id, err }, + "Could not sign the limit order again for its chain's EntryPoint", + ) + } + continue + } // A row touched moments ago has a posting in flight: a create posts after // its insert, and a repost posts after its cancel, both leaving the row // live with nothing on the book to find. Posting now would put a second @@ -790,6 +827,52 @@ export class LimitOrderService { return report } + /** + * Withdraws a posting signed for an EntryPoint its fill chain no longer uses, + * and posts it again signed for the one it uses now. Answers whether a new + * posting landed. + * + * The row is read again and claimed out of `open` first: a fill can have worked + * it down or started its own repost since reconciliation listed it, and that + * write is the one to follow. A fill that lands once the claim is taken waits + * in `resize` for this to finish. The EntryPoint is only written with a posting + * that landed, so a pass cut short anywhere leaves a row the next pass still + * reads as stale, or as having no entry, and finishes the job. + */ + private async resign(listed: LimitOrder): Promise { + const entryPoint = this.configService.getEntryPointAddress(listed.fillChain) + if (!entryPoint || sameAddress(signedFor(listed), entryPoint)) return false + + const order = await this.store.get(listed.id) + if (!order || order.status !== "open" || order.commitment !== listed.commitment) return false + if (sameAddress(signedFor(order), entryPoint) || this.resigning.has(order.id)) return false + + // Registered in the same tick the claim is written in, so a resize either + // claimed the row first or finds this one to wait for. + const resigned = this.signAgain(order, entryPoint) + this.resigning.set( + order.id, + resigned.catch(() => false), + ) + try { + return await resigned + } finally { + this.resigning.delete(order.id) + } + } + + private async signAgain(order: LimitOrder, entryPoint: HexString): Promise { + const claimed = await this.store.setStatus(order.id, "resizing", null, ["open"]) + if (!claimed) return false + + this.logger.warn( + { id: order.id, commitment: order.commitment, signedFor: signedFor(order), entryPoint }, + "Limit order was signed for an EntryPoint its chain no longer uses; signing it again", + ) + const posted = await this.repost(claimed) + return !!posted?.commitment && sameAddress(signedFor(posted), entryPoint) + } + /** * Lowers every open order's `remaining` to what the solver's account will still * pay for it, and puts the order back on the book at that size. @@ -884,6 +967,7 @@ export class LimitOrderService { bookExpiresAt: null, bookPrice: null, orderNonce: order.orderNonce, + entryPoint: null, status: "expired", lastError: null, }) @@ -948,12 +1032,12 @@ export class LimitOrderService { /** Builds and submits the posting, then writes the orderbook's answer onto the row. */ private async post(order: LimitOrder): Promise { - const { result, orderNonce } = await this.submit(order) + const { result, orderNonce, entryPoint } = await this.submit(order) if (result.kind === "accepted" || result.kind === "unchanged") { const posted = result.order this.logger.info( - { id: order.id, commitment: posted.commitment, price: posted.price }, + { id: order.id, commitment: posted.commitment, price: posted.price, entryPoint }, "Limit order posted to the orderbook", ) const stored = await this.store.setPosting( @@ -963,6 +1047,10 @@ export class LimitOrderService { bookExpiresAt: posted.expiresAt, bookPrice: posted.price, orderNonce: orderNonce.toString(), + // An entry the orderbook already held was signed by an earlier attempt, + // for whichever EntryPoint was current then. Recorded as unknown, which + // reconciliation reads as v0.8 and signs again once the chain is past it. + entryPoint: result.kind === "accepted" ? entryPoint : null, status: "open", lastError: null, }, @@ -1004,6 +1092,7 @@ export class LimitOrderService { bookExpiresAt: null, bookPrice: null, orderNonce: orderNonce.toString(), + entryPoint: null, status: "open", lastError: message, }))!, @@ -1029,25 +1118,36 @@ export class LimitOrderService { * on a new nonce would put a second entry behind the same liability and record * only the second one. */ - private async submit(order: LimitOrder): Promise<{ result: SubmitOrderResult; orderNonce: bigint }> { + private async submit( + order: LimitOrder, + ): Promise<{ result: SubmitOrderResult; orderNonce: bigint; entryPoint: HexString }> { + const entryPoint = this.entryPointFor(order.fillChain) const orderNonce = BigInt(order.orderNonce) - const attempt = await this.buildAndSubmit(order, orderNonce) + const attempt = await this.buildAndSubmit(order, orderNonce, entryPoint) const first = attempt.result if (first.kind !== "rejected" || (first.code !== "REPLAYED" && first.code !== "ORDER_EXISTS")) { - return { result: first, orderNonce } + return { result: first, orderNonce, entryPoint } } if (first.code === "ORDER_EXISTS" && attempt.commitment) { const live = await this.entryAt(order, attempt.commitment) if (live) { this.logger.info({ id: order.id, commitment: live.commitment }, "This posting is already on the orderbook") - return { result: { kind: "unchanged", order: live }, orderNonce } + return { result: { kind: "unchanged", order: live }, orderNonce, entryPoint } } } this.logger.warn({ id: order.id, code: first.code }, "Orderbook has seen this op before; reposting on a new nonce") const retried = orderNonce + 1n - return { result: (await this.buildAndSubmit(order, retried)).result, orderNonce: retried } + const { result } = await this.buildAndSubmit(order, retried, entryPoint) + return { result, orderNonce: retried, entryPoint } + } + + /** The EntryPoint a posting on `chain` is signed for now. */ + private entryPointFor(chain: string): HexString { + const entryPoint = this.configService.getEntryPointAddress(chain) + if (!entryPoint) throw new LimitOrderValidationError(`No EntryPoint is configured for ${chain}`) + return entryPoint } /** @@ -1068,8 +1168,9 @@ export class LimitOrderService { private async buildAndSubmit( order: LimitOrder, orderNonce: bigint, + entryPoint: HexString, ): Promise<{ result: SubmitOrderResult; commitment?: HexString }> { - const { commitment, userOp } = await this.buildUserOp(order, orderNonce) + const { commitment, userOp } = await this.buildUserOp(order, orderNonce, entryPoint) try { return { result: await this.client.submitOrder(userOp), commitment } } catch (err) { @@ -1080,7 +1181,11 @@ export class LimitOrderService { } } - private async buildUserOp(order: LimitOrder, orderNonce: bigint): Promise<{ commitment: HexString; userOp: HexString }> { + private async buildUserOp( + order: LimitOrder, + orderNonce: bigint, + entryPointAddress: HexString, + ): Promise<{ commitment: HexString; userOp: HexString }> { const baseToken = this.assetRegistry.getAddress(order.base, order.fillChain)! const quoteToken = this.assetRegistry.getAddress(order.quote, order.fillChain)! const [baseDecimals, quoteDecimals] = await Promise.all([ @@ -1098,11 +1203,6 @@ export class LimitOrderService { quoteDecimals, }) - const entryPointAddress = this.configService.getEntryPointAddress(order.fillChain) - if (!entryPointAddress) { - throw new LimitOrderValidationError(`No EntryPoint is configured for ${order.fillChain}`) - } - return this.contractService.prepareLimitOrderUserOp({ fillChain: order.fillChain, entryPointAddress, @@ -1152,6 +1252,15 @@ function hasExpired(expiresAt: string | null, now: Date): boolean { } +/** The EntryPoint an order's posting was signed for. One stored without it was signed for v0.8. */ +function signedFor(order: LimitOrder): string { + return order.entryPoint ?? ENTRY_POINT_V08 +} + +function sameAddress(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + /** How long ago a row was written. Its stamps are UTC but not marked as such. */ function sinceMs(updatedAt: string, now: Date): number { const written = Date.parse(`${updatedAt.replace(" ", "T")}Z`) diff --git a/sdk/packages/simplex/src/services/FillerConfigService.ts b/sdk/packages/simplex/src/services/FillerConfigService.ts index 6d211aef9c..8cffd5d510 100644 --- a/sdk/packages/simplex/src/services/FillerConfigService.ts +++ b/sdk/packages/simplex/src/services/FillerConfigService.ts @@ -567,7 +567,7 @@ export class FillerConfigService { } getEntryPointAddress(chain: string): HexString | undefined { - return this.chainConfigService.getEntryPointV08Address(chain) as HexString | undefined + return this.chainConfigService.getEntryPointAddress(chain) } getSolverAccountContractAddress(chain: string): HexString | undefined { diff --git a/sdk/packages/simplex/src/services/UserOpSender.ts b/sdk/packages/simplex/src/services/UserOpSender.ts index 530db0261c..ce88cc4a27 100644 --- a/sdk/packages/simplex/src/services/UserOpSender.ts +++ b/sdk/packages/simplex/src/services/UserOpSender.ts @@ -1,4 +1,11 @@ -import { CryptoUtils, BundlerMethod, type PackedUserOperation, type HexString } from "@hyperbridge/sdk" +import { + CryptoUtils, + BundlerMethod, + applyRundlerPriorityFee, + fetchRundlerPriorityFee, + type PackedUserOperation, + type HexString, +} from "@hyperbridge/sdk" import { toHex, type PublicClient } from "viem" import { ENTRYPOINT_ABI } from "@/config/abis/Entrypoint" import type { ChainClientManager } from "./ChainClientManager" @@ -64,6 +71,9 @@ const FALLBACK_VERIFICATION_GAS_LIMIT = 250_000n const FALLBACK_CALL_GAS_LIMIT = 1_500_000n const FALLBACK_PRE_VERIFICATION_GAS = 150_000n +const PRIORITY_FEE_BUMP_PERCENT = 8n +const MAX_FEE_BUMP_PERCENT = 10n + /** * Submits self-initiated, paymaster-sponsored UserOperations through the bundler, * so the solver pays gas in stablecoins rather than native token. Shared by the @@ -344,7 +354,11 @@ export class UserOpSender { return userOp } - /** Mirrors the gas-price selection used by the delegation and fill paths. */ + /** + * 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. + */ private async getGasPrice( bundlerUrl: string, publicClient: PublicClient, @@ -371,8 +385,23 @@ export class UserOpSender { const bufferedBaseFee = baseFeePerGas + (baseFeePerGas * 50n) / 100n return { maxFeePerGas: bufferedBaseFee + maxPriorityFeePerGas, maxPriorityFeePerGas } } - const gasPrice = await publicClient.getGasPrice() - return { maxFeePerGas: gasPrice + (gasPrice * 10n) / 100n, maxPriorityFeePerGas: gasPrice + (gasPrice * 8n) / 100n } + const [gasPrice, rundlerPriorityFee] = await Promise.all([ + publicClient.getGasPrice(), + fetchRundlerPriorityFee(bundlerUrl), + ]) + 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, + }) } 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 new file mode 100644 index 0000000000..7a59933311 --- /dev/null +++ b/sdk/packages/simplex/src/services/bundler-preflight.ts @@ -0,0 +1,112 @@ +import { chainByChainId } from "@/cli/init/chains" +import { formatChainKey } from "@/config/interpolated-curve" +import type { FillerConfigService, ResolvedChainConfig } from "@/services/FillerConfigService" +import { moduleLogger, type Logger } from "@/services/Logger" + +export const BUNDLER_PREFLIGHT_TIMEOUT_MS = 10_000 + +export interface BundlerPreflightOptions { + timeoutMs?: number + /** Chains marked true here never send a UserOperation, so their bundler is not asked. */ + watchOnly?: Record +} + +/** + * 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. + */ +export async function assertBundlersServeEntryPoint( + chains: Pick[], + configService: Pick, + options: BundlerPreflightOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? BUNDLER_PREFLIGHT_TIMEOUT_MS + const logger = moduleLogger(configService.loggers, "bundler-preflight") + const failures = await Promise.all( + chains.map(async ({ chainId, bundlerUrl }) => { + if (options.watchOnly?.[chainId] === true) return undefined + if (!bundlerUrl?.trim()) return undefined + const entryPoint = configService.getEntryPointAddress(formatChainKey(chainId)) + if (!entryPoint) return undefined + return checkBundler(chainId, bundlerUrl.trim(), entryPoint, timeoutMs, logger) + }), + ) + const messages = failures.filter((failure): failure is string => failure !== undefined) + if (messages.length > 0) throw new Error(messages.join("; ")) +} + +export function servesEntryPoint(listed: unknown, entryPoint: string): boolean { + return Array.isArray(listed) && listed.some((address) => String(address).toLowerCase() === entryPoint.toLowerCase()) +} + +export function describeEntryPoints(listed: unknown): string { + return Array.isArray(listed) && listed.length > 0 ? listed.map(String).join(", ") : "none" +} + +/** The host alone: bundler URLs carry API keys in their paths and query strings. */ +export function bundlerHost(url: string): string { + try { + return new URL(url).host || "an unparseable URL" + } catch { + return "an unparseable URL" + } +} + +function chainName(chainId: number): string { + const label = chainByChainId(chainId)?.label + return label ? `${label} (${formatChainKey(chainId)})` : formatChainKey(chainId) +} + +async function checkBundler( + chainId: number, + bundlerUrl: string, + entryPoint: string, + timeoutMs: number, + logger: Logger, +): Promise { + const host = bundlerHost(bundlerUrl) + const subject = `Bundler ${host} for ${chainName(chainId)}` + const signal = AbortSignal.timeout(timeoutMs) + let listed: string[] + try { + listed = await supportedEntryPoints(bundlerUrl, signal) + } catch (err) { + const reason = signal.aborted ? `timed out after ${timeoutMs}ms` : errorReason(err) + logger.warn( + { chain: formatChainKey(chainId), bundler: host }, + `${subject} did not answer eth_supportedEntryPoints: ${reason.split(bundlerUrl).join(host)}. ` + + `Continuing without confirming it serves EntryPoint ${entryPoint}`, + ) + return undefined + } + if (servesEntryPoint(listed, entryPoint)) return undefined + return `${subject} does not support EntryPoint ${entryPoint}; it lists ${describeEntryPoints(listed)}` +} + +async function supportedEntryPoints(bundlerUrl: string, signal: AbortSignal): Promise { + const response = await fetch(bundlerUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_supportedEntryPoints", params: [] }), + signal, + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const json = (await response.json()) as { result?: unknown; error?: { message?: string } } + if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error)) + const { result } = json + if (!Array.isArray(result) || !result.every((address) => typeof address === "string")) { + throw new Error("the result is not a list of addresses") + } + return result +} + +function errorReason(err: unknown): string { + if (!(err instanceof Error)) return String(err) + // Node's fetch reports every network failure as "fetch failed" and keeps the useful part in `cause`. + const { cause } = err as { cause?: unknown } + return cause instanceof Error ? `${err.message} (${cause.message})` : err.message +} diff --git a/sdk/packages/simplex/src/services/server/setup-api.ts b/sdk/packages/simplex/src/services/server/setup-api.ts index e92963f8f4..49783b2f3a 100644 --- a/sdk/packages/simplex/src/services/server/setup-api.ts +++ b/sdk/packages/simplex/src/services/server/setup-api.ts @@ -7,6 +7,7 @@ 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" @@ -228,6 +229,31 @@ export async function validateBundler(body: Record, deps: Requi 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 { diff --git a/sdk/packages/simplex/src/simplex.ts b/sdk/packages/simplex/src/simplex.ts index ab4edfe8cc..a21db5c1e0 100644 --- a/sdk/packages/simplex/src/simplex.ts +++ b/sdk/packages/simplex/src/simplex.ts @@ -8,6 +8,7 @@ import { assertPairSymbolsResolve, validatePairConfigs, type PairConfig } from " import { assertConfirmationCoverage, type VaultToml } from "@/config/filler-toml" import type { ChainConfirmationPolicy, FillerTomlConfig, RebalancingConfig } from "@/config/filler-toml" import { resolveChainConfigs, validateRpcUrls, type AllowlistConfig } from "@/services/FillerConfigService" +import { assertBundlersServeEntryPoint } from "@/services/bundler-preflight" import { LoggerContext, type Logger, type LogLevel, type LogSink } from "@/services/Logger" import type { ActivityEvent, BidStats, LimitOrderFill, SimplexDataStore, StoredBid, WalletTx } from "@/data/types" import { patchRuntimeState } from "@/data/state" @@ -568,11 +569,13 @@ export class ChainController { ) } - configService.addChain(resolved) // A filler the operator put in watch-only must not start filling on a chain // added later, and a signerless observer never fills at all. // `chain.watchOnly` still wins when given explicitly (checked above). const watchOnly = chain.watchOnly ?? (this.runtime.signerless || this.runtime.globalWatchOnly) + await assertBundlersServeEntryPoint([resolved], configService, { watchOnly: { [chainId]: watchOnly } }) + + configService.addChain(resolved) if (watchOnly) intentFiller.setWatchOnly(chainId, true) try { @@ -694,6 +697,9 @@ export class ChainController { 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 @@ -712,6 +718,10 @@ export class ChainController { "Watch-only cannot be disabled: this solver was started without a signer. Restart with `signer` to fill.", ) } + if (!watchOnly) { + const chain = this.runtime.resolvedChains.find((resolved) => resolved.chainId === chainId) + if (chain) await assertBundlersServeEntryPoint([chain], this.runtime.configService) + } this.runtime.intentFiller.setWatchOnly(chainId, watchOnly) this.syncWatchOnlyToConfig() await this.persist() 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 new file mode 100644 index 0000000000..8dfe1884af --- /dev/null +++ b/sdk/packages/simplex/src/tests/core/boot-bundler-preflight.test.ts @@ -0,0 +1,48 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { bootFiller } from "@/core/boot" +import type { FillerTomlConfig } from "@/config/filler-toml" +import { LoggerContext } from "@/services/Logger" +import { MemoryDataStore } from "@/data/memory" +import { privateKeySigner } from "@/services/wallet" +import { startMockRpc, type MockRpc } from "../helpers/mock-rpc" +import { stubOrderScanner } from "../helpers/stub-scanner" + +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. +let rpc: MockRpc + +beforeAll(async () => { + rpc = await startMockRpc({ chainId: CHAIN_ID }) +}) + +afterAll(() => { + rpc.close() +}) + +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 }], + orderbook: { url: "https://orderbook.example/graphql" }, + } +} + +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 127\.0\.0\.1:\d+ for Base \(EVM-8453\) does not support EntryPoint 0x[0-9a-fA-F]{40}; it lists 0x0000000071727De22E5E9d8BAf0edAc6f37da032/, + ) + }) +}) 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 7a440e4400..590890dd67 100644 --- a/sdk/packages/simplex/src/tests/core/chain-lifecycle.test.ts +++ b/sdk/packages/simplex/src/tests/core/chain-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { FillerConfigService } from "@/services/FillerConfigService" import { ChainClientManager } from "@/services/ChainClientManager" import { ConfirmationPolicy } from "@/config/interpolated-curve" @@ -351,3 +351,150 @@ describe("ChainController.add rollback", () => { expect(supplied.addChain).not.toHaveBeenCalled() }) }) + +describe("ChainController bundler EntryPoint check", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + /** A bundler that serves only EntryPoint v0.7, which no SolverAccount validates against. */ + function stubV07Bundler() { + vi.stubGlobal( + "fetch", + async () => + new Response( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: ["0x0000000071727De22E5E9d8BAf0edAc6f37da032"] }), + ), + ) + } + + function emptyScanner(): OrderScanner { + return { + subscribe: () => ({ close: () => {}, dropped: 0 }), + chains: () => [], + addChain: vi.fn(async () => 0), + setRpcUrls: async () => {}, + removeChain: async () => {}, + close: async () => {}, + } + } + + function runtimeFor( + service: FillerConfigService, + resolvedChains: FillerRuntime["resolvedChains"] = [], + watchOnly: Record = {}, + ) { + return { + configService: service, + config: { + simplex: {}, + chains: resolvedChains.map(({ rpcUrls, bundlerUrl }) => ({ rpcUrls, bundlerUrl })), + confirmationPolicies: {}, + }, + intentFiller: { + setWatchOnly: vi.fn(), + clearWatchOnly: vi.fn(), + addChain: vi.fn(), + getWatchOnly: () => watchOnly, + }, + globalWatchOnly: false, + signerless: false, + resolvedChains, + chainClientManager: { invalidate: vi.fn() }, + confirmationPolicy: new ConfirmationPolicy({}), + } as unknown as FillerRuntime + } + + it("refuses to add a chain whose bundler lacks its EntryPoint, mutating nothing", async () => { + stubV07Bundler() + const service = configService() + const scanner = emptyScanner() + const controller = new ChainController(runtimeFor(service), async () => {}, scanner, true) + + await expect( + controller.add({ rpcUrls: ["https://base.example"], bundlerUrl: "https://bundler.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 () => { + 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 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") + expect(persist).not.toHaveBeenCalled() + }) + + it("swaps the bundler on a watch-only chain without asking it", 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 persist = vi.fn() + const controller = new ChainController(runtime, persist, emptyScanner(), true) + + 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("takes a chain out of watch-only when its bundler cannot be asked", async () => { + vi.stubGlobal("fetch", async () => new Response("unavailable", { status: 503 })) + 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 expect(controller.setWatchOnly(8453, false)).resolves.toBeUndefined() + expect(runtime.intentFiller.setWatchOnly).toHaveBeenCalledWith(8453, false) + }) + + it("adds a watch-only chain without asking its bundler", async () => { + const fetchSpy = vi.fn() + vi.stubGlobal("fetch", fetchSpy) + const service = configService() + 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, + }) + 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/data/limit-order-store.test.ts b/sdk/packages/simplex/src/tests/data/limit-order-store.test.ts index 340158e094..235b8fe141 100644 --- a/sdk/packages/simplex/src/tests/data/limit-order-store.test.ts +++ b/sdk/packages/simplex/src/tests/data/limit-order-store.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "fs" import { tmpdir } from "os" import { join } from "path" import { DatabaseSync } from "node:sqlite" +import { ENTRY_POINT_V09 } from "@hyperbridge/sdk" import { LoggerContext } from "@/services/Logger" import { MemoryDataStore } from "@/data/memory" import { SqliteDataStore } from "@/data/sqlite" @@ -46,6 +47,7 @@ describe.each(backends)("%s", (_name, open) => { expect(created.reserved).toBe("0") expect(created.commitment).toBeNull() expect(created.orderNonce).toBe("0") + expect(created.entryPoint).toBeNull() expect(created.acceptedSources).toEqual(["EVM-1", "EVM-42161"]) await close() }) @@ -69,6 +71,7 @@ describe.each(backends)("%s", (_name, open) => { bookExpiresAt: "2026-09-15T12:00:00.000Z", bookPrice: "1490000000000000000000", orderNonce: "3", + entryPoint: ENTRY_POINT_V09, status: "open", lastError: null, }) @@ -76,6 +79,29 @@ describe.each(backends)("%s", (_name, open) => { expect(posted?.commitment).toBe("0xabc") expect(posted?.orderNonce).toBe("3") expect(posted?.bookPrice).toBe("1490000000000000000000") + expect(posted?.entryPoint).toBe(ENTRY_POINT_V09) + await close() + }) + + it("keeps a posting's EntryPoint until a new posting replaces it", async () => { + const { store, close } = open() + await store.create(ORDER) + const posting = { + commitment: "0xabc", + bookExpiresAt: null, + bookPrice: null, + orderNonce: "1", + entryPoint: ENTRY_POINT_V09, + status: "open" as const, + lastError: null, + } + await store.setPosting(ORDER.id, posting) + + // A repost marks the row before the new posting lands, and the old one is still up meanwhile. + expect((await store.setStatus(ORDER.id, "resizing"))?.entryPoint).toBe(ENTRY_POINT_V09) + + const cleared = await store.setPosting(ORDER.id, { ...posting, commitment: null, entryPoint: null }) + expect(cleared?.entryPoint).toBeNull() await close() }) @@ -374,6 +400,87 @@ describe("SqliteLimitOrderStore", () => { await store.close() }) + it("adds entry_point in place to a database written before it, reading its postings as null", async () => { + const dir = dataDir() + const legacy = new DatabaseSync(join(dir, "bids.db")) + legacy.exec(` + CREATE TABLE limit_orders ( + id TEXT PRIMARY KEY, + book TEXT NOT NULL, + base TEXT NOT NULL, + quote TEXT NOT NULL, + side TEXT NOT NULL, + fill_chain TEXT NOT NULL, + price TEXT NOT NULL, + size TEXT NOT NULL, + remaining TEXT NOT NULL, + reserved TEXT NOT NULL DEFAULT '0', + accepted_sources TEXT NOT NULL, + ttl_secs INTEGER NOT NULL, + expires_at TEXT, + status TEXT NOT NULL, + commitment TEXT, + order_nonce TEXT NOT NULL DEFAULT '0', + book_expires_at TEXT, + book_price TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `) + legacy + .prepare( + ` + INSERT INTO limit_orders ( + id, book, base, quote, side, fill_chain, price, size, remaining, + accepted_sources, ttl_secs, status, commitment, order_nonce + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', '0xa1', '4') + `, + ) + .run( + ORDER.id, + ORDER.book, + ORDER.base, + ORDER.quote, + ORDER.side, + ORDER.fillChain, + ORDER.price, + ORDER.size, + ORDER.size, + JSON.stringify(ORDER.acceptedSources), + ORDER.ttlSecs, + ) + legacy.close() + + const migrated = new SqliteDataStore(dir, new LoggerContext({ level: "warn" })) + expect(await migrated.limitOrders.get(ORDER.id)).toMatchObject({ + status: "open", + commitment: "0xa1", + orderNonce: "4", + remaining: ORDER.size, + acceptedSources: ORDER.acceptedSources, + entryPoint: null, + }) + await migrated.limitOrders.setPosting(ORDER.id, { + commitment: "0xa2", + bookExpiresAt: null, + bookPrice: null, + orderNonce: "5", + entryPoint: ENTRY_POINT_V09, + status: "open", + lastError: null, + }) + await migrated.close() + + const reopened = new SqliteDataStore(dir, new LoggerContext({ level: "warn" })) + expect(await reopened.limitOrders.get(ORDER.id)).toMatchObject({ + commitment: "0xa2", + entryPoint: ENTRY_POINT_V09, + }) + await reopened.close() + }) + it("survives a reopen of the same data directory", async () => { const dir = dataDir() const first = new SqliteDataStore(dir, new LoggerContext({ level: "warn" })) @@ -557,6 +664,7 @@ describe.each(backends)("%s fill history", (_name, open) => { bookExpiresAt: null, bookPrice: null, orderNonce: "1", + entryPoint: null, status: "open", lastError: null, }) diff --git a/sdk/packages/simplex/src/tests/orderbook/entry-point-cutover.test.ts b/sdk/packages/simplex/src/tests/orderbook/entry-point-cutover.test.ts new file mode 100644 index 0000000000..577ab3f5e3 --- /dev/null +++ b/sdk/packages/simplex/src/tests/orderbook/entry-point-cutover.test.ts @@ -0,0 +1,304 @@ +import { afterEach, describe, expect, it } from "vitest" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { ENTRY_POINT_V08, ENTRY_POINT_V09, type HexString } from "@hyperbridge/sdk" +import { SqliteDataStore } from "@/data/sqlite" +import type { LimitOrderStore } from "@/data/types" +import { LoggerContext } from "@/services/Logger" +import { + CREATE_REQUEST as REQUEST, + fakeClient, + limitOrderService, + ORDERBOOK_FIXTURES, + postedOrder, +} from "../helpers/limit-orders" + +const { ONE } = ORDERBOOK_FIXTURES + +/** + * A posted op is signed over one EntryPoint. When a fill chain moves to another, + * reconciliation withdraws every entry signed for the old one and posts it again + * for the new. + */ + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +/** Past every grace period, for a reconciliation that should treat a row as settled. */ +function later(): Date { + return new Date(Date.now() + 10 * 60 * 1000) +} + +/** A client that also records the entries it was asked to cancel. */ +function countingClient(...args: Parameters) { + const client = fakeClient(...args) + const cancelled: string[] = [] + const inner = client.cancelOrder + client.cancelOrder = async (params) => { + cancelled.push(params.commitment) + return inner(params) + } + return Object.assign(client, { cancelled }) +} + +/** + * A service whose fill chain can be moved to another EntryPoint, recording the + * EntryPoint each op it posts was signed for and the output it pays. + */ +function cutover(client: ReturnType, store?: LimitOrderStore) { + const made = limitOrderService(client, store) + const chain = { entryPoint: ENTRY_POINT_V08 } + const signedFor: string[] = [] + const paid: bigint[] = [] + const internals = made.service as any + internals.configService.getEntryPointAddress = () => chain.entryPoint + const prepare = internals.contractService.prepareLimitOrderUserOp + internals.contractService.prepareLimitOrderUserOp = async (params: { + entryPointAddress: HexString + orderNonce: bigint + outputAmount: bigint + }) => { + signedFor.push(params.entryPointAddress) + paid.push(params.outputAmount) + return prepare(params) + } + return { ...made, chain, signedFor, paid } +} + +const accepted = (commitment: HexString) => ({ + kind: "accepted" as const, + order: postedOrder({ commitment }), + surfaced: true, +}) + +describe("a posting signed for an EntryPoint the chain has moved off", () => { + it("is withdrawn and posted again for the new EntryPoint, on a fresh nonce, once", async () => { + const client = countingClient([accepted("0xa1"), accepted("0xa2")]) + const { service, store, chain, signedFor } = cutover(client) + const { order } = await service.create(REQUEST) + expect(order.entryPoint).toBe(ENTRY_POINT_V08) + + client.entries = [postedOrder({ commitment: "0xa1" })] + chain.entryPoint = ENTRY_POINT_V09 + + expect(await service.reconcile()).toEqual({ cancelled: 0, reposted: 1, underFunded: 0 }) + expect(client.cancelled).toEqual(["0xa1"]) + expect(client.submitted).toEqual(["0x00", "0x01"]) + expect(signedFor).toEqual([ENTRY_POINT_V08, ENTRY_POINT_V09]) + expect(await store.get(order.id)).toMatchObject({ + status: "open", + commitment: "0xa2", + entryPoint: ENTRY_POINT_V09, + orderNonce: "1", + lastError: null, + }) + + client.entries = [postedOrder({ commitment: "0xa2" })] + expect(await service.reconcile()).toEqual({ cancelled: 0, reposted: 0, underFunded: 0 }) + expect(client.cancelled).toEqual(["0xa1"]) + expect(client.submitted).toHaveLength(2) + }) + + it("is read as signed for v0.8 when it was stored without an EntryPoint", async () => { + const client = countingClient([accepted("0xa1"), accepted("0xa2")]) + const { service, store, chain, signedFor } = cutover(client) + const { order } = await service.create(REQUEST) + // How a posting stored before EntryPoints were recorded reads. + await store.setPosting(order.id, { + commitment: "0xa1", + bookExpiresAt: null, + bookPrice: null, + orderNonce: order.orderNonce, + entryPoint: null, + status: "open", + lastError: null, + }) + client.entries = [postedOrder({ commitment: "0xa1" })] + + expect(await service.reconcile()).toEqual({ cancelled: 0, reposted: 0, underFunded: 0 }) + expect(client.cancelled).toEqual([]) + + chain.entryPoint = ENTRY_POINT_V09 + expect(await service.reconcile()).toMatchObject({ reposted: 1 }) + expect(client.cancelled).toEqual(["0xa1"]) + expect(signedFor).toEqual([ENTRY_POINT_V08, ENTRY_POINT_V09]) + expect((await store.get(order.id))?.entryPoint).toBe(ENTRY_POINT_V09) + }) + + it("is left alone when it was signed for the chain's current EntryPoint, however that is cased", async () => { + const client = countingClient([]) + const { service, chain, signedFor } = cutover(client) + chain.entryPoint = ENTRY_POINT_V09 + await service.create(REQUEST) + client.entries = [postedOrder()] + chain.entryPoint = ENTRY_POINT_V09.toLowerCase() as HexString + + expect(await service.reconcile()).toEqual({ cancelled: 0, reposted: 0, underFunded: 0 }) + expect(client.cancelled).toEqual([]) + expect(signedFor).toEqual([ENTRY_POINT_V09]) + }) + + it("stays up when the old entry will not come down, and is tried again on the next pass", async () => { + const client = countingClient( + [accepted("0xa1"), accepted("0xa2")], + [{ kind: "rejected", code: "SOLVER_MISMATCH", message: "wrong key" }], + ) + const { service, store, chain } = cutover(client) + const { order } = await service.create(REQUEST) + client.entries = [postedOrder({ commitment: "0xa1" })] + chain.entryPoint = ENTRY_POINT_V09 + + expect(await service.reconcile()).toMatchObject({ reposted: 0 }) + expect(client.submitted).toEqual(["0x00"]) + const kept = await store.get(order.id) + expect(kept).toMatchObject({ status: "open", commitment: "0xa1", entryPoint: ENTRY_POINT_V08 }) + expect(kept?.lastError).toMatch(/SOLVER_MISMATCH/) + + expect(await service.reconcile()).toMatchObject({ reposted: 1 }) + expect(await store.get(order.id)).toMatchObject({ + commitment: "0xa2", + entryPoint: ENTRY_POINT_V09, + lastError: null, + }) + }) + + it("is left to a repost that claimed the row after reconciliation listed it", async () => { + const client = countingClient([accepted("0xa1")]) + const { service, store, chain } = cutover(client) + const { order } = await service.create(REQUEST) + client.entries = [postedOrder({ commitment: "0xa1" })] + chain.entryPoint = ENTRY_POINT_V09 + + // A fill's repost takes the row while the orderbook is still answering. + const walk = client.myOrders.bind(client) + client.myOrders = async () => { + await Promise.resolve() + await store.setStatus(order.id, "resizing") + return walk() + } + + expect(await service.reconcile()).toMatchObject({ reposted: 0 }) + expect(client.cancelled).toEqual([]) + expect(client.submitted).toEqual(["0x00"]) + }) + + it("leaves one entry, at the fill's size, when a fill lands while the old entry is coming down", async () => { + const client = countingClient([accepted("0xa1"), accepted("0xa2"), accepted("0xa3")]) + const { service, store, chain, signedFor, paid } = cutover(client) + const { order } = await service.create(REQUEST) + client.entries = [postedOrder({ commitment: "0xa1" })] + chain.entryPoint = ENTRY_POINT_V09 + + // The fill is drawn down and runs as far as it can before the withdrawal answers. + let fill: Promise | undefined + const cancel = client.cancelOrder + client.cancelOrder = async (params) => { + if (!fill) { + fill = service.settleFill(order.id, 500_000n * ONE) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + return cancel(params) + } + + expect(await service.reconcile()).toEqual({ cancelled: 0, reposted: 1, underFunded: 0 }) + await fill + + // Everything posted after the create was withdrawn but the last. + expect(client.submitted).toEqual(["0x00", "0x01", "0x02"]) + expect(client.cancelled).toEqual(["0xa1", "0xa2"]) + expect(signedFor).toEqual([ENTRY_POINT_V08, ENTRY_POINT_V09, ENTRY_POINT_V09]) + expect(paid).toEqual([1_500_000n * ONE, 1_500_000n * ONE, 1_000_000n * ONE]) + expect(await store.get(order.id)).toMatchObject({ + status: "open", + commitment: "0xa3", + entryPoint: ENTRY_POINT_V09, + orderNonce: "2", + remaining: (1_000_000n * ONE).toString(), + }) + }) + + it("stays closed when a fill takes it under the dust floor while reconciliation reaches it", async () => { + const client = countingClient([accepted("0xa1"), accepted("0xa2")]) + const { service, store, chain } = cutover(client) + const events: string[] = [] + let closed!: () => void + const filled = new Promise((resolve) => (closed = resolve)) + service.listen((event) => { + events.push(event.kind) + if (event.kind === "filled") closed() + }) + const { order } = await service.create(REQUEST) + client.entries = [postedOrder({ commitment: "0xa1" })] + chain.entryPoint = ENTRY_POINT_V09 + + // The fill's withdrawal is still out when a pass that listed the old entry + // reaches the order, and a re-sign's withdrawal would only answer once the + // fill had closed the row. + let pass: Promise | undefined + const cancel = client.cancelOrder + client.cancelOrder = async (params) => { + if (pass) { + await filled + return { kind: "rejected", code: "UNKNOWN_ORDER", message: "gone" } + } + pass = service.reconcile() + await new Promise((resolve) => setTimeout(resolve, 0)) + return cancel(params) + } + + await service.settleFill(order.id, 1_499_500n * ONE) + await pass + + expect(events).toEqual(["filled"]) + expect(client.submitted).toEqual(["0x00"]) + expect(client.cancelled).toEqual(["0xa1"]) + expect(await store.get(order.id)).toMatchObject({ status: "filled", commitment: null, entryPoint: null }) + }) + + it("is finished after a restart that came between the withdrawal and the new posting", async () => { + const dir = mkdtempSync(join(tmpdir(), "simplex-entry-point-")) + dirs.push(dir) + const loggers = new LoggerContext({ level: "warn" }) + + const first = new SqliteDataStore(dir, loggers) + const before = countingClient([accepted("0xa1")]) + const old = cutover(before, first.limitOrders) + const { order } = await old.service.create(REQUEST) + before.entries = [postedOrder({ commitment: "0xa1" })] + old.chain.entryPoint = ENTRY_POINT_V09 + before.submitOrder = async () => { + throw new Error("the process died") + } + + expect(await old.service.reconcile()).toMatchObject({ reposted: 0 }) + expect(before.cancelled).toEqual(["0xa1"]) + expect(await first.limitOrders.get(order.id)).toMatchObject({ + status: "resizing", + commitment: "0xa1", + entryPoint: ENTRY_POINT_V08, + }) + await first.close() + + const second = new SqliteDataStore(dir, loggers) + const after = countingClient([accepted("0xa2")]) + const restarted = cutover(after, second.limitOrders) + restarted.chain.entryPoint = ENTRY_POINT_V09 + + // The row was touched moments ago, so its posting may still be in flight. + expect(await restarted.service.reconcile()).toMatchObject({ reposted: 0 }) + expect(await restarted.service.reconcile(later())).toEqual({ cancelled: 0, reposted: 1, underFunded: 0 }) + expect(after.cancelled).toEqual([]) + expect(restarted.signedFor).toEqual([ENTRY_POINT_V09]) + expect(await second.limitOrders.get(order.id)).toMatchObject({ + status: "open", + commitment: "0xa2", + entryPoint: ENTRY_POINT_V09, + orderNonce: "1", + }) + await second.close() + }) +}) diff --git a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts index ed9a8e54f8..5b5326b656 100644 --- a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts @@ -499,6 +499,24 @@ describe("LimitOrderService.settleFill", () => { expect(settled.remaining).toBe("0") expect(settled.status).toBe("filled") }) + + it.each(["cancelled", "rejected", "filled"] as const)( + "leaves a %s order closed when a fill settles on it afterwards", + async (status) => { + const client = fakeClient([]) + const { service, store } = makeService(client) + const events: string[] = [] + service.listen((event) => events.push(event.kind)) + const created = await service.create(REQUEST) + await store.setStatus(created.order.id, status) + + expect(await service.settleFill(created.order.id, 500_000n * ONE)).toBeNull() + expect(await service.settleFill(created.order.id, 999_999n * ONE)).toBeNull() + expect((await store.get(created.order.id))?.status).toBe(status) + expect(client.submitted).toEqual(["0x00"]) + expect(events).toEqual([]) + }, + ) }) describe("LimitOrderService.reconcileTallies", () => { @@ -747,6 +765,36 @@ describe("reposting when the old entry will not come down", () => { expect(reposted?.lastError).toMatch(/ECONNREFUSED/) expect(client.submitted).toEqual(["0x00"]) }) + + it.each(["filled", "cancelled", "expired", "rejected"] as const)( + "leaves a row that went %s while the old entry was coming down", + async (status) => { + const client = fakeClient([]) + const { service, store } = makeService(client) + const created = await service.create(REQUEST) + client.cancelOrder = async () => { + await store.setStatus(created.order.id, status) + return { kind: "rejected", code: "UNKNOWN_ORDER", message: "gone" } + } + + expect(await service.repost(created.order)).toBeNull() + expect(client.submitted).toEqual(["0x00"]) + expect((await store.get(created.order.id))?.status).toBe(status) + }, + ) + + it("leaves a row that was cancelled while a refused withdrawal was out", async () => { + const client = fakeClient([]) + const { service, store } = makeService(client) + const created = await service.create(REQUEST) + client.cancelOrder = async () => { + await store.setStatus(created.order.id, "cancelled") + return { kind: "failed", message: "connect ECONNREFUSED" } + } + + expect(await service.repost(created.order)).toBeNull() + expect((await store.get(created.order.id))?.status).toBe("cancelled") + }) }) describe("a posting that lands after the order moved on", () => { diff --git a/sdk/packages/simplex/src/tests/orderbook/matching.test.ts b/sdk/packages/simplex/src/tests/orderbook/matching.test.ts index 813178ee77..0e97bca002 100644 --- a/sdk/packages/simplex/src/tests/orderbook/matching.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/matching.test.ts @@ -31,6 +31,7 @@ function limitOrder(overrides: Partial = {}): LimitOrder { status: "open", commitment: "0xabc", orderNonce: "0", + entryPoint: null, bookExpiresAt: null, bookPrice: null, lastError: null, diff --git a/sdk/packages/simplex/src/tests/orderbook/orderbook.live.test.ts b/sdk/packages/simplex/src/tests/orderbook/orderbook.live.test.ts index 9216be5a75..ff79f9abd1 100644 --- a/sdk/packages/simplex/src/tests/orderbook/orderbook.live.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/orderbook.live.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os" import { fileURLToPath } from "node:url" import { join } from "node:path" import { afterAll, beforeAll, describe, expect, it } from "vitest" -import type { HexString } from "@hyperbridge/sdk" +import { ChainConfigService, type HexString } from "@hyperbridge/sdk" import { MemoryDataStore } from "@/data/memory" import { OrderbookClient } from "@/orderbook/client" import { LimitOrderService } from "@/orderbook/limit-orders" @@ -38,8 +38,7 @@ const RUNNING = process.env.HYPERFX_ORDERBOOK_URL const BINARY = process.env.HYPERFX_ORDERBOOK_BIN const GATEWAY = "0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716" as HexString const CHAIN_ID = 8453 -/** EntryPoint v0.8, which is what the solver account validates against. */ -const ENTRY_POINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" as HexString +const chainConfig = new ChainConfigService() const ONE = ORDERBOOK_SCALE /** Take in 1,500,000 cNGN, pay out 1,000.5 USDC: a cNGN to USDC order at about 1,499. */ @@ -152,7 +151,10 @@ describe.skipIf(!RUNNING && !BINARY)("a limit order on a running orderbook", () client, contractService, // biome-ignore lint/suspicious/noExplicitAny: the chain and the EntryPoint are all this path reads - { getConfiguredChainIds: () => [CHAIN_ID], getEntryPointAddress: () => ENTRY_POINT } as any, + { + getConfiguredChainIds: () => [CHAIN_ID], + getEntryPointAddress: (chain: string) => chainConfig.getEntryPointAddress(chain), + } as any, // biome-ignore lint/suspicious/noExplicitAny: two symbols on one chain baseAssetRegistry() as any, signer, diff --git a/sdk/packages/simplex/src/tests/orderbook/posted-userop.test.ts b/sdk/packages/simplex/src/tests/orderbook/posted-userop.test.ts index 99588d1895..73a897d1b8 100644 --- a/sdk/packages/simplex/src/tests/orderbook/posted-userop.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/posted-userop.test.ts @@ -4,6 +4,7 @@ import { BASE_CHAIN, BASE_CNGN, BASE_USDC, postingRig } from "../helpers/posting import { ADDRESS_ZERO, CryptoUtils, + ENTRY_POINT_V09, decodeERC7821ExecuteBatch, decodeFillOrder, decodePhantomBidDeclaration, @@ -38,11 +39,13 @@ const MIN_TTL_SECS = 900 const TAKEN_IN = 1_500_000_000_000n const PAID_OUT = 1_000_500_000n -async function postOne(overrides: { ttlSecs?: number; acceptedSourceChains?: string[] } = {}) { +async function postOne( + overrides: { ttlSecs?: number; acceptedSourceChains?: string[]; entryPointAddress?: HexString } = {}, +) { const { service, signer } = await postingRig({ gateway: GATEWAY, chainId: BASE_CHAIN_ID }) const built = await service.prepareLimitOrderUserOp({ fillChain: BASE_CHAIN, - entryPointAddress: ENTRY_POINT, + entryPointAddress: overrides.entryPointAddress ?? ENTRY_POINT, inputToken: BASE_CNGN, outputToken: BASE_USDC, inputAmount: TAKEN_IN, @@ -58,8 +61,8 @@ async function postOne(overrides: { ttlSecs?: number; acceptedSourceChains?: str * Who signed the op, over the bare userOpHash the EntryPoint derives. The * commitment prefix is not covered by that hash, so it is stripped first. */ -async function recoverSigner(op: PackedUserOperation): Promise { - const typed = CryptoUtils.packedUserOpTypedData({ ...op, signature: "0x" }, ENTRY_POINT, BigInt(BASE_CHAIN_ID)) +async function recoverSigner(op: PackedUserOperation, entryPoint: HexString = ENTRY_POINT): Promise { + const typed = CryptoUtils.packedUserOpTypedData({ ...op, signature: "0x" }, entryPoint, BigInt(BASE_CHAIN_ID)) return recoverTypedDataAddress({ ...typed, signature: `0x${op.signature.slice(66)}` as Hex }) } @@ -117,4 +120,18 @@ describe("the op simplex posts", () => { // The low 64 bits are the sequence the EntryPoint counts within that key. expect(BigInt(op.nonce) & ((1n << 64n) - 1n)).toBe(0n) }) + + it("signs for the EntryPoint it is given, under the same commitment and nonce", async () => { + // The nonce is the op's own key at its first sequence, never read from an + // EntryPoint, so signing the same posting for another one changes the + // signature alone. + const before = await postOne() + const after = await postOne({ entryPointAddress: ENTRY_POINT_V09 }) + + expect(after.commitment).toBe(before.commitment) + expect(after.op.nonce).toBe(before.op.nonce) + expect(after.op.signature).not.toBe(before.op.signature) + expect(await recoverSigner(after.op, ENTRY_POINT_V09)).toBe(after.signer.address) + expect(await recoverSigner(after.op)).not.toBe(after.signer.address) + }) }) diff --git a/sdk/packages/simplex/src/tests/orderbook/usd.test.ts b/sdk/packages/simplex/src/tests/orderbook/usd.test.ts index d75f0b0930..c56641b8b1 100644 --- a/sdk/packages/simplex/src/tests/orderbook/usd.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/usd.test.ts @@ -24,6 +24,7 @@ function limitOrder(base: string, quote: string, price: string): LimitOrder { status: "open", commitment: null, orderNonce: "0", + entryPoint: null, bookExpiresAt: null, bookPrice: null, lastError: null, diff --git a/sdk/packages/simplex/src/tests/pimlicoBundler.ts b/sdk/packages/simplex/src/tests/pimlicoBundler.ts index 400c16100a..5cb8996eeb 100644 --- a/sdk/packages/simplex/src/tests/pimlicoBundler.ts +++ b/sdk/packages/simplex/src/tests/pimlicoBundler.ts @@ -18,8 +18,14 @@ export function getPimlicoBundlerApiKey(): string | undefined { return process.env.BUNDLER_API_KEY } -/** Pimlico v2 RPC URL for `chainId`, or `undefined` if no API key is configured. */ +/** + * Bundler URL for `chainId`. `BUNDLER_URL_` (e.g. `BUNDLER_URL_97=http://127.0.0.1:3000` + * for a local bundler on BSC Chapel) wins when set. Otherwise the Pimlico v2 RPC URL, or + * `undefined` if no API key is configured. + */ export function pimlicoBundlerUrlForChain(chainId: number): string | undefined { + const override = process.env[`BUNDLER_URL_${chainId}`] + if (override) return override const apiKey = getPimlicoBundlerApiKey() return apiKey ? `https://api.pimlico.io/v2/${chainId}/rpc?apikey=${apiKey}` : undefined } diff --git a/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts b/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts index 48c54ce998..3cf6538af6 100644 --- a/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts +++ b/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts @@ -264,3 +264,47 @@ describe("setupDelegation ordering", () => { expect(trySendSponsored).toHaveBeenCalledOnce() }) }) + +describe("setupDelegation after a SolverAccount upgrade", () => { + const OLD_SOLVER_ACCOUNT = "0x00000000000000000000000000000000000000bb" as HexString + + it("re-delegates a wallet still delegated to the old SolverAccount", async () => { + vi.mocked(resolvePendingPermit2Approval).mockResolvedValue(null) + trySendSponsored.mockResolvedValue({ txHash: "0x" + "cd".repeat(32) }) + const { service, sendTransaction } = build({ native: 0n, delegatedTo: OLD_SOLVER_ACCOUNT }) + + expect(await service.isDelegated(CHAIN)).toBe(false) + expect(await service.setupDelegation(CHAIN)).toBe(true) + + expect(sendTransaction).not.toHaveBeenCalled() + expect(trySendSponsored).toHaveBeenCalledOnce() + const [req] = trySendSponsored.mock.calls[0] as unknown as [ + { eip7702Auth?: () => Promise<{ address: HexString }> }, + ] + expect(req.eip7702Auth).toBeTypeOf("function") + expect((await req.eip7702Auth!()).address).toBe(SOLVER_ACCOUNT) + }) + + it("re-delegates through a direct tx when the sponsored path is unavailable", async () => { + vi.mocked(resolvePendingPermit2Approval).mockResolvedValue(null) + trySendSponsored.mockResolvedValue(null) + const { service, sendTransaction } = build({ native: DIRECT_TX_COST, delegatedTo: OLD_SOLVER_ACCOUNT }) + + expect(await service.setupDelegation(CHAIN)).toBe(true) + + expect(sendTransaction).toHaveBeenCalledOnce() + const [tx] = sendTransaction.mock.calls[0] as unknown as [{ authorizationList: { address: HexString }[] }] + expect(tx.authorizationList.map((auth) => auth.address)).toEqual([SOLVER_ACCOUNT]) + }) + + it("leaves a wallet already delegated to the new SolverAccount alone", async () => { + vi.mocked(resolvePendingPermit2Approval).mockResolvedValue(null) + const { service, sendTransaction } = build({ native: DIRECT_TX_COST, delegatedTo: SOLVER_ACCOUNT }) + + expect(await service.isDelegated(CHAIN)).toBe(true) + expect(await service.setupDelegation(CHAIN)).toBe(true) + + expect(trySendSponsored).not.toHaveBeenCalled() + expect(sendTransaction).not.toHaveBeenCalled() + }) +}) diff --git a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts index 222f00569a..3e82d3c49c 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 type { HexString } from "@hyperbridge/sdk" +import { fetchRundlerPriorityFee, type HexString } from "@hyperbridge/sdk" import { UserOpSender, type Eip7702Authorization } from "@/services/UserOpSender" import { buildPaymasterAndData } from "@/services/paymaster" @@ -23,6 +23,11 @@ vi.mock("@/services/paymaster", () => ({ buildPaymasterAndData: vi.fn(), })) +vi.mock("@hyperbridge/sdk", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, fetchRundlerPriorityFee: vi.fn(actual.fetchRundlerPriorityFee) } +}) + const CHAIN = "EVM-56" const CHAIN_ID = 56 const ENTRY_POINT = "0x4337843378433784337843378433784337843378" as HexString @@ -34,14 +39,18 @@ const SOLVER = "0x13E41CdE1D55880cbe031c69f206C2E9BC3c94C2" as HexString // Fixed limits as passed by the delegation flow — skips bundler gas estimation. const GAS = { verificationGasLimit: 150_000n, callGasLimit: 50_000n, preVerificationGas: 100_000n } -const configService = { - getEntryPointAddress: () => ENTRY_POINT, - getBundlerUrl: () => "http://127.0.0.1:1/bundler", - getChainId: () => CHAIN_ID, -} as unknown as FillerConfigService +const configServiceFor = (bundlerUrl: string) => + ({ + getEntryPointAddress: () => ENTRY_POINT, + getBundlerUrl: () => bundlerUrl, + getChainId: () => CHAIN_ID, + }) as unknown as FillerConfigService + +const configService = configServiceFor("http://127.0.0.1:1/bundler") const publicClient = { getGasPrice: async () => 1_000_000_000n, + getBlock: async () => ({ baseFeePerGas: 900_000_000n }), // Only EntryPoint.getNonce is read on this path (fixed gas limits skip estimation). readContract: async () => 0n, } @@ -64,13 +73,18 @@ const permit2ModePaymasterAndData = packPaymasterAndData({ }) let bundlerCalls: Array<{ method: string; params: unknown[] }> +let bundlerReplies: Record beforeEach(() => { bundlerCalls = [] + bundlerReplies = {} vi.mocked(buildPaymasterAndData).mockReset() + vi.mocked(fetchRundlerPriorityFee).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 }) + const reply = bundlerReplies[method] + if (reply) return { json: async () => ({ jsonrpc: "2.0", id: 1, ...reply }) } let result: unknown switch (method) { case "eth_sendUserOperation": @@ -181,3 +195,74 @@ describe("UserOpSender EIP-7702 authorization ordering", () => { expect(bundlerCalls.some((c) => c.method === "eth_sendUserOperation")).toBe(false) }) }) + +describe("UserOpSender gas price", () => { + const sponsorWith = async (bundlerUrl: string) => { + vi.mocked(buildPaymasterAndData).mockResolvedValue({ + paymasterAndData: permit2ModePaymasterAndData, + type: "simplex", + address: PAYMASTER, + token: TOKEN, + }) + const sender = new UserOpSender(clientManager, configServiceFor(bundlerUrl), signer) + return sender.trySendSponsored({ chain: CHAIN, callData: "0x" as HexString, gas: GAS }) + } + + const sentFees = () => + bundlerCalls + .filter((c) => c.method === "eth_sendUserOperation") + .map((c) => { + const [op] = c.params as [{ maxFeePerGas: HexString; maxPriorityFeePerGas: HexString }] + return { maxFeePerGas: BigInt(op.maxFeePerGas), maxPriorityFeePerGas: BigInt(op.maxPriorityFeePerGas) } + }) + + const rundlerCalls = () => bundlerCalls.filter((c) => c.method === "rundler_maxPriorityFeePerGas") + + it("raises the fees to a rundler bundler's priority fee when it is above the chain estimate", async () => { + // 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() + + 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" } } + + 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() + }) +}) diff --git a/sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts b/sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts new file mode 100644 index 0000000000..d9d2ffc2d0 --- /dev/null +++ b/sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { FillerConfigService } from "@/services/FillerConfigService" +import { LoggerContext } from "@/services/Logger" +import { assertBundlersServeEntryPoint, bundlerHost } from "@/services/bundler-preflight" + +const logLines: string[] = [] +const configService = new FillerConfigService( + [], + undefined, + new LoggerContext({ sink: { write: (line: string) => logLines.push(line) } }), +) +const ENTRY_POINT = configService.getEntryPointAddress("EVM-8453")! +const V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032" +const BUNDLER = "https://bundler.example/rpc" +const SECRET_BUNDLER = "https://api.pimlico.io/v2/8453/rpc?apikey=supersecretpimlicokey" + +type Answer = { result: unknown } | { error: string } | { status: number } | { body: string } | Error | "hang" + +/** Answers eth_supportedEntryPoints per bundler URL, and records every call. */ +function stubBundlers(answers: Record) { + const calls: Array<{ url: string; method: string }> = [] + vi.stubGlobal("fetch", async (url: string | URL, init?: { body?: string; signal?: AbortSignal }) => { + const { method } = JSON.parse(init?.body ?? "{}") as { method: string } + calls.push({ url: String(url), method }) + const answer = answers[String(url)] + if (answer === undefined) throw new Error(`unexpected fetch to ${url}`) + if (answer === "hang") { + return new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)) + }) + } + if (answer instanceof Error) throw answer + if ("status" in answer) return new Response("nope", { status: answer.status }) + if ("body" in answer) return new Response(answer.body, { status: 200 }) + const body = "error" in answer ? { error: { message: answer.error } } : { result: answer.result } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, ...body }), { status: 200 }) + }) + return calls +} + +function warnings(): Array<{ msg: string; bundler: string; chain: string }> { + return logLines.map((line) => JSON.parse(line)).filter((record) => record.level === 40) +} + +afterEach(() => { + vi.unstubAllGlobals() + logLines.length = 0 +}) + +describe("assertBundlersServeEntryPoint", () => { + it("passes when the bundler lists the chain's EntryPoint, asking once per chain", async () => { + const calls = stubBundlers({ [BUNDLER]: { result: [V07, ENTRY_POINT] } }) + + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).resolves.toBeUndefined() + expect(calls).toEqual([{ url: BUNDLER, method: "eth_supportedEntryPoints" }]) + expect(warnings()).toEqual([]) + }) + + it("matches the EntryPoint in any letter case", async () => { + stubBundlers({ [BUNDLER]: { result: [ENTRY_POINT.toLowerCase()] } }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).resolves.toBeUndefined() + + stubBundlers({ [BUNDLER]: { result: [`0x${ENTRY_POINT.slice(2).toUpperCase()}`] } }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).resolves.toBeUndefined() + }) + + it("refuses a bundler that does not list the EntryPoint, naming the chain, host, requirement and listing", async () => { + stubBundlers({ [BUNDLER]: { result: [V07] } }) + + const error = await assertBundlersServeEntryPoint( + [{ chainId: 8453, bundlerUrl: BUNDLER }], + configService, + ).catch((err: Error) => err) + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain("Base (EVM-8453)") + expect(message).toContain("bundler.example") + expect(message).toContain(ENTRY_POINT) + expect(message).toContain(`it lists ${V07}`) + }) + + it("refuses a bundler that lists nothing", async () => { + stubBundlers({ [BUNDLER]: { result: [] } }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).rejects.toThrow(/it lists none/) + }) + + it("warns and passes on an RPC error", async () => { + stubBundlers({ [BUNDLER]: { error: "method eth_supportedEntryPoints not found" } }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).resolves.toBeUndefined() + const [warning] = warnings() + expect(warning.msg).toMatch( + /Bundler bundler\.example for Base \(EVM-8453\) did not answer eth_supportedEntryPoints: method eth_supportedEntryPoints not found/, + ) + expect(warning.bundler).toBe("bundler.example") + expect(warning.chain).toBe("EVM-8453") + }) + + it("warns and passes on an HTTP error and an unreachable bundler", async () => { + stubBundlers({ [BUNDLER]: { status: 401 } }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).resolves.toBeUndefined() + + stubBundlers({ + [BUNDLER]: Object.assign(new TypeError("fetch failed"), { + cause: new Error("getaddrinfo ENOTFOUND bundler.example"), + }), + }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).resolves.toBeUndefined() + + const messages = warnings().map((warning) => warning.msg) + expect(messages[0]).toMatch(/did not answer eth_supportedEntryPoints: HTTP 401/) + expect(messages[1]).toMatch(/fetch failed \(getaddrinfo ENOTFOUND bundler\.example\)/) + }) + + it("warns and passes on a bundler that does not answer in time", async () => { + stubBundlers({ [BUNDLER]: "hang" }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService, { timeoutMs: 20 }), + ).resolves.toBeUndefined() + expect(warnings()[0].msg).toMatch(/did not answer eth_supportedEntryPoints: timed out after 20ms/) + }) + + it("warns and passes on an answer that is not a list of addresses", async () => { + for (const answer of [{ body: "gateway" }, { result: null }, { result: "0x" }, { result: [1] }]) { + stubBundlers({ [BUNDLER]: answer }) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: BUNDLER }], configService), + ).resolves.toBeUndefined() + } + expect(warnings()).toHaveLength(4) + }) + + it("names the bundler by host alone, never its key-bearing path or query", async () => { + stubBundlers({ [SECRET_BUNDLER]: { result: [V07] } }) + const missing = await assertBundlersServeEntryPoint( + [{ chainId: 8453, bundlerUrl: SECRET_BUNDLER }], + configService, + ).catch((err: Error) => err.message) + expect(missing).toContain("api.pimlico.io") + expect(missing).not.toContain("supersecretpimlicokey") + expect(missing).not.toContain("/v2/8453") + + stubBundlers({ [SECRET_BUNDLER]: new Error(`request to ${SECRET_BUNDLER} failed`) }) + await assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: SECRET_BUNDLER }], configService) + expect(warnings()[0].msg).toContain("request to api.pimlico.io failed") + expect(logLines.join("\n")).not.toContain("supersecretpimlicokey") + expect(logLines.join("\n")).not.toContain("/v2/8453") + }) + + it("refuses every chain whose bundler lacks the EntryPoint, and only warns about one that cannot answer", async () => { + stubBundlers({ + "https://base.example": { result: [V07] }, + "https://arb.example": { result: [] }, + "https://op.example": { status: 500 }, + }) + const message = await assertBundlersServeEntryPoint( + [ + { chainId: 8453, bundlerUrl: "https://base.example" }, + { chainId: 42161, bundlerUrl: "https://arb.example" }, + { chainId: 10, bundlerUrl: "https://op.example" }, + ], + configService, + ).catch((err: Error) => err.message) + expect(message).toContain("Base (EVM-8453)") + expect(message).toContain("Arbitrum (EVM-42161)") + expect(message).not.toContain("EVM-10") + expect(warnings().map((warning) => warning.chain)).toEqual(["EVM-10"]) + }) + + it("skips watch-only chains, and asks for chains marked false", async () => { + const calls = stubBundlers({ + "https://base.example": { result: [V07] }, + "https://arb.example": { result: [V07] }, + }) + const chains = [ + { chainId: 8453, bundlerUrl: "https://base.example" }, + { chainId: 42161, bundlerUrl: "https://arb.example" }, + ] + + await expect( + assertBundlersServeEntryPoint(chains, configService, { watchOnly: { 8453: true, 42161: true } }), + ).resolves.toBeUndefined() + expect(calls).toEqual([]) + + await expect( + assertBundlersServeEntryPoint(chains, configService, { watchOnly: { 8453: true, 42161: false } }), + ).rejects.toThrow(/Arbitrum \(EVM-42161\) does not support EntryPoint/) + expect(calls.map((call) => call.url)).toEqual(["https://arb.example"]) + }) + + it("skips chains with no bundler or no known EntryPoint", async () => { + const calls = stubBundlers({}) + await expect( + assertBundlersServeEntryPoint( + [ + { chainId: 8453, bundlerUrl: undefined }, + { chainId: 8453, bundlerUrl: " " }, + { chainId: 31337, bundlerUrl: BUNDLER }, + ], + configService, + ), + ).resolves.toBeUndefined() + expect(calls).toEqual([]) + }) +}) + +describe("bundlerHost", () => { + it("keeps the host and port, dropping credentials, path and query", () => { + expect(bundlerHost("https://user:pass@bundler.example:8443/v1/abcdef?apikey=x")).toBe("bundler.example:8443") + expect(bundlerHost("not a url")).toBe("an unparseable URL") + }) +}) diff --git a/sdk/packages/simplex/src/tests/setup-api.test.ts b/sdk/packages/simplex/src/tests/setup-api.test.ts index ccd0819091..b9aaf636b2 100644 --- a/sdk/packages/simplex/src/tests/setup-api.test.ts +++ b/sdk/packages/simplex/src/tests/setup-api.test.ts @@ -7,6 +7,7 @@ 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" @@ -130,6 +131,43 @@ describe("setup API", () => { 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() From af2bf2695208f68ae04dda431ec1b8e5678bb23b Mon Sep 17 00:00:00 2001 From: royvardhan Date: Tue, 6 Oct 2026 22:46:32 +0530 Subject: [PATCH 04/12] [evm, docs]: testnet v0.9 addresses, runbook and docs --- .../2026-10-06-entrypoint-v09-end-to-end.md | 217 ++++++++++++++++++ .../evm/contract-addresses/testnet.mdx | 6 + .../evm/intent-gateway/overview.mdx | 20 +- .../developers/evm/simplex/api/setup.mdx | 9 +- .../developers/evm/simplex/limit-orders.mdx | 2 +- .../evm/simplex/troubleshooting.mdx | 6 + .../developers/sdk/api/intent-gateway.mdx | 12 +- docs/content/developers/sdk/api/simplex.mdx | 21 +- evm/config.testnet.toml | 26 ++- evm/script/testnet/README.md | 50 +++- evm/script/testnet/entrypoint-v09.sh | 37 +-- 11 files changed, 351 insertions(+), 55 deletions(-) create mode 100644 docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md diff --git a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md new file mode 100644 index 0000000000..0baeff072d --- /dev/null +++ b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md @@ -0,0 +1,217 @@ +# EntryPoint v0.9 end to end: solver selection by userOpHash + +An order's session key now selects one bid UserOperation instead of a solver address. With +`solverSelection` on, `fillOrder` succeeds only while ERC-4337 EntryPoint v0.9 +(`0x433709009B8330FDa32311DF1C2AFA402eD8D009`) is executing that operation. The SDK, simplex and +the indexer handle both selection formats. The paymaster and `SolverAccount` move to v0.9 is +described in `docs/ai/changelog/2026-10-05-simplex-paymaster-entrypoint-v09-and-bundler-allowlist.md`. + +## Contracts + +- `SELECT_SOLVER_TYPEHASH` is `keccak256("SelectSolver(bytes32 commitment,bytes32 userOpHash)")`. + `SelectOptions` (`sdk/packages/core/contracts/apps/IntentGatewayV2.sol`) is + `{ bytes32 commitment; bytes32 userOpHash; bytes signature; }`, so `select` has selector + `0x81b06069`. On a gateway that selects by address, `select((bytes32,address,bytes))` is + `0x6d20e7dc`. +- `select` is permissionless. It recovers the signer of `SelectSolver(commitment, userOpHash)` and + sets transient slot `keccak256(abi.encode(commitment, userOpHash, signer))` to 1. Keying on the + signer means a selection signed by any other key cannot overwrite the session key's. It still + reverts `Filled` on a finalized order. +- `fillOrder` with `solverSelection` on reads `getCurrentUserOpHash()` from the v0.9 constant + `ENTRYPOINT_V09`, never from the caller. It reverts `Unauthorized` when the hash is zero (no + operation executing, as for a direct call) or the slot for + `(commitment, userOpHash, order.session)` is unset. `msg.sender` is not checked, so any contract + the selected operation calls can fill. +- `SolverAccount.validateUserOp` passes its `userOpHash` into `select`. The 162-byte signature + layout and the nonce key `uint192(keccak256(commitment ‖ sessionKey ‖ keccak256(callData)))` are + unchanged. +- The gateway keeps `VERSION` 3 and its storage layout. The implementation upgrade carries empty + init data. + +## SDK (`@hyperbridge/sdk` 2.9.0) + +Breaking: + +- `ChainConfigData.addresses.EntryPointV08` is now `addresses.EntryPoint`, the EntryPoint the + chain's `SolverAccount` validates against. It changes together with `SolverAccount`. +- `ChainConfigService.getEntryPointV08Address` is now `getEntryPointAddress(chain)`, which returns + `HexString | undefined`. +- `SelectOptions.solver` is now `SelectOptions.userOpHash`. +- `CryptoUtils.signSolverSelection` is removed. In its place: + - `signUserOpHashSelection(commitment, userOpHash, domainSeparator, privateKey)` signs the + userOpHash format. + - `signLegacySolverSelection(commitment, solverAddress, domainSeparator, privateKey)` signs the + address format. + - Both return the signature and never `null`. + +Added exports: + +- `ENTRY_POINT_V08` and `ENTRY_POINT_V09`. +- `KNOWN_ENTRY_POINTS`, which lists v0.9 first. +- `LEGACY_SELECT_SOLVER_TYPEHASH`. +- `readSelectionFormat` and `SelectionFormat` (`"userOpHash" | "address"`). +- `fetchRundlerPriorityFee` and `applyRundlerPriorityFee`. + +### Bid routing + +`BidImpl` finds the bid's EntryPoint by recovering the solver signature (`userOp.signature[32:97]`) +over the userOpHash for each entry of `KNOWN_ENTRY_POINTS`. + +A v0.9 bid pairs only with a gateway whose format is `userOpHash`, and a v0.8 bid only with an +`address` gateway, because each `SolverAccount` calls its own `select`. The format comes from the +gateway's `SELECT_SOLVER_TYPEHASH()`: + +- `readSelectionFormat` caches it per chain id and gateway address. +- A failed read is not cached. +- An unknown typehash throws. +- On a mismatch, `refreshSelectionFormat` reads it once more before the bid is refused, so a gateway + upgraded in place is picked up. + +A bid that recovers under no known EntryPoint, or does not pair with the gateway, fails `simulate()` +and `execute()`, so `selectAndExecuteBest` skips it. `execute()` submits to the recovered EntryPoint +and reads its fill logs from that EntryPoint's events. The session signature is made in the +gateway's format and cached. + +### Simulation and estimation + +`Bid.simulate()` is a single `eth_call` of `userOp.callData` from the solver account. The value is +the native outputs plus the dispatch fee. It makes no `select` call and no signature. + +The call carries a state override from `selectionOffStateDiff`, which reads gateway slot 5 live and +clears only the `solverSelection` byte (bits 160 to 167). `GasEstimator.buildStateOverride` uses the +same diff, so it no longer needs the call dispatcher address. + +`GasEstimator.estimateFillOrder` estimates against `getEntryPointAddress`. With no EntryPoint +configured, it takes the bundler-failure path. It signs the selection in the gateway's format. + +### Rundler fee floor + +For a bundler URL containing neither `pimlico.io` nor `alchemy.com`, `fetchRundlerPriorityFee` asks +for `rundler_maxPriorityFeePerGas`: + +- A bundler that answers `-32600`, `-32601` or `-32602` is not asked again. +- Other failures are not remembered. + +When the bundler gives a fee, `applyRundlerPriorityFee` raises: + +- the priority fee to at least that fee plus the priority bump; +- the max fee to at least the base fee plus the max-fee bump, plus that priority floor. + +`GasEstimator` and simplex's `UserOpSender.getGasPrice` both apply it. + +### Used-op keys + +`OrderExecutor` always hashes its used-UserOp keys with `ENTRY_POINT_V08`. This keeps keys already +persisted per order valid when a chain's EntryPoint changes. + +### Chain config + +- Chapel (97) and Amoy (80002) set `EntryPoint` v0.9, `SolverAccount` + `0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2`, and `SimplexPaymaster`: + - Chapel: `0x9f4a6F1254f27373C2aca5bDd0e48FbA45EA3aeC`, plus `Permit2`. + - Amoy: `0x6085a14078B7d26259145a894140f7C8361e02ae`. +- Every other chain keeps v0.8 until it is rolled out. + +## simplex (0.17.0) + +### Bundler preflight + +`assertBundlersServeEntryPoint` (`src/services/bundler-preflight.ts`) asks each chain's bundler for +`eth_supportedEntryPoints`, with a 10 second timeout. + +- **Refused:** a list of addresses without the chain's `getEntryPointAddress`. It throws + `Bundler for does not support EntryPoint
; it lists ...`, with every + failing chain joined into one error. Messages show only the bundler's host, because URLs carry API + keys. +- **Warning only:** no answer, an HTTP or JSON-RPC error, a timeout, or a result that is not a list + of strings. +- **Skipped:** watch-only chains, and chains with no bundler URL or no known EntryPoint. + +It runs: + +- in `bootFiller`, before chain clients are built; +- in `ChainController.add`, before the chain is registered; +- in `setBundlerUrl`; +- in `setWatchOnly(chainId, false)`. + +### Setup API + +`validate-bundler` takes an optional `chainId`. `ok` is `false` only for an empty URL. Otherwise it +is `true`, and the response carries a `warning` when: + +- the bundler does not list the chain's EntryPoint (Simplex will refuse it); +- the answer is not a list of addresses (Simplex will use it and warn); +- the bundler does not answer. + +### Limit orders + +- `LimitOrder.entryPoint` and `LimitOrderPosting.entryPoint` record the EntryPoint the posted op was + signed for. +- SQLite stores it in column `entry_point`, which is added in place to older databases. +- A null value on a live posting means v0.8. +- It is written only with a posting that landed. + +`reconcile` checks every open posting the orderbook still lists. When its `entryPoint` differs +from the fill chain's, it +claims the order (`open` to `resizing`), withdraws the posting, and posts it again signed for +the current EntryPoint. These count in `ReconcileReport.reposted`. A fill's `resize` waits for an +in-flight re-sign rather than posting alongside it. + +## Indexer + +- `ENTRY_POINTS` (`src/utils/userOp.helpers.ts`) includes v0.9, so fills inside v0.9 bundles are + attributed to the operation's sender. +- `IntentGatewayV3.abi.json` carries the new `SelectOptions`. +- The testnet `solverAccount` lists for Chapel and Amoy add + `0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2` next to the earlier accounts. + +## Testnet deployment + +Deployed on Chapel (97) and Amoy (80002): + +- `SolverAccount` `0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2` +- `IntentGatewayV2` implementation `0x38a82f8283a0fAdB763888707aD18fCd34AF719D` +- `SimplexPaymaster` proxies Chapel `0x9f4a6F1254f27373C2aca5bDd0e48FbA45EA3aeC` and Amoy + `0x6085a14078B7d26259145a894140f7C8361e02ae`, each funded on EntryPoint v0.9 with a deposit and + a stake (86400 second unstake delay) + +Remaining steps, each a Gargantua sudo call described in `evm/script/testnet/README.md`: +registering the paymasters (step 4), upgrading the gateway proxies to the new implementation +(step 5), and setting the paymaster bundler allowlist (step 8). + +The SDK and indexer testnet configs already point at the new contracts. Until the gateway proxy +upgrade lands, fills on Chapel and Amoy fail with SDK 2.9.0 and simplex 0.17.0. A v0.9 +`SolverAccount` calls the new `select`, which the current gateway implementation rejects, and the +SDK refuses a v0.9 bid on a gateway whose format is `address`. + +Tooling: + +- `evm/script/testnet/entrypoint-v09.sh` runs the EVM side, and + `sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs` runs the Gargantua sudo side. +- `scripts/docker/rundler/` runs the rundler fork as a v0.9-only, `--unsafe` bundler. +- `sdk/packages/simplex/e2e/entrypoint-v09-local.mjs` verifies the whole stack on an anvil fork of + Chapel with a local rundler. + +## Mainnet rollout + +Upgrading a chain's gateway is a hard cutover for that chain: + +- Solvers still delegated to the old `SolverAccount` cannot fill there until they re-delegate. +- Users on an SDK older than 2.9.0 cannot select bids there until they upgrade. + +Order: + +1. Release `@hyperbridge/sdk` 2.9.0 and get integrators onto it before any mainnet gateway upgrade. + It routes v0.8 bids on gateways not yet upgraded and v0.9 bids on upgraded ones. +2. List the chain's new `SolverAccount` in the indexer config before any solver re-delegates to it. + Otherwise HyperFX drops those solvers' orders. +3. Make sure the chain's bundlers serve v0.9. Simplex refuses a bundler that lists EntryPoints + without the chain's. +4. Upgrade the chain's gateway implementation, with empty init data, and its paymaster, with + `migrate` as described in the paymaster note. +5. With each gateway upgrade, ship the SDK chain config that sets `EntryPoint` v0.9 and the new + `SolverAccount` together, so solvers re-delegate. + +Polkadot Hub (420420419) has `solverSelection` on but no EntryPoint v0.9. Before upgrading its +gateway, turn selection off there, or leave the chain out of the upgrade. Otherwise `fillOrder` +reverts there for every order. diff --git a/docs/content/developers/evm/contract-addresses/testnet.mdx b/docs/content/developers/evm/contract-addresses/testnet.mdx index d511802b16..c362d7f362 100644 --- a/docs/content/developers/evm/contract-addresses/testnet.mdx +++ b/docs/content/developers/evm/contract-addresses/testnet.mdx @@ -114,6 +114,9 @@ The current testnet environment for the Hyperbridge network. | `SP1Verifier` | [`0xe46f1D4A1fC2130C3a3f3f5f78432774D28d249D`](https://testnet.bscscan.com/address/0xe46f1D4A1fC2130C3a3f3f5f78432774D28d249D) | | `HostManager` | [`0xb05fd51329b4dd28cf07e273efb496edd243dbee`](https://testnet.bscscan.com/address/0xb05fd51329b4dd28cf07e273efb496edd243dbee) | | `CallDispatcher` | [`0x2b332088275bc9e3c26d81b2975de2483320c181`](https://testnet.bscscan.com/address/0x2b332088275bc9e3c26d81b2975de2483320c181) | +| `SolverAccount` | [`0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2`](https://testnet.bscscan.com/address/0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2) | +| `SimplexPaymaster` | [`0x9f4a6F1254f27373C2aca5bDd0e48FbA45EA3aeC`](https://testnet.bscscan.com/address/0x9f4a6F1254f27373C2aca5bDd0e48FbA45EA3aeC) | +| `SimplexPaymaster (Implementation)` | [`0xFfdfc50838dC2D015e7a045Fa0961D8D229Ca0aC`](https://testnet.bscscan.com/address/0xFfdfc50838dC2D015e7a045Fa0961D8D229Ca0aC) | | `BandwidthManager` | [`0xFe6D81417CFe618aCaA34dF23376c738900290DE`](https://testnet.bscscan.com/address/0xFe6D81417CFe618aCaA34dF23376c738900290DE) | | `TokenFaucet` | [`0x1794aB22388303ce9Cb798bE966eeEBeFe59C3a3`](https://testnet.bscscan.com/address/0x1794aB22388303ce9Cb798bE966eeEBeFe59C3a3) | | `FeeToken (USD.h)` | [`0xA801da100bF16D07F668F4A49E1f71fc54D05177`](https://testnet.bscscan.com/address/0xA801da100bF16D07F668F4A49E1f71fc54D05177) | @@ -138,6 +141,9 @@ The current testnet environment for the Hyperbridge network. | `SP1Verifier` | [`0xe46f1D4A1fC2130C3a3f3f5f78432774D28d249D`](https://amoy.polygonscan.com/address/0xe46f1D4A1fC2130C3a3f3f5f78432774D28d249D) | | `HostManager` | [`0xb05fd51329b4dd28cf07e273efb496edd243dbee`](https://amoy.polygonscan.com/address/0xb05fd51329b4dd28cf07e273efb496edd243dbee) | | `CallDispatcher` | [`0x2b332088275bc9e3c26d81b2975de2483320c181`](https://amoy.polygonscan.com/address/0x2b332088275bc9e3c26d81b2975de2483320c181) | +| `SolverAccount` | [`0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2`](https://amoy.polygonscan.com/address/0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2) | +| `SimplexPaymaster` | [`0x6085a14078B7d26259145a894140f7C8361e02ae`](https://amoy.polygonscan.com/address/0x6085a14078B7d26259145a894140f7C8361e02ae) | +| `SimplexPaymaster (Implementation)` | [`0xFfdfc50838dC2D015e7a045Fa0961D8D229Ca0aC`](https://amoy.polygonscan.com/address/0xFfdfc50838dC2D015e7a045Fa0961D8D229Ca0aC) | | `BandwidthManager` | [`0xFe6D81417CFe618aCaA34dF23376c738900290DE`](https://amoy.polygonscan.com/address/0xFe6D81417CFe618aCaA34dF23376c738900290DE) | | `TokenFaucet` | [`0xcb00f5b86aac5e2fdca9dc7f34d9bfe00b967c18`](https://amoy.polygonscan.com/address/0xcb00f5b86aac5e2fdca9dc7f34d9bfe00b967c18) | | `FeeToken (USD.h)` | [`0xBe97E73126d66188d72FBf99029126d0340a7f18`](https://amoy.polygonscan.com/address/0xBe97E73126d66188d72FBf99029126d0340a7f18) | diff --git a/docs/content/developers/evm/intent-gateway/overview.mdx b/docs/content/developers/evm/intent-gateway/overview.mdx index b567be557b..ba89a0fe93 100644 --- a/docs/content/developers/evm/intent-gateway/overview.mdx +++ b/docs/content/developers/evm/intent-gateway/overview.mdx @@ -163,11 +163,11 @@ After execution, any tokens remaining in the `CallDispatcher` are swept back to ## Solver Selection -When `solverSelection` is enabled in the intent gateway parameters, orders are protected from unauthorized fills. At order placement, the user specifies a `session` key — a temporary keypair generated for this order. Only a solver explicitly authorized by the session key can fill the order. +When `solverSelection` is enabled in the intent gateway parameters, orders are protected from unauthorized fills. At order placement, the user specifies a `session` key — a temporary keypair generated for this order. The session key selects one solver bid, a UserOperation, and only that UserOperation can fill the order. Gateways at this release select by `userOpHash`, which requires ERC-4337 EntryPoint v0.9 (`0x433709009B8330FDa32311DF1C2AFA402eD8D009`) on the destination chain, because `fillOrder` reads the executing UserOperation from it. Earlier deployments select a solver address with `SelectSolver(bytes32 commitment,address solver)` until they are upgraded, and the SDK reads `SELECT_SOLVER_TYPEHASH()` to tell which form a gateway uses. ### `SolverAccount` -The SolverAccount is a smart account designed for solvers that combines [ERC-4337](https://eips.ethereum.org/EIPS/eip-4337) (account abstraction), [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702), and [ERC-7821](https://eips.ethereum.org/EIPS/eip-7821) (batch execution) to batch `gateway.select(...)` and `gateway.fillOrder(...)` into a single atomic UserOperation. Solvers delegate their EOA to the SolverAccount via EIP-7702 and submit bundled operations through the ERC-4337 EntryPoint. +The SolverAccount is a smart account designed for solvers that combines [ERC-4337](https://eips.ethereum.org/EIPS/eip-4337) (account abstraction), [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702), and [ERC-7821](https://eips.ethereum.org/EIPS/eip-7821) (batch execution): it calls `gateway.select(...)` while validating a bid UserOperation and runs `gateway.fillOrder(...)` when that operation executes. Solvers delegate their EOA to the SolverAccount via EIP-7702 and submit operations through EntryPoint v0.9, the only EntryPoint the account accepts. `SolverAccount.validateUserOp` accepts two signature formats, discriminated by length: a standard 65-byte ECDSA signature over the `userOpHash` for regular account operations (delegation no-ops, approvals, treasury batches), and the 162-byte intent-selection payload `abi.encodePacked(commitment, solverSignature, sessionSignature)` for fills. UserOperations whose calldata contains a `fillOrder` call to the gateway are refused on the standard path. This guard exists because bids are public on Hyperbridge and embed a valid 65-byte solver signature over the `userOpHash` — without it, anyone could strip the commitment and session signature from a bid and submit the bare operation: the fill would revert (no selection is staged during validation), but it would still consume the bid's nonce and grief the solver of the gas fees. Fills therefore only validate through the intent-selection path. @@ -179,22 +179,24 @@ The user places an order with `order.session` set to the session key's public ad ### Auction -Solvers observe the `OrderPlaced` event and compete by posting UserOperations to hyperbridge that specify the output amounts they'll provide. A bid is bound to its order structurally: the UserOperation's ERC-4337 nonce key (the upper 192 bits of the nonce) must equal the lower 192 bits of `keccak256(commitment ‖ session)` — binding both the order and the session key the solver is bidding against — and its calldata carries the `fillOrder` call for that order. The solver then signs the operation as EntryPoint v0.8 EIP-712 typed data (domain `ERC4337`/`1`, type `PackedUserOperation`) — the digest of which *is* the `userOpHash` — so the signed payload remains fully inspectable by the solver's signing infrastructure (hardware wallets, MPC or TEE policy engines) rather than an opaque digest. +Solvers observe the `OrderPlaced` event and compete by posting UserOperations to hyperbridge that specify the output amounts they'll provide. A bid is bound to its order structurally: the UserOperation's ERC-4337 nonce key (the upper 192 bits of the nonce) must equal the lower 192 bits of `keccak256(commitment ‖ session ‖ keccak256(callData))` — binding the order, the session key the solver is bidding against, and the bid's own calldata, so each price a solver bids on one order executes independently — and its calldata carries the `fillOrder` call for that order. The solver then signs the operation as EntryPoint v0.9 EIP-712 typed data (domain `ERC4337`/`1`, type `PackedUserOperation`) — the digest of which *is* the `userOpHash` — so the signed payload remains fully inspectable by the solver's signing infrastructure (hardware wallets, MPC or TEE policy engines) rather than an opaque digest. ### Bid selection -The user reviews all bids and picks the best one. To authorize the winning solver, the user signs an EIP-712 `SolverSelection` message with the session key: +The user reviews all bids and picks the best one. To select it, the user signs an EIP-712 `SelectSolver` message with the session key over the bid's `userOpHash`: ```solidity -struct SolverSelection { - bytes32 commitment, // order commitment hash - address solver // authorized solver address +struct SelectSolver { + bytes32 commitment; // order commitment hash + bytes32 userOpHash; // EntryPoint v0.9 hash of the selected bid UserOperation } ``` -The session signature is appended to the solver's `UserOperation` as `abi.encodePacked(commitment, solverSignature, sessionSignature)` and submitted to an ERC-4337 bundler. The solver pays all gas costs for on-chain execution. +The session signature is appended to the solver's `UserOperation` as `abi.encodePacked(commitment, solverSignature, sessionSignature)` and submitted to an ERC-4337 bundler that serves EntryPoint v0.9. The solver pays all gas costs for on-chain execution. ### On-chain Execution -During `SolverAccount.validateUserOp()`, `IntentGatewayV2.select()` is called which recovers the session key from the EIP-712 signature, verifies it matches the order's session key, and stores the authorized solver in transient storage (`tstore`). The account then verifies the solver's signature against the plain `userOpHash` and checks that the UserOperation's nonce key matches `keccak256(commitment ‖ recovered session key)` — rejecting, during validation rather than at the solver's expense during execution, any attempt to pair a solver's signed bid with a different order or with a session signature from a key the solver never bid against. When `fillOrder()` runs in the same transaction, it reads the authorized solver from transient storage (`tload`) and verifies `msg.sender` matches. Since transient storage is cleared after each transaction, the selection cannot be replayed or front-run. +During `SolverAccount.validateUserOp()`, the account calls `IntentGatewayV2.select(SelectOptions)` (selector `0x81b06069`) with the commitment, the `userOpHash` the EntryPoint is validating, and the session signature. `select` recovers the signer of `SelectSolver(commitment, userOpHash)` and marks the slot `keccak256(abi.encode(commitment, userOpHash, signer))` in transient storage (`tstore`). The account then verifies the solver's signature against the plain `userOpHash` and checks that the UserOperation's nonce key derives from the commitment, the recovered session key and the calldata — rejecting, during validation rather than at the solver's expense during execution, any attempt to pair a solver's signed bid with a different order or with a session signature from a key the solver never bid against. + +When `fillOrder()` runs, it does not check `msg.sender`. It reads `getCurrentUserOpHash()` from EntryPoint v0.9 and reverts `Unauthorized` unless that hash is non-zero and the slot for `(commitment, userOpHash, order.session)` is marked (`tload`). A fill therefore succeeds only while the selected UserOperation executes, from any contract that operation calls, and never from a direct call. Because the slot is keyed on the signer, a selection signed by any other key cannot overwrite the session key's, and since transient storage is cleared after each transaction, the selection cannot be replayed or front-run. diff --git a/docs/content/developers/evm/simplex/api/setup.mdx b/docs/content/developers/evm/simplex/api/setup.mdx index 7e06cdd002..2827468cfb 100644 --- a/docs/content/developers/evm/simplex/api/setup.mdx +++ b/docs/content/developers/evm/simplex/api/setup.mdx @@ -68,16 +68,19 @@ Send `urls`, or a single `url`. `expectedChainId` is optional. The URLs must be ### POST /api/setup/validate-bundler -Checks that a bundler answers `eth_supportedEntryPoints`. A bundler that does not answer is a warning, not a failure, so `ok` is `true` unless the URL is empty. +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://api.pimlico.io/v2/8453/rpc?apikey=KEY" }' + -d '{ "url": "https://api.pimlico.io/v2/8453/rpc?apikey=KEY", "chainId": 8453 }' # → { "ok": true, "entryPoints": ["0x…"] } ``` -When the bundler does not answer, the response has a `warning` in place of `entryPoints`. +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 diff --git a/docs/content/developers/evm/simplex/limit-orders.mdx b/docs/content/developers/evm/simplex/limit-orders.mdx index 5e7dfdd1c0..71b08038a6 100644 --- a/docs/content/developers/evm/simplex/limit-orders.mdx +++ b/docs/content/developers/evm/simplex/limit-orders.mdx @@ -227,7 +227,7 @@ The log names which check failed: `No profitable strategy found for order` means **A pending bid does not block the next one.** Each bid holds its payout against its order until it settles. The next bid is still sized from the order's full remaining amount. So if several pending bids win at once, fills can briefly pay out more than the order's size, up to what the wallet holds. - Simplex keeps the orderbook in sync on its own. It sends heartbeats so your postings stay live, withdraws expired orders every 30 seconds, and reconciles against the orderbook every `reconcileIntervalSecs` (5 minutes by default). Reconciliation reposts any order the orderbook has lost and cancels any entry that no longer has an order. If the orderbook is unreachable when Simplex starts, filling still works, because Simplex prices from its local copy of your orders. + Simplex keeps the orderbook in sync on its own. It sends heartbeats so your postings stay live, withdraws expired orders every 30 seconds, and reconciles against the orderbook every `reconcileIntervalSecs` (5 minutes by default). Reconciliation reposts any order the orderbook has lost, cancels any entry that no longer has an order, and signs again any posting whose fill chain has moved to a different EntryPoint since it was posted. If the orderbook is unreachable when Simplex starts, filling still works, because Simplex prices from its local copy of your orders. ## Example: a two-sided USDC/cNGN market diff --git a/docs/content/developers/evm/simplex/troubleshooting.mdx b/docs/content/developers/evm/simplex/troubleshooting.mdx index 3fd3f9dcea..9669927bef 100644 --- a/docs/content/developers/evm/simplex/troubleshooting.mdx +++ b/docs/content/developers/evm/simplex/troubleshooting.mdx @@ -70,6 +70,12 @@ Switch to a premium RPC provider, reduce `maxConcurrentOrders`, or raise `blockS --- +## **`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, or mark the chain watch-only. 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. diff --git a/docs/content/developers/sdk/api/intent-gateway.mdx b/docs/content/developers/sdk/api/intent-gateway.mdx index 01d7a722b4..89771a398a 100644 --- a/docs/content/developers/sdk/api/intent-gateway.mdx +++ b/docs/content/developers/sdk/api/intent-gateway.mdx @@ -439,7 +439,7 @@ async selectBid(order: Order, bids: FillerBid[], sessionPrivateKey?: HexString): ### prepareSubmitBid(options) -Solver-side helper: constructs a signed `PackedUserOperation` for a solver to submit as a bid. +Solver-side helper: constructs a signed `PackedUserOperation` for a solver to submit as a bid. `options.entryPointAddress` must be the EntryPoint the solver's `SolverAccount` validates against, since the user's SDK recovers it from the signature and executes the bid there. ```typescript lineNumbers async prepareSubmitBid(options: SubmitBidOptions): Promise @@ -664,6 +664,8 @@ 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. + --- ### quoteOrderFees(order, options?) @@ -867,16 +869,18 @@ interface Bid { /** Raw UserOperation for advanced consumers (custom batching, paymaster flows). */ readonly userOp: PackedUserOperation - /** Runs the batched `select` + `fillOrder` `eth_call` simulation. Throws if it reverts. */ + /** Runs the bid's `fillOrder` as an `eth_call` with the gateway's solver selection off. Throws if the bid cannot execute or the call reverts. */ simulate(): Promise - /** Signs the `SelectSolver` message, packs the signature, and submits the UserOp to the bundler. */ + /** Signs the `SelectSolver` message, packs the signature, and submits the UserOp to the bundler on the bid's EntryPoint. */ execute(): Promise /** Prices the bid's outputs in USD. Returns `null` when any output token cannot be priced. */ outputUsdValue(): Promise } ``` -The session-key signature over the `SelectSolver` message is resolved lazily and cached, so it is signed at most once whether you call `simulate()`, `execute()`, or both. +A bid executes on the EntryPoint its solver signed it for, found by recovering the solver's signature under EntryPoint v0.9 and then v0.8. The destination gateway's `SELECT_SOLVER_TYPEHASH()` decides what the session key signs: `SelectSolver(bytes32 commitment,bytes32 userOpHash)` pairs only with v0.9 bids, and `SelectSolver(bytes32 commitment,address solver)` only with v0.8 bids. The typehash is read once per chain and read again before a bid that does not pair is refused, so an in-place gateway upgrade is picked up. A bid signed for no known EntryPoint, or for one the gateway does not pair with, fails `simulate()` and `execute()`, and the autopilot helpers skip it. + +`simulate()` makes a single `eth_call` of the bid's calldata from the solver account, with a state override that clears the `solverSelection` byte in the gateway's params (storage slot 5). No `select` is called and nothing is signed, so the session key's selection is not part of the simulation. The session-key signature is produced by `execute()` and cached. --- diff --git a/docs/content/developers/sdk/api/simplex.mdx b/docs/content/developers/sdk/api/simplex.mdx index 08f2bfcbf4..ee397ce276 100644 --- a/docs/content/developers/sdk/api/simplex.mdx +++ b/docs/content/developers/sdk/api/simplex.mdx @@ -46,8 +46,8 @@ static async start(options: SimplexOptions): Promise **Returns:** `Promise` — a solver that is already scanning -**Throws:** If the config is invalid, a chain's RPCs are unreachable, or EIP-7702 delegation fails on -every chain. A partially-started solver is never returned, so there is no readiness state to poll. +**Throws:** If the config is invalid, a chain's RPCs are unreachable, a filling chain's bundler lists +EntryPoints without the chain's, or EIP-7702 delegation fails on every chain. A partially-started solver is never returned, so there is no readiness state to poll. **Example:** @@ -436,8 +436,9 @@ unknown id a `404` on `GET` and a `400` on `DELETE`. The While the solver runs, a fill draws its order down by what was delivered and reposts the remainder, closing it as `filled` once what is left is under the dust floor. The solver heartbeats the orderbook while anything is posted, reconciles against the orderbook's copy every -`orderbook.reconcileIntervalSecs` (default 300) — withdrawing entries no order owns and reposting -orders whose entry has gone — and sweeps expired orders every 30 seconds. +`orderbook.reconcileIntervalSecs` (default 300) — withdrawing entries no order owns, reposting +orders whose entry has gone, and reposting orders signed for an EntryPoint their fill chain no longer +uses — and sweeps expired orders every 30 seconds. ## PairController @@ -522,8 +523,9 @@ add(chain: ChainInput): Promise | `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, or delegation fails. The endpoints are probed and the -chain id read back before anything is mutated, and a failed add rolls itself back. +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 +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 `scanner.addChain(...)` first, then this. On a scanner the solver owns, it is added for you. @@ -558,6 +560,10 @@ belong to a scanner you supplied — see [Scanner ownership](#scanner-ownership) 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. @@ -566,6 +572,9 @@ 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. + 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 generated throwaway key that holds no funds and dies with the process, so watch-only is a one-way diff --git a/evm/config.testnet.toml b/evm/config.testnet.toml index 5055bd2316..29d5eba853 100644 --- a/evm/config.testnet.toml +++ b/evm/config.testnet.toml @@ -93,15 +93,16 @@ CALL_DISPATCHER = "0x2B332088275Bc9E3C26D81B2975de2483320C181" BANDWIDTH_MANAGER = "0xfE6d81417CFe618ACAA34Df23376C738900290de" SP1_VERIFIER = "0xe46f1D4A1fC2130C3a3f3f5f78432774D28d249D" INTENT_GATEWAY_V2 = "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA" -SOLVER_ACCOUNT = "0xf98E484858F59C30e31D2695ac18F92bc7C7b799" +SOLVER_ACCOUNT = "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2" PRICE_ORACLE = "0x0000000000000000000000000000000000000000" HOST_MANAGER = "0xa7273Cb04E99978CF5c0A935e92EC95F09e9D44a" -INTENT_GATEWAY_V2_IMPL = "0x5E03fe27c7372E044296B4BA38B2cF9f760984CA" -INTENT_GATEWAY_V2_INTRINSIC_MODULE = "0x19587e181634Fd118258e2Af37e5c6531e6cFf43" -INTENT_GATEWAY_V2_EXTRINSIC_MODULE = "0x0205338774146975D86E3164FfB3882b26b6d742" -NATIVE_ORACLE = "0x0000000000000000000000000000000000000000" +INTENT_GATEWAY_V2_IMPL = "0x38a82f8283a0fAdB763888707aD18fCd34AF719D" +INTENT_GATEWAY_V2_INTRINSIC_MODULE = "0xc9aaCFf03B89c265f55c44Ce56A27eceE7e68C81" +INTENT_GATEWAY_V2_EXTRINSIC_MODULE = "0x5c50e3e1a2Dc4D52c5C5C64b3729b3a015e646bf" +NATIVE_ORACLE = "0xB143CC4812520663a2715fcBDF141eB3dc22124b" USDC_TOKEN = "0xA801da100bF16D07F668F4A49E1f71fc54D05177" -USDC_ORACLE = "0x0000000000000000000000000000000000000000" +USDC_ORACLE = "0x74222Ae222d61C9C034561e4D1b0aD47ADDb6c54" +SIMPLEX_PAYMASTER = "0x9f4a6F1254f27373C2aca5bDd0e48FbA45EA3aeC" [97.bool] is_mainnet = false @@ -132,15 +133,16 @@ CALL_DISPATCHER = "0x2B332088275Bc9E3C26D81B2975de2483320C181" BANDWIDTH_MANAGER = "0xfE6d81417CFe618ACAA34Df23376C738900290de" SP1_VERIFIER = "0xe46f1D4A1fC2130C3a3f3f5f78432774D28d249D" INTENT_GATEWAY_V2 = "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA" -SOLVER_ACCOUNT = "0xf98E484858F59C30e31D2695ac18F92bc7C7b799" +SOLVER_ACCOUNT = "0x7484C6Ff790b898bf7e6960Ffc82f63330445Dc2" PRICE_ORACLE = "0x0000000000000000000000000000000000000000" HOST_MANAGER = "0xa7273Cb04E99978CF5c0A935e92EC95F09e9D44a" -INTENT_GATEWAY_V2_IMPL = "0x5E03fe27c7372E044296B4BA38B2cF9f760984CA" -INTENT_GATEWAY_V2_INTRINSIC_MODULE = "0x19587e181634Fd118258e2Af37e5c6531e6cFf43" -INTENT_GATEWAY_V2_EXTRINSIC_MODULE = "0x0205338774146975D86E3164FfB3882b26b6d742" -NATIVE_ORACLE = "0x0000000000000000000000000000000000000000" +INTENT_GATEWAY_V2_IMPL = "0x38a82f8283a0fAdB763888707aD18fCd34AF719D" +INTENT_GATEWAY_V2_INTRINSIC_MODULE = "0xc9aaCFf03B89c265f55c44Ce56A27eceE7e68C81" +INTENT_GATEWAY_V2_EXTRINSIC_MODULE = "0x5c50e3e1a2Dc4D52c5C5C64b3729b3a015e646bf" +NATIVE_ORACLE = "0x938D7B383414201559f49b1bA2667676c58147a5" USDC_TOKEN = "0xBE97E73126D66188d72fbF99029126D0340a7f18" -USDC_ORACLE = "0x0000000000000000000000000000000000000000" +USDC_ORACLE = "0x74222Ae222d61C9C034561e4D1b0aD47ADDb6c54" +SIMPLEX_PAYMASTER = "0x6085a14078B7d26259145a894140f7C8361e02ae" [80002.bool] is_mainnet = false diff --git a/evm/script/testnet/README.md b/evm/script/testnet/README.md index d537519f0c..ff37c93393 100644 --- a/evm/script/testnet/README.md +++ b/evm/script/testnet/README.md @@ -6,16 +6,19 @@ Moves BSC Chapel (`bsc-testnet`, 97) and Polygon Amoy (`polygon-amoy`, 80002) to governed from Gargantua. The EVM steps run with `entrypoint-v09.sh` from `evm/`. The sudo steps run with `e2e/entrypoint-v09-sudo.mjs` from `sdk/packages/simplex`. -Both scripts read `PRIVATE_KEY`, `SECRET_PHRASE`, `BSC_CHAPEL`, `POLYGON_AMOY` and +Both scripts read `PRIVATE_KEY`, `GARGANTUA_SUDO_SEED`, `BSC_CHAPEL`, `POLYGON_AMOY` and `HYPERBRIDGE_GARGANTUA` from `sdk/.env.local`, and skip a step that already holds on a chain. -With `--dry-run`: +`GARGANTUA_SUDO_SEED` is the seed of Gargantua's `sudo.key()`, checked before anything is signed. It +is not `SECRET_PHRASE`, which is the e2e BRIDGE funder. With `--dry-run`: - the EVM steps run on local anvil forks against a temporary copy of the config; - the sudo steps print each decoded call and its hex without signing. -Every EVM broadcast goes through `script/deploy.sh`, which asks for confirmation and verifies the -contracts. Live runs write the new addresses into `config.testnet.toml`. Keep `VERSION` (default -`entrypoint-v09`) the same for every EVM step, because it seeds every CREATE2 salt. +Every EVM broadcast goes through `script/deploy.sh`, which asks for confirmation on each chain and +verifies the contracts. Live runs write the new addresses into `config.testnet.toml`. Keep `VERSION` +(default `entrypoint-v09`) the same for every EVM step, because it seeds every CREATE2 salt. +On Amoy, forge pays `ETH_PRIORITY_GAS_PRICE=30gwei` and `ETH_GAS_PRICE=40gwei` unless you set them, +because the RPC suggests a priority fee about 10x what lands. | # | Command | Verified by the script | |---|---------|------------------------| @@ -49,3 +52,40 @@ To recheck state: - `script/testnet/entrypoint-v09.sh status` re-reads every EVM check. - `node e2e/entrypoint-v09-sudo.mjs metadata` prints the live call shapes, the sudo key and what the pallet stores. + +## Local end-to-end run + +`e2e/entrypoint-v09-local.mjs` in `sdk/packages/simplex` runs the v0.9 stack on an anvil fork of +Chapel. It needs no Gargantua access, so it stands in for a live run. It starts anvil and the +rundler fork's native binary, checks every step, and stops both processes at the end. + +1. Governance is delivered with the host impersonated. Step 5's `Execute` body upgrades the gateway, + and step 8's nested `UpgradeContract` route sets the allowlist, using the bytes `init-data` prints. +2. A fresh solver EOA delegates to the v0.9 SolverAccount through EIP-7702. +3. The SDK places a same-chain USD.h for cNGN order. The solver signs a bid sponsored in PERMIT2 + mode, using the SDK's gas estimate, simplex's paymaster builder and `prepareSubmitBid`. +4. The SDK's `buildBids`, `simulate` and `execute` send the bid to the local rundler, which bundles + it. The script checks: + - the fill and the balances + - the bundle sender and the `UserOperationEvent` + - the call trace: `select` during validation, and `getCurrentUserOpHash` inside `fillOrder` + - the indexer's `findUserOpHash` +5. Step 9 runs for the rundler signer alone. A second sponsored bid must then fail with + `UnauthorizedBundler(signer)`. + +Run it from `sdk/`: + +```sh +pnpm -C packages/sdk build +FORK_URL= pnpm -C packages/simplex exec tsx e2e/entrypoint-v09-local.mjs +``` + +It needs `anvil` and a build of the rundler fork (`cargo build --bin rundler`). The script looks for +the binary at `../rundler/target/debug/rundler` beside this repository, and `RUNDLER_BIN` overrides +it. It reads `RUNDLER_SIGNER_PRIVATE_KEY` from `sdk/.env.local`, and `FORK_URL` falls back to +`BSC_CHAPEL` there. Anvil takes the fork URL only as an argument, so any API key in it shows in +`ps` on this machine; the script's output and its anvil and rundler logs redact it. + +Rundler in unsafe mode simulates validation from `0x0643…11b5`. With only the signer removed, rundler +still accepts the second op. The bundle then reverts on chain with +`FailedOpWithRevert(AA33, UnauthorizedBundler)`, and the signer pays for the reverted transaction. diff --git a/evm/script/testnet/entrypoint-v09.sh b/evm/script/testnet/entrypoint-v09.sh index b9631579dc..a1c54ff1d9 100755 --- a/evm/script/testnet/entrypoint-v09.sh +++ b/evm/script/testnet/entrypoint-v09.sh @@ -55,6 +55,8 @@ print_usage() { echo " VERSION CREATE2 salt seed (default: entrypoint-v09)" echo " GOVERNANCE_RELAYER paymaster relayer (default: 0xc8809DD0b00370be097382d741A43347Ad582757)" echo " ADMIN, TREASURY default: the PRIVATE_KEY address" + echo " ETH_PRIORITY_GAS_PRICE forge priority fee (default: 30gwei on polygon-amoy, else the RPC's)" + echo " ETH_GAS_PRICE forge max fee (default: 40gwei on polygon-amoy, else the RPC's)" } while [[ $# -gt 0 ]]; do @@ -141,6 +143,14 @@ default_stake() { rpc_url() { local var; var=$(rpc_var "$1"); printf '%s' "${!var}"; } +# Amoy's RPC suggests a priority fee about 10x what lands. Both fees go together, since a priority +# fee above the max fee is rejected. A caller's own values win. +gas_env() { + case $1 in + polygon-amoy) echo "ETH_PRIORITY_GAS_PRICE=${ETH_PRIORITY_GAS_PRICE:-30gwei} ETH_GAS_PRICE=${ETH_GAS_PRICE:-40gwei}" ;; + esac +} + # Reads `[.address] KEY` from the active config. config_get() { awk -v section="[$1.address]" -v key="$2" ' @@ -168,26 +178,23 @@ implementation_of() { cast parse-bytes32-address "$word" } -# Runs one forge script on the chains given: straight onto the anvil forks in a dry run, -# through deploy.sh (confirmation prompt, broadcast, verification) otherwise. +# Runs one forge script on each chain given, with that chain's gas settings: straight onto the +# anvil forks in a dry run, through deploy.sh (confirmation prompt, broadcast, verification) otherwise. run_script() { local script=$1; shift - [ $# -gt 0 ] || return 0 - if $DRY_RUN; then - local chain rpc - for chain in "$@"; do + local chain rpc + for chain in "$@"; do + if $DRY_RUN; then rpc=$(rpc_url "$chain") [[ "$rpc" == http://127.0.0.1:* ]] || die "Dry run refuses a non-local RPC for $chain" info "${YELLOW}forge script $script on the $chain fork${NC}" - (cd "$EVM_DIR" && forge script "script/$script.s.sol" --sig "run()" --rpc-url "$rpc" -g 150 \ - --broadcast --sender "$ADMIN") || die "$script failed on the $chain fork" - done - else - local list - list=$(IFS=,; echo "$*") - (cd "$EVM_DIR" && bash script/deploy.sh --mode full -c "$CONFIG" "$script" "$list") || - warn "deploy.sh reported a failure (verification included); checking on-chain state" - fi + (cd "$EVM_DIR" && env $(gas_env "$chain") forge script "script/$script.s.sol" --sig "run()" \ + --rpc-url "$rpc" -g 150 --broadcast --sender "$ADMIN") || die "$script failed on the $chain fork" + else + (cd "$EVM_DIR" && env $(gas_env "$chain") bash script/deploy.sh --mode full -c "$CONFIG" "$script" "$chain") || + warn "deploy.sh reported a failure on $chain (verification included); checking on-chain state" + fi + done } # ── Environment ───────────────────────────────────────────────────── From 1941c67f9e14b70e034276c0dbc07997aa1d0d31 Mon Sep 17 00:00:00 2001 From: royvardhan Date: Tue, 6 Oct 2026 22:55:43 +0530 Subject: [PATCH 05/12] [evm, simplex]: drop the one-off testnet rollout tooling --- .../2026-10-06-entrypoint-v09-end-to-end.md | 14 +- evm/script/deploy.sh | 8 +- evm/script/testnet/DeployFixedPriceFeed.s.sol | 33 - evm/script/testnet/FixedPriceAggregator.sol | 21 - evm/script/testnet/FundSimplexPaymaster.s.sol | 43 - evm/script/testnet/README.md | 91 --- evm/script/testnet/entrypoint-v09.sh | 448 ---------- .../SimplexPaymasterBundlerRolloutTest.t.sol | 115 --- scripts/docker/rundler/.env.example | 8 - scripts/docker/rundler/README.md | 57 -- scripts/docker/rundler/docker-compose.yml | 51 -- .../e2e/entrypoint-v09-local-indexer.cjs | 13 - .../simplex/e2e/entrypoint-v09-local.mjs | 769 ------------------ .../simplex/e2e/entrypoint-v09-sudo.mjs | 590 -------------- 14 files changed, 6 insertions(+), 2255 deletions(-) delete mode 100644 evm/script/testnet/DeployFixedPriceFeed.s.sol delete mode 100644 evm/script/testnet/FixedPriceAggregator.sol delete mode 100644 evm/script/testnet/FundSimplexPaymaster.s.sol delete mode 100644 evm/script/testnet/README.md delete mode 100755 evm/script/testnet/entrypoint-v09.sh delete mode 100644 evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol delete mode 100644 scripts/docker/rundler/.env.example delete mode 100644 scripts/docker/rundler/README.md delete mode 100644 scripts/docker/rundler/docker-compose.yml delete mode 100644 sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs delete mode 100644 sdk/packages/simplex/e2e/entrypoint-v09-local.mjs delete mode 100644 sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs diff --git a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md index 0baeff072d..88266cea3f 100644 --- a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md +++ b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md @@ -175,23 +175,15 @@ Deployed on Chapel (97) and Amoy (80002): `0x6085a14078B7d26259145a894140f7C8361e02ae`, each funded on EntryPoint v0.9 with a deposit and a stake (86400 second unstake delay) -Remaining steps, each a Gargantua sudo call described in `evm/script/testnet/README.md`: -registering the paymasters (step 4), upgrading the gateway proxies to the new implementation -(step 5), and setting the paymaster bundler allowlist (step 8). +Remaining steps, each a Gargantua sudo call: `intentsCoprocessor.addPaymasterDeployment` for each +paymaster proxy, and `intentsCoprocessor.executeOnGateway` carrying +`upgradeToAndCall(0x38a82f8283a0fAdB763888707aD18fCd34AF719D, "")` for each chain's gateway. The SDK and indexer testnet configs already point at the new contracts. Until the gateway proxy upgrade lands, fills on Chapel and Amoy fail with SDK 2.9.0 and simplex 0.17.0. A v0.9 `SolverAccount` calls the new `select`, which the current gateway implementation rejects, and the SDK refuses a v0.9 bid on a gateway whose format is `address`. -Tooling: - -- `evm/script/testnet/entrypoint-v09.sh` runs the EVM side, and - `sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs` runs the Gargantua sudo side. -- `scripts/docker/rundler/` runs the rundler fork as a v0.9-only, `--unsafe` bundler. -- `sdk/packages/simplex/e2e/entrypoint-v09-local.mjs` verifies the whole stack on an anvil fork of - Chapel with a local rundler. - ## Mainnet rollout Upgrading a chain's gateway is a hard cutover for that chain: diff --git a/evm/script/deploy.sh b/evm/script/deploy.sh index 21ed37dc65..07e09232ce 100755 --- a/evm/script/deploy.sh +++ b/evm/script/deploy.sh @@ -159,8 +159,6 @@ if [[ ! "$SCRIPT_NAME" =~ \.s\.sol$ ]]; then fi SCRIPT_PATH="script/${SCRIPT_NAME}" -# forge keys broadcast artifacts by file name, so testnet/X.s.sol lands under broadcast/X.s.sol -BROADCAST_DIR="broadcast/$(basename "$SCRIPT_NAME")" if [ ! -f "$SCRIPT_PATH" ]; then echo -e "${RED}Error: Script not found: $SCRIPT_PATH${NC}" @@ -308,7 +306,7 @@ for chain in "${CHAIN_ARRAY[@]}"; do continue fi - BROADCAST_FILE="${BROADCAST_DIR}/${CHAIN_ID}/run-latest.json" + BROADCAST_FILE="broadcast/${SCRIPT_NAME}/${CHAIN_ID}/run-latest.json" if [ ! -f "$BROADCAST_FILE" ]; then echo -e "${RED}No broadcast artifacts found: $BROADCAST_FILE${NC}" FAILED_CHAINS+=("$chain") @@ -423,11 +421,11 @@ if [ ${#FAILED_CHAINS[@]} -eq 0 ]; then elif [ "$MODE" = "verify" ]; then echo "" echo -e "${YELLOW}Verification completed using artifacts from:${NC}" - echo -e " ${BROADCAST_DIR}//" + echo -e " broadcast/${SCRIPT_NAME}//" else echo "" echo -e "${YELLOW}Deployment artifacts saved in:${NC}" - echo -e " ${BROADCAST_DIR}//" + echo -e " broadcast/${SCRIPT_NAME}//" fi exit 0 else diff --git a/evm/script/testnet/DeployFixedPriceFeed.s.sol b/evm/script/testnet/DeployFixedPriceFeed.s.sol deleted file mode 100644 index 95362f556c..0000000000 --- a/evm/script/testnet/DeployFixedPriceFeed.s.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.17; - -import "forge-std/Script.sol"; - -import {BaseScript} from "../BaseScript.sol"; -import {FixedPriceAggregator} from "./FixedPriceAggregator.sol"; - -/// @notice Deploys fixed-price native/USD and USDC/USD feeds for the testnet SimplexPaymaster -/// and writes them to NATIVE_ORACLE and USDC_ORACLE. -contract DeployScript is BaseScript { - uint8 internal constant FEED_DECIMALS = 8; - int256 internal constant USDC_USD = 1e8; - - function deploy() internal override { - FixedPriceAggregator nativeFeed = new FixedPriceAggregator{salt: salt}(nativeUsd(), FEED_DECIMALS); - FixedPriceAggregator usdcFeed = new FixedPriceAggregator{salt: salt}(USDC_USD, FEED_DECIMALS); - - vm.stopBroadcast(); - - console.log("Native/USD feed deployed at:", address(nativeFeed)); - console.log("USDC/USD feed deployed at:", address(usdcFeed)); - - config.set("NATIVE_ORACLE", address(nativeFeed)); - config.set("USDC_ORACLE", address(usdcFeed)); - } - - function nativeUsd() internal view returns (int256) { - if (block.chainid == 97) return 600e8; // BNB - if (block.chainid == 80002) return 0.25e8; // POL - revert("no fixed native price for this chain"); - } -} diff --git a/evm/script/testnet/FixedPriceAggregator.sol b/evm/script/testnet/FixedPriceAggregator.sol deleted file mode 100644 index 6ff08f0fcc..0000000000 --- a/evm/script/testnet/FixedPriceAggregator.sol +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.17; - -/// @notice Testnet only: a Chainlink AggregatorV3 stand-in that reports a fixed price which never goes stale. -contract FixedPriceAggregator { - int256 public immutable answer; - uint8 public immutable decimals; - - constructor(int256 answer_, uint8 decimals_) { - answer = answer_; - decimals = decimals_; - } - - function latestRoundData() - external - view - returns (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) - { - return (1, answer, block.timestamp, block.timestamp, 1); - } -} diff --git a/evm/script/testnet/FundSimplexPaymaster.s.sol b/evm/script/testnet/FundSimplexPaymaster.s.sol deleted file mode 100644 index c102ff2041..0000000000 --- a/evm/script/testnet/FundSimplexPaymaster.s.sol +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.17; - -import "forge-std/Script.sol"; -import "stringutils/strings.sol"; - -import {SimplexPaymaster, IStakeManager} from "../../src/utils/SimplexPaymaster.sol"; -import {BaseScript} from "../BaseScript.sol"; - -/// @notice Tops the SIMPLEX_PAYMASTER EntryPoint deposit and stake up to PAYMASTER_DEPOSIT and -/// PAYMASTER_STAKE (wei). Sends nothing for a balance already at its target, so a re-run -/// is a no-op. The stake goes through `addStake`, which only the treasury may call. -contract DeployScript is BaseScript { - using strings for *; - - uint32 internal constant UNSTAKE_DELAY = 1 days; - - function deploy() internal override { - SimplexPaymaster paymaster = SimplexPaymaster(payable(config.get("SIMPLEX_PAYMASTER").toAddress())); - uint256 depositTarget = vm.envUint("PAYMASTER_DEPOSIT"); - uint256 stakeTarget = vm.envUint("PAYMASTER_STAKE"); - IStakeManager entryPoint = IStakeManager(address(paymaster.entryPoint())); - - IStakeManager.DepositInfo memory info = entryPoint.getDepositInfo(address(paymaster)); - if (info.deposit < depositTarget) { - paymaster.deposit{value: depositTarget - info.deposit}(); - } - if (info.stake < stakeTarget || info.unstakeDelaySec < UNSTAKE_DELAY) { - require(paymaster.treasury() == vm.addr(uint256(privateKey)), "PRIVATE_KEY is not the paymaster treasury"); - uint256 topUp = info.stake < stakeTarget ? stakeTarget - info.stake : 0; - paymaster.addStake{value: topUp}(UNSTAKE_DELAY); - } - - vm.stopBroadcast(); - - info = entryPoint.getDepositInfo(address(paymaster)); - console.log("SimplexPaymaster:", address(paymaster)); - console.log(" EntryPoint:", address(entryPoint)); - console.log(" deposit:", info.deposit); - console.log(" stake:", info.stake); - console.log(" unstakeDelaySec:", info.unstakeDelaySec); - } -} diff --git a/evm/script/testnet/README.md b/evm/script/testnet/README.md deleted file mode 100644 index ff37c93393..0000000000 --- a/evm/script/testnet/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Testnet scripts - -## EntryPoint v0.9 rollout - -Moves BSC Chapel (`bsc-testnet`, 97) and Polygon Amoy (`polygon-amoy`, 80002) to EntryPoint v0.9, -governed from Gargantua. The EVM steps run with `entrypoint-v09.sh` from `evm/`. The sudo steps run -with `e2e/entrypoint-v09-sudo.mjs` from `sdk/packages/simplex`. - -Both scripts read `PRIVATE_KEY`, `GARGANTUA_SUDO_SEED`, `BSC_CHAPEL`, `POLYGON_AMOY` and -`HYPERBRIDGE_GARGANTUA` from `sdk/.env.local`, and skip a step that already holds on a chain. -`GARGANTUA_SUDO_SEED` is the seed of Gargantua's `sudo.key()`, checked before anything is signed. It -is not `SECRET_PHRASE`, which is the e2e BRIDGE funder. With `--dry-run`: - -- the EVM steps run on local anvil forks against a temporary copy of the config; -- the sudo steps print each decoded call and its hex without signing. - -Every EVM broadcast goes through `script/deploy.sh`, which asks for confirmation on each chain and -verifies the contracts. Live runs write the new addresses into `config.testnet.toml`. Keep `VERSION` -(default `entrypoint-v09`) the same for every EVM step, because it seeds every CREATE2 salt. -On Amoy, forge pays `ETH_PRIORITY_GAS_PRICE=30gwei` and `ETH_GAS_PRICE=40gwei` unless you set them, -because the RPC suggests a priority fee about 10x what lands. - -| # | Command | Verified by the script | -|---|---------|------------------------| -| 1 | `script/testnet/entrypoint-v09.sh solver-account` | `entryPoint()` is v0.9, and both chains have the same address | -| 2 | `script/testnet/entrypoint-v09.sh gateway-impl` | the implementation has the new `SELECT_SOLVER_TYPEHASH`, and the `execute_on_gateway` calldata is printed | -| 3 | `script/testnet/entrypoint-v09.sh paymaster` | feeds deployed, and the proxy is at version 3 with relayer `0xc8809D…2757` and the deployer as treasury | -| 4 | `node e2e/entrypoint-v09-sudo.mjs register-paymaster` | `Paymasters(Evm(id))` holds the proxy | -| 5 | `node e2e/entrypoint-v09-sudo.mjs upgrade-gateway` | the gateway implementation slot is the new one, and `SELECT_SOLVER_TYPEHASH()` is `0xe706…e09e` | -| 6 | `script/testnet/entrypoint-v09.sh fund` | the EntryPoint deposit and stake are at target, with an unstake delay of 86400 | -| 7 | Start the bundlers with [`scripts/docker/rundler`](../../../scripts/docker/rundler/README.md) | `eth_supportedEntryPoints` returns only v0.9 | -| 8 | `node e2e/entrypoint-v09-sudo.mjs allow-bundlers` | `getBundlers()` lists the rundler signer and its simulation origin | -| 9 | `node e2e/entrypoint-v09-sudo.mjs clear-bundlers`, once acceptance passes | `getBundlers()` is empty | - -Step details: - -- **Step 5** waits for the relayer, which can take minutes. Steps 8 and 9 wait for it too. Use - `--timeout-min` to change how long they wait. -- **Step 6** tops up to 0.3 tBNB deposit and 0.05 stake on Chapel, and 1 POL deposit and 0.1 stake - on Amoy. Use `--deposit` and `--stake` (in ether) to change the targets. -- **Steps 8 and 9.** The runtime has no `set_paymaster_bundlers`, so the allowlist rides on - `upgrade_paymaster`: - - The proxy is pointed at its current implementation. - - The init data is an `onAccept` call carrying a nested `SetBundlers` request: source - `KUSAMA-4009`, from `pallet-intents`, relayer `0xc8809D…2757`. - - The proxy delegatecalls this init data with the host still the caller. - - `node e2e/entrypoint-v09-sudo.mjs init-data` prints the bytes, and - `tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol` pins them. - -To recheck state: - -- `script/testnet/entrypoint-v09.sh status` re-reads every EVM check. -- `node e2e/entrypoint-v09-sudo.mjs metadata` prints the live call shapes, the sudo key and what the - pallet stores. - -## Local end-to-end run - -`e2e/entrypoint-v09-local.mjs` in `sdk/packages/simplex` runs the v0.9 stack on an anvil fork of -Chapel. It needs no Gargantua access, so it stands in for a live run. It starts anvil and the -rundler fork's native binary, checks every step, and stops both processes at the end. - -1. Governance is delivered with the host impersonated. Step 5's `Execute` body upgrades the gateway, - and step 8's nested `UpgradeContract` route sets the allowlist, using the bytes `init-data` prints. -2. A fresh solver EOA delegates to the v0.9 SolverAccount through EIP-7702. -3. The SDK places a same-chain USD.h for cNGN order. The solver signs a bid sponsored in PERMIT2 - mode, using the SDK's gas estimate, simplex's paymaster builder and `prepareSubmitBid`. -4. The SDK's `buildBids`, `simulate` and `execute` send the bid to the local rundler, which bundles - it. The script checks: - - the fill and the balances - - the bundle sender and the `UserOperationEvent` - - the call trace: `select` during validation, and `getCurrentUserOpHash` inside `fillOrder` - - the indexer's `findUserOpHash` -5. Step 9 runs for the rundler signer alone. A second sponsored bid must then fail with - `UnauthorizedBundler(signer)`. - -Run it from `sdk/`: - -```sh -pnpm -C packages/sdk build -FORK_URL= pnpm -C packages/simplex exec tsx e2e/entrypoint-v09-local.mjs -``` - -It needs `anvil` and a build of the rundler fork (`cargo build --bin rundler`). The script looks for -the binary at `../rundler/target/debug/rundler` beside this repository, and `RUNDLER_BIN` overrides -it. It reads `RUNDLER_SIGNER_PRIVATE_KEY` from `sdk/.env.local`, and `FORK_URL` falls back to -`BSC_CHAPEL` there. Anvil takes the fork URL only as an argument, so any API key in it shows in -`ps` on this machine; the script's output and its anvil and rundler logs redact it. - -Rundler in unsafe mode simulates validation from `0x0643…11b5`. With only the signer removed, rundler -still accepts the second op. The bundle then reverts on chain with -`FailedOpWithRevert(AA33, UnauthorizedBundler)`, and the signer pays for the reverted transaction. diff --git a/evm/script/testnet/entrypoint-v09.sh b/evm/script/testnet/entrypoint-v09.sh deleted file mode 100755 index a1c54ff1d9..0000000000 --- a/evm/script/testnet/entrypoint-v09.sh +++ /dev/null @@ -1,448 +0,0 @@ -#!/bin/bash - -# EntryPoint v0.9 testnet rollout, EVM side, on BSC Chapel and Polygon Amoy. -# Usage: script/testnet/entrypoint-v09.sh [OPTIONS] [step ...] -# The Hyperbridge governance steps run from sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs. -# README.md next to this script has the full order. - -set -eo pipefail - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -EVM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -REPO_DIR="$(cd "$EVM_DIR/.." && pwd)" -ENV_FILE="${ENV_FILE:-$REPO_DIR/sdk/.env.local}" -CONFIG="${CONFIG:-config.testnet.toml}" - -ENTRYPOINT_V09=0x433709009B8330FDa32311DF1C2AFA402eD8D009 -SELECT_SOLVER_TYPEHASH=0xe706bdab7d945360dcd9d81d355f856754dd1cfa461edfc0a7502e2583b4e09e -IMPLEMENTATION_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc -UNSTAKE_DELAY=86400 - -DRY_RUN=false -CHAINS="bsc-testnet,polygon-amoy" -DEPOSIT="" -STAKE="" -STEPS=() - -print_usage() { - echo -e "${BLUE}EntryPoint v0.9 testnet rollout (EVM steps)${NC}" - echo "" - echo "Usage: $0 [OPTIONS] [step ...]" - echo "" - echo "Steps, each skipped on a chain where it already holds:" - echo " solver-account 1. CREATE2 SolverAccount; checks entryPoint() is v0.9 and one address on every chain" - echo " gateway-impl 2. IntentGatewayV2 implementation; prints the execute_on_gateway calldata" - echo " paymaster 3. fixed-price feeds, then a SimplexPaymaster proxy" - echo " fund 6. tops the paymaster's EntryPoint deposit and stake up to the targets" - echo " status read-only report of every check above" - echo "" - echo "Options:" - echo " --dry-run Run on local anvil forks with a temporary config copy; nothing leaves the machine" - echo " -c, --chains LIST bsc-testnet,polygon-amoy (default: both)" - echo " --deposit ETHER Deposit target (default: 0.3 on bsc-testnet, 1 on polygon-amoy)" - echo " --stake ETHER Stake target (default: 0.05 on bsc-testnet, 0.1 on polygon-amoy)" - echo " -h, --help Show this help message" - echo "" - echo "Environment (each falls back as listed; secrets are read from $ENV_FILE with grep, never sourced):" - echo " PRIVATE_KEY deployer key, else PRIVATE_KEY in the env file" - echo " BSC_TESTNET_RPC_URL else BSC_CHAPEL in the env file" - echo " POLYGON_AMOY_RPC_URL else POLYGON_AMOY in the env file" - echo " VERSION CREATE2 salt seed (default: entrypoint-v09)" - echo " GOVERNANCE_RELAYER paymaster relayer (default: 0xc8809DD0b00370be097382d741A43347Ad582757)" - echo " ADMIN, TREASURY default: the PRIVATE_KEY address" - echo " ETH_PRIORITY_GAS_PRICE forge priority fee (default: 30gwei on polygon-amoy, else the RPC's)" - echo " ETH_GAS_PRICE forge max fee (default: 40gwei on polygon-amoy, else the RPC's)" -} - -while [[ $# -gt 0 ]]; do - case $1 in - --dry-run) DRY_RUN=true; shift ;; - -c|--chains) CHAINS="$2"; shift 2 ;; - --deposit) DEPOSIT="$2"; shift 2 ;; - --stake) STAKE="$2"; shift 2 ;; - -h|--help) print_usage; exit 0 ;; - -*) echo -e "${RED}Error: Unknown option $1${NC}"; print_usage; exit 1 ;; - *) STEPS+=("$1"); shift ;; - esac -done - -if [ ${#STEPS[@]} -eq 0 ]; then - echo -e "${RED}Error: Missing step${NC}\n" - print_usage - exit 1 -fi - -die() { echo -e "${RED}Error: $*${NC}" >&2; exit 1; } -ok() { echo -e " ${GREEN}✓${NC} $*"; } -info() { echo -e " $*"; } -warn() { echo -e " ${YELLOW}$*${NC}"; } -step() { echo ""; echo -e "${BLUE}━━ $* ━━${NC}"; } - -lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } -same() { [ "$(lower "$1")" = "$(lower "$2")" ]; } - -# Decimal strings, so values past 2^63 wei still compare. -at_least() { - local a=$1 b=$2 - [ ${#a} -gt ${#b} ] && return 0 - [ ${#a} -lt ${#b} ] && return 1 - [[ "$a" > "$b" || "$a" == "$b" ]] -} - -# One line of the env file; it is not valid shell, so never source it. -env_get() { - local value - value=$(grep -m1 "^$1=" "$ENV_FILE" 2>/dev/null | cut -d= -f2-) || true - value=${value%$'\r'} - value="${value%"${value##*[![:space:]]}"}" - value=${value#\"}; value=${value%\"} - value=${value#\'}; value=${value%\'} - printf '%s' "$value" -} - -chain_id() { - case $1 in - bsc-testnet) echo 97 ;; - polygon-amoy) echo 80002 ;; - *) die "Unsupported chain '$1': use bsc-testnet or polygon-amoy" ;; - esac -} - -rpc_var() { - case $1 in - bsc-testnet) echo BSC_TESTNET_RPC_URL ;; - polygon-amoy) echo POLYGON_AMOY_RPC_URL ;; - esac -} - -rpc_fallback() { - case $1 in - bsc-testnet) echo BSC_CHAPEL ;; - polygon-amoy) echo POLYGON_AMOY ;; - esac -} - -default_deposit() { - case $1 in - bsc-testnet) echo 0.3 ;; - polygon-amoy) echo 1 ;; - esac -} - -default_stake() { - case $1 in - bsc-testnet) echo 0.05 ;; - polygon-amoy) echo 0.1 ;; - esac -} - -rpc_url() { local var; var=$(rpc_var "$1"); printf '%s' "${!var}"; } - -# Amoy's RPC suggests a priority fee about 10x what lands. Both fees go together, since a priority -# fee above the max fee is rejected. A caller's own values win. -gas_env() { - case $1 in - polygon-amoy) echo "ETH_PRIORITY_GAS_PRICE=${ETH_PRIORITY_GAS_PRICE:-30gwei} ETH_GAS_PRICE=${ETH_GAS_PRICE:-40gwei}" ;; - esac -} - -# Reads `[.address] KEY` from the active config. -config_get() { - awk -v section="[$1.address]" -v key="$2" ' - /^\[/ { found = ($0 == section); next } - found && $1 == key { gsub(/"/, "", $3); print $3; exit } - ' "$EVM_DIR/$CONFIG" -} - -# The RPC rides in ETH_RPC_URL so it never shows up in argv or output. -rpc_call() { - local chain=$1; shift - ETH_RPC_URL="$(rpc_url "$chain")" cast call "$@" 2>/dev/null | awk '{print $1}' || true -} - -has_code() { - [ -n "$2" ] && [ "$(lower "$2")" != "0x0000000000000000000000000000000000000000" ] || return 1 - local code - code=$(ETH_RPC_URL="$(rpc_url "$1")" cast code "$2" 2>/dev/null) || return 1 - [ -n "$code" ] && [ "$code" != "0x" ] -} - -implementation_of() { - local word - word=$(ETH_RPC_URL="$(rpc_url "$1")" cast storage "$2" "$IMPLEMENTATION_SLOT" 2>/dev/null) || return 0 - cast parse-bytes32-address "$word" -} - -# Runs one forge script on each chain given, with that chain's gas settings: straight onto the -# anvil forks in a dry run, through deploy.sh (confirmation prompt, broadcast, verification) otherwise. -run_script() { - local script=$1; shift - local chain rpc - for chain in "$@"; do - if $DRY_RUN; then - rpc=$(rpc_url "$chain") - [[ "$rpc" == http://127.0.0.1:* ]] || die "Dry run refuses a non-local RPC for $chain" - info "${YELLOW}forge script $script on the $chain fork${NC}" - (cd "$EVM_DIR" && env $(gas_env "$chain") forge script "script/$script.s.sol" --sig "run()" \ - --rpc-url "$rpc" -g 150 --broadcast --sender "$ADMIN") || die "$script failed on the $chain fork" - else - (cd "$EVM_DIR" && env $(gas_env "$chain") bash script/deploy.sh --mode full -c "$CONFIG" "$script" "$chain") || - warn "deploy.sh reported a failure on $chain (verification included); checking on-chain state" - fi - done -} - -# ── Environment ───────────────────────────────────────────────────── - -IFS=',' read -ra CHAIN_ARRAY <<< "$CHAINS" -for chain in "${CHAIN_ARRAY[@]}"; do chain_id "$chain" > /dev/null; done - -[ -f "$ENV_FILE" ] || die "Env file not found: $ENV_FILE" - -PRIVATE_KEY="${PRIVATE_KEY:-$(env_get PRIVATE_KEY)}" -[ -n "$PRIVATE_KEY" ] || die "PRIVATE_KEY is unset and missing from $ENV_FILE" -[[ "$PRIVATE_KEY" == 0x* ]] || PRIVATE_KEY="0x$PRIVATE_KEY" -export PRIVATE_KEY -export ADMIN="${ADMIN:-$(cast wallet address --private-key "$PRIVATE_KEY")}" -export TREASURY="${TREASURY:-$ADMIN}" -export VERSION="${VERSION:-entrypoint-v09}" -export GOVERNANCE_RELAYER="${GOVERNANCE_RELAYER:-0xc8809DD0b00370be097382d741A43347Ad582757}" -# BaseScript reads both at construction; no step here uses them. -export CONSENSUS_STATE="${CONSENSUS_STATE:-0x}" -export SP1_VERIFICATION_KEY="${SP1_VERIFICATION_KEY:-0x0000000000000000000000000000000000000000000000000000000000000000}" -ETHEREUM_ETHERSCAN_API_KEY="${ETHEREUM_ETHERSCAN_API_KEY:-$(env_get ETHERSCAN_API)}" -export ETHEREUM_ETHERSCAN_API_KEY - -for chain in "${CHAIN_ARRAY[@]}"; do - var=$(rpc_var "$chain") - [ -n "${!var}" ] || export "$var=$(env_get "$(rpc_fallback "$chain")")" - [ -n "${!var}" ] || die "$var is unset and $(rpc_fallback "$chain") is missing from $ENV_FILE" -done - -# The forge config loader resolves every ${VAR} in the TOML; chains outside this rollout get a stub. -for var in $(grep -o '\${[A-Za-z0-9_]*}' "$EVM_DIR/$CONFIG" | tr -d '${}' | sort -u); do - [ -n "${!var}" ] || export "$var=http://127.0.0.1:1" -done - -echo -e "${BLUE}EntryPoint v0.9 rollout${NC}" -echo -e " Chains: ${YELLOW}${CHAINS}${NC}" -echo -e " Steps: ${YELLOW}${STEPS[*]}${NC}" -echo -e " Deployer: ${YELLOW}${ADMIN}${NC}" -echo -e " Treasury: ${YELLOW}${TREASURY}${NC}" -echo -e " Relayer: ${YELLOW}${GOVERNANCE_RELAYER}${NC}" -echo -e " VERSION: ${YELLOW}${VERSION}${NC}" -echo -e " Dry run: ${YELLOW}${DRY_RUN}${NC}" - -if $DRY_RUN; then - TMP_DIR=$(mktemp -d) - ANVIL_PIDS=() - # forge only writes inside the project, so the copy lives next to the original. - DRY_CONFIG="config.testnet.dryrun.$$.toml" - cp "$EVM_DIR/$CONFIG" "$EVM_DIR/$DRY_CONFIG" - CONFIG="$DRY_CONFIG" - export FOUNDRY_BROADCAST="$TMP_DIR/broadcast" - cleanup() { - for pid in "${ANVIL_PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done - rm -f "$EVM_DIR/$DRY_CONFIG" - rm -rf "$TMP_DIR" - } - trap cleanup EXIT - - step "Compiling before the forks start" - (cd "$EVM_DIR" && forge build) || die "forge build failed" - - port=${ANVIL_PORT_BASE:-18545} - for chain in "${CHAIN_ARRAY[@]}"; do - var=$(rpc_var "$chain") - anvil --fork-url "${!var}" --port "$port" --silent > /dev/null 2>&1 & - ANVIL_PIDS+=($!) - export "$var=http://127.0.0.1:$port" - for _ in $(seq 1 60); do - cast chain-id --rpc-url "http://127.0.0.1:$port" > /dev/null 2>&1 && break - sleep 1 - done - [ "$(cast chain-id --rpc-url "http://127.0.0.1:$port" 2>/dev/null)" = "$(chain_id "$chain")" ] || - die "The $chain fork did not come up on port $port" - info "$chain forked on 127.0.0.1:$port" - port=$((port + 1)) - done -fi -export CONFIG - -# ── Steps ─────────────────────────────────────────────────────────── - -step_solver_account() { - step "1. SolverAccount" - local chain id account pending=() - for chain in "${CHAIN_ARRAY[@]}"; do - id=$(chain_id "$chain") - account=$(config_get "$id" SOLVER_ACCOUNT) - if has_code "$chain" "$account" && same "$(rpc_call "$chain" "$account" "entryPoint()(address)")" "$ENTRYPOINT_V09"; then - ok "$chain: already on v0.9 at $account" - else - pending+=("$chain") - fi - done - run_script DeploySolverAccount "${pending[@]}" - - local first="" - for chain in "${CHAIN_ARRAY[@]}"; do - account=$(config_get "$(chain_id "$chain")" SOLVER_ACCOUNT) - same "$(rpc_call "$chain" "$account" "entryPoint()(address)")" "$ENTRYPOINT_V09" || - die "$chain: SolverAccount $account does not report EntryPoint v0.9" - ok "$chain: SolverAccount $account, entryPoint() v0.9" - [ -z "$first" ] && first=$account - same "$first" "$account" || die "SolverAccount differs across chains: $first vs $account" - done - [ ${#CHAIN_ARRAY[@]} -gt 1 ] && ok "Same SolverAccount on every chain" - return 0 -} - -step_gateway_impl() { - step "2. IntentGatewayV2 implementation" - local chain id gateway version impl pending=() - for chain in "${CHAIN_ARRAY[@]}"; do - id=$(chain_id "$chain") - gateway=$(config_get "$id" INTENT_GATEWAY_V2) - version=$(rpc_call "$chain" "$gateway" "version()(uint64)") - [ "$version" = 3 ] || die "$chain: gateway $gateway is at version '$version'; the upgrade calldata assumes 3" - impl=$(config_get "$id" INTENT_GATEWAY_V2_IMPL) - if has_code "$chain" "$impl" && same "$(rpc_call "$chain" "$impl" "SELECT_SOLVER_TYPEHASH()(bytes32)")" "$SELECT_SOLVER_TYPEHASH"; then - ok "$chain: implementation already deployed at $impl" - else - pending+=("$chain") - fi - done - run_script DeployIntentGatewayImpl "${pending[@]}" - - for chain in "${CHAIN_ARRAY[@]}"; do - impl=$(config_get "$(chain_id "$chain")" INTENT_GATEWAY_V2_IMPL) - same "$(rpc_call "$chain" "$impl" "SELECT_SOLVER_TYPEHASH()(bytes32)")" "$SELECT_SOLVER_TYPEHASH" || - die "$chain: implementation $impl has the wrong SELECT_SOLVER_TYPEHASH" - ok "$chain: implementation $impl" - info "execute_on_gateway data: $(cast calldata "upgradeToAndCall(address,bytes)" "$impl" 0x)" - done -} - -paymaster_ready() { - local paymaster - paymaster=$(config_get "$(chain_id "$1")" SIMPLEX_PAYMASTER) - has_code "$1" "$paymaster" && - [ "$(rpc_call "$1" "$paymaster" "version()(uint64)")" = 3 ] && - same "$(rpc_call "$1" "$paymaster" "entryPoint()(address)")" "$ENTRYPOINT_V09" -} - -step_paymaster() { - step "3. Fixed-price feeds and SimplexPaymaster" - local chain id feeds=() paymasters=() - for chain in "${CHAIN_ARRAY[@]}"; do - id=$(chain_id "$chain") - if paymaster_ready "$chain"; then - ok "$chain: paymaster already deployed at $(config_get "$id" SIMPLEX_PAYMASTER)" - continue - fi - paymasters+=("$chain") - if has_code "$chain" "$(config_get "$id" NATIVE_ORACLE)" && has_code "$chain" "$(config_get "$id" USDC_ORACLE)"; then - ok "$chain: feeds already deployed" - else - feeds+=("$chain") - fi - done - run_script testnet/DeployFixedPriceFeed "${feeds[@]}" - run_script DeploySimplexPaymaster "${paymasters[@]}" - - local paymaster host - for chain in "${CHAIN_ARRAY[@]}"; do - id=$(chain_id "$chain") - paymaster=$(config_get "$id" SIMPLEX_PAYMASTER) - paymaster_ready "$chain" || die "$chain: no v0.9 SimplexPaymaster at '$paymaster'" - host=$(config_get "$id" HOST) - same "$(rpc_call "$chain" "$paymaster" "host()(address)")" "$host" || die "$chain: paymaster host is not $host" - same "$(rpc_call "$chain" "$paymaster" "relayer()(address)")" "$GOVERNANCE_RELAYER" || - die "$chain: paymaster relayer is not $GOVERNANCE_RELAYER" - same "$(rpc_call "$chain" "$paymaster" "treasury()(address)")" "$TREASURY" || - die "$chain: paymaster treasury is not $TREASURY" - ok "$chain: SimplexPaymaster $paymaster (implementation $(implementation_of "$chain" "$paymaster"))" - done -} - -# deposit, stake and unstake delay, one per line -deposit_info() { - ETH_RPC_URL="$(rpc_url "$1")" cast call "$ENTRYPOINT_V09" \ - "getDepositInfo(address)(uint256,bool,uint112,uint32,uint48)" "$2" 2>/dev/null | - awk 'NR == 1 || NR == 3 || NR == 4 {print $1}' || true -} - -step_fund() { - step "6. Paymaster deposit and stake" - local chain paymaster deposit stake funds have_deposit have_stake have_delay - for chain in "${CHAIN_ARRAY[@]}"; do - paymaster=$(config_get "$(chain_id "$chain")" SIMPLEX_PAYMASTER) - paymaster_ready "$chain" || die "$chain: deploy the paymaster first" - deposit=$(cast to-wei "${DEPOSIT:-$(default_deposit "$chain")}") - stake=$(cast to-wei "${STAKE:-$(default_stake "$chain")}") - - funds=$(deposit_info "$chain" "$paymaster") - have_deposit=$(sed -n 1p <<< "$funds"); have_stake=$(sed -n 2p <<< "$funds"); have_delay=$(sed -n 3p <<< "$funds") - if at_least "$have_deposit" "$deposit" && at_least "$have_stake" "$stake" && at_least "$have_delay" "$UNSTAKE_DELAY"; then - ok "$chain: already funded" - else - export PAYMASTER_DEPOSIT=$deposit PAYMASTER_STAKE=$stake - run_script testnet/FundSimplexPaymaster "$chain" - fi - - funds=$(deposit_info "$chain" "$paymaster") - have_deposit=$(sed -n 1p <<< "$funds"); have_stake=$(sed -n 2p <<< "$funds"); have_delay=$(sed -n 3p <<< "$funds") - at_least "$have_deposit" "$deposit" || die "$chain: deposit $have_deposit is below $deposit" - at_least "$have_stake" "$stake" || die "$chain: stake $have_stake is below $stake" - at_least "$have_delay" "$UNSTAKE_DELAY" || die "$chain: unstake delay $have_delay is below $UNSTAKE_DELAY" - ok "$chain: deposit $(cast from-wei "$have_deposit"), stake $(cast from-wei "$have_stake"), unstake delay ${have_delay}s" - done -} - -step_status() { - step "Status" - local chain id account gateway impl paymaster funds - for chain in "${CHAIN_ARRAY[@]}"; do - id=$(chain_id "$chain") - echo -e " ${YELLOW}$chain ($id)${NC}" - account=$(config_get "$id" SOLVER_ACCOUNT) - info "SolverAccount $account entryPoint() $(rpc_call "$chain" "$account" "entryPoint()(address)")" - gateway=$(config_get "$id" INTENT_GATEWAY_V2) - impl=$(implementation_of "$chain" "$gateway") - info "Gateway $gateway version() $(rpc_call "$chain" "$gateway" "version()(uint64)")" - info " implementation $impl (config: $(config_get "$id" INTENT_GATEWAY_V2_IMPL))" - info " typehash ok $(same "$(rpc_call "$chain" "$gateway" "SELECT_SOLVER_TYPEHASH()(bytes32)")" "$SELECT_SOLVER_TYPEHASH" && echo yes || echo no)" - paymaster=$(config_get "$id" SIMPLEX_PAYMASTER) - if has_code "$chain" "$paymaster"; then - info "SimplexPaymaster $paymaster version() $(rpc_call "$chain" "$paymaster" "version()(uint64)")" - info " implementation $(implementation_of "$chain" "$paymaster")" - info " relayer $(rpc_call "$chain" "$paymaster" "relayer()(address)")" - info " treasury $(rpc_call "$chain" "$paymaster" "treasury()(address)")" - info " bundlers $(ETH_RPC_URL="$(rpc_url "$chain")" cast call "$paymaster" "getBundlers()(address[])" 2>/dev/null)" - funds=$(deposit_info "$chain" "$paymaster") - info " deposit / stake $(sed -n 1p <<< "$funds") / $(sed -n 2p <<< "$funds") wei, delay $(sed -n 3p <<< "$funds")s" - else - info "SimplexPaymaster not deployed" - fi - done -} - -for name in "${STEPS[@]}"; do - case $name in - solver-account) step_solver_account ;; - gateway-impl) step_gateway_impl ;; - paymaster) step_paymaster ;; - fund) step_fund ;; - status) step_status ;; - *) die "Unknown step '$name'" ;; - esac -done - -echo "" -echo -e "${GREEN}✓ Done${NC}" diff --git a/evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol b/evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol deleted file mode 100644 index 9bee207c4d..0000000000 --- a/evm/tests/foundry/SimplexPaymasterBundlerRolloutTest.t.sol +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.17; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; -import {IncomingPostRequest} from "@hyperbridge/core/interfaces/IApp.sol"; -import {PostRequest} from "@hyperbridge/core/libraries/Message.sol"; - -import {SimplexPaymaster, AggregatorV3Interface} from "../../src/utils/SimplexPaymaster.sol"; -import {FixedPriceAggregator} from "../../script/testnet/FixedPriceAggregator.sol"; - -contract KusamaHost { - function hyperbridge() external pure returns (bytes memory) { - return bytes("KUSAMA-4009"); - } -} - -/// @notice Pins the testnet `upgrade_paymaster` init_data that sets the bundler allowlist on a -/// runtime without `set_paymaster_bundlers`. The outer `UpgradeContract` points the proxy -/// at its current implementation and delegatecalls this init_data, a nested `onAccept` -/// carrying `SetBundlers`, with the host still `msg.sender`. -contract SimplexPaymasterBundlerRolloutTest is Test { - address constant RELAYER = 0xc8809DD0b00370be097382d741A43347Ad582757; - address constant RUNDLER_SIGNER = 0x2E0E23636E27826193EA4A585a05CD1808d3c96c; - address constant RUNDLER_SIMULATION_ORIGIN = 0x0643866dA50efE0b055Cd15aF95191968c8411b5; - - // `node e2e/entrypoint-v09-sudo.mjs init-data` in sdk/packages/simplex prints both. - bytes constant ALLOW_INIT_DATA = - hex"0fee32ce00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c8809dd0b00370be097382d741a43347ad58275700000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000b4b5553414d412d343030390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e70616c6c65742d696e74656e7473000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1080000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000002e0e23636e27826193ea4a585a05cd1808d3c96c0000000000000000000000000643866da50efe0b055cd15af95191968c8411b500000000000000000000000000000000000000000000000000000000000000"; - bytes constant CLEAR_INIT_DATA = - hex"0fee32ce00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c8809dd0b00370be097382d741a43347ad58275700000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000b4b5553414d412d343030390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e70616c6c65742d696e74656e7473000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1080000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000002e0e23636e27826193ea4a585a05cd1808d3c96c0000000000000000000000000643866da50efe0b055cd15af95191968c8411b500000000000000000000000000000000000000000000000000000000000000"; - - KusamaHost host; - SimplexPaymaster paymaster; - address implementation; - - function setUp() public { - host = new KusamaHost(); - implementation = address(new SimplexPaymaster()); - SimplexPaymaster.Params memory params = SimplexPaymaster.Params({ - nativeOracle: AggregatorV3Interface(address(new FixedPriceAggregator(600e8, 8))), - markupBps: 200, - treasury: address(this), - maxOracleAge: 90_000, - swapSlippageBps: 200 - }); - bytes memory init = abi.encodeCall( - SimplexPaymaster.initialize, - (address(host), params, new address[](0), new AggregatorV3Interface[](0), RELAYER) - ); - paymaster = SimplexPaymaster(payable(address(new ERC1967Proxy(implementation, init)))); - } - - function testInitDataIsTheNestedSetBundlersRequest() public pure { - assertEq(ALLOW_INIT_DATA, _initData(_defaultBundlers(), true, RELAYER)); - assertEq(CLEAR_INIT_DATA, _initData(_defaultBundlers(), false, RELAYER)); - } - - function testUpgradeToCurrentImplementationSetsThenClearsBundlers() public { - _upgradePaymaster(ALLOW_INIT_DATA); - assertEq(paymaster.getBundlers(), _defaultBundlers()); - assertEq(_implementation(), implementation); - assertEq(paymaster.relayer(), RELAYER); - - _upgradePaymaster(CLEAR_INIT_DATA); - assertEq(paymaster.getBundlers().length, 0); - assertEq(_implementation(), implementation); - } - - function testNestedRequestFromAnotherRelayerReverts() public { - vm.expectRevert(SimplexPaymaster.UnauthorizedRelayer.selector); - _upgradePaymaster(_initData(_defaultBundlers(), true, makeAddr("other"))); - } - - /// @dev What the relayer delivers for `upgrade_paymaster(Evm(_), implementation, initData)`. - function _upgradePaymaster(bytes memory initData) internal { - PostRequest memory request = PostRequest({ - source: bytes("KUSAMA-4009"), - dest: bytes("EVM-97"), - nonce: 0, - from: bytes("pallet-intents"), - to: abi.encodePacked(address(paymaster)), - timeoutTimestamp: 0, - body: bytes.concat( - bytes1(uint8(SimplexPaymaster.RequestKind.UpgradeContract)), abi.encode(implementation, initData) - ) - }); - vm.prank(address(host)); - paymaster.onAccept(IncomingPostRequest({request: request, relayer: RELAYER})); - } - - function _initData(address[] memory bundlers, bool allowed, address relayer) internal pure returns (bytes memory) { - PostRequest memory request = PostRequest({ - source: bytes("KUSAMA-4009"), - dest: "", - nonce: 0, - from: bytes("pallet-intents"), - to: "", - timeoutTimestamp: 0, - body: bytes.concat(bytes1(uint8(SimplexPaymaster.RequestKind.SetBundlers)), abi.encode(bundlers, allowed)) - }); - return abi.encodeCall(SimplexPaymaster.onAccept, (IncomingPostRequest({request: request, relayer: relayer}))); - } - - function _defaultBundlers() internal pure returns (address[] memory bundlers) { - bundlers = new address[](2); - bundlers[0] = RUNDLER_SIGNER; - bundlers[1] = RUNDLER_SIMULATION_ORIGIN; - } - - function _implementation() internal view returns (address) { - return address(uint160(uint256(vm.load(address(paymaster), ERC1967Utils.IMPLEMENTATION_SLOT)))); - } -} diff --git a/scripts/docker/rundler/.env.example b/scripts/docker/rundler/.env.example deleted file mode 100644 index 486f1ed2ff..0000000000 --- a/scripts/docker/rundler/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# Path to the rundler fork checkout. Defaults to ../../../../rundler, a sibling of this repo. -RUNDLER_DIR= -# Plain EOA private key, shared by both bundlers. -RUNDLER_SIGNER_PRIVATE_KEY= -CHAPEL_RPC_URL= -AMOY_RPC_URL= -# Defaults to info. -RUST_LOG= diff --git a/scripts/docker/rundler/README.md b/scripts/docker/rundler/README.md deleted file mode 100644 index d259e44389..0000000000 --- a/scripts/docker/rundler/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Rundler testnet bundlers - -Runs the rundler fork as the ERC-4337 bundler for BSC Chapel and Polygon Amoy. Both serve -EntryPoint v0.9 (`0x433709009B8330FDa32311DF1C2AFA402eD8D009`) only and run in unsafe mode, -because the paymaster reads `tx.origin` during validation. - -| Chain | Chain ID | Network spec | RPC | Metrics | -|-------|----------|--------------|-----|---------| -| BSC Chapel | 97 | `bsc_testnet` (base `bsc`) | `http://127.0.0.1:4337` | `9337` | -| Polygon Amoy | 80002 | `polygon_amoy` (base `polygon`), EIP-7702 turned on | `http://127.0.0.1:4338` | `9338` | - -The signer (`RUNDLER_SIGNER_PRIVATE_KEY`) must be a plain EOA funded on both chains. - -## Start - -The image builds from the fork checkout at `RUNDLER_DIR`, which needs its submodules -(`git submodule update --init --recursive` in the fork). - -```sh -cd scripts/docker/rundler -cp .env.example .env # then fill it in -docker compose up -d --build -docker compose logs -f -``` - -## Native binary - -With the variables from `.env` exported, run these from the fork checkout: - -```sh -NODE_HTTP="$CHAPEL_RPC_URL" SIGNER_PRIVATE_KEYS="$RUNDLER_SIGNER_PRIVATE_KEY" RUST_LOG=info \ - cargo run --bin rundler -- node --network bsc_testnet --enabled_entry_points v0.9 --unsafe \ - --rpc.port 4337 --metrics.port 9337 - -CHAIN_EIP7702_ENABLED=true NODE_HTTP="$AMOY_RPC_URL" SIGNER_PRIVATE_KEYS="$RUNDLER_SIGNER_PRIVATE_KEY" RUST_LOG=info \ - cargo run --bin rundler -- node --network polygon_amoy --enabled_entry_points v0.9 --unsafe \ - --rpc.port 4338 --metrics.port 9338 -``` - -## Check - -Each bundler should return only the v0.9 EntryPoint: - -```sh -for port in 4337 4338; do - curl -s -X POST -H 'content-type: application/json' \ - --data '{"jsonrpc":"2.0","id":1,"method":"eth_supportedEntryPoints","params":[]}' \ - "http://127.0.0.1:$port" - echo -done -``` - -## Paymaster bundler list - -When `SimplexPaymaster.getBundlers()` is non-empty on a chain, both the signer address and -rundler's simulation origin `0x0643866dA50efE0b055Cd15aF95191968c8411b5` must be on it. -Otherwise validation reverts with `UnauthorizedBundler`. diff --git a/scripts/docker/rundler/docker-compose.yml b/scripts/docker/rundler/docker-compose.yml deleted file mode 100644 index 978c8e3e67..0000000000 --- a/scripts/docker/rundler/docker-compose.yml +++ /dev/null @@ -1,51 +0,0 @@ -# ERC-4337 bundlers from the rundler fork for BSC Chapel and Polygon Amoy, EntryPoint v0.9 only. -# Chapel: RPC 4337, metrics 9337. Amoy: RPC 4338, metrics 9338. -x-rundler: &rundler - build: - context: ${RUNDLER_DIR:-../../../../rundler} - restart: unless-stopped - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - -services: - rundler-chapel: - <<: *rundler - container_name: rundler-chapel - command: - - node - - --network=bsc_testnet - - --enabled_entry_points=v0.9 - # No traced validation: the paymaster reads tx.origin during validation. - - --unsafe - - --rpc.port=4337 - - --metrics.port=9337 - ports: - - "127.0.0.1:4337:4337" - - "127.0.0.1:9337:9337" - environment: - NODE_HTTP: ${CHAPEL_RPC_URL} - SIGNER_PRIVATE_KEYS: ${RUNDLER_SIGNER_PRIVATE_KEY} - RUST_LOG: ${RUST_LOG:-info} - - rundler-amoy: - <<: *rundler - container_name: rundler-amoy - command: - - node - - --network=polygon_amoy - - --enabled_entry_points=v0.9 - - --unsafe - - --rpc.port=4338 - - --metrics.port=9338 - ports: - - "127.0.0.1:4338:4338" - - "127.0.0.1:9338:9338" - environment: - NODE_HTTP: ${AMOY_RPC_URL} - SIGNER_PRIVATE_KEYS: ${RUNDLER_SIGNER_PRIVATE_KEY} - # The polygon base spec has EIP-7702 off; the solver wallets are 7702-delegated. - CHAIN_EIP7702_ENABLED: "true" - RUST_LOG: ${RUST_LOG:-info} diff --git a/sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs b/sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs deleted file mode 100644 index fbb41ddcc7..0000000000 --- a/sdk/packages/simplex/e2e/entrypoint-v09-local-indexer.cjs +++ /dev/null @@ -1,13 +0,0 @@ -// The indexer's own userOpHash extraction, run on a bundle receipt for e2e/entrypoint-v09-local.mjs. -// It needs the indexer's tsconfig paths, so it runs as its own tsx process from that package. -// Reads `{ logs, sender, logIndex }` as JSON on stdin and prints `{ userOpHash }`. -const { findUserOpHash } = require("../../indexer/src/utils/userOp.helpers.ts") - -let input = "" -process.stdin.on("data", (chunk) => { - input += chunk -}) -process.stdin.on("end", () => { - const { logs, sender, logIndex } = JSON.parse(input) - process.stdout.write(JSON.stringify({ userOpHash: findUserOpHash(logs, sender, logIndex) ?? null })) -}) diff --git a/sdk/packages/simplex/e2e/entrypoint-v09-local.mjs b/sdk/packages/simplex/e2e/entrypoint-v09-local.mjs deleted file mode 100644 index f5a2e99040..0000000000 --- a/sdk/packages/simplex/e2e/entrypoint-v09-local.mjs +++ /dev/null @@ -1,769 +0,0 @@ -// EntryPoint v0.9 end to end on a local anvil fork of BSC Chapel, in place of a live testnet run. -// -// 1. Governance, delivered as the host: the IntentGatewayV2 proxy moves to the userOpHash -// implementation through `Execute`, and the SimplexPaymaster bundler allowlist is set through -// the nested `UpgradeContract(sameImpl, onAccept(SetBundlers))` route of the sudo script. -// 2. A fresh solver EOA delegates to the v0.9 SolverAccount through EIP-7702. -// 3. A user places a same-chain order with the SDK. The solver prices it with the SDK's gas -// estimate, takes `paymasterAndData` from simplex's paymaster builder and signs it with -// `prepareSubmitBid`. -// 4. The user runs the SDK's `buildBids`, `sortBids`, `simulate` and `execute`, which sends the -// bid to a local rundler that bundles it on the fork. -// 5. The rundler signer is taken off the allowlist, and a second sponsored bid must be refused. -// -// From sdk/, after `pnpm -C packages/sdk build`: -// FORK_URL= pnpm -C packages/simplex exec tsx e2e/entrypoint-v09-local.mjs -// -// It runs under tsx because simplex does not export its paymaster builder. Environment: -// FORK_URL Chapel RPC to fork (else BSC_TESTNET_RPC_URL, else BSC_CHAPEL in the env file) -// FORK_BLOCK Block to fork at (default: latest) -// RUNDLER_SIGNER_PRIVATE_KEY Rundler's signer (else read by exact key from the env file) -// RUNDLER_BIN Default: ../rundler/target/debug/rundler beside this repository -// E2E_ENV_FILE Default: sdk/.env.local, read line by line, never sourced -// GATEWAY_IMPL Default: INTENT_GATEWAY_V2_IMPL of [97.address] in evm/config.testnet.toml -import { execFileSync, spawn } from "node:child_process" -import fs from "node:fs" -import net from "node:net" -import os from "node:os" -import path from "node:path" -import readline from "node:readline" -import { fileURLToPath } from "node:url" -import { - ChainConfigService, - CryptoUtils, - DEFAULT_GRAFFITI, - EvmChain, - FILL_ORDER_SELECTOR, - IntentGateway, - bytes20ToBytes32, - encodeERC7821ExecuteBatch, - encodeFillOrder, - transformOrderForContract, -} from "@hyperbridge/sdk" -import { - concat, - createPublicClient, - createWalletClient, - decodeErrorResult, - encodeAbiParameters, - encodeFunctionData, - erc20Abi, - getAddress, - http, - isAddressEqual, - keccak256, - parseAbi, - parseEther, - parseEventLogs, - parseUnits, - toFunctionSelector, - toHex, - zeroAddress, -} from "viem" -import { generatePrivateKey, privateKeyToAccount } from "viem/accounts" -import { bscTestnet } from "viem/chains" -import { FillerConfigService } from "../src/services/FillerConfigService.ts" -import { buildPaymasterAndData } from "../src/services/paymaster/index.ts" - -const HERE = path.dirname(fileURLToPath(import.meta.url)) -const SDK_DIR = path.resolve(HERE, "../../..") -const REPO = path.resolve(SDK_DIR, "..") -const INDEXER = path.join(SDK_DIR, "packages/indexer") -const ENV_FILE = process.env.E2E_ENV_FILE || path.join(SDK_DIR, ".env.local") -const RUNDLER_BIN = process.env.RUNDLER_BIN || path.resolve(REPO, "../rundler/target/debug/rundler") - -const CHAIN = "EVM-97" -const SIMULATION_ORIGIN = "0x0643866dA50efE0b055Cd15aF95191968c8411b5" -const SELECT_SOLVER_TYPEHASH = "0xe706bdab7d945360dcd9d81d355f856754dd1cfa461edfc0a7502e2583b4e09e" -const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" -// `IntentsBase.RequestKind.Execute` and `SimplexPaymaster.RequestKind.UpgradeContract`. -const GATEWAY_EXECUTE = 5 -const PAYMASTER_UPGRADE = 0 -const ORDER_IN = parseUnits("5", 18) -const ORDER_OUT = parseUnits("7500", 6) -const UNAUTHORIZED_BUNDLER = toFunctionSelector("UnauthorizedBundler(address)") -const SELECT = toFunctionSelector("select((bytes32,bytes32,bytes))") -const GET_CURRENT_USER_OP_HASH = toFunctionSelector("getCurrentUserOpHash()") -const VALIDATE_PAYMASTER = toFunctionSelector( - "validatePaymasterUserOp((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes),bytes32,uint256)", -) - -const config = new ChainConfigService() -const HOST = config.getHostAddress(CHAIN) -const GATEWAY = config.getIntentGatewayAddress(CHAIN) -const ENTRY_POINT = config.getEntryPointAddress(CHAIN) -const SOLVER_ACCOUNT = config.getSolverAccountAddress(CHAIN) -const PAYMASTER = config.getSimplexPaymasterAddress(CHAIN) -const USDH = config.getUsdcAsset(CHAIN) -const CNGN = config.getCNgnAsset(CHAIN) - -const abi = parseAbi([ - "struct PostRequest { bytes source; bytes dest; uint64 nonce; bytes from; bytes to; uint64 timeoutTimestamp; bytes body; }", - "struct IncomingPostRequest { PostRequest request; address relayer; }", - "function onAccept(IncomingPostRequest incoming)", - "function upgradeToAndCall(address newImplementation, bytes data)", - "function hyperbridge() view returns (bytes)", - "function host() view returns (bytes)", - "function version() view returns (uint64)", - "function SELECT_SOLVER_TYPEHASH() view returns (bytes32)", - "function params() view returns ((address host, address dispatcher, bool solverSelection, uint256 surplusShareBps, uint256 protocolFeeBps, address priceOracle))", - "function _filled(bytes32) view returns (address)", - "function _orders(bytes32, uint256) view returns (uint256)", - "function getBundlers() view returns (address[])", - "function relayer() view returns (address)", - "function treasury() view returns (address)", - "function deposit() payable", - "function addStake(uint32 unstakeDelaySec) payable", - "function getNonce(address sender, uint192 key) view returns (uint256)", - "function balanceOf(address account) view returns (uint256)", - "function getDepositInfo(address account) view returns ((uint256 deposit, bool staked, uint112 stake, uint32 unstakeDelaySec, uint48 withdrawTime))", - "event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed)", - "event OrderFilled(bytes32 indexed commitment, address filler, (bytes32 token, uint256 amount)[] outputs, (bytes32 token, uint256 amount)[] inputs)", - "error FailedOp(uint256 opIndex, string reason)", - "error FailedOpWithRevert(uint256 opIndex, string reason, bytes inner)", - "error UnauthorizedBundler(address origin)", -]) - -// ── Output ─────────────────────────────────────────────────────────── - -// Keys and endpoints never reach the output, whatever an error message quotes. Longest first. -const SECRETS = [] -const secret = (value) => { - if (value && value.length > 8) { - SECRETS.push(value) - SECRETS.sort((a, b) => b.length - a.length) - } - return value -} -const redact = (text) => SECRETS.reduce((out, value) => out.split(value).join("***"), String(text)) -for (const method of ["log", "info", "warn", "error"]) { - const original = console[method].bind(console) - console[method] = (...args) => - original( - ...args.map((arg) => (typeof arg === "string" || arg instanceof Error ? redact(arg.stack ?? arg) : arg)), - ) -} -const json = (value) => JSON.stringify(value, (_key, v) => (typeof v === "bigint" ? v.toString() : v)) -const log = (line) => console.log(`${new Date().toISOString()} ${line}`) -const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - -const checks = [] -function check(name, ok, observed) { - checks.push({ name, ok: Boolean(ok) }) - log(`${ok ? "PASS" : "FAIL"} ${name}: ${typeof observed === "string" ? observed : json(observed)}`) -} - -function ensure(value, message) { - if (!value) throw new Error(message) - return value -} - -/** One `KEY=value` line of the env file, by exact key; nothing else is kept. */ -function envFileValue(key) { - if (!fs.existsSync(ENV_FILE)) return undefined - const line = fs - .readFileSync(ENV_FILE, "utf8") - .split(/\r?\n/) - .find((entry) => entry.startsWith(`${key}=`)) - return line - ?.slice(key.length + 1) - .trim() - .replace(/^(["'])(.*)\1$/, "$2") -} - -function gatewayImplementation() { - if (process.env.GATEWAY_IMPL) return getAddress(process.env.GATEWAY_IMPL) - const toml = fs.readFileSync(path.join(REPO, "evm/config.testnet.toml"), "utf8") - const table = /^\[97\.address\]$([\s\S]*?)(?=^\[)/m.exec(toml)?.[1] ?? "" - const impl = /^INTENT_GATEWAY_V2_IMPL\s*=\s*"(0x[0-9a-fA-F]{40})"/m.exec(table)?.[1] - return getAddress(ensure(impl, "INTENT_GATEWAY_V2_IMPL is missing from [97.address] in evm/config.testnet.toml")) -} - -// ── Processes ──────────────────────────────────────────────────────── - -const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "entrypoint-v09-local-")) -const children = [] -const running = (child) => child.exitCode === null && child.signalCode === null - -function start(name, command, args, env = {}) { - const logFile = path.join(workdir, `${name}.log`) - const out = fs.openSync(logFile, "w") - const child = spawn(command, args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }) - // Anvil's errors quote the fork URL from its argv, so lines are redacted before they reach the file. - for (const input of [child.stdout, child.stderr]) - readline.createInterface({ input }).on("line", (line) => fs.writeSync(out, `${redact(line)}\n`)) - // Unlike "exit", "close" fires only once every line has been written. - Object.assign(child, { label: name, logFile, exited: new Promise((resolve) => child.on("close", resolve)) }) - child.on("error", (error) => log(`${name}: ${error.message}`)) - children.push(child) - log(`${name} started, pid ${child.pid}, log ${logFile}`) - return child -} - -/** Polls `probe` until it answers, failing as soon as `child` exits. */ -async function ready(child, probe) { - const deadline = Date.now() + 60_000 - for (;;) { - if (!running(child)) throw new Error(`${child.label} exited; see ${child.logFile}`) - const value = await probe().catch(() => undefined) - if (value) return value - if (Date.now() > deadline) throw new Error(`${child.label} not ready after 60s; see ${child.logFile}`) - await pause(500) - } -} - -async function stopAll() { - await Promise.all( - children.filter(running).map(async (child) => { - child.kill("SIGTERM") - const timer = setTimeout(() => child.kill("SIGKILL"), 5_000) - await child.exited - clearTimeout(timer) - }), - ) - await Promise.all(children.map((child) => child.exited)) - for (const child of children) - log(`${child.label} pid ${child.pid}: ${running(child) ? "STILL RUNNING" : "stopped"}`) -} - -for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => stopAll().finally(() => process.exit(130))) - -function freePort() { - return new Promise((resolve, reject) => { - const server = net.createServer() - server.on("error", reject) - server.listen(0, "127.0.0.1", () => { - const { port } = server.address() - server.close(() => resolve(port)) - }) - }) -} - -async function jsonRpc(url, method, params = []) { - const response = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), - }) - const body = await response.json() - if (body.error) throw new Error(`${method}: ${body.error.message ?? json(body.error)}`) - return body.result -} - -// ── Chain helpers ──────────────────────────────────────────────────── - -let client -let transport - -const rpc = (method, params) => client.request({ method, params }) -const read = (address, functionName, args = []) => client.readContract({ address, abi, functionName, args }) -const balanceOf = (token, account) => - client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account] }) -const fund = (address) => rpc("anvil_setBalance", [address, toHex(parseEther("100"))]) - -async function confirmed(hash, label) { - const receipt = await client.waitForTransactionReceipt({ hash }) - if (receipt.status !== "success") throw new Error(`${label}: ${hash} reverted`) - return receipt -} - -/** Sends `request` as `from`, which anvil impersonates. */ -async function impersonated(from, request, label) { - await rpc("anvil_impersonateAccount", [from]) - await fund(from) - const wallet = createWalletClient({ account: from, chain: bscTestnet, transport }) - const receipt = await confirmed(await wallet.sendTransaction(request), label) - await rpc("anvil_stopImpersonatingAccount", [from]) - return receipt -} - -/** Writes `account`'s balance of `token` into the first storage slot that `balanceOf` reads it from. */ -async function deal(token, account, amount) { - for (let slot = 0n; slot < 16n; slot++) { - const key = keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, slot])) - const previous = await client.getStorageAt({ address: token, slot: key }) - await rpc("anvil_setStorageAt", [token, key, toHex(amount, { size: 32 })]) - if ((await balanceOf(token, account)) === amount) return - await rpc("anvil_setStorageAt", [token, key, previous ?? toHex(0n, { size: 32 })]) - } - throw new Error(`No balance slot found for ${token}`) -} - -async function approve(wallet, token, spender) { - const args = [spender, 2n ** 255n] - await confirmed( - await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args }), - "approve", - ) -} - -async function implementationOf(proxy) { - const word = await client.getStorageAt({ address: proxy, slot: IMPLEMENTATION_SLOT }) - return getAddress(`0x${word.slice(-40)}`) -} - -/** Every call `hash` made, depth first, from anvil's call tracer. */ -async function callsOf(hash) { - const flatten = (call) => [call, ...(call.calls ?? []).flatMap(flatten)] - return flatten(await rpc("debug_traceTransaction", [hash, { tracer: "callTracer" }])) -} - -/** The origin a handleOps revert names in `FailedOpWithRevert(_, _, UnauthorizedBundler(origin))`. */ -function unauthorizedOrigin(data) { - try { - const failed = decodeErrorResult({ abi, data }) - const inner = - failed.errorName === "FailedOpWithRevert" ? decodeErrorResult({ abi, data: failed.args[2] }) : failed - return inner.errorName === "UnauthorizedBundler" ? inner.args[0] : undefined - } catch { - return undefined - } -} - -/** The origin of an `UnauthorizedBundler(address)` revert quoted anywhere in `text`. */ -function unauthorizedIn(text) { - const word = new RegExp(`${UNAUTHORIZED_BUNDLER.slice(2)}(0{24}[0-9a-f]{40})`, "i").exec(text ?? "")?.[1] - return word && getAddress(`0x${word.slice(24)}`) -} - -/** The indexer's own `findUserOpHash`, run in the indexer package (see entrypoint-v09-local-indexer.cjs). */ -function indexerUserOpHash(logs, sender, logIndex) { - const input = json({ - logs: logs.map(({ address, topics, data, logIndex }) => ({ address, topics, data, logIndex })), - sender, - logIndex, - }) - const helper = path.join(HERE, "entrypoint-v09-local-indexer.cjs") - const tsx = path.join(INDEXER, "node_modules/.bin/tsx") - const tsconfig = path.join(INDEXER, "tsconfig.json") - const out = execFileSync(tsx, ["--tsconfig", tsconfig, helper], { cwd: INDEXER, encoding: "utf8", input }) - return JSON.parse(out).userOpHash -} - -// ── Governance ─────────────────────────────────────────────────────── - -let governanceNonce = 0n - -/** `onAccept` on `to` as the host delivers a `pallet-intents` request from Hyperbridge. */ -async function deliver(to, body, relayer, label) { - const request = { - source: await read(HOST, "hyperbridge"), - dest: await read(HOST, "host"), - nonce: governanceNonce++, - from: toHex("pallet-intents"), - to, - timeoutTimestamp: 0n, - body, - } - const data = encodeFunctionData({ abi, functionName: "onAccept", args: [{ request, relayer }] }) - const receipt = await impersonated(HOST, { to, data }, label) - log(`${label}: delivered in ${receipt.transactionHash}`) - return receipt -} - -/** `allow-bundlers` or `clear-bundlers`, with the init data the sudo script prints for it. */ -async function setBundlers(bundlers, allowed, relayer) { - const args = ["init-data", "--bundlers", bundlers.join(","), "--relayer", relayer, "--env-file", os.devNull] - const out = execFileSync(process.execPath, [path.join(HERE, "entrypoint-v09-sudo.mjs"), ...args], { - encoding: "utf8", - }) - const line = allowed ? "allow" : "clear" - const init = ensure(new RegExp(`^${line}:\\s+(0x[0-9a-fA-F]+)$`, "m").exec(out)?.[1], `no ${line} init data`) - const upgrade = encodeAbiParameters( - [{ type: "address" }, { type: "bytes" }], - [await implementationOf(PAYMASTER), init], - ) - await deliver(PAYMASTER, concat([toHex(PAYMASTER_UPGRADE, { size: 1 }), upgrade]), relayer, `${line}-bundlers`) - return read(PAYMASTER, "getBundlers") -} - -// ── Main ───────────────────────────────────────────────────────────── - -async function main() { - const forkUrl = secret(process.env.FORK_URL || process.env.BSC_TESTNET_RPC_URL || envFileValue("BSC_CHAPEL")) - ensure(forkUrl, `Set FORK_URL, or BSC_CHAPEL in ${ENV_FILE}`) - const signerKey = secret(process.env.RUNDLER_SIGNER_PRIVATE_KEY || envFileValue("RUNDLER_SIGNER_PRIVATE_KEY")) - ensure(signerKey, `Set RUNDLER_SIGNER_PRIVATE_KEY, or add it to ${ENV_FILE}`) - ensure(fs.existsSync(RUNDLER_BIN), `No rundler at ${RUNDLER_BIN}; run \`cargo build --bin rundler\` in the fork`) - const bundler = privateKeyToAccount(signerKey).address - const gatewayImpl = gatewayImplementation() - - const [anvilPort, rundlerPort, metricsPort] = [await freePort(), await freePort(), await freePort()] - const anvilUrl = `http://127.0.0.1:${anvilPort}` - const rundlerUrl = `http://127.0.0.1:${rundlerPort}` - const forkBlock = process.env.FORK_BLOCK ? ["--fork-block-number", process.env.FORK_BLOCK] : [] - const anvil = start("anvil", "anvil", [ - ...["--fork-url", forkUrl, ...forkBlock, "--port", String(anvilPort)], - ..."--chain-id 97 --hardfork prague --block-time 1 --silent".split(" "), - ]) - transport = http(anvilUrl) - client = createPublicClient({ chain: bscTestnet, transport }) - log(`anvil forked Chapel at block ${await ready(anvil, () => client.getBlockNumber())} on ${anvilUrl}`) - - // ── 1. Governance ── - // The one relayer Hyperbridge delivers through, which both contracts accept. - const relayer = await read(PAYMASTER, "relayer") - const gatewayBefore = await implementationOf(GATEWAY) - const upgrade = encodeFunctionData({ abi, functionName: "upgradeToAndCall", args: [gatewayImpl, "0x"] }) - const upgraded = await deliver( - GATEWAY, - concat([toHex(GATEWAY_EXECUTE, { size: 1 }), upgrade]), - relayer, - "upgrade-gateway", - ) - const gatewayAfter = await implementationOf(GATEWAY) - const typehash = await read(GATEWAY, "SELECT_SOLVER_TYPEHASH") - check( - "gateway implementation upgraded through Execute", - isAddressEqual(gatewayAfter, gatewayImpl) && typehash === SELECT_SOLVER_TYPEHASH, - { tx: upgraded.transactionHash, before: gatewayBefore, after: gatewayAfter, typehash }, - ) - const { solverSelection } = await read(GATEWAY, "params") - check("gateway solver selection is on", solverSelection, { solverSelection }) - - const treasury = await read(PAYMASTER, "treasury") - for (const [functionName, args] of [ - ["deposit", []], - ["addStake", [86_400]], - ]) { - const data = encodeFunctionData({ abi, functionName, args }) - await impersonated(treasury, { to: PAYMASTER, data, value: parseEther("1") }, functionName) - } - log(`paymaster funded by ${treasury}: ${json(await read(ENTRY_POINT, "getDepositInfo", [PAYMASTER]))}`) - - const allowed = await setBundlers([bundler, SIMULATION_ORIGIN], true, relayer) - check( - "paymaster allowlist holds the rundler signer and simulation origin", - [bundler, SIMULATION_ORIGIN].every((entry) => allowed.some((listed) => isAddressEqual(listed, entry))), - allowed, - ) - - // ── Bundler ── - await fund(bundler) - const rundler = start( - "rundler", - RUNDLER_BIN, - [ - ..."node --network bsc_testnet --enabled_entry_points v0.9 --unsafe".split(" "), - ...["--rpc.port", String(rundlerPort), "--metrics.port", String(metricsPort)], - ], - { NODE_HTTP: anvilUrl, SIGNER_PRIVATE_KEYS: signerKey, RUST_LOG: "info", NO_COLOR: "1" }, - ) - const entryPoints = await ready(rundler, () => jsonRpc(rundlerUrl, "eth_supportedEntryPoints")) - check( - "rundler serves only EntryPoint v0.9", - entryPoints.length === 1 && isAddressEqual(entryPoints[0], ENTRY_POINT), - entryPoints, - ) - - // ── 2. Accounts and delegation ── - const user = privateKeyToAccount(generatePrivateKey()) - const solver = privateKeyToAccount(generatePrivateKey()) - const userWallet = createWalletClient({ account: user, chain: bscTestnet, transport }) - const solverWallet = createWalletClient({ account: solver, chain: bscTestnet, transport }) - await Promise.all([fund(user.address), fund(solver.address)]) - - const authorization = await solverWallet.signAuthorization({ contractAddress: SOLVER_ACCOUNT, executor: "self" }) - const delegation = await confirmed( - await solverWallet.sendTransaction({ authorizationList: [authorization], to: solver.address, data: "0x" }), - "delegation", - ) - const code = await client.getCode({ address: solver.address }) - check( - "solver EOA delegated to the v0.9 SolverAccount", - code?.toLowerCase() === concat(["0xef0100", SOLVER_ACCOUNT]).toLowerCase(), - { solver: solver.address, tx: delegation.transactionHash, code }, - ) - - const sdkChain = EvmChain.fromParams({ chainId: 97, host: HOST, rpcUrl: anvilUrl, bundlerUrl: rundlerUrl }) - // The SDK keeps session keys under ./.hyperbridge-cache. - process.chdir(workdir) - const gateway = await IntentGateway.create(sdkChain, sdkChain) - const feeToken = await sdkChain.getFeeTokenWithDecimals() - - await deal(USDH, user.address, parseUnits("100", 18)) - await deal(feeToken.address, user.address, parseUnits("10", feeToken.decimals)) - await deal(USDH, solver.address, parseUnits("50", 18)) - await deal(CNGN, solver.address, parseUnits("100000", 6)) - await approve(userWallet, USDH, GATEWAY) - await approve(userWallet, feeToken.address, GATEWAY) - - /** Places a same-chain USD.h for cNGN order through the SDK's `execute`, stopping once it is placed. */ - async function placeOrder(label) { - const beneficiary = bytes20ToBytes32(user.address) - const order = { - user: beneficiary, - source: toHex(CHAIN), - destination: toHex(CHAIN), - deadline: (await client.getBlockNumber()) + 10_000n, - nonce: 0n, - // Set, so placement skips the fee quote. - fees: parseUnits("0.01", feeToken.decimals), - session: zeroAddress, - predispatch: { assets: [], call: "0x" }, - inputs: [{ token: bytes20ToBytes32(USDH), amount: ORDER_IN }], - output: { beneficiary, assets: [{ token: bytes20ToBytes32(CNGN), amount: ORDER_OUT }], call: "0x" }, - } - const stream = gateway.execute(order, DEFAULT_GRAFFITI, { auctionTimeMs: 0 }) - try { - const awaiting = (await stream.next()).value - ensure(awaiting?.status === "AWAITING_PLACE_ORDER", `${label}: SDK yielded ${awaiting?.status}`) - const { to, data, value } = awaiting - const request = await userWallet.prepareTransactionRequest({ to, data, value }) - const placed = (await stream.next(await userWallet.signTransaction(request))).value - ensure(placed?.status === "ORDER_PLACED", `${label}: SDK yielded ${placed?.status}`) - log(`${label}: order ${placed.order.id} placed in ${placed.receipt.transactionHash}`) - return { order: placed.order, sessionPrivateKey: awaiting.sessionPrivateKey } - } finally { - await stream.return(undefined) - } - } - - /** The solver's sponsored bid, built the way simplex's `prepareBidUserOp` builds one. */ - async function buildBid(order, label) { - const approveOutput = { - target: CNGN, - value: 0n, - data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [GATEWAY, ORDER_OUT] }), - } - const estimate = await gateway.estimateFillOrder({ - order, - prependCalls: [approveOutput], - requireBundlerEstimate: true, - }) - const fillOptions = { - relayerFee: estimate.fillOptions.relayerFee, - nativeDispatchFee: 0n, - validUntil: (await client.getBlockNumber()) + 600n, - outputs: order.output.assets, - inputs: order.inputs, - } - const callData = encodeERC7821ExecuteBatch([ - approveOutput, - { target: GATEWAY, value: 0n, data: encodeFillOrder(transformOrderForContract(order), fillOptions) }, - ]) - const nonceKey = CryptoUtils.bidNonceKey(order.id, order.session, callData) - const paymaster = await buildPaymasterAndData({ - chain: CHAIN, - solverAccount: solver.address, - publicClient: client, - walletClient: solverWallet, - signer: solver, - configService: new FillerConfigService([]), - prefund: { - baseGas: estimate.callGasLimit + estimate.verificationGasLimit + estimate.preVerificationGas, - maxFeePerGas: estimate.maxFeePerGas, - }, - }) - ensure(paymaster.type === "simplex", `${label}: simplex chose no paymaster: ${paymaster.reason}`) - const bidGas = { - solverAccount: solver.address, - nonce: await read(ENTRY_POINT, "getNonce", [solver.address, nonceKey]), - entryPointAddress: ENTRY_POINT, - callGasLimit: estimate.callGasLimit, - verificationGasLimit: estimate.verificationGasLimit, - maxFeePerGas: estimate.maxFeePerGas, - maxPriorityFeePerGas: estimate.maxPriorityFeePerGas, - callData, - paymasterAndData: paymaster.paymasterAndData, - } - const preVerificationGas = await gateway.estimateBidPreVerificationGas(bidGas) - const solverSigner = solver - const userOp = await gateway.prepareSubmitBid({ - ...bidGas, - order, - fillOptions, - solverSigner, - preVerificationGas, - }) - const { callGasLimit, verificationGasLimit, maxFeePerGas, maxPriorityFeePerGas } = bidGas - const mode = paymaster.paymasterAndData.slice(106, 108) - log( - `${label}: bid signed ${json({ callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas, maxPriorityFeePerGas })}, paymaster mode 0x${mode}`, - ) - return userOp - } - - /** The user's side, as the SDK runs it on the bids of an auction. */ - async function executeBid(order, userOp, sessionPrivateKey) { - const fillerBid = { filler: "local", bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n } - const bids = gateway.buildBids(order, [fillerBid], sessionPrivateKey) - ensure((await gateway.sortBids(order, bids)).length === 1, "sortBids dropped the bid") - await bids[0].simulate() - return bids[0].execute() - } - - const snapshot = async () => ({ - userCngn: await balanceOf(CNGN, user.address), - solverCngn: await balanceOf(CNGN, solver.address), - solverUsdh: await balanceOf(USDH, solver.address), - paymasterUsdh: await balanceOf(USDH, PAYMASTER), - paymasterDeposit: await read(ENTRY_POINT, "balanceOf", [PAYMASTER]), - }) - - // ── 3-6. The sponsored fill ── - const first = await placeOrder("fill") - const firstOp = await buildBid(first.order, "fill") - const before = await snapshot() - const result = await executeBid(first.order, firstOp, first.sessionPrivateKey) - const after = await snapshot() - - check( - "SDK execute filled the order with the v0.9 userOpHash the solver signed", - result.fillStatus === "full" && result.userOpHash === CryptoUtils.computeUserOpHash(firstOp, ENTRY_POINT, 97n), - { userOpHash: result.userOpHash, txnHash: result.txnHash, fillStatus: result.fillStatus }, - ) - - const receipt = await client.getTransactionReceipt({ hash: result.txnHash }) - const bundle = await client.getTransaction({ hash: result.txnHash }) - check( - "bundle sent by the rundler signer to EntryPoint v0.9", - isAddressEqual(bundle.from, bundler) && isAddressEqual(bundle.to, ENTRY_POINT), - { tx: result.txnHash, from: bundle.from, to: bundle.to, gasUsed: receipt.gasUsed, block: receipt.blockNumber }, - ) - - const calls = await callsOf(result.txnHash) - const at = (from, to, selector) => - calls.findIndex( - (call) => isAddressEqual(call.from, from) && isAddressEqual(call.to, to) && call.input.startsWith(selector), - ) - const steps = { - select: at(solver.address, GATEWAY, SELECT), - validatePaymasterUserOp: at(ENTRY_POINT, PAYMASTER, VALIDATE_PAYMASTER), - fillOrder: at(solver.address, GATEWAY, FILL_ORDER_SELECTOR), - getCurrentUserOpHash: at(GATEWAY, ENTRY_POINT, GET_CURRENT_USER_OP_HASH), - } - check( - "trace: SolverAccount selects and the paymaster validates, then fillOrder reads getCurrentUserOpHash", - Object.values(steps).every((index) => index >= 0) && - Math.max(steps.select, steps.validatePaymasterUserOp) < steps.fillOrder && - steps.fillOrder < steps.getCurrentUserOpHash, - { callIndex: steps, txOrigin: bundle.from }, - ) - - const [operation] = parseEventLogs({ abi, logs: receipt.logs, eventName: "UserOperationEvent" }).filter( - (event) => isAddressEqual(event.address, ENTRY_POINT) && event.args.userOpHash === result.userOpHash, - ) - check( - "UserOperationEvent from EntryPoint v0.9, sponsored by the paymaster proxy, successful", - operation?.args.success && - isAddressEqual(operation.args.paymaster, PAYMASTER) && - isAddressEqual(operation.args.sender, solver.address), - operation && { address: operation.address, ...operation.args }, - ) - - const [filled] = parseEventLogs({ abi, logs: receipt.logs, eventName: "OrderFilled" }).filter( - (event) => isAddressEqual(event.address, GATEWAY) && event.args.commitment === first.order.id, - ) - const filledBy = await read(GATEWAY, "_filled", [first.order.id]) - const escrowLeft = await read(GATEWAY, "_orders", [first.order.id, 0n]) - check( - "OrderFilled by the solver, escrow released and order closed", - filled && isAddressEqual(filled.args.filler, solver.address) && isAddressEqual(filledBy, solver.address), - { - commitment: first.order.id, - filler: filled?.args.filler, - filledBy, - escrowLeft, - released: filled?.args.inputs, - }, - ) - const escrow = first.order.inputs[0].amount - const moved = { - userCngn: after.userCngn - before.userCngn, - solverCngn: after.solverCngn - before.solverCngn, - solverUsdh: after.solverUsdh - before.solverUsdh, - paymasterUsdhCharged: after.paymasterUsdh - before.paymasterUsdh, - escrowReleased: escrow, - paymasterDepositSpent: before.paymasterDeposit - after.paymasterDeposit, - actualGasCost: operation?.args.actualGasCost, - } - check( - "balances moved as the fill and its sponsorship say", - moved.userCngn === ORDER_OUT && - moved.solverCngn === -ORDER_OUT && - moved.paymasterUsdhCharged > 0n && - moved.solverUsdh + moved.paymasterUsdhCharged === escrow && - moved.paymasterDepositSpent === moved.actualGasCost, - moved, - ) - - const indexed = indexerUserOpHash(receipt.logs, filled?.args.filler, filled?.logIndex) - check("indexer findUserOpHash resolves the fill's userOpHash", indexed === result.userOpHash, indexed) - - // ── 7. The rundler signer off the allowlist ── - const remaining = await setBundlers([bundler], false, relayer) - check( - "allowlist cleared of the rundler signer only", - remaining.length === 1 && isAddressEqual(remaining[0], SIMULATION_ORIGIN), - remaining, - ) - - const second = await placeOrder("refused") - const secondOp = await buildBid(second.order, "refused") - const secondHash = CryptoUtils.computeUserOpHash(secondOp, ENTRY_POINT, 97n) - const logOffset = fs.statSync(rundler.logFile).size - const fromBlock = await client.getBlockNumber() - const refusal = await executeBid(second.order, secondOp, second.sessionPrivateKey).then( - (executed) => ({ executed }), - (error) => ({ error: error.message }), - ) - // Leave rundler a few blocks to report what it did with the op. - await pause(3_000) - - const bundles = [] - const toBlock = await client.getBlockNumber() - for (let number = fromBlock; number <= toBlock; number++) { - const block = await client.getBlock({ blockNumber: number, includeTransactions: true }) - for (const tx of block.transactions.filter((tx) => isAddressEqual(tx.from, bundler))) { - const { status, gasUsed } = await client.getTransactionReceipt({ hash: tx.hash }) - const [top] = status === "reverted" ? await callsOf(tx.hash) : [] - bundles.push({ tx: tx.hash, block: number, status, gasUsed, origin: top && unauthorizedOrigin(top.output) }) - } - } - const logged = fs.readFileSync(rundler.logFile, "utf8").slice(logOffset).split("\n").filter(unauthorizedIn) - const { where, origin } = [ - ...bundles.map(({ tx, origin }) => ({ where: `bundle ${tx} reverted on chain`, origin })), - { where: "rundler refused eth_sendUserOperation", origin: unauthorizedIn(refusal.error) }, - { where: "rundler dropped it before bundling", origin: logged.map(unauthorizedIn).find(Boolean) }, - ].find((entry) => entry.origin) ?? { where: "nowhere" } - const opReceipt = await jsonRpc(rundlerUrl, "eth_getUserOperationReceipt", [secondHash]).catch(() => null) - const secondFilledBy = await read(GATEWAY, "_filled", [second.order.id]) - check( - "second sponsored op refused with UnauthorizedBundler(rundler signer)", - refusal.error && - origin && - isAddressEqual(origin, bundler) && - !opReceipt && - isAddressEqual(secondFilledBy, zeroAddress), - { - where, - origin, - userOpHash: secondHash, - sdk: refusal.error ?? refusal.executed, - bundles, - rundlerLog: logged.map((line) => line.trim().slice(0, 300)), - opReceipt, - orderFilledBy: secondFilledBy, - }, - ) -} - -let exitCode = 0 -try { - await main() -} catch (error) { - exitCode = 1 - console.error(error) - const rundler = children.find((child) => child.label === "rundler") - if (rundler) console.error(redact(fs.readFileSync(rundler.logFile, "utf8").split("\n").slice(-40).join("\n"))) -} finally { - await stopAll() -} -const failed = checks.filter((entry) => !entry.ok).map((entry) => entry.name) -if (failed.length > 0) exitCode = 1 -log( - `${checks.length - failed.length}/${checks.length} checks passed${failed.length ? `; failed: ${failed.join("; ")}` : ""}`, -) -log(`logs in ${workdir}`) -process.exit(exitCode) diff --git a/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs b/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs deleted file mode 100644 index 01d75422d1..0000000000 --- a/sdk/packages/simplex/e2e/entrypoint-v09-sudo.mjs +++ /dev/null @@ -1,590 +0,0 @@ -// EntryPoint v0.9 testnet rollout, governance side: the Gargantua sudo calls that register the -// SimplexPaymaster, upgrade the IntentGatewayV2 proxies and set or clear the paymaster's bundler -// allowlist. Every step that reaches an EVM chain polls it until the relayer has delivered. -// -// Usage: node e2e/entrypoint-v09-sudo.mjs [--dry-run] [options] (run with --help) -// The EVM steps and the full order: evm/script/testnet/entrypoint-v09.sh and the README beside it. -import fs from "node:fs" -import path from "node:path" -import { fileURLToPath } from "node:url" -import { parseArgs } from "node:util" -import { ApiPromise, Keyring, WsProvider } from "@polkadot/api" -import { u8aToHex } from "@polkadot/util" -import { cryptoWaitReady, keccakAsU8a } from "@polkadot/util-crypto" -import { - concat, - createPublicClient, - encodeAbiParameters, - encodeFunctionData, - getAddress, - hexToString, - http, - isAddress, - toHex, -} from "viem" - -const HERE = path.dirname(fileURLToPath(import.meta.url)) -const REPO = path.resolve(HERE, "../../../..") - -const HYPERBRIDGE = "KUSAMA-4009" -const RELAYER = "0xc8809DD0b00370be097382d741A43347Ad582757" -// The rundler signer and the origin rundler simulates validation from. -const BUNDLERS = ["0x2E0E23636E27826193EA4A585a05CD1808d3c96c", "0x0643866dA50efE0b055Cd15aF95191968c8411b5"] -const ENTRYPOINT_V09 = "0x433709009B8330FDa32311DF1C2AFA402eD8D009" -const SELECT_SOLVER_TYPEHASH = "0xe706bdab7d945360dcd9d81d355f856754dd1cfa461edfc0a7502e2583b4e09e" -const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" -// `SimplexPaymaster.RequestKind.SetBundlers` -const SET_BUNDLERS = 8 -const POLL_MS = 15_000 - -const CHAINS = { - 97: { name: "BSC Chapel", rpc: "BSC_TESTNET_RPC_URL", fallback: "BSC_CHAPEL" }, - 80002: { name: "Polygon Amoy", rpc: "POLYGON_AMOY_RPC_URL", fallback: "POLYGON_AMOY" }, -} - -const CALLS = [ - "addPaymasterDeployment", - "executeOnGateway", - "upgradePaymaster", - "upgradeGateway", - "setPaymasterBundlers", -] - -const postRequest = { - name: "request", - type: "tuple", - components: [ - { name: "source", type: "bytes" }, - { name: "dest", type: "bytes" }, - { name: "nonce", type: "uint64" }, - { name: "from", type: "bytes" }, - { name: "to", type: "bytes" }, - { name: "timeoutTimestamp", type: "uint64" }, - { name: "body", type: "bytes" }, - ], -} - -const view = (name, output) => ({ - type: "function", - name, - stateMutability: "view", - inputs: [], - outputs: [{ type: output }], -}) - -const paymasterAbi = [ - { - type: "function", - name: "onAccept", - stateMutability: "nonpayable", - inputs: [{ name: "incoming", type: "tuple", components: [postRequest, { name: "relayer", type: "address" }] }], - outputs: [], - }, - view("getBundlers", "address[]"), - view("version", "uint64"), - view("relayer", "address"), - view("entryPoint", "address"), - view("host", "address"), -] - -const gatewayAbi = [ - { - type: "function", - name: "upgradeToAndCall", - stateMutability: "nonpayable", - inputs: [ - { name: "newImplementation", type: "address" }, - { name: "data", type: "bytes" }, - ], - outputs: [], - }, - view("version", "uint64"), - view("SELECT_SOLVER_TYPEHASH", "bytes32"), -] - -const hostAbi = [view("hyperbridge", "bytes")] - -const USAGE = `Usage: node e2e/entrypoint-v09-sudo.mjs [options] - -Commands: - metadata Arg shapes of the intentsCoprocessor calls on the live runtime, and what it stores - init-data The nested onAccept init_data for the bundler allowlist (no network) - register-paymaster 4. addPaymasterDeployment(Evm(chain), SIMPLEX_PAYMASTER) - upgrade-gateway 5. executeOnGateway(Evm(chain), upgradeToAndCall(INTENT_GATEWAY_V2_IMPL, "")), - then polls the gateway's implementation slot and SELECT_SOLVER_TYPEHASH() - allow-bundlers 8. upgradePaymaster(Evm(chain), current implementation, onAccept(SetBundlers(list, true))) - clear-bundlers 9. the same with allowed = false, then expects an empty getBundlers() - -Options: - --dry-run Print each decoded call and its hex; sign and send nothing - --chains 97,80002 Chain ids (default: both) - --paymaster 0x.. Paymaster proxy (default: SIMPLEX_PAYMASTER in the config) - --paymaster-impl 0x.. Implementation to re-point the proxy at (default: read from the proxy) - --impl 0x.. Gateway implementation (default: INTENT_GATEWAY_V2_IMPL in the config) - --bundlers 0x..,0x.. Allowlist (default: ${BUNDLERS.join(",")}) - --relayer 0x.. Relayer named in the nested request (default: ${RELAYER}) - --timeout-min N How long to poll an EVM chain for the relayer (default: 30) - --env-file PATH Default: sdk/.env.local, read line by line, never sourced - --config PATH Default: evm/config.testnet.toml - -Environment: HYPERBRIDGE_GARGANTUA, GARGANTUA_SUDO_SEED (seed of sudo.key(); live runs only), -BSC_TESTNET_RPC_URL or BSC_CHAPEL, POLYGON_AMOY_RPC_URL or POLYGON_AMOY. The process environment wins -over the env file.` - -const { values: options, positionals } = parseArgs({ - allowPositionals: true, - options: { - "dry-run": { type: "boolean", default: false }, - chains: { type: "string", default: "97,80002" }, - paymaster: { type: "string" }, - "paymaster-impl": { type: "string" }, - impl: { type: "string" }, - bundlers: { type: "string" }, - relayer: { type: "string", default: RELAYER }, - "timeout-min": { type: "string", default: "30" }, - "env-file": { type: "string", default: path.join(REPO, "sdk/.env.local") }, - config: { type: "string", default: path.join(REPO, "evm/config.testnet.toml") }, - help: { type: "boolean", short: "h", default: false }, - }, -}) - -// Keys and endpoints never reach the output, whatever an error message quotes. Longest first, so -// a value is never half-redacted by one it contains. -const SECRETS = [] -const secret = (value) => { - if (value && value.length > 8 && !SECRETS.includes(value)) { - SECRETS.push(value) - SECRETS.sort((a, b) => b.length - a.length) - } - return value -} -const redact = (text) => SECRETS.reduce((out, value) => out.split(value).join("***"), String(text)) -for (const method of ["log", "info", "warn", "error"]) { - const original = console[method].bind(console) - console[method] = (...args) => - original( - ...args.map((arg) => - typeof arg === "string" || arg instanceof Error - ? redact(arg instanceof Error ? (arg.stack ?? arg.message) : arg) - : arg, - ), - ) -} -const log = (line) => console.log(`${new Date().toISOString()} ${line}`) -const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) -const same = (a, b) => Boolean(a) && Boolean(b) && a.toLowerCase() === b.toLowerCase() -const dryRun = options["dry-run"] - -/** `KEY=value` lines by exact key; the file holds lines that are not valid shell. */ -function readEnvFile(file) { - const entries = {} - if (!fs.existsSync(file)) return entries - for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { - const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line) - if (!match || match[1] in entries) continue - entries[match[1]] = match[2].trim().replace(/^(["'])(.*)\1$/, "$2") - } - return entries -} - -const fileEnv = readEnvFile(options["env-file"]) -const setting = (name, fallback = name) => process.env[name] || fileEnv[fallback] - -/** `[.address]` tables of the forge config; a zero address counts as unset. */ -function readConfig(file) { - const tables = {} - let table - for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { - const header = /^\[(.+)\]\s*$/.exec(line) - if (header) { - table = tables[header[1]] = {} - continue - } - const entry = /^\s*([A-Za-z0-9_]+)\s*=\s*"?([^"]*)"?\s*$/.exec(line) - if (entry && table) table[entry[1]] = entry[2] - } - return (id, key) => { - const value = tables[`${id}.address`]?.[key] - return value && !/^0x0{40}$/i.test(value) ? getAddress(value) : undefined - } -} - -function address(value, label) { - if (!value || !isAddress(value)) throw new Error(`${label} is not an address: ${value}`) - return getAddress(value) -} - -function evm(id) { - const chain = CHAINS[id] - const url = secret(setting(chain.rpc, chain.fallback)) - if (!url) throw new Error(`${chain.rpc} is unset and ${chain.fallback} is missing from the env file`) - return createPublicClient({ transport: http(url) }) -} - -const read = (client, address, abi, functionName) => client.readContract({ address, abi, functionName }) - -async function implementationOf(client, proxy) { - const word = await client.getStorageAt({ address: proxy, slot: IMPLEMENTATION_SLOT }) - return word && BigInt(word) !== 0n ? getAddress(`0x${word.slice(-40)}`) : undefined -} - -/** `abi.encodeCall(SimplexPaymaster.onAccept, (IncomingPostRequest))` carrying a `SetBundlers` body. */ -function bundlerInitData(bundlers, allowed, relayer) { - const body = concat([ - toHex(SET_BUNDLERS, { size: 1 }), - encodeAbiParameters([{ type: "address[]" }, { type: "bool" }], [bundlers, allowed]), - ]) - const request = { - source: toHex(HYPERBRIDGE), - dest: "0x", - nonce: 0n, - from: toHex("pallet-intents"), - to: "0x", - timeoutTimestamp: 0n, - body, - } - return encodeFunctionData({ abi: paymasterAbi, functionName: "onAccept", args: [{ request, relayer }] }) -} - -async function connect() { - const url = secret(setting("HYPERBRIDGE_GARGANTUA")) - if (!url) throw new Error("HYPERBRIDGE_GARGANTUA is unset and missing from the env file") - await cryptoWaitReady() - const api = await ApiPromise.create({ - provider: new WsProvider(url), - noInitWarn: true, - // Hyperbridge hashes with keccak; the default blake2 registry signs and reads wrongly. - typesBundle: { spec: { nexus: { hasher: keccakAsU8a }, gargantua: { hasher: keccakAsU8a } } }, - }) - const version = api.runtimeVersion - log(`Gargantua ${version.specName} spec ${version.specVersion}`) - return api -} - -/** Read before anything connects, so a live run without it stops at once. */ -function sudoSeed() { - const seed = secret(setting("GARGANTUA_SUDO_SEED")) - if (!seed) { - throw new Error( - `GARGANTUA_SUDO_SEED is unset and missing from ${options["env-file"]}; live runs sign with the Gargantua sudo key`, - ) - } - return seed -} - -/** The pair GARGANTUA_SUDO_SEED derives that is `sudo.key()`, sr25519 first. */ -async function sudoSigner(api, seed) { - const key = await api.query.sudo.key() - if (key.isNone) throw new Error("Gargantua has no sudo key") - const sudo = key.unwrap().toHex() - const tried = [] - for (const type of ["sr25519", "ed25519", "ecdsa"]) { - let pair - try { - pair = new Keyring({ type }).addFromUri(seed) - } catch (error) { - tried.push(`${type}: ${error.message}`) - continue - } - if (u8aToHex(pair.addressRaw) === sudo) return pair - tried.push(`${type} ${pair.address}`) - } - throw new Error(`GARGANTUA_SUDO_SEED is not the sudo key ${key.toString()} (${tried.join(", ")})`) -} - -function describe(api, error) { - if (error.isModule) { - const { section, name, docs } = api.registry.findMetaError(error.asModule) - return `${section}.${name}: ${docs.join(" ")}` - } - return error.toString() -} - -/** Prints the call in a dry run; otherwise sends it through `sudo.sudo` and waits for inclusion. */ -async function dispatch(api, signer, label, call) { - const wrapped = api.tx.sudo.sudo(call) - log(`${label}: ${call.method.section}.${call.method.method}`) - console.log(` decoded: ${JSON.stringify(call.method.toHuman())}`) - console.log(` call: ${call.method.toHex()}`) - console.log(` sudo: ${wrapped.method.toHex()}`) - if (dryRun) return false - await new Promise((resolve, reject) => { - let unsubscribe - const stop = () => unsubscribe?.() - wrapped - .signAndSend(signer, { nonce: -1 }, ({ status, events, dispatchError }) => { - if (dispatchError) { - stop() - reject(new Error(`${label}: ${describe(api, dispatchError)}`)) - return - } - if (!status.isInBlock) return - stop() - const sudid = events.find(({ event }) => api.events.sudo.Sudid.is(event)) - if (!sudid) return reject(new Error(`${label}: no sudo.Sudid event in ${status.asInBlock.toHex()}`)) - const result = sudid.event.data[0] - if (result.isErr) return reject(new Error(`${label}: sudo call failed: ${describe(api, result.asErr)}`)) - const emitted = events.map(({ event }) => `${event.section}.${event.method}`).join(", ") - log(`${label}: included in ${status.asInBlock.toHex()} (${emitted})`) - resolve() - }) - .then((unsub) => { - unsubscribe = unsub - }) - .catch(reject) - }) - return true -} - -/** Re-reads `check` until it reports done, printing progress, or fails after `--timeout-min`. */ -async function poll(label, check) { - const limit = Number(options["timeout-min"]) * 60_000 - const start = Date.now() - for (;;) { - const { done, progress } = await check().catch((error) => ({ - done: false, - progress: `read failed: ${error.shortMessage ?? error.message}`, - })) - const elapsed = `${Math.round((Date.now() - start) / 1000)}s` - if (done) return log(`${label}: landed after ${elapsed} (${progress})`) - if (Date.now() - start > limit) - throw new Error(`${label}: nothing after ${options["timeout-min"]} min (${progress})`) - log(`${label}: waiting for the relayer, ${elapsed}: ${progress}`) - await pause(POLL_MS) - } -} - -/** A failed precondition stops a live run; a dry run reports it and still prints the call. */ -function precondition(condition, message) { - if (condition) return - if (!dryRun) throw new Error(message) - log(`warning: ${message}`) -} - -function selectedChains() { - return options.chains.split(",").map((id) => { - const chain = Number(id.trim()) - if (!CHAINS[chain]) throw new Error(`Unsupported chain ${id}: use ${Object.keys(CHAINS).join(" or ")}`) - return chain - }) -} - -function bundlerList() { - const list = options.bundlers ? options.bundlers.split(",") : BUNDLERS - return list.map((bundler) => address(bundler.trim(), "bundler")) -} - -async function paymasterOf(api, configured, id) { - const registered = (await api.query.intentsCoprocessor.paymasters({ Evm: id })).unwrapOr(undefined)?.toHex() - const paymaster = options.paymaster ?? configured(id, "SIMPLEX_PAYMASTER") ?? registered - if (!paymaster) throw new Error(`EVM-${id}: no paymaster in --paymaster, the config or the pallet`) - return { paymaster: address(paymaster, "paymaster"), registered: registered && getAddress(registered) } -} - -/** The checks the paymaster runs on a governance delivery, read from the chain. */ -async function checkPaymaster(client, id, paymaster) { - const [version, entryPoint, relayer, host] = await Promise.all( - ["version", "entryPoint", "relayer", "host"].map((name) => read(client, paymaster, paymasterAbi, name)), - ).catch(() => []) - precondition(version === 3n, `EVM-${id}: paymaster ${paymaster} reports version ${version}, expected 3`) - precondition(same(entryPoint, ENTRYPOINT_V09), `EVM-${id}: paymaster entryPoint() is ${entryPoint}, expected v0.9`) - precondition( - same(relayer, options.relayer), - `EVM-${id}: paymaster relayer() is ${relayer}, the nested request names ${options.relayer}`, - ) - if (host) { - const source = hexToString(await read(client, host, hostAbi, "hyperbridge")) - precondition( - source === HYPERBRIDGE, - `EVM-${id}: host hyperbridge() is ${source}, the nested request names ${HYPERBRIDGE}`, - ) - } -} - -// ── Commands ───────────────────────────────────────────────────────── - -async function metadata(api) { - const pallet = api.tx.intentsCoprocessor - for (const name of CALLS) { - const call = pallet[name] - if (!call) { - console.log(`intentsCoprocessor.${name}: not in this runtime`) - continue - } - const args = call.meta.args.map( - (arg) => `${arg.name}: ${arg.typeName.toString() || arg.type.toString()} (${arg.type.toString()})`, - ) - console.log(`intentsCoprocessor.${name} [call index ${u8aToHex(call.callIndex)}]`) - for (const arg of args) console.log(` ${arg}`) - } - console.log( - `StateMachine Evm(97) encodes as ${api.createType(pallet.addPaymasterDeployment.meta.args[0].type.toString(), { Evm: 97 }).toHex()}`, - ) - console.log(`sudo.key: ${(await api.query.sudo.key()).toString()}`) - for (const id of selectedChains()) { - const gateway = (await api.query.intentsCoprocessor.gateways({ Evm: id })).unwrapOr(undefined) - const paymaster = (await api.query.intentsCoprocessor.paymasters({ Evm: id })).unwrapOr(undefined) - console.log( - `EVM-${id}: gateway ${gateway ? gateway.gateway.toHex() : "unset"}, paymaster ${paymaster ? paymaster.toHex() : "unset"}`, - ) - } -} - -function initData() { - const bundlers = bundlerList() - console.log(`bundlers: ${bundlers.join(", ")}`) - console.log(`relayer: ${getAddress(options.relayer)}`) - console.log(`allow: ${bundlerInitData(bundlers, true, getAddress(options.relayer))}`) - console.log(`clear: ${bundlerInitData(bundlers, false, getAddress(options.relayer))}`) -} - -async function registerPaymaster(api, signer, configured) { - for (const id of selectedChains()) { - const label = `EVM-${id} register-paymaster` - const { paymaster, registered } = await paymasterOf(api, configured, id) - await checkPaymaster(evm(id), id, paymaster) - if (same(registered, paymaster)) { - log(`${label}: ${paymaster} already registered`) - continue - } - if (registered) log(`${label}: replaces ${registered}`) - const sent = await dispatch( - api, - signer, - label, - api.tx.intentsCoprocessor.addPaymasterDeployment({ Evm: id }, paymaster), - ) - if (!sent) continue - const stored = (await api.query.intentsCoprocessor.paymasters({ Evm: id })).unwrapOr(undefined)?.toHex() - if (!same(stored, paymaster)) throw new Error(`${label}: the pallet stores ${stored}, not ${paymaster}`) - log(`${label}: Paymasters(Evm(${id})) = ${paymaster}`) - } -} - -async function upgradeGateway(api, signer, configured) { - const pending = [] - for (const id of selectedChains()) { - const label = `EVM-${id} upgrade-gateway` - const client = evm(id) - const impl = address(options.impl ?? configured(id, "INTENT_GATEWAY_V2_IMPL"), "gateway implementation") - const info = (await api.query.intentsCoprocessor.gateways({ Evm: id })).unwrapOr(undefined) - if (!info) throw new Error(`${label}: no gateway registered for Evm(${id})`) - const gateway = getAddress(info.gateway.toHex()) - - const typehash = await read(client, impl, gatewayAbi, "SELECT_SOLVER_TYPEHASH").catch(() => undefined) - precondition( - typehash === SELECT_SOLVER_TYPEHASH, - `${label}: ${impl} reports SELECT_SOLVER_TYPEHASH ${typehash}`, - ) - const version = await read(client, gateway, gatewayAbi, "version").catch(() => undefined) - precondition( - version === 3n, - `${label}: gateway ${gateway} is at version ${version}; the empty init data assumes 3`, - ) - - const landed = async () => { - const current = await implementationOf(client, gateway) - const live = await read(client, gateway, gatewayAbi, "SELECT_SOLVER_TYPEHASH").catch(() => undefined) - return { - done: same(current, impl) && live === SELECT_SOLVER_TYPEHASH, - progress: `implementation ${current}, typehash ${live}`, - } - } - const before = await landed() - if (before.done) { - log(`${label}: gateway ${gateway} already on ${impl}`) - continue - } - const data = encodeFunctionData({ abi: gatewayAbi, functionName: "upgradeToAndCall", args: [impl, "0x"] }) - console.log(` gateway ${gateway}: ${before.progress}, target ${impl}`) - if (await dispatch(api, signer, label, api.tx.intentsCoprocessor.executeOnGateway({ Evm: id }, data))) { - pending.push(poll(label, landed)) - } - } - await Promise.all(pending) -} - -async function setBundlers(api, signer, configured, allowed) { - const bundlers = bundlerList() - const relayer = getAddress(options.relayer) - const pending = [] - for (const id of selectedChains()) { - const label = `EVM-${id} ${allowed ? "allow" : "clear"}-bundlers` - const client = evm(id) - const { paymaster, registered } = await paymasterOf(api, configured, id) - precondition( - same(registered, paymaster), - `${label}: Paymasters(Evm(${id})) is ${registered}, not ${paymaster}; run register-paymaster first`, - ) - await checkPaymaster(client, id, paymaster) - - const fromProxy = await implementationOf(client, paymaster).catch(() => undefined) - const impl = options["paymaster-impl"] - ? address(options["paymaster-impl"], "paymaster implementation") - : fromProxy - if (!impl) throw new Error(`${label}: cannot read the implementation of ${paymaster}; pass --paymaster-impl`) - precondition(!fromProxy || same(fromProxy, impl), `${label}: ${paymaster} runs ${fromProxy}, not ${impl}`) - - const landed = async () => { - const listed = await read(client, paymaster, paymasterAbi, "getBundlers") - const done = allowed - ? bundlers.every((bundler) => listed.some((entry) => same(entry, bundler))) - : listed.length === 0 - return { done, progress: `getBundlers() [${listed.join(", ")}]` } - } - const before = await landed().catch((error) => ({ - done: false, - progress: `unread: ${error.shortMessage ?? error.message}`, - })) - if (before.done) { - log(`${label}: already ${before.progress}`) - continue - } - const init = bundlerInitData(bundlers, allowed, relayer) - console.log(` paymaster ${paymaster} on ${impl}, ${before.progress}`) - console.log(` init_data ${init}`) - if (await dispatch(api, signer, label, api.tx.intentsCoprocessor.upgradePaymaster({ Evm: id }, impl, init))) { - pending.push(poll(label, landed)) - } - } - await Promise.all(pending) -} - -async function main() { - const [command] = positionals - if (options.help || !command) { - console.log(USAGE) - return - } - options.relayer = address(options.relayer, "--relayer") - if (command === "init-data") return initData() - - const commands = { - metadata: (api) => metadata(api), - "register-paymaster": (api, signer, configured) => registerPaymaster(api, signer, configured), - "upgrade-gateway": (api, signer, configured) => upgradeGateway(api, signer, configured), - "allow-bundlers": (api, signer, configured) => setBundlers(api, signer, configured, true), - "clear-bundlers": (api, signer, configured) => setBundlers(api, signer, configured, false), - } - if (!commands[command]) throw new Error(`Unknown command ${command}\n\n${USAGE}`) - - const live = command !== "metadata" && !dryRun - const seed = live ? sudoSeed() : undefined - const configured = readConfig(options.config) - const api = await connect() - try { - const signer = live ? await sudoSigner(api, seed) : undefined - if (dryRun) log("dry run: nothing is signed or sent") - await commands[command](api, signer, configured) - } finally { - await api.disconnect().catch(() => {}) - } -} - -main().then( - () => process.exit(0), - (error) => { - console.error(error) - process.exit(1) - }, -) From 64830dd9717a717277f7667e6f59a35b5e61137d Mon Sep 17 00:00:00 2001 From: David Salami Date: Fri, 9 Oct 2026 13:12:24 +0000 Subject: [PATCH 06/12] [simplex]: run the testnet swaps through a Pimlico bundler Alchemy, the run's RPC provider, does not serve EntryPoint v0.9 as a bundler, so every scenario timed out without a fill. The workflow now passes E2E_BSC_TESTNET_BUNDLER_URL and E2E_POLYGON_AMOY_BUNDLER_URL, and the run requires them instead of defaulting to the RPC URLs. --- .github/workflows/test-simplex-e2e.yml | 3 +++ docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md | 5 +++++ .../docs/ai/changelog/2026-09-22-testnet-swap-e2e.md | 6 ++++-- sdk/packages/simplex/e2e/env.mjs | 7 ++++--- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-simplex-e2e.yml b/.github/workflows/test-simplex-e2e.yml index c9e0a5ccb4..2a8edce910 100644 --- a/.github/workflows/test-simplex-e2e.yml +++ b/.github/workflows/test-simplex-e2e.yml @@ -95,6 +95,9 @@ 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 }} + # Pimlico. The RPC provider, Alchemy, does not serve EntryPoint v0.9 as a bundler. + E2E_BSC_TESTNET_BUNDLER_URL: ${{ secrets.E2E_BSC_TESTNET_BUNDLER_URL }} + E2E_POLYGON_AMOY_BUNDLER_URL: ${{ secrets.E2E_POLYGON_AMOY_BUNDLER_URL }} 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-06-entrypoint-v09-end-to-end.md b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md index 88266cea3f..d859f520a3 100644 --- a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md +++ b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md @@ -184,6 +184,11 @@ upgrade lands, fills on Chapel and Amoy fail with SDK 2.9.0 and simplex 0.17.0. `SolverAccount` calls the new `select`, which the current gateway implementation rejects, and the SDK refuses a v0.9 bid on a gateway whose format is `address`. +The testnet swaps run (`.github/workflows/test-simplex-e2e.yml`) sends its UserOperations through +Pimlico, which serves v0.9 on both chains. Its bundler URLs, `E2E_BSC_TESTNET_BUNDLER_URL` and +`E2E_POLYGON_AMOY_BUNDLER_URL`, are required and no longer default to the RPC URLs: Alchemy, the +run's RPC provider, answers a v0.9 operation with `EntryPoint version 0.9 is not currently enabled`. + ## Mainnet rollout Upgrading a chain's gateway is a hard cutover for that chain: diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md index 06865545c3..8806c88f2e 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md @@ -92,8 +92,10 @@ by another simplex instance. | Variable | Value | | --- | --- | -| `E2E_BSC_TESTNET_RPC_URL` | BSC Chapel RPC; also the bundler unless `E2E_BSC_TESTNET_BUNDLER_URL` is set | -| `E2E_POLYGON_AMOY_RPC_URL` | Polygon Amoy RPC; also the bundler unless `E2E_POLYGON_AMOY_BUNDLER_URL` is set | +| `E2E_BSC_TESTNET_RPC_URL` | BSC Chapel RPC | +| `E2E_POLYGON_AMOY_RPC_URL` | Polygon Amoy RPC | +| `E2E_BSC_TESTNET_BUNDLER_URL` | BSC Chapel ERC-4337 bundler serving the chain's EntryPoint; CI uses Pimlico (`https://api.pimlico.io/v2/97/rpc?apikey=...`) | +| `E2E_POLYGON_AMOY_BUNDLER_URL` | Polygon Amoy ERC-4337 bundler serving the chain's EntryPoint; CI uses Pimlico (`https://api.pimlico.io/v2/80002/rpc?apikey=...`) | | `E2E_ORDERBOOK_URL` | HyperFX orderbook URL; `/graphql` is appended unless it already ends with it | | `E2E_HYPERBRIDGE_WS_URL` | Hyperbridge (Gargantua) WebSocket endpoint | | `E2E_SOLVER{1,2,3}_PRIVATE_KEY` | solver EVM keys, funded on both chains | diff --git a/sdk/packages/simplex/e2e/env.mjs b/sdk/packages/simplex/e2e/env.mjs index c5de387c38..a71c48777a 100644 --- a/sdk/packages/simplex/e2e/env.mjs +++ b/sdk/packages/simplex/e2e/env.mjs @@ -8,6 +8,10 @@ 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", @@ -25,9 +29,6 @@ export function readEnv() { // The account that tops the solvers' Hyperbridge balances up. Optional: without it a solver // short of BRIDGE is reported rather than funded. env.substrateFunder = process.env.SECRET_PHRASE - // A bundler is optional: Alchemy serves ERC-4337 on the same endpoint as the RPC. - env.bscBundler = process.env.E2E_BSC_TESTNET_BUNDLER_URL || env.bscRpc - env.amoyBundler = process.env.E2E_POLYGON_AMOY_BUNDLER_URL || env.amoyRpc // Simplex posts to the orderbook URL as given; a deployment's base URL serves GraphQL under it. const orderbook = env.orderbook.replace(/\/+$/, "") env.orderbook = orderbook.endsWith("/graphql") ? orderbook : `${orderbook}/graphql` From 8b5534116d9e31da8ee90285302560685aa485ba Mon Sep 17 00:00:00 2001 From: David Salami Date: Fri, 9 Oct 2026 13:51:40 +0000 Subject: [PATCH 07/12] [sdk]: stub the paymaster while a bundler prices a bid's preVerificationGas Pimlico simulates an estimate with paymasterPostOpGasLimit at 2,000,000, which SimplexPaymaster refuses with InvalidPostOpGasLimit. estimateBidPreVerificationGas then failed for every sponsored bid, simplex signed the fill estimate's smaller preVerificationGas, and Pimlico rejected the bid on submission as "preVerificationGas is not enough". The estimate now overrides the paymaster's code, as it already does the solver account's, with a stub that accepts every operation with an empty context. --- .../2026-10-06-entrypoint-v09-end-to-end.md | 8 ++++ .../sdk/src/protocols/intents/GasEstimator.ts | 39 +++++++++++++------ .../src/tests/bidPreVerificationGas.test.ts | 20 +++++++++- ...erification-gas-priced-on-the-signed-op.md | 12 ++++-- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md index d859f520a3..4a051afbdb 100644 --- a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md +++ b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md @@ -84,6 +84,14 @@ same diff, so it no longer needs the call dispatcher address. `GasEstimator.estimateFillOrder` estimates against `getEntryPointAddress`. With no EntryPoint configured, it takes the bundler-failure path. It signs the selection in the gateway's format. +`GasEstimator.estimateBidPreVerificationGas` overrides the code of the bid's paymaster with +`BID_PVG_ESTIMATION_PAYMASTER_CODE`, alongside the solver account's. The stub accepts every +operation with an empty context, so the estimate does not run the paymaster's validation. Pimlico +simulates an estimate with `paymasterPostOpGasLimit` at 2,000,000, outside the 30,000 to 100,000 +that `SimplexPaymaster` accepts. Without the override the estimate fails there, the bid is signed +with the fill estimate's smaller `preVerificationGas`, and Pimlico rejects it as +`preVerificationGas is not enough`. + ### Rundler fee floor For a bundler URL containing neither `pimlico.io` nor `alchemy.com`, `fetchRundlerPriorityFee` asks diff --git a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts index 349de107f9..eb36de16fc 100644 --- a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts +++ b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts @@ -87,15 +87,26 @@ export const NO_BUNDLER_PAYMASTER_POST_OP_GAS = 100_000n /** * Code the bundler runs in place of the solver account while it estimates a bid's - * `preVerificationGas`. It answers `isValidSignature` with the ERC-1271 magic value, so - * Permit2 accepts the paymaster's permit, and every other call with 32 zero bytes, so - * `validateUserOp` succeeds and `execute` does nothing. The estimate then prices the - * bid's real bytes without running a fill that cannot pass in simulation: the session's - * selection is missing and funding calls do not resolve there. + * `preVerificationGas`. It answers `isValidSignature` with the ERC-1271 magic value and + * every other call with 32 zero bytes, so `validateUserOp` succeeds and `execute` does + * nothing. The estimate then prices the bid's real bytes without running a fill that + * cannot pass in simulation: the session's selection is missing and funding calls do not + * resolve there. */ export const BID_PVG_ESTIMATION_ACCOUNT_CODE = "0x60003560e01c631626ba7e1460145760206000f35b631626ba7e60e01b60005260206000f3" as HexString +/** + * Code the bundler runs in place of the bid's paymaster during the same estimate. It + * answers every call with an empty context and zero validation data, so + * `validatePaymasterUserOp` succeeds and the EntryPoint makes no `postOp` call. + * + * A bundler may simulate an estimate with its own gas limits in place of the op's. Pimlico + * sets `paymasterPostOpGasLimit` to 2,000,000, which `SimplexPaymaster` refuses with + * `InvalidPostOpGasLimit`, so the real paymaster fails every estimate there. + */ +export const BID_PVG_ESTIMATION_PAYMASTER_CODE = "0x604060005260606000f3" as HexString + /** * A bid's signature once the user selects it: the order commitment, the solver's signature * and the session key's selection signature. The bundler prices these bytes, so the @@ -525,7 +536,9 @@ export class GasEstimator { * `maxFeePerGas`. * * The solver account's code is overridden with {@link BID_PVG_ESTIMATION_ACCOUNT_CODE} - * for the call, so only the bytes are priced and the fill itself is not simulated. + * and the paymaster's, if the bid has one, with {@link BID_PVG_ESTIMATION_PAYMASTER_CODE} + * for the call, so only the bytes are priced and neither the fill nor the paymaster's + * validation is simulated. * * @param params - The bid's fields as they will be signed. * @returns The `preVerificationGas` to sign, with {@link BID_PVG_HEADROOM_PERCENT} added. @@ -554,13 +567,17 @@ export class GasEstimator { signature: `0x${"ff".repeat(SELECTED_BID_SIGNATURE_BYTES)}` as HexString, } + const stateOverride: Record = { + [solverAccount]: { code: BID_PVG_ESTIMATION_ACCOUNT_CODE }, + } + // `paymasterAndData` opens with the paymaster's address. + if (userOp.paymasterAndData.length >= 42) { + stateOverride[userOp.paymasterAndData.slice(0, 42)] = { code: BID_PVG_ESTIMATION_PAYMASTER_CODE } + } + const estimate = await this.crypto.sendBundler( BundlerMethod.ETH_ESTIMATE_USER_OPERATION_GAS, - [ - CryptoUtils.prepareBundlerCall(userOp), - params.entryPointAddress, - { [solverAccount]: { code: BID_PVG_ESTIMATION_ACCOUNT_CODE } }, - ], + [CryptoUtils.prepareBundlerCall(userOp), params.entryPointAddress, stateOverride], ) return (BigInt(estimate.preVerificationGas) * (100n + BID_PVG_HEADROOM_PERCENT)) / 100n } diff --git a/sdk/packages/sdk/src/tests/bidPreVerificationGas.test.ts b/sdk/packages/sdk/src/tests/bidPreVerificationGas.test.ts index 5bccdb0fa8..9db040c503 100644 --- a/sdk/packages/sdk/src/tests/bidPreVerificationGas.test.ts +++ b/sdk/packages/sdk/src/tests/bidPreVerificationGas.test.ts @@ -1,11 +1,16 @@ import { CryptoUtils } from "@/protocols/intents/CryptoUtils" -import { BID_PVG_ESTIMATION_ACCOUNT_CODE, GasEstimator } from "@/protocols/intents/GasEstimator" +import { + BID_PVG_ESTIMATION_ACCOUNT_CODE, + BID_PVG_ESTIMATION_PAYMASTER_CODE, + GasEstimator, +} from "@/protocols/intents/GasEstimator" import { BundlerMethod, type IntentGatewayContext } from "@/protocols/intents/types" import type { BidPreVerificationGasParams, HexString } from "@/types" import { describe, expect, it, vi } from "vitest" const SOLVER = "0x21426d68a9e5df153fe75ce0fed20173ebcb80ef" as HexString const ENTRY_POINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" as HexString +const PAYMASTER = "0x15b3b03c870c7ef252029c35a12d3b339f5c8d7f" as HexString const BID: BidPreVerificationGasParams = { solverAccount: SOLVER, @@ -75,6 +80,19 @@ describe("GasEstimator.estimateBidPreVerificationGas", () => { signature: `0x${"ff".repeat(162)}` as HexString, }) expect(userOp).toEqual(expected) + expect(stateOverride).toEqual({ + [SOLVER]: { code: BID_PVG_ESTIMATION_ACCOUNT_CODE }, + [PAYMASTER]: { code: BID_PVG_ESTIMATION_PAYMASTER_CODE }, + }) + }) + + it("replaces only the solver account's code when the bid has no paymaster", async () => { + const { estimator, sendBundler } = estimatorAt(5_000_000n) + + await estimator.estimateBidPreVerificationGas({ ...BID, paymasterAndData: undefined }) + + const [, [userOp, , stateOverride]] = sendBundler.mock.calls[0] + expect(userOp.paymaster).toBeUndefined() expect(stateOverride).toEqual({ [SOLVER]: { code: BID_PVG_ESTIMATION_ACCOUNT_CODE } }) }) diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-24-bid-pre-verification-gas-priced-on-the-signed-op.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-24-bid-pre-verification-gas-priced-on-the-signed-op.md index a790506e1a..5e2a2282fd 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-24-bid-pre-verification-gas-priced-on-the-signed-op.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-24-bid-pre-verification-gas-priced-on-the-signed-op.md @@ -20,10 +20,14 @@ pair (about a third of its calldata on mainnet) and is bundled below its max fee - It is priced at the bundle's price: the latest base fee plus the op's priority fee, capped at its `maxFeePerGas`. - A state override replaces the solver account's code with `BID_PVG_ESTIMATION_ACCOUNT_CODE`, - which answers `isValidSignature` with the ERC-1271 magic value (Permit2 checks the paymaster's - permit through it) and every other call with 32 zero bytes. Validation passes and `execute` - does nothing, so only the op's bytes are priced; the fill itself cannot pass in simulation - without the session's selection, and funding calls do not resolve there. + which answers `isValidSignature` with the ERC-1271 magic value and every other call with 32 + zero bytes. Validation passes and `execute` does nothing, so only the op's bytes are priced; + the fill itself cannot pass in simulation without the session's selection, and funding calls + do not resolve there. +- The same override replaces the paymaster's code with `BID_PVG_ESTIMATION_PAYMASTER_CODE`, which + accepts every operation with an empty context. A bundler may simulate with its own gas limits + in place of the op's, and `SimplexPaymaster` refuses a `paymasterPostOpGasLimit` outside 30,000 + to 100,000. - `BID_PVG_HEADROOM_PERCENT` (10%) is added for L1 prices moving before the bid executes. If the estimate fails, the bid keeps the fill estimate's `preVerificationGas` and logs a warning. From 57975ee927423c42a05220e6fa0b9119b61b8575 Mon Sep 17 00:00:00 2001 From: David Salami Date: Fri, 9 Oct 2026 14:14:43 +0000 Subject: [PATCH 08/12] [simplex]: convert bid validity at the chain's measured block time bidValidUntilBlock converted bidValiditySeconds to blocks with viem's Chain.blockTime, or 2 seconds for a chain that declares none. BSC Chapel and Polygon Amoy declare none and BSC declares 0.75s against a real 0.45s, so bids there reverted FillExpired early: after about 142 of 600 configured seconds on Chapel. The block time is now measured over the last 1,000 blocks and reused per chain for 10 minutes. Where the chain declares a figure the faster of the two is used, and unreadable blocks fall back to the declared figure, then to 2 seconds. --- .../2026-10-06-entrypoint-v09-end-to-end.md | 8 ++ ...y-an-on-chain-expiry-bidvalidityseconds.md | 4 +- ...igured-in-seconds-and-written-in-blocks.md | 10 ++- .../services/ContractInteractionService.ts | 70 ++++++++++++++-- .../services/bid-validity-config.test.ts | 82 +++++++++++++++++-- 5 files changed, 156 insertions(+), 18 deletions(-) diff --git a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md index 4a051afbdb..b0605f1a84 100644 --- a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md +++ b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md @@ -165,6 +165,14 @@ claims the order (`open` to `resizing`), withdraws the posting, and posts it aga the current EntryPoint. These count in `ReconcileReport.reposted`. A fill's `resize` waits for an in-flight re-sign rather than posting alongside it. +### Bid validity + +`bidValidUntilBlock` converts `bidValiditySeconds` to blocks at the chain's measured block time, taken +from the head and the block 1,000 before it and reused per chain for 10 minutes, where it used viem's +`Chain.blockTime` or 2 seconds. Where the chain declares a block time, the faster of the two is used. +Chapel (0.45s), Amoy (1s) and BSC (0.45s against a declared 0.75s) were converted at too slow a block +time, so their bids reverted `FillExpired` early: after about 142 of 600 configured seconds on Chapel. + ## Indexer - `ENTRY_POINTS` (`src/utils/userOp.helpers.ts`) includes v0.9, so fills inside v0.9 bundles are diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-08-27-bids-carry-an-on-chain-expiry-bidvalidityseconds.md b/sdk/packages/simplex/docs/ai/changelog/2026-08-27-bids-carry-an-on-chain-expiry-bidvalidityseconds.md index aa5276df16..34f2945fde 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-08-27-bids-carry-an-on-chain-expiry-bidvalidityseconds.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-08-27-bids-carry-an-on-chain-expiry-bidvalidityseconds.md @@ -10,8 +10,8 @@ if the rate moved against us — a written option on this filler's inventory, at (USDC/CNGN) are the worst case, since the naira reprices in steps rather than drifting. Operators configure seconds because that is the unit the risk is in; the contract compares block numbers, so the -value is converted per destination chain from the chain's nominal block time (`Chain.blockTime`, milliseconds in -viem), with a 30-second discovery allowance added before the conversion and the result rounded up — seconds rather +value is converted per destination chain from the chain's measured block time (see the bid tenor decision note), with +a 30-second discovery allowance added before the conversion and the result rounded up — seconds rather than a block count, because the lag between reading the head and the fill landing is wall-clock and does not scale with block time. diff --git a/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md b/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md index 754f5bace3..69320aa0dd 100644 --- a/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md +++ b/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md @@ -10,6 +10,14 @@ mean something different on every chain, and price risk is denominated in time, keeps both sides in their natural unit. Rounding is deliberately up: erring long costs a slightly stale quote, erring short silently drops bids we would have won. +The block time is measured: the timestamps of the head and of the block 1,000 before it, reused per chain for 10 +minutes. viem's nominal `Chain.blockTime` is not enough on its own. It goes stale when a chain speeds up (BSC declares +0.75s and makes a block every 0.45s) and some chains declare none (BSC Chapel at 0.45s and Polygon Amoy at 1s, which +fell to a 2s guess), and converting at too slow a block time ends the bid early: 600 configured seconds lasted about +142 on Chapel. Where a chain declares a figure, the faster of the two is used, so a stalled stretch inside the sample +cannot shorten a bid either. Blocks that cannot be read fall back to the declared figure, then to 2s, and the next bid +measures again. + 300 seconds is chosen to cover the quote-to-fill path and little more: cross-chain confirmation waits reach roughly 180s on the deepest default policies, so 5 minutes clears the mechanical part of the round trip while keeping the window over which the quoted price is a firm commitment short. On a volatile pair that is the number that matters — @@ -26,7 +34,7 @@ short an option, and the two should not be assumed to move together. The window carries a 30-second discovery allowance on top of the configured validity, added before the conversion so the rounding happens once. The head is read before the bid is built, signed, submitted to Hyperbridge and finally -discovered and selected, so it is already behind by the time a fill lands, and `Chain.blockTime` is nominal rather +discovered and selected, so it is already behind by the time a fill lands, and the block time is an average rather than exact. It is denominated in seconds rather than blocks because that lag is wall-clock and does not scale with block time — diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index d84ada5c0d..92156dc47d 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -85,12 +85,26 @@ const LIMIT_ORDER_PRE_VERIFICATION_GAS = 50_000n */ const BID_DISCOVERY_PAD_SECONDS = 30 +/** + * How many blocks back a chain's block time is measured over. Block timestamps are whole + * seconds, so the span has to be long enough for one second not to matter: 1,000 blocks is + * 250s on Arbitrum. + */ +const BLOCK_TIME_SAMPLE_BLOCKS = 1_000n + +/** How long a measured block time is reused. It moves when a chain changes how it produces blocks. */ +const BLOCK_TIME_TTL_MS = 10 * 60 * 1000 + +/** Assumed when a chain declares no block time and its blocks cannot be read. */ +const FALLBACK_BLOCK_TIME_SECONDS = 2 + export class ContractInteractionService { private configService: FillerConfigService public cacheService: CacheService private logger: Logger private sdkHelperCache: Map = new Map() + private blockTimes: Map }> = new Map() private solverAccountAddress: HexString private signer: Signer @@ -897,22 +911,62 @@ export class ContractInteractionService { * * Operators configure seconds because that is the unit the risk is actually in, but the * contract compares against block numbers so `order.deadline` and this read the same - * clock. The conversion uses the chain's nominal block time; it is deliberately rounded - * up, since erring long costs a slightly stale quote while erring short silently drops - * winnable bids. + * clock. The conversion uses the chain's block time from {@link blockTimeSeconds}; it is + * deliberately rounded up, since erring long costs a slightly stale quote while erring + * short silently drops winnable bids. */ private async bidValidUntilBlock(chain: string): Promise { const client = this.clientManager.getPublicClient(chain) - const currentBlock = await client.getBlockNumber() - // viem documents `Chain.blockTime` in milliseconds (Ethereum 12000, Base 2000, - // Arbitrum 250), hence the conversion; the fallback is already in seconds. - const blockTimeMs = client.chain?.blockTime - const blockTimeSec = blockTimeMs ? blockTimeMs / 1000 : 2 + const [currentBlock, blockTimeSec] = await Promise.all([client.getBlockNumber(), this.blockTimeSeconds(chain)]) // Converted once, so the rounding happens in one place rather than twice. const windowSec = this.configService.getBidValiditySeconds() + BID_DISCOVERY_PAD_SECONDS return currentBlock + BigInt(Math.ceil(windowSec / blockTimeSec)) } + /** + * Seconds per block on `chain`, measured over its last {@link BLOCK_TIME_SAMPLE_BLOCKS} + * blocks and reused for {@link BLOCK_TIME_TTL_MS}. + * + * The nominal `Chain.blockTime` cannot be relied on alone: it goes stale when a chain + * speeds up (BSC makes a block every 0.45s against a declared 0.75s) and some chains + * declare none. Converting at a slower block time than the chain's ends a bid early. + */ + private blockTimeSeconds(chain: string): Promise { + const cached = this.blockTimes.get(chain) + if (cached && Date.now() - cached.measuredAt < BLOCK_TIME_TTL_MS) return cached.seconds + const seconds = this.measureBlockTimeSeconds(chain) + this.blockTimes.set(chain, { measuredAt: Date.now(), seconds }) + return seconds + } + + /** + * Takes the faster of the measured and the nominal block time, so that a stalled or idle + * stretch in the sample never shortens a bid. Falls back to the nominal figure, then to + * {@link FALLBACK_BLOCK_TIME_SECONDS}, when the blocks cannot be read, and leaves that + * answer uncached so the next bid measures again. + */ + private async measureBlockTimeSeconds(chain: string): Promise { + const client = this.clientManager.getPublicClient(chain) + // viem documents `Chain.blockTime` in milliseconds (Ethereum 12000, Base 2000, Arbitrum 250). + const nominalMs = client.chain?.blockTime + const nominal = nominalMs ? nominalMs / 1000 : undefined + try { + const head = await client.getBlock({ blockTag: "latest" }) + const span = head.number < BLOCK_TIME_SAMPLE_BLOCKS ? head.number : BLOCK_TIME_SAMPLE_BLOCKS + const tail = await client.getBlock({ blockNumber: head.number - span }) + const measured = Number(head.timestamp - tail.timestamp) / Number(span) + if (!Number.isFinite(measured) || measured <= 0) { + throw new Error(`${span} blocks span ${head.timestamp - tail.timestamp}s`) + } + return nominal ? Math.min(measured, nominal) : measured + } catch (err) { + this.blockTimes.delete(chain) + const seconds = nominal ?? FALLBACK_BLOCK_TIME_SECONDS + this.logger.warn({ err, chain, seconds }, "Could not measure the block time; using the nominal one") + return seconds + } + } + /** * Builds the signed, never-executed UserOperation that carries one limit order * to the HyperFX orderbook. diff --git a/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts b/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts index 64d02ce971..d138e52e2c 100644 --- a/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts +++ b/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts @@ -33,18 +33,29 @@ describe("bidValiditySeconds", () => { describe("bidValidUntilBlock", () => { const HEAD = 1_000_000n + const HEAD_TIMESTAMP = 1_800_000_000n + const SAMPLE_BLOCKS = 1_000n /** - * `blockTime` is milliseconds in viem (Ethereum 12000, Base 2000, Arbitrum 250), so the - * helper divides by 1000. Passing `undefined` exercises the seconds-denominated fallback. + * `nominalMs` is the block time the chain declares, in milliseconds as viem has it (Ethereum + * 12000, Base 2000, Arbitrum 250), or `undefined` for a chain that declares none. + * `actualSec` is what its recent blocks show and defaults to the declared figure; `null` + * makes the blocks unreadable. * * The window is the configured validity plus a 30s discovery allowance, converted once. */ - function service(blockTimeMs: number | undefined, bidValiditySeconds?: number) { + function setup(nominalMs: number | undefined, bidValiditySeconds?: number, actualSec?: number | null) { + const blockTimeSec = actualSec === undefined ? (nominalMs ?? 2000) / 1000 : actualSec + const getBlock = vi.fn().mockImplementation(async (args: { blockTag?: string; blockNumber?: bigint }) => { + if (blockTimeSec === null) throw new Error("block not available") + const number = args.blockNumber ?? HEAD + return { number, timestamp: HEAD_TIMESTAMP - BigInt(Math.round(Number(HEAD - number) * blockTimeSec)) } + }) const clientManager = { getPublicClient: vi.fn().mockReturnValue({ getBlockNumber: vi.fn().mockResolvedValue(HEAD), - chain: blockTimeMs === undefined ? {} : { blockTime: blockTimeMs }, + getBlock, + chain: nominalMs === undefined ? {} : { blockTime: nominalMs }, }), } as any const configService = { @@ -58,9 +69,13 @@ describe("bidValidUntilBlock", () => { { address: "0x1111111111111111111111111111111111111111" } as any, { getTokenDecimals: () => undefined, setTokenDecimals: () => {} } as any, ) - return (svc as any).bidValidUntilBlock("EVM-8453") as Promise + const validUntil = (chain = "EVM-8453") => (svc as any).bidValidUntilBlock(chain) as Promise + return { validUntil, getBlock } } + const service = (nominalMs: number | undefined, bidValiditySeconds?: number, actualSec?: number | null) => + setup(nominalMs, bidValiditySeconds, actualSec).validUntil() + it("converts the validity window plus the discovery pad at the chain's block time", async () => { // Base: 2000ms blocks => (300 + 30)s is 165 blocks. await expect(service(2000)).resolves.toBe(HEAD + 165n) @@ -89,7 +104,60 @@ describe("bidValidUntilBlock", () => { await expect(service(750, 100)).resolves.toBe(HEAD + 174n) }) - it("falls back to a 2-second block time when the chain does not declare one", async () => { - await expect(service(undefined)).resolves.toBe(HEAD + 165n) + it("uses the measured block time when the chain runs faster than it declares", async () => { + // BSC declares 750ms and makes a block every 0.45s: 330s is 733.33 blocks, not 440. + await expect(service(750, 300, 0.45)).resolves.toBe(HEAD + 734n) + }) + + it("measures a chain that declares no block time", async () => { + // BSC Chapel at 0.45s and Polygon Amoy at 1s, neither with a declared figure. + await expect(service(undefined, 300, 0.45)).resolves.toBe(HEAD + 734n) + await expect(service(undefined, 300, 1)).resolves.toBe(HEAD + 330n) + }) + + it("keeps the declared block time when the sample ran slower", async () => { + // A stall in the last 1,000 blocks must not shorten the bid once the chain recovers. + await expect(service(2000, 300, 3)).resolves.toBe(HEAD + 165n) + }) + + it("samples the last 1,000 blocks", async () => { + const { validUntil, getBlock } = setup(2000) + await validUntil() + + expect(getBlock.mock.calls).toEqual([[{ blockTag: "latest" }], [{ blockNumber: HEAD - SAMPLE_BLOCKS }]]) + }) + + it("measures a chain once and reuses the figure", async () => { + const { validUntil, getBlock } = setup(undefined, 300, 0.45) + + await expect(Promise.all([validUntil(), validUntil()])).resolves.toEqual([HEAD + 734n, HEAD + 734n]) + await validUntil() + + expect(getBlock).toHaveBeenCalledTimes(2) + }) + + it("measures each chain separately", async () => { + const { validUntil, getBlock } = setup(undefined, 300, 0.45) + + await validUntil("EVM-97") + await validUntil("EVM-80002") + + expect(getBlock).toHaveBeenCalledTimes(4) + }) + + it("falls back to the declared block time when the blocks cannot be read", async () => { + await expect(service(750, 300, null)).resolves.toBe(HEAD + 440n) + }) + + it("falls back to a 2-second block time when there is neither a measurement nor a declared figure", async () => { + await expect(service(undefined, 300, null)).resolves.toBe(HEAD + 165n) + }) + + it("measures again after a failed measurement", async () => { + const { validUntil, getBlock } = setup(undefined, 300, 0.45) + getBlock.mockRejectedValueOnce(new Error("rate limited")) + + await expect(validUntil()).resolves.toBe(HEAD + 165n) + await expect(validUntil()).resolves.toBe(HEAD + 734n) }) }) From b24a2498530728d378b85002ae7039fafd6fec10 Mon Sep 17 00:00:00 2001 From: David Salami Date: Fri, 9 Oct 2026 14:26:58 +0000 Subject: [PATCH 09/12] [simplex]: measure the block time once, when simplex starts The bid validity conversion re-measured each chain's block time every 10 minutes. A chain's block time only moves at a hard fork, so each configured chain is now measured once in the startup warm-up and the figure is kept until simplex stops. A chain that could not be measured then, or was added later, is measured at its first bid. --- .../2026-10-06-entrypoint-v09-end-to-end.md | 12 +-- ...igured-in-seconds-and-written-in-blocks.md | 15 ++-- .../services/ContractInteractionService.ts | 87 +++++++++++-------- .../services/bid-validity-config.test.ts | 72 ++++++++++++--- 4 files changed, 125 insertions(+), 61 deletions(-) diff --git a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md index b0605f1a84..1010fa0e41 100644 --- a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md +++ b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md @@ -167,11 +167,13 @@ in-flight re-sign rather than posting alongside it. ### Bid validity -`bidValidUntilBlock` converts `bidValiditySeconds` to blocks at the chain's measured block time, taken -from the head and the block 1,000 before it and reused per chain for 10 minutes, where it used viem's -`Chain.blockTime` or 2 seconds. Where the chain declares a block time, the faster of the two is used. -Chapel (0.45s), Amoy (1s) and BSC (0.45s against a declared 0.75s) were converted at too slow a block -time, so their bids reverted `FillExpired` early: after about 142 of 600 configured seconds on Chapel. +`bidValidUntilBlock` converts `bidValiditySeconds` to blocks at the chain's measured block time, where +it used viem's `Chain.blockTime` or 2 seconds. Each configured chain is measured once, when simplex +starts, from the head and the block 1,000 before it, and the figure is kept until simplex stops. A +chain that could not be measured then, or was added later, is measured at its first bid. Where the +chain declares a block time, the faster of the two is used. Chapel (0.45s), Amoy (1s) and BSC (0.45s +against a declared 0.75s) were converted at too slow a block time, so their bids reverted +`FillExpired` early: after about 142 of 600 configured seconds on Chapel. ## Indexer diff --git a/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md b/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md index 69320aa0dd..b2d5976686 100644 --- a/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md +++ b/sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md @@ -10,13 +10,14 @@ mean something different on every chain, and price risk is denominated in time, keeps both sides in their natural unit. Rounding is deliberately up: erring long costs a slightly stale quote, erring short silently drops bids we would have won. -The block time is measured: the timestamps of the head and of the block 1,000 before it, reused per chain for 10 -minutes. viem's nominal `Chain.blockTime` is not enough on its own. It goes stale when a chain speeds up (BSC declares -0.75s and makes a block every 0.45s) and some chains declare none (BSC Chapel at 0.45s and Polygon Amoy at 1s, which -fell to a 2s guess), and converting at too slow a block time ends the bid early: 600 configured seconds lasted about -142 on Chapel. Where a chain declares a figure, the faster of the two is used, so a stalled stretch inside the sample -cannot shorten a bid either. Blocks that cannot be read fall back to the declared figure, then to 2s, and the next bid -measures again. +The block time is measured once per chain, when simplex starts, from the timestamps of the head and of the block +1,000 before it, and kept until simplex stops: a chain that changes its block time is picked up at the next restart. +viem's nominal `Chain.blockTime` is not enough on its own. It goes stale when a chain speeds up (BSC declares 0.75s +and makes a block every 0.45s) and some chains declare none (BSC Chapel at 0.45s and Polygon Amoy at 1s, which fell to +a 2s guess), and converting at too slow a block time ends the bid early: 600 configured seconds lasted about 142 on +Chapel. Where a chain declares a figure, the faster of the two is used, so a stalled stretch inside the sample cannot +shorten a bid either. Blocks that cannot be read fall back to the declared figure, then to 2s. That answer is not +kept: a chain left unmeasured at startup, or added afterwards, is measured at its first bid. 300 seconds is chosen to cover the quote-to-fill path and little more: cross-chain confirmation waits reach roughly 180s on the deepest default policies, so 5 minutes clears the mechanical part of the round trip while keeping the diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index 92156dc47d..8c31ea1188 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -1,4 +1,4 @@ -import { formatUnits, encodeFunctionData, formatEther } from "viem" +import { formatUnits, encodeFunctionData, formatEther, type PublicClient } from "viem" import { ADDRESS_ZERO, CryptoUtils, @@ -92,9 +92,6 @@ const BID_DISCOVERY_PAD_SECONDS = 30 */ const BLOCK_TIME_SAMPLE_BLOCKS = 1_000n -/** How long a measured block time is reused. It moves when a chain changes how it produces blocks. */ -const BLOCK_TIME_TTL_MS = 10 * 60 * 1000 - /** Assumed when a chain declares no block time and its blocks cannot be read. */ const FALLBACK_BLOCK_TIME_SECONDS = 2 @@ -104,7 +101,7 @@ export class ContractInteractionService { private logger: Logger private sdkHelperCache: Map = new Map() - private blockTimes: Map }> = new Map() + private blockTimes: Map> = new Map() private solverAccountAddress: HexString private signer: Signer @@ -189,6 +186,17 @@ export class ContractInteractionService { try { const chainIds = this.configService.getConfiguredChainIds() const chainNames = chainIds.map((id) => `EVM-${id}`) + // First and together: a bid needs its chain's block time, and the reads below are slower. + await Promise.all( + chainNames.map(async (chainName) => { + try { + await this.blockTimeSeconds(chainName) + } catch (err) { + this.logger.warn({ err, chain: chainName }, "Could not measure the block time at startup") + } + }), + ) + for (const chainName of chainNames) { try { await this.getFeeTokenWithDecimals(chainName) @@ -924,47 +932,52 @@ export class ContractInteractionService { } /** - * Seconds per block on `chain`, measured over its last {@link BLOCK_TIME_SAMPLE_BLOCKS} - * blocks and reused for {@link BLOCK_TIME_TTL_MS}. + * Seconds per block on `chain`. Measured once, when simplex starts ({@link initCache}), and + * kept until it stops. A chain with no measurement yet, because it was added later or its + * blocks could not be read at startup, is measured at its first bid. * * The nominal `Chain.blockTime` cannot be relied on alone: it goes stale when a chain * speeds up (BSC makes a block every 0.45s against a declared 0.75s) and some chains - * declare none. Converting at a slower block time than the chain's ends a bid early. + * declare none. Converting at a slower block time than the chain's ends a bid early, so + * the faster of the measured and the declared figure is used, and a stalled or idle + * stretch in the sample never shortens a bid. + * + * Blocks that cannot be read fall back to the declared figure, then to + * {@link FALLBACK_BLOCK_TIME_SECONDS}. That answer is not kept. */ private blockTimeSeconds(chain: string): Promise { - const cached = this.blockTimes.get(chain) - if (cached && Date.now() - cached.measuredAt < BLOCK_TIME_TTL_MS) return cached.seconds - const seconds = this.measureBlockTimeSeconds(chain) - this.blockTimes.set(chain, { measuredAt: Date.now(), seconds }) - return seconds - } + const known = this.blockTimes.get(chain) + if (known) return known - /** - * Takes the faster of the measured and the nominal block time, so that a stalled or idle - * stretch in the sample never shortens a bid. Falls back to the nominal figure, then to - * {@link FALLBACK_BLOCK_TIME_SECONDS}, when the blocks cannot be read, and leaves that - * answer uncached so the next bid measures again. - */ - private async measureBlockTimeSeconds(chain: string): Promise { const client = this.clientManager.getPublicClient(chain) // viem documents `Chain.blockTime` in milliseconds (Ethereum 12000, Base 2000, Arbitrum 250). - const nominalMs = client.chain?.blockTime - const nominal = nominalMs ? nominalMs / 1000 : undefined - try { - const head = await client.getBlock({ blockTag: "latest" }) - const span = head.number < BLOCK_TIME_SAMPLE_BLOCKS ? head.number : BLOCK_TIME_SAMPLE_BLOCKS - const tail = await client.getBlock({ blockNumber: head.number - span }) - const measured = Number(head.timestamp - tail.timestamp) / Number(span) - if (!Number.isFinite(measured) || measured <= 0) { - throw new Error(`${span} blocks span ${head.timestamp - tail.timestamp}s`) - } - return nominal ? Math.min(measured, nominal) : measured - } catch (err) { - this.blockTimes.delete(chain) - const seconds = nominal ?? FALLBACK_BLOCK_TIME_SECONDS - this.logger.warn({ err, chain, seconds }, "Could not measure the block time; using the nominal one") - return seconds + const declaredMs = client.chain?.blockTime + const declared = declaredMs ? declaredMs / 1000 : undefined + const seconds = this.measureBlockTimeSeconds(client) + .then((measured) => (declared ? Math.min(measured, declared) : measured)) + .catch((err) => { + this.blockTimes.delete(chain) + const fallback = declared ?? FALLBACK_BLOCK_TIME_SECONDS + this.logger.warn( + { err, chain, seconds: fallback }, + "Could not measure the block time; using the declared one", + ) + return fallback + }) + this.blockTimes.set(chain, seconds) + return seconds + } + + /** The average block time over the chain's last {@link BLOCK_TIME_SAMPLE_BLOCKS} blocks, in seconds. */ + private async measureBlockTimeSeconds(client: PublicClient): Promise { + const head = await client.getBlock({ blockTag: "latest" }) + const span = head.number < BLOCK_TIME_SAMPLE_BLOCKS ? head.number : BLOCK_TIME_SAMPLE_BLOCKS + const tail = await client.getBlock({ blockNumber: head.number - span }) + const measured = Number(head.timestamp - tail.timestamp) / Number(span) + if (!Number.isFinite(measured) || measured <= 0) { + throw new Error(`${span} blocks span ${head.timestamp - tail.timestamp}s`) } + return measured } /** diff --git a/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts b/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts index d138e52e2c..06252df07f 100644 --- a/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts +++ b/sdk/packages/simplex/src/tests/services/bid-validity-config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest" +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest" import { FillerConfigService } from "@/services/FillerConfigService" import { ContractInteractionService } from "@/services/ContractInteractionService" @@ -40,14 +40,21 @@ describe("bidValidUntilBlock", () => { * `nominalMs` is the block time the chain declares, in milliseconds as viem has it (Ethereum * 12000, Base 2000, Arbitrum 250), or `undefined` for a chain that declares none. * `actualSec` is what its recent blocks show and defaults to the declared figure; `null` - * makes the blocks unreadable. + * makes the blocks unreadable. `chains` are the chain ids simplex is configured with, which it + * measures as it starts, and the first `failedReads` block reads fail. * * The window is the configured validity plus a 30s discovery allowance, converted once. */ - function setup(nominalMs: number | undefined, bidValiditySeconds?: number, actualSec?: number | null) { + function setup( + nominalMs: number | undefined, + bidValiditySeconds?: number, + actualSec?: number | null, + { chains = [], failedReads = 0 }: { chains?: number[]; failedReads?: number } = {}, + ) { const blockTimeSec = actualSec === undefined ? (nominalMs ?? 2000) / 1000 : actualSec + let reads = 0 const getBlock = vi.fn().mockImplementation(async (args: { blockTag?: string; blockNumber?: bigint }) => { - if (blockTimeSec === null) throw new Error("block not available") + if (blockTimeSec === null || ++reads <= failedReads) throw new Error("block not available") const number = args.blockNumber ?? HEAD return { number, timestamp: HEAD_TIMESTAMP - BigInt(Math.round(Number(HEAD - number) * blockTimeSec)) } }) @@ -60,8 +67,10 @@ describe("bidValidUntilBlock", () => { } as any const configService = { getBidValiditySeconds: () => bidValiditySeconds ?? 300, - getConfiguredChainIds: () => [], + getConfiguredChainIds: () => chains, getHostAddress: () => "0x620128E2B19193d6Bd244a3AC8D3bBa0541B19c3", + getUsdcAsset: () => "0x", + getUsdtAsset: () => "0x", } as any const svc = new ContractInteractionService( clientManager, @@ -70,9 +79,18 @@ describe("bidValidUntilBlock", () => { { getTokenDecimals: () => undefined, setTokenDecimals: () => {} } as any, ) const validUntil = (chain = "EVM-8453") => (svc as any).bidValidUntilBlock(chain) as Promise - return { validUntil, getBlock } + return { svc, validUntil, getBlock } } + // The startup warm-up also reads token decimals, which these tests leave out. + beforeEach(() => { + vi.spyOn(ContractInteractionService.prototype, "getFeeTokenWithDecimals").mockResolvedValue({} as any) + vi.spyOn(ContractInteractionService.prototype, "getTokenDecimals").mockResolvedValue(18) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + const service = (nominalMs: number | undefined, bidValiditySeconds?: number, actualSec?: number | null) => setup(nominalMs, bidValiditySeconds, actualSec).validUntil() @@ -127,8 +145,35 @@ describe("bidValidUntilBlock", () => { expect(getBlock.mock.calls).toEqual([[{ blockTag: "latest" }], [{ blockNumber: HEAD - SAMPLE_BLOCKS }]]) }) - it("measures a chain once and reuses the figure", async () => { + it("measures every configured chain when simplex starts, and not again at a bid", async () => { + const { svc, validUntil, getBlock } = setup(undefined, 300, 0.45, { chains: [97, 80002] }) + await svc.initCache() + expect(getBlock).toHaveBeenCalledTimes(4) + + await expect(validUntil("EVM-97")).resolves.toBe(HEAD + 734n) + await expect(validUntil("EVM-80002")).resolves.toBe(HEAD + 734n) + + expect(getBlock).toHaveBeenCalledTimes(4) + }) + + it("keeps the startup figure for as long as simplex runs", async () => { + vi.useFakeTimers() + try { + const { svc, validUntil, getBlock } = setup(undefined, 300, 0.45, { chains: [97] }) + await svc.initCache() + + vi.advanceTimersByTime(7 * 24 * 60 * 60 * 1000) + await expect(validUntil("EVM-97")).resolves.toBe(HEAD + 734n) + + expect(getBlock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it("measures a chain that was not there at startup once, at its first bid", async () => { const { validUntil, getBlock } = setup(undefined, 300, 0.45) + expect(getBlock).not.toHaveBeenCalled() await expect(Promise.all([validUntil(), validUntil()])).resolves.toEqual([HEAD + 734n, HEAD + 734n]) await validUntil() @@ -153,11 +198,14 @@ describe("bidValidUntilBlock", () => { await expect(service(undefined, 300, null)).resolves.toBe(HEAD + 165n) }) - it("measures again after a failed measurement", async () => { - const { validUntil, getBlock } = setup(undefined, 300, 0.45) - getBlock.mockRejectedValueOnce(new Error("rate limited")) + it("measures at the first bid when the blocks could not be read at startup", async () => { + const { svc, validUntil, getBlock } = setup(undefined, 300, 0.45, { chains: [97], failedReads: 1 }) + await svc.initCache() + + await expect(validUntil("EVM-97")).resolves.toBe(HEAD + 734n) + await validUntil("EVM-97") - await expect(validUntil()).resolves.toBe(HEAD + 165n) - await expect(validUntil()).resolves.toBe(HEAD + 734n) + // One failed read at startup, then the head and the earlier block once. + expect(getBlock).toHaveBeenCalledTimes(3) }) }) From 7ac704be9e23f33560fbd0a60bb20d5adaeda99c Mon Sep 17 00:00:00 2001 From: David Salami Date: Fri, 9 Oct 2026 15:22:47 +0000 Subject: [PATCH 10/12] [docs, simplex]: suggest the Hyperbridge bundler in place of Alchemy The simplex guides and the example config showed Alchemy's RPC endpoint as each chain's bundlerUrl. They now use the Hyperbridge bundler at https://bundler.polytope.technology/, listed for Ethereum, Base, Arbitrum, Polygon and BNB Chain in a new Bundler section of the configuration page. --- .../evm/simplex/api/configuration.mdx | 4 ++-- .../developers/evm/simplex/api/setup.mdx | 4 ++-- .../developers/evm/simplex/configuration.mdx | 22 ++++++++++++++++++- .../developers/evm/simplex/confirmations.mdx | 2 +- .../developers/evm/simplex/installation.mdx | 11 +++++----- .../evm/simplex/troubleshooting.mdx | 2 +- .../simplex/filler-config-example.toml | 13 ++++++----- 7 files changed, 41 insertions(+), 17 deletions(-) diff --git a/docs/content/developers/evm/simplex/api/configuration.mdx b/docs/content/developers/evm/simplex/api/configuration.mdx index ea16f61ddf..f00a9c23f6 100644 --- a/docs/content/developers/evm/simplex/api/configuration.mdx +++ b/docs/content/developers/evm/simplex/api/configuration.mdx @@ -101,7 +101,7 @@ 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://base-mainnet.g.alchemy.com/v2/KEY", + "bundlerUrl": "https://bundler.polytope.technology/base", "watchOnly": false } ] }' @@ -112,7 +112,7 @@ 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. | +| `bundlerUrl` | Required. The bundler that submits fill UserOperations, such as 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. diff --git a/docs/content/developers/evm/simplex/api/setup.mdx b/docs/content/developers/evm/simplex/api/setup.mdx index 2827468cfb..2852432f9f 100644 --- a/docs/content/developers/evm/simplex/api/setup.mdx +++ b/docs/content/developers/evm/simplex/api/setup.mdx @@ -73,7 +73,7 @@ Checks that a bundler answers `eth_supportedEntryPoints` and, when `chainId` is ```bash lineNumbers curl -s -X POST $SIMPLEX/api/setup/validate-bundler \ -H 'Content-Type: application/json' -H 'X-Simplex-UI: 1' \ - -d '{ "url": "https://api.pimlico.io/v2/8453/rpc?apikey=KEY", "chainId": 8453 }' + -d '{ "url": "https://bundler.polytope.technology/base", "chainId": 8453 }' # → { "ok": true, "entryPoints": ["0x…"] } ``` @@ -84,7 +84,7 @@ The response carries a `warning` when: ### POST /api/setup/validate-alchemy-key -Checks an Alchemy API key, and derives an RPC URL for each mainnet chain Alchemy serves. Alchemy serves bundler methods on the same endpoint, so each chain's `bundlerUrl` is the same URL. +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. When you save the chain, set its bundler to the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler) instead. ```bash lineNumbers curl -s -X POST $SIMPLEX/api/setup/validate-alchemy-key \ diff --git a/docs/content/developers/evm/simplex/configuration.mdx b/docs/content/developers/evm/simplex/configuration.mdx index 1c59456513..f3e7a393ae 100644 --- a/docs/content/developers/evm/simplex/configuration.mdx +++ b/docs/content/developers/evm/simplex/configuration.mdx @@ -1,6 +1,6 @@ --- title: Configuration -description: The [simplex] block, signer backends, the [orderbook] section, EIP-7702 delegation and gas payment, allowlists, and scan intervals. +description: The [simplex] block, signer backends, the [orderbook] section, bundlers, EIP-7702 delegation and gas payment, allowlists, and scan intervals. --- # Configuration @@ -137,6 +137,26 @@ signWith = "0xYourWalletAddress" Create an API keypair in the Turnkey Dashboard under user details. The signWith field is any Ethereum address from your Turnkey wallet. See the Turnkey documentation for setup instructions. +## 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: + +| Chain | `bundlerUrl` | +|---|---| +| 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` | + +```toml lineNumbers +[[chains]] # Base +rpcUrls = ["https://..."] +bundlerUrl = "https://bundler.polytope.technology/base" +``` + +On any other chain, use a bundler that serves the EntryPoint the chain's solver account uses. Simplex [refuses one that does not](/developers/evm/simplex/troubleshooting). + ## 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. diff --git a/docs/content/developers/evm/simplex/confirmations.mdx b/docs/content/developers/evm/simplex/confirmations.mdx index 58a5cbf634..03a1e4a754 100644 --- a/docs/content/developers/evm/simplex/confirmations.mdx +++ b/docs/content/developers/evm/simplex/confirmations.mdx @@ -60,7 +60,7 @@ rpcUrls = [ "https://mainnet.infura.io/v3/YOUR_KEY", "https://rpc.ankr.com/eth/YOUR_KEY", ] -bundlerUrl = "https://..." +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 8369080d55..e8f7ec3e59 100644 --- a/docs/content/developers/evm/simplex/installation.mdx +++ b/docs/content/developers/evm/simplex/installation.mdx @@ -105,7 +105,7 @@ simplex # browser wizard: opens automatically when no config exists simplex init # the same flow in the terminal ``` -Both ask only for the minimum needed to run: chains, RPCs, bundler, signer and Hyperbridge account. They point the `[orderbook]` section at the HyperFX orderbook for your network, and explain each value as they ask for it. They also derive sibling RPC and bundler URLs from a single Alchemy or Pimlico API key, and validate every endpoint live before writing the file. Neither asks for prices. Once the solver is running, you set those by posting [limit orders](/developers/evm/simplex/limit-orders) from the dashboard. The browser wizard can also generate the Substrate account for bids and check its BRIDGE balance; when it finishes, the same process transitions straight into running the solver — no restart. A local web UI (status, pause/resume, balances, limit orders) stays available at `127.0.0.1:8686` while the solver runs (`--no-ui` for headless). Prefer a file-first workflow? Use the reference config below. +Both ask only for the minimum needed to run: chains, RPCs, bundler, signer and Hyperbridge account. They point the `[orderbook]` section at the HyperFX orderbook for your network, and explain each value as they ask for it. They also derive sibling RPC URLs from a single Alchemy API key, and validate every endpoint live before writing the file. For each chain's bundler, use the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler). Neither asks for prices. Once the solver is running, you set those by posting [limit orders](/developers/evm/simplex/limit-orders) from the dashboard. The browser wizard can also generate the Substrate account for bids and check its BRIDGE balance; when it finishes, the same process transitions straight into running the solver — no restart. A local web UI (status, pause/resume, balances, limit orders) stays available at `127.0.0.1:8686` while the solver runs (`--no-ui` for headless). Prefer a file-first workflow? Use the reference config below. @@ -179,33 +179,34 @@ 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. [[chains]] # Ethereum rpcUrls = [ "https://eth-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY", "https://mainnet.infura.io/v3/YOUR_INFURA_KEY", ] -bundlerUrl = "https://eth-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_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://base-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_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://arb-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_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://polygon-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_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 9669927bef..7ebced6983 100644 --- a/docs/content/developers/evm/simplex/troubleshooting.mdx +++ b/docs/content/developers/evm/simplex/troubleshooting.mdx @@ -72,7 +72,7 @@ Switch to a premium RPC provider, reduce `maxConcurrentOrders`, or raise `blockS ## **`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, 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. 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. --- diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index 8296a53d91..89f131886e 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -251,6 +251,9 @@ url = "https://orderbook.hyperfx.finance/mainnet/graphql" # testnet: https://o # 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 IDs are resolved automatically from the RPC endpoints. # All other chain data (addresses, assets, etc.) comes from the SDK automatically. # @@ -261,23 +264,23 @@ 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 = "" # ERC-4337 bundler (e.g. Pimlico) +bundlerUrl = "https://bundler.polytope.technology/ethereum" [[chains]] rpcUrls = [""] # BSC Mainnet -bundlerUrl = "" +bundlerUrl = "https://bundler.polytope.technology/bsc" [[chains]] rpcUrls = [""] # Arbitrum Mainnet -bundlerUrl = "" +bundlerUrl = "https://bundler.polytope.technology/arbitrum" [[chains]] rpcUrls = [""] # Base Mainnet -bundlerUrl = "" +bundlerUrl = "https://bundler.polytope.technology/base" [[chains]] rpcUrls = [""] # Polygon Mainnet -bundlerUrl = "" +bundlerUrl = "https://bundler.polytope.technology/polygon" [[chains]] rpcUrls = [""] # Unichain Mainnet From 704d58cd3574241f0d2bba4c398a65e0b9776e67 Mon Sep 17 00:00:00 2001 From: David Salami Date: Fri, 9 Oct 2026 15:47:51 +0000 Subject: [PATCH 11/12] [simplex]: drop the bundler from the UI and always use Hyperbridge's The setup wizard and the operator's Chains panel no longer show a bundler field or an Alchemy key prefill. Ethereum, Base, Arbitrum, Polygon and BNB Chain are saved with Hyperbridge's bundler, https://bundler.polytope.technology/, named in the chain catalog as hyperbridgeBundlerUrl. The Chains panel writes it over a different bundler the config held, keeps the config's bundler for a chain outside the catalog, and will not enable a chain that has neither. simplex init sets it without asking and only asks on chains with none. Both UIs verify RPC endpoints only. bundlerUrl is still required in filler-config.toml and PUT /api/chains, where any bundler serving the chain's EntryPoint is accepted. --- ...6-09-30-simplex-side-panel-rpc-defaults.md | 7 +- .../2026-10-06-entrypoint-v09-end-to-end.md | 17 ++ .../evm/simplex/api/configuration.mdx | 4 +- .../developers/evm/simplex/api/setup.mdx | 2 +- .../developers/evm/simplex/configuration.mdx | 2 +- .../developers/evm/simplex/installation.mdx | 2 +- ...09-23-wizard-ships-public-rpc-endpoints.md | 10 +- sdk/packages/simplex/src/cli/init/chains.ts | 23 +++ .../simplex/src/cli/init/help-text.ts | 2 +- .../simplex/src/cli/init/steps/bundlers.ts | 7 + .../tests/cli/hyperbridge-bundlers.test.ts | 57 +++++++ .../components/EndpointVerificationStatus.tsx | 2 +- .../simplex/ui/src/operator/Chains.tsx | 74 ++------- .../simplex/ui/src/operator/Operations.tsx | 2 +- .../operator/chains/useChainSettings.test.ts | 155 ++++++------------ .../src/operator/chains/useChainSettings.ts | 99 +---------- sdk/packages/simplex/ui/src/wizard/Wizard.tsx | 3 +- .../simplex/ui/src/wizard/bundler.test.ts | 35 ++++ .../ui/src/wizard/orderbook-pairs.test.ts | 4 +- .../src/wizard/secret-phrase-signer.test.ts | 6 +- .../simplex/ui/src/wizard/startError.ts | 2 +- sdk/packages/simplex/ui/src/wizard/state.ts | 15 +- .../simplex/ui/src/wizard/steps/Chains.tsx | 138 +--------------- 23 files changed, 237 insertions(+), 431 deletions(-) create mode 100644 sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts create mode 100644 sdk/packages/simplex/ui/src/wizard/bundler.test.ts diff --git a/docs/ai/changelog/2026-09-30-simplex-side-panel-rpc-defaults.md b/docs/ai/changelog/2026-09-30-simplex-side-panel-rpc-defaults.md index a54e80e7eb..75c9de162a 100644 --- a/docs/ai/changelog/2026-09-30-simplex-side-panel-rpc-defaults.md +++ b/docs/ai/changelog/2026-09-30-simplex-side-panel-rpc-defaults.md @@ -4,7 +4,6 @@ New chains in the operator's Chains panel start with their catalog's `defaultRpc the setup wizard. Chains without bundled endpoints start with one empty RPC field. Configured chains retain their saved RPC endpoints, including custom chains outside the catalog. -Alchemy prefill sets bundlers only, preserving the entire public or saved RPC list. The derived -Alchemy RPC URL serves as the bundler fallback when no separate bundler URL is returned, matching -the wizard. Chains without public defaults still require an RPC URL from the operator. Editing -the bundler clears its Alchemy badge; editing an RPC does not. +The panel has no bundler field. Every catalog chain is saved with Hyperbridge's bundler; see +`docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md`. Chains without public defaults still +require an RPC URL from the operator. diff --git a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md index 1010fa0e41..70c8c13e12 100644 --- a/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md +++ b/docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md @@ -165,6 +165,23 @@ claims the order (`open` to `resizing`), withdraws the posting, and posts it aga the current EntryPoint. These count in `ReconcileReport.reposted`. A fill's `resize` waits for an in-flight re-sign rather than posting alongside it. +### Bundler in the wizards and the Chains panel + +The setup wizard, the terminal wizard and the operator's Chains panel have no bundler field and no +Alchemy key prefill. `InitChainMeta.hyperbridgeBundlerUrl` (`cli/init/chains.ts`) names Hyperbridge's +bundler, `https://bundler.polytope.technology/`, for Ethereum, Base, Arbitrum, Polygon and +BNB Chain, and that is the `bundlerUrl` they write for those chains: + +- The web wizard's `assembleConfig` writes it for every enabled chain. +- The Chains panel sends it on save for every catalog chain, replacing a different bundler the + config held. A configured chain outside the catalog keeps the bundler its config names. A chain + with neither cannot be enabled from the panel and is added in the config file. +- `simplex init` sets it without a question and asks for a bundler only on chains that have none. + +Both UIs verify RPC endpoints only. `bundlerUrl` is still required in `filler-config.toml` and in +`PUT /api/chains`, and any bundler serving the chain's EntryPoint is accepted there. The +`validate-bundler` and `validate-alchemy-key` routes remain, uncalled by the UI. + ### Bid validity `bidValidUntilBlock` converts `bidValiditySeconds` to blocks at the chain's measured block time, where diff --git a/docs/content/developers/evm/simplex/api/configuration.mdx b/docs/content/developers/evm/simplex/api/configuration.mdx index f00a9c23f6..6c203b7cd1 100644 --- a/docs/content/developers/evm/simplex/api/configuration.mdx +++ b/docs/content/developers/evm/simplex/api/configuration.mdx @@ -112,7 +112,7 @@ 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, such as the chain's [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler). | +| `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. @@ -182,4 +182,4 @@ An unknown index answers `404`. The last market cannot be removed while the solv ## Validation probes -The dashboard's chain editor checks RPC endpoints, bundlers and Alchemy keys before it saves them. It uses four routes from the setup wizard, which stay open while the solver runs. See [Validation probes](/developers/evm/simplex/api/setup#validation-probes) on the setup wizard page. +The dashboard's chain editor checks RPC endpoints before it saves them, with `validate-rpc` from the setup wizard. The wizard's four validation routes stay open while the solver runs. See [Validation probes](/developers/evm/simplex/api/setup#validation-probes) on the setup wizard page. diff --git a/docs/content/developers/evm/simplex/api/setup.mdx b/docs/content/developers/evm/simplex/api/setup.mdx index 2852432f9f..0400df21a4 100644 --- a/docs/content/developers/evm/simplex/api/setup.mdx +++ b/docs/content/developers/evm/simplex/api/setup.mdx @@ -84,7 +84,7 @@ The response carries a `warning` when: ### 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. When you save the chain, set its bundler to the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler) instead. +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. ```bash lineNumbers curl -s -X POST $SIMPLEX/api/setup/validate-alchemy-key \ diff --git a/docs/content/developers/evm/simplex/configuration.mdx b/docs/content/developers/evm/simplex/configuration.mdx index f3e7a393ae..0d2babf6a1 100644 --- a/docs/content/developers/evm/simplex/configuration.mdx +++ b/docs/content/developers/evm/simplex/configuration.mdx @@ -155,7 +155,7 @@ rpcUrls = ["https://..."] bundlerUrl = "https://bundler.polytope.technology/base" ``` -On any other chain, use a bundler that serves the EntryPoint the chain's solver account uses. Simplex [refuses one that does not](/developers/evm/simplex/troubleshooting). +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). ## Delegation & Gas Payment diff --git a/docs/content/developers/evm/simplex/installation.mdx b/docs/content/developers/evm/simplex/installation.mdx index e8f7ec3e59..5de65aa0e5 100644 --- a/docs/content/developers/evm/simplex/installation.mdx +++ b/docs/content/developers/evm/simplex/installation.mdx @@ -105,7 +105,7 @@ simplex # browser wizard: opens automatically when no config exists simplex init # the same flow in the terminal ``` -Both ask only for the minimum needed to run: chains, RPCs, bundler, signer and Hyperbridge account. They point the `[orderbook]` section at the HyperFX orderbook for your network, and explain each value as they ask for it. They also derive sibling RPC URLs from a single Alchemy API key, and validate every endpoint live before writing the file. For each chain's bundler, use the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler). Neither asks for prices. Once the solver is running, you set those by posting [limit orders](/developers/evm/simplex/limit-orders) from the dashboard. The browser wizard can also generate the Substrate account for bids and check its BRIDGE balance; when it finishes, the same process transitions straight into running the solver — no restart. A local web UI (status, pause/resume, balances, limit orders) stays available at `127.0.0.1:8686` while the solver runs (`--no-ui` for headless). Prefer a file-first workflow? Use the reference config below. +Both ask only for the minimum needed to run: chains, signer and Hyperbridge account. Each mainnet chain starts with a set of public RPC endpoints, which you can replace, and fills through the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler), which neither wizard asks about. They point the `[orderbook]` section at the HyperFX orderbook for your network, and explain each value as they ask for it. Neither asks for prices. Once the solver is running, you set those by posting [limit orders](/developers/evm/simplex/limit-orders) from the dashboard. The browser wizard can also generate the Substrate account for bids and check its BRIDGE balance; when it finishes, the same process transitions straight into running the solver — no restart. A local web UI (status, pause/resume, balances, limit orders) stays available at `127.0.0.1:8686` while the solver runs (`--no-ui` for headless). Prefer a file-first workflow? Use the reference config below. diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-23-wizard-ships-public-rpc-endpoints.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-23-wizard-ships-public-rpc-endpoints.md index b5708474c2..cd6f587739 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-23-wizard-ships-public-rpc-endpoints.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-23-wizard-ships-public-rpc-endpoints.md @@ -1,8 +1,8 @@ # 2026-09-23 — The wizards ship public RPC endpoints -Both setup wizards start each mainnet chain with a working set of public RPC endpoints, so the only -endpoint an operator has to supply is an ERC-4337 bundler. One Alchemy API key covers that for every -supported chain. +Both setup wizards start each mainnet chain with a working set of public RPC endpoints. The bundler +is not asked for either: every mainnet chain uses Hyperbridge's (see +`docs/ai/changelog/2026-10-06-entrypoint-v09-end-to-end.md`). ## What changed for the operator @@ -10,10 +10,6 @@ Before, every chain needed an RPC URL typed or pasted in, and the wizard's own c that free endpoints would break event scanning. That was true of a single free endpoint. It is not true of a quorum of them, which is what the filler has read through since quorum scanning landed. -The Alchemy key now fills in bundlers only. It no longer overwrites the first RPC field, because -pointing the scan at Alchemy would spend the key's quota on polling and put the chain back on a -single provider. - ## The endpoint sets `FREE_RPC_URLS` in `cli/init/chains.ts`, surfaced per chain as `InitChainMeta.defaultRpcUrls`. The diff --git a/sdk/packages/simplex/src/cli/init/chains.ts b/sdk/packages/simplex/src/cli/init/chains.ts index 43dcf7e57a..a6c865d49e 100644 --- a/sdk/packages/simplex/src/cli/init/chains.ts +++ b/sdk/packages/simplex/src/cli/init/chains.ts @@ -18,6 +18,12 @@ export interface InitChainMeta { * operator needs no RPC provider account to run. See {@link FREE_RPC_URLS}. */ 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}. + */ + hyperbridgeBundlerUrl?: string } /** @@ -91,6 +97,18 @@ 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", @@ -123,6 +141,7 @@ export const INIT_CHAINS: InitChainMeta[] = [ alchemySubdomain: "eth-mainnet", explorerUrl: externalLinks.chainExplorers.ethereum, defaultRpcUrls: FREE_RPC_URLS[1], + hyperbridgeBundlerUrl: HYPERBRIDGE_BUNDLER_URLS[1], }, { chainId: 42161, @@ -132,6 +151,7 @@ export const INIT_CHAINS: InitChainMeta[] = [ alchemySubdomain: "arb-mainnet", explorerUrl: externalLinks.chainExplorers.arbitrum, defaultRpcUrls: FREE_RPC_URLS[42161], + hyperbridgeBundlerUrl: HYPERBRIDGE_BUNDLER_URLS[42161], }, { chainId: 8453, @@ -141,6 +161,7 @@ export const INIT_CHAINS: InitChainMeta[] = [ alchemySubdomain: "base-mainnet", explorerUrl: externalLinks.chainExplorers.base, defaultRpcUrls: FREE_RPC_URLS[8453], + hyperbridgeBundlerUrl: HYPERBRIDGE_BUNDLER_URLS[8453], }, { chainId: 137, @@ -150,6 +171,7 @@ export const INIT_CHAINS: InitChainMeta[] = [ alchemySubdomain: "polygon-mainnet", explorerUrl: externalLinks.chainExplorers.polygon, defaultRpcUrls: FREE_RPC_URLS[137], + hyperbridgeBundlerUrl: HYPERBRIDGE_BUNDLER_URLS[137], }, { chainId: 56, @@ -159,6 +181,7 @@ export const INIT_CHAINS: InitChainMeta[] = [ alchemySubdomain: "bnb-mainnet", explorerUrl: externalLinks.chainExplorers.bnb, defaultRpcUrls: FREE_RPC_URLS[56], + hyperbridgeBundlerUrl: HYPERBRIDGE_BUNDLER_URLS[56], }, { chainId: 11155111, diff --git a/sdk/packages/simplex/src/cli/init/help-text.ts b/sdk/packages/simplex/src/cli/init/help-text.ts index 45bdd6af67..5edc38f7d1 100644 --- a/sdk/packages/simplex/src/cli/init/help-text.ts +++ b/sdk/packages/simplex/src/cli/init/help-text.ts @@ -7,7 +7,7 @@ export const WHY = { 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. Alchemy RPC endpoints double as bundlers, or use a dedicated provider like Pimlico.", + "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/steps/bundlers.ts b/sdk/packages/simplex/src/cli/init/steps/bundlers.ts index 515d81eb58..0b7d0a1488 100644 --- a/sdk/packages/simplex/src/cli/init/steps/bundlers.ts +++ b/sdk/packages/simplex/src/cli/init/steps/bundlers.ts @@ -7,6 +7,13 @@ 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. diff --git a/sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts b/sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts new file mode 100644 index 0000000000..a1ef296312 --- /dev/null +++ b/sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } 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 }, + } +}) + +/** + * 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. + */ +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({ + 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", + }) + }) + + 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("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) + + expect(state.chains.map((chain) => chain.bundlerUrl)).toEqual(mainnet.map((meta) => meta.hyperbridgeBundlerUrl)) + }) +}) diff --git a/sdk/packages/simplex/ui/src/components/EndpointVerificationStatus.tsx b/sdk/packages/simplex/ui/src/components/EndpointVerificationStatus.tsx index 0c83bb167d..8448e90555 100644 --- a/sdk/packages/simplex/ui/src/components/EndpointVerificationStatus.tsx +++ b/sdk/packages/simplex/ui/src/components/EndpointVerificationStatus.tsx @@ -12,7 +12,7 @@ const ICONS: Record = { error: "×", } -/** Persistent, row-local result for an RPC and bundler verification attempt. */ +/** Persistent, row-local result for an RPC verification attempt. */ export function EndpointVerificationStatus({ state, message }: EndpointVerificationStatusProps) { if (!state || !message) return null return ( diff --git a/sdk/packages/simplex/ui/src/operator/Chains.tsx b/sdk/packages/simplex/ui/src/operator/Chains.tsx index 44eb8c2a95..81c5316076 100644 --- a/sdk/packages/simplex/ui/src/operator/Chains.tsx +++ b/sdk/packages/simplex/ui/src/operator/Chains.tsx @@ -1,56 +1,22 @@ import * as Collapsible from "@radix-ui/react-collapsible" -import externalLinks from "@/config/external-links.json" import { ChainCollapseTrigger, isHeaderControl, useChainPanels } from "../components/ChainPanel" import { ChainLogo } from "../components/ChainLogo" import { EndpointVerificationStatus } from "../components/EndpointVerificationStatus" -import { ExternalLinkIcon } from "../components/InterfaceIcons" import { useChainSettings } from "./chains/useChainSettings" export function Chains() { const model = useChainSettings() const panels = useChainPanels() - const { - dto, - chains, - loaded, - patch, - alchemyKey, - updateAlchemyKey, - alchemy, - saved, - message, - error, - applyAlchemyKey, - verifyChain, - toggleChain, - save, - } = model + const { dto, chains, loaded, patch, saved, message, error, verifyChain, toggleChain, save } = model return (
-

Provider key

+

RPC providers

- Use one{" "} - - Alchemy key - {" "} - to fill supported bundler endpoints, or enter a bundler manually below. New chains start with - bundled public RPC endpoints where available. You can edit them or use your own providers. + New chains start with bundled public RPC endpoints where available. You can edit them or use your + own providers.

-
- updateAlchemyKey(e.target.value)} - /> - -
{chains.map((chain) => ( @@ -72,17 +38,25 @@ export function Chains() {

{chain.meta.label}

- {chain.viaAlchemy ? Bundler via Alchemy : null} {chain.enabled && !chain.running ? ( Applies after restart ) : null} + {chain.bundlerUrl ? null : Add in the config file}
-
-
-
-
- {state.orderbookError ? (

{state.orderbookError}

@@ -189,7 +76,6 @@ export function StepChains({ state, setState }: StepProps) {

{chain.meta.label}

- {chain.viaAlchemy && Bundler via Alchemy}
@@ -280,26 +166,6 @@ export function StepChains({ state, setState }: StepProps) {

- -