diff --git a/evm/config.mainnet.toml b/evm/config.mainnet.toml index b4c9e8139..c40bc4fd8 100644 --- a/evm/config.mainnet.toml +++ b/evm/config.mainnet.toml @@ -10,6 +10,7 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0x66C4459fa61E5Ca647152EEb6dA56150EE975512" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0x0000000000000000000000000000000000000000" [1.bool] is_mainnet = true @@ -27,6 +28,7 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0x66C4459fa61E5Ca647152EEb6dA56150EE975512" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0x0000000000000000000000000000000000000000" [42161.bool] is_mainnet = true @@ -44,6 +46,7 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0x66C4459fa61E5Ca647152EEb6dA56150EE975512" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0x0000000000000000000000000000000000000000" [10.bool] is_mainnet = true @@ -61,6 +64,10 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0x66C4459fa61E5Ca647152EEb6dA56150EE975512" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0x18654D0319ffDC18c200c90C1A666AcbDf0E3762" +NATIVE_ORACLE = "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70" +USDC_TOKEN = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +USDC_ORACLE = "0x7e860098F58bBFC8648a4311b374B1D669a2bc6B" [8453.bool] is_mainnet = true @@ -78,6 +85,12 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0x66C4459fa61E5Ca647152EEb6dA56150EE975512" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0xCcE8B5f5C453e1Ff1acd227585d9e680c012462c" +NATIVE_ORACLE = "0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE" +USDC_TOKEN = "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" +USDC_ORACLE = "0x51597f405303C4377E36123cBc172b13269EA163" +USDT_TOKEN = "0x55d398326f99059ff775485246999027b3197955" +USDT_ORACLE = "0xB97Ad0E74fa7d920791E90258A6E2085088b4320" [56.bool] is_mainnet = true @@ -95,6 +108,7 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0xd4d594C99f23b1Fb9d65fdd9062854B1A1C5780b" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0x0000000000000000000000000000000000000000" [100.bool] is_mainnet = true @@ -124,6 +138,7 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0x66C4459fa61E5Ca647152EEb6dA56150EE975512" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0x0000000000000000000000000000000000000000" [137.bool] is_mainnet = true @@ -141,6 +156,7 @@ INTENT_GATEWAY = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" INTENT_GATEWAY_V2 = "0x2d61624A17f361020679FaA16fbB566C344AaF4B" SOLVER_ACCOUNT = "0x66C4459fa61E5Ca647152EEb6dA56150EE975512" PRICE_ORACLE = "0x7BF7ffE89909d3952a7ae3C089a007857e26218b" +SIMPLEX_PAYMASTER = "0x0000000000000000000000000000000000000000" [130.bool] is_mainnet = true diff --git a/evm/package.json b/evm/package.json index 4d2bc4e60..a73d1c1f7 100644 --- a/evm/package.json +++ b/evm/package.json @@ -13,6 +13,7 @@ "description": "", "dependencies": { "@hyperbridge/core": "^1.6.0", + "@openzeppelin/community-contracts": "github:OpenZeppelin/openzeppelin-community-contracts#9e1baed", "@openzeppelin/contracts": "^5.4.0", "@polytope-labs/solidity-merkle-trees": "0.4.0", "@uniswap/swap-router-contracts": "^1.3.1", diff --git a/evm/pnpm-lock.yaml b/evm/pnpm-lock.yaml index 929a76629..40ef14c6a 100644 --- a/evm/pnpm-lock.yaml +++ b/evm/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hyperbridge/core': specifier: ^1.6.0 version: 1.6.0 + '@openzeppelin/community-contracts': + specifier: github:OpenZeppelin/openzeppelin-community-contracts#9e1baed + version: https://codeload.github.com/OpenZeppelin/openzeppelin-community-contracts/tar.gz/9e1baed '@openzeppelin/contracts': specifier: ^5.4.0 version: 5.4.0 @@ -202,6 +205,10 @@ packages: resolution: {integrity: sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==} engines: {node: '>= 12'} + '@openzeppelin/community-contracts@https://codeload.github.com/OpenZeppelin/openzeppelin-community-contracts/tar.gz/9e1baed': + resolution: {tarball: https://codeload.github.com/OpenZeppelin/openzeppelin-community-contracts/tar.gz/9e1baed} + version: 0.0.1 + '@openzeppelin/contracts@3.4.2-solc-0.7': resolution: {integrity: sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA==} @@ -211,6 +218,9 @@ packages: '@openzeppelin/contracts@5.4.0': resolution: {integrity: sha512-eCYgWnLg6WO+X52I16TZt8uEjbtdkgLC0SUX/xnAksjjrQI4Xfn4iBRoI5j55dmlOhDv1Y7BoR3cU7e3WWhC6A==} + '@openzeppelin/contracts@5.6.1': + resolution: {integrity: sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w==} + '@polytope-labs/solidity-merkle-trees@0.3.4': resolution: {integrity: sha512-ldLirdWNd7qNi8iRfpwlHCwy/FrVMd5wbsu9qVxx/IyOnbQnzzU816AfL3Zn01iN8DhB5UFLMkQ/HmF+VOLGFA==} @@ -1102,7 +1112,7 @@ snapshots: '@hyperbridge/core@1.6.0': dependencies: - '@openzeppelin/contracts': 5.4.0 + '@openzeppelin/contracts': 5.6.1 '@polytope-labs/solidity-merkle-trees': 0.3.4 prettier: 3.7.4 prettier-plugin-solidity: 1.4.3(prettier@3.7.4) @@ -1178,12 +1188,16 @@ snapshots: '@nomicfoundation/solidity-analyzer-linux-x64-musl': 0.1.2 '@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.2 + '@openzeppelin/community-contracts@https://codeload.github.com/OpenZeppelin/openzeppelin-community-contracts/tar.gz/9e1baed': {} + '@openzeppelin/contracts@3.4.2-solc-0.7': {} '@openzeppelin/contracts@5.0.2': {} '@openzeppelin/contracts@5.4.0': {} + '@openzeppelin/contracts@5.6.1': {} + '@polytope-labs/solidity-merkle-trees@0.3.4': dependencies: openzeppelin-solidity: 4.8.1 @@ -1192,7 +1206,7 @@ snapshots: '@polytope-labs/solidity-merkle-trees@0.4.0': dependencies: - '@openzeppelin/contracts': 5.4.0 + '@openzeppelin/contracts': 5.6.1 prettier: 3.7.4 prettier-plugin-solidity: 1.4.3(prettier@3.7.4) diff --git a/evm/script/DeploySimplexPaymaster.s.sol b/evm/script/DeploySimplexPaymaster.s.sol new file mode 100644 index 000000000..e34de515e --- /dev/null +++ b/evm/script/DeploySimplexPaymaster.s.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import "forge-std/Script.sol"; +import "stringutils/strings.sol"; + +import {SimplexPaymaster, AggregatorV3Interface} from "../src/utils/SimplexPaymaster.sol"; +import {BaseScript} from "./BaseScript.sol"; + +contract DeployScript is BaseScript { + using strings for *; + + function deploy() internal override { + // ── Read from TOML config ──────────────────────────────────── + address nativeOracleAddr = config.get("NATIVE_ORACLE").toAddress(); + uint256 markupBps = vm.envOr("MARKUP_BPS", uint256(200)); // default 2% + address treasury = vm.envOr("TREASURY", admin); // default to owner + + // ── Deploy SimplexPaymaster ────────────────────────────────── + SimplexPaymaster paymaster = new SimplexPaymaster{salt: salt}( + AggregatorV3Interface(nativeOracleAddr), + markupBps, + treasury, + admin + ); + + console.log("SimplexPaymaster deployed at:", address(paymaster)); + console.log(" nativeOracle:", nativeOracleAddr); + console.log(" markupBps:", markupBps); + console.log(" treasury:", treasury); + console.log(" owner:", admin); + + // ── Register tokens from config ────────────────────────────── + address usdcToken = config.get("USDC_TOKEN").toAddress(); + address usdcOracle = config.get("USDC_ORACLE").toAddress(); + address usdtToken = vm.envOr("USDT_TOKEN", address(0)); + address usdtOracle = vm.envOr("USDT_ORACLE", address(0)); + + if (usdcToken != address(0) && usdcOracle != address(0)) { + paymaster.registerToken(usdcToken, AggregatorV3Interface(usdcOracle)); + console.log(" Registered USDC:", usdcToken, "oracle:", usdcOracle); + } + + if (usdtToken != address(0) && usdtOracle != address(0)) { + paymaster.registerToken(usdtToken, AggregatorV3Interface(usdtOracle)); + console.log(" Registered USDT:", usdtToken, "oracle:", usdtOracle); + } + + // ── Update config ──────────────────────────────────────────── + config.set("SIMPLEX_PAYMASTER", address(paymaster)); + + console.log(""); + console.log("=== IMPORTANT: Post-deployment steps ==="); + console.log("1. Fund EntryPoint deposit for the paymaster:"); + console.log(" cast send \"depositTo(address)\" ", address(paymaster), " --value 0.01ether"); + } +} diff --git a/evm/src/utils/SimplexPaymaster.sol b/evm/src/utils/SimplexPaymaster.sol new file mode 100644 index 000000000..8ff5f8330 --- /dev/null +++ b/evm/src/utils/SimplexPaymaster.sol @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; +import {PaymasterERC20} from "@openzeppelin/community-contracts/contracts/account/paymaster/PaymasterERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/// @notice Minimal Chainlink AggregatorV3 interface — no external dependency needed. +interface AggregatorV3Interface { + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + + function decimals() external view returns (uint8); +} + +/// @title SimplexPaymaster +/// @author Polytope Labs +/// @notice Fully onchain, permissionless ERC-4337 v0.8 paymaster that accepts +/// ERC-20 stablecoins (USDC, USDT, or any token with a Chainlink feed) +/// for gas payment. +/// +/// Modes (byte 0 of paymasterData): +/// 0x00 PERMIT — EIP-2612 permit signature included; token pre-approved via +/// the permit in the same UserOp (gas-efficient on chains +/// with native USDC/USDT that support permit). +/// 0x01 APPROVE — Token must be pre-approved to this paymaster (BSC path, +/// or any chain where permit is unavailable). Batch the +/// approve() call in the UserOp's calls array. +/// +/// paymasterData encoding: +/// Mode 0x00 (permit): +/// abi.encodePacked(uint8(0), address(token), uint256(permitAmount), +/// uint256(deadline), uint8(v), bytes32(r), bytes32(s)) +/// Mode 0x01 (approve): +/// abi.encodePacked(uint8(1), address(token)) +/// +/// Price conversion uses two Chainlink feeds per token: +/// - token/USD (e.g. USDC/USD) → 8 decimals +/// - nativeAsset/USD (e.g. BNB/USD, ETH/USD) → 8 decimals +/// tokenPrice = (nativePrice * 10^tokenDecimals) / tokenPrice_USD +/// This gives the cost in token-units per 1 wei of native gas. +/// +/// Treasury: markup surplus accumulates in the contract and is withdrawable +/// to a separate treasury address. Unused gas is always refunded +/// to the solver (handled by OZ's PaymasterERC20._postOp). +contract SimplexPaymaster is PaymasterERC20, Ownable { + using SafeERC20 for IERC20; + using ERC4337Utils for PackedUserOperation; + + // ── Structs ────────────────────────────────────────────────────── + + struct TokenConfig { + AggregatorV3Interface tokenOracle; // token/USD feed + uint8 tokenOracleDecimals; // cached decimals() of tokenOracle + uint8 tokenDecimals; // decimals() of the ERC-20 + bool active; // kill-switch per token + } + + // ── Constants ──────────────────────────────────────────────────── + + /// @dev 10% markup in basis points. Owner-configurable. + uint256 public constant MAX_MARKUP_BPS = 5_000; // 50% hard cap + + /// @dev Maximum oracle staleness. Chainlink heartbeats vary per chain: + /// BSC stablecoins ~27s, Base/Arbitrum USDC ~24h for stablecoins. + /// Default 86400 (24 hours). Owner-configurable to match chain-specific heartbeats. + uint256 public maxOracleAge = 86400; + + // ── State ──────────────────────────────────────────────────────── + + /// @notice Native asset / USD oracle (BNB/USD on BSC, ETH/USD on Ethereum, etc.) + AggregatorV3Interface public immutable nativeOracle; + uint8 public immutable nativeOracleDecimals; + + /// @notice Markup in basis points (100 = 1%). Applied on top of oracle price. + uint256 public markupBps; + + /// @notice Treasury address — receives accumulated markup surplus only. + /// Unused gas refunds are returned to the solver by OZ's base + /// PaymasterERC20._postOp automatically. + address public treasury; + + /// @notice Per-token configuration. token address → config. + mapping(address => TokenConfig) public tokenConfigs; + + /// @notice Set of registered token addresses (for enumeration). + address[] public registeredTokens; + + // ── Events ─────────────────────────────────────────────────────── + + event TokenRegistered(address indexed token, address indexed oracle); + event TokenDeactivated(address indexed token); + event MarkupUpdated(uint256 oldBps, uint256 newBps); + event TreasuryUpdated(address oldTreasury, address newTreasury); + event PermitExecuted(address indexed token, address indexed owner, uint256 amount); + + // ── Errors ─────────────────────────────────────────────────────── + + error TokenNotRegistered(address token); + error TokenNotActive(address token); + error StaleOraclePrice(address oracle, uint256 updatedAt); + error InvalidOraclePrice(address oracle, int256 price); + error InvalidMarkup(uint256 bps); + error InvalidMode(uint8 mode); + error PermitFailed(address token); + error ZeroAddress(); + + // ── Constructor ────────────────────────────────────────────────── + + /// @param _nativeOracle Chainlink native/USD feed (e.g. BNB/USD on BSC) + /// @param _markupBps Initial markup in basis points (e.g. 1000 = 10%) + /// @param _treasury Address that receives markup surplus + /// @param _owner Owner for admin functions + constructor( + AggregatorV3Interface _nativeOracle, + uint256 _markupBps, + address _treasury, + address _owner + ) Ownable(_owner) { + if (address(_nativeOracle) == address(0)) revert ZeroAddress(); + if (_treasury == address(0)) revert ZeroAddress(); + if (_markupBps > MAX_MARKUP_BPS) revert InvalidMarkup(_markupBps); + + nativeOracle = _nativeOracle; + nativeOracleDecimals = _nativeOracle.decimals(); + markupBps = _markupBps; + treasury = _treasury; + } + + // ── Admin: token management ────────────────────────────────────── + + /// @notice Register or update a supported ERC-20 token. + /// @param token The ERC-20 token address + /// @param oracle Chainlink token/USD price feed + function registerToken(address token, AggregatorV3Interface oracle) external onlyOwner { + if (token == address(0) || address(oracle) == address(0)) revert ZeroAddress(); + + bool isNew = !tokenConfigs[token].active && address(tokenConfigs[token].tokenOracle) == address(0); + + tokenConfigs[token] = TokenConfig({ + tokenOracle: oracle, + tokenOracleDecimals: oracle.decimals(), + tokenDecimals: IERC20Metadata(token).decimals(), + active: true + }); + + if (isNew) { + registeredTokens.push(token); + } + + emit TokenRegistered(token, address(oracle)); + } + + /// @notice Deactivate a token (stops new UserOps from using it). + function deactivateToken(address token) external onlyOwner { + tokenConfigs[token].active = false; + emit TokenDeactivated(token); + } + + // ── Admin: parameters ──────────────────────────────────────────── + + function setMarkup(uint256 _markupBps) external onlyOwner { + if (_markupBps > MAX_MARKUP_BPS) revert InvalidMarkup(_markupBps); + uint256 old = markupBps; + markupBps = _markupBps; + emit MarkupUpdated(old, _markupBps); + } + + function setTreasury(address _treasury) external onlyOwner { + if (_treasury == address(0)) revert ZeroAddress(); + address old = treasury; + treasury = _treasury; + emit TreasuryUpdated(old, _treasury); + } + + function setMaxOracleAge(uint256 _maxOracleAge) external onlyOwner { + maxOracleAge = _maxOracleAge; + } + + // ── Admin: withdrawals ─────────────────────────────────────────── + + /// @notice Withdraw accumulated ERC-20 tokens to the treasury. + function withdrawTokenToTreasury(IERC20 token, uint256 amount) external onlyOwner { + token.safeTransfer(treasury, amount); + } + + /// @notice Withdraw native gas token from EntryPoint deposit. + function withdrawEntryPointDeposit(uint256 amount) external onlyOwner { + withdraw(payable(treasury), amount); + } + + // ── Core: OZ PaymasterERC20 hook ───────────────────────────────── + + /// @dev Called by PaymasterERC20._validatePaymasterUserOp. + /// Returns the token to charge and its price relative to native gas. + /// + /// tokenPrice semantics (from OZ docs): + /// tokenPrice = cost in token-wei per 1 wei of native gas cost. + /// Internally OZ computes: erc20Cost = (gasCost * tokenPrice) / TOKEN_PRICE_DENOMINATOR + /// where TOKEN_PRICE_DENOMINATOR = 1e18. + /// + /// So tokenPrice = (nativeUsdPrice * 1e18 * 10^tokenDecimals) + /// / (tokenUsdPrice * 10^18) [native is 18 decimals] + /// Simplified: = (nativeUsdPrice * 10^tokenDecimals) / tokenUsdPrice + /// With markup: *= (10000 + markupBps) / 10000 + function _fetchDetails( + PackedUserOperation calldata userOp, + bytes32 /* userOpHash */ + ) + internal + view + override + returns (uint256 validationData, IERC20 token, uint256 tokenPrice) + { + // Decode mode + token from paymasterData + bytes calldata data = userOp.paymasterData(); + uint8 mode = uint8(data[0]); + address tokenAddr = address(bytes20(data[1:21])); + + // Validate token + TokenConfig memory cfg = tokenConfigs[tokenAddr]; + if (address(cfg.tokenOracle) == address(0)) revert TokenNotRegistered(tokenAddr); + if (!cfg.active) revert TokenNotActive(tokenAddr); + + // If mode == 0x00 (permit), execute permit before validation + // NOTE: permit is executed in _fetchDetails which is called during + // validatePaymasterUserOp. The permit sets allowance so that + // the subsequent transferFrom in prefund() succeeds. + if (mode == 0x00) { + // This is a view function override — we cannot execute permit here. + // The permit must be handled in a separate hook. See _permitIfNeeded below. + // For _fetchDetails, we just return the pricing. + } else if (mode != 0x01) { + revert InvalidMode(mode); + } + + // Fetch oracle prices + uint256 nativeUsd = _getOraclePrice(nativeOracle, nativeOracleDecimals); + uint256 tokenUsd = _getOraclePrice(cfg.tokenOracle, cfg.tokenOracleDecimals); + + // tokenPrice = (nativeUsd * 10^tokenDecimals * (10000 + markupBps)) / (tokenUsd * 10000) + // This gives token-units per 1 wei of native gas, scaled by 1e18 (TOKEN_PRICE_DENOMINATOR). + // + // OZ PaymasterERC20._erc20Cost does: + // erc20Cost = (cost * feePerGas * tokenPrice) / TOKEN_PRICE_DENOMINATOR + // where cost is in gas units and feePerGas is in wei. So cost*feePerGas = wei cost. + // We need tokenPrice such that: tokenAmount = (weiCost * tokenPrice) / 1e18 + // + // tokenAmount should be in token base units (e.g. 6-decimal USDC). + // If BNB = $600, USDC = $1, and we spend 0.001 BNB ($0.60): + // tokenAmount = 0.60 USDC = 600000 (6 decimals) + // weiCost = 1e15 (0.001 BNB) + // tokenPrice = tokenAmount * 1e18 / weiCost = 600000 * 1e18 / 1e15 = 6e8 * 1e18 / 1e15 + // Let's verify: nativeUsd=600e8, tokenUsd=1e8, tokenDecimals=6 + // tokenPrice = (600e8 * 1e6 * 1e18) / (1e8 * 1e18) = 600 * 1e6 = 6e8 ✓ + // (then with markup applied on top) + + tokenPrice = (nativeUsd * (10 ** cfg.tokenDecimals) * (10_000 + markupBps)) / (tokenUsd * 10_000); + + token = IERC20(tokenAddr); + validationData = 0; // no time-range restriction + } + + // ── Permit execution ───────────────────────────────────────────── + + /// @dev Override _validatePaymasterUserOp to execute permit before prefund. + /// OZ's PaymasterERC20._validatePaymasterUserOp calls _fetchDetails + /// then prefund(). We intercept to run the permit between the two. + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) internal override returns (bytes memory context, uint256 validationData) { + // Execute permit if mode == 0x00 + bytes calldata data = userOp.paymasterData(); + if (data.length > 0 && uint8(data[0]) == 0x00) { + _executePermit(userOp); + } + + // Delegate to OZ base implementation (calls _fetchDetails → prefund) + return super._validatePaymasterUserOp(userOp, userOpHash, maxCost); + } + + /// @dev Parse and execute EIP-2612 permit from paymasterData. + /// Layout: mode(1) + token(20) + permitAmount(32) + deadline(32) + v(1) + r(32) + s(32) + /// Total: 150 bytes + function _executePermit(PackedUserOperation calldata userOp) internal { + bytes calldata data = userOp.paymasterData(); + // Minimum length: 1 + 20 + 32 + 32 + 1 + 32 + 32 = 150 + require(data.length == 150, "SimplexPaymaster: invalid permit data length"); + + address tokenAddr = address(bytes20(data[1:21])); + uint256 permitAmount = uint256(bytes32(data[21:53])); + uint256 deadline = uint256(bytes32(data[53:85])); + uint8 v = uint8(data[85]); + bytes32 r = bytes32(data[86:118]); + bytes32 s = bytes32(data[118:150]); + + address owner = userOp.sender; // the smart account + + // Execute permit — sets allowance from owner to this paymaster + try IERC20Permit(tokenAddr).permit(owner, address(this), permitAmount, deadline, v, r, s) { + emit PermitExecuted(tokenAddr, owner, permitAmount); + } catch { + revert PermitFailed(tokenAddr); + } + } + + // ── Oracle helpers ─────────────────────────────────────────────── + + /// @dev Fetch price from a Chainlink feed, normalized to 8 decimals. + /// Reverts on stale or non-positive prices. + function _getOraclePrice( + AggregatorV3Interface oracle, + uint8 oracleDecimals + ) internal view returns (uint256) { + (, int256 answer,, uint256 updatedAt,) = oracle.latestRoundData(); + + if (answer <= 0) revert InvalidOraclePrice(address(oracle), answer); + if (block.timestamp - updatedAt > maxOracleAge) { + revert StaleOraclePrice(address(oracle), updatedAt); + } + + // Normalize to 8 decimals for consistent math + if (oracleDecimals < 8) { + return uint256(answer) * (10 ** (8 - oracleDecimals)); + } else if (oracleDecimals > 8) { + return uint256(answer) / (10 ** (oracleDecimals - 8)); + } + return uint256(answer); + } + + // ── View helpers ───────────────────────────────────────────────── + + /// @notice Get the current token price (for gas estimation offchain). + function getTokenPrice(address token) external view returns (uint256) { + TokenConfig memory cfg = tokenConfigs[token]; + if (address(cfg.tokenOracle) == address(0)) revert TokenNotRegistered(token); + + uint256 nativeUsd = _getOraclePrice(nativeOracle, nativeOracleDecimals); + uint256 tokenUsd = _getOraclePrice(cfg.tokenOracle, cfg.tokenOracleDecimals); + + return (nativeUsd * (10 ** cfg.tokenDecimals) * (10_000 + markupBps)) / (tokenUsd * 10_000); + } + + /// @notice Estimate token cost for a given gas amount and fee. + function estimateTokenCost( + address token, + uint256 gasAmount, + uint256 maxFeePerGas + ) external view returns (uint256) { + uint256 price = this.getTokenPrice(token); + uint256 weiCost = gasAmount * maxFeePerGas; + // Mirror OZ's _erc20Cost: (cost * tokenPrice) / 1e18 + // But our tokenPrice already accounts for decimals, so: + return (weiCost * price) / 1e18; + } + + /// @notice List all registered tokens. + function getRegisteredTokens() external view returns (address[] memory) { + return registeredTokens; + } + + // ── PaymasterCore: authorize withdrawal ───────────────────────── + + /// @dev Only the owner can withdraw from the EntryPoint deposit. + function _authorizeWithdraw() internal view override { + _checkOwner(); + } +} diff --git a/sdk/packages/sdk/src/configs/ChainConfigService.ts b/sdk/packages/sdk/src/configs/ChainConfigService.ts index 825dff906..d6d7a8618 100644 --- a/sdk/packages/sdk/src/configs/ChainConfigService.ts +++ b/sdk/packages/sdk/src/configs/ChainConfigService.ts @@ -187,8 +187,12 @@ export class ChainConfigService { return this.getConfig(chain)?.addresses.EntryPointV08! } - getCirclePaymasterV08Address(chain: string): HexString | undefined { - return this.getConfig(chain)?.addresses.CirclePaymasterV08 as HexString | undefined + getSimplexPaymasterAddress(chain: string): HexString | undefined { + return this.getConfig(chain)?.addresses.SimplexPaymaster as HexString | undefined + } + + getCirclePaymasterAddress(chain: string): HexString | undefined { + return this.getConfig(chain)?.addresses.CirclePaymaster as HexString | undefined } getHyperbridgeAddress(): string { diff --git a/sdk/packages/sdk/src/configs/chain.ts b/sdk/packages/sdk/src/configs/chain.ts index c89b3ad92..5f5d2d29e 100644 --- a/sdk/packages/sdk/src/configs/chain.ts +++ b/sdk/packages/sdk/src/configs/chain.ts @@ -131,8 +131,10 @@ export interface ChainConfigData { UniswapV4PoolManager?: `0x${string}` /** Uniswap V4 StateView (canonical CREATE2 address) for pool state reads via extsload */ UniswapV4StateView?: `0x${string}` - /** Circle Paymaster v0.8 contract address (ERC-4337 onchain USDC paymaster) */ - CirclePaymasterV08?: `0x${string}` + /** SimplexPaymaster v0.8 contract address (ERC-4337 onchain ERC-20 paymaster) */ + SimplexPaymaster?: `0x${string}` + /** Circle Paymaster contract address (USDC-based ERC-4337 paymaster) */ + CirclePaymaster?: `0x${string}` } rpcEnvKey?: string defaultRpcUrl?: string @@ -252,7 +254,7 @@ export const chainConfigs: Record = { UniswapV3Factory: "0x0000000000000000000000000000000000000000", Calldispatcher: "0xC7f13b6D03A0A7F3239d38897503E90553ABe155", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", + SimplexPaymaster: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", }, rpcEnvKey: "SEPOLIA", defaultRpcUrl: "https://1rpc.io/sepolia", @@ -301,7 +303,7 @@ export const chainConfigs: Record = { Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", + CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", Usdt0Oft: "0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee", }, rpcEnvKey: "ETH_MAINNET", @@ -414,7 +416,7 @@ export const chainConfigs: Record = { Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", + CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", Usdt0Oft: "0x14E4A1B13bf7F943c8ff7C51fb60FA964A298D92", }, rpcEnvKey: "ARBITRUM_MAINNET", @@ -457,7 +459,7 @@ export const chainConfigs: Record = { addresses: { IntentGateway: "0x1a4ee689a004b10210a1df9f24a387ea13359acf", IntentGatewayV2: "0x2d61624A17f361020679FaA16fbB566C344AaF4B", - SolverAccount: "0xb7d5Bb305Fd102C9B0a343978f3b9Accc00e9603", + SolverAccount: "0x66C4459fa61E5Ca647152EEb6dA56150EE975512", TokenGateway: "0xFd413e3AFe560182C4471F4d143A96d3e259B6dE", Host: "0x6FFe92e4d7a9D589549644544780e6725E84b248", UniswapRouter02: "0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24", @@ -469,7 +471,7 @@ export const chainConfigs: Record = { Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", + CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", AerodromeRouter: "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", UniswapV4PositionManager: "0x7c5f5a4bbd8fd63184577525326123b519429bdc", UniswapV4PoolManager: "0x498581ff718922c3f8e6a244956af099b2652b2b", @@ -531,7 +533,7 @@ export const chainConfigs: Record = { Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", + CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", Usdt0Oft: "0x6BA10300f0DC58B7a1e4c0e41f5daBb7D7829e13", }, rpcEnvKey: "POLYGON_MAINNET", @@ -579,7 +581,7 @@ export const chainConfigs: Record = { Calldispatcher: "0xc71251c8b3e7b02697a84363eef6dce8dfbdf333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", + CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", Usdt0Oft: "0xc07be8994d035631c36fb4a89c918cefb2f03ec3", }, rpcEnvKey: "UNICHAIN_MAINNET", @@ -650,7 +652,7 @@ export const chainConfigs: Record = { Calldispatcher: "0x876F1891982E260026630c233A4897160A281Fb8", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", + SimplexPaymaster: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", SolverAccount: "0xCDFcFeD7A14154846808FddC8Ba971A2f8a830a3", }, rpcEnvKey: "POLYGON_AMOY", @@ -684,7 +686,7 @@ export const chainConfigs: Record = { Calldispatcher: "0xC71251c8b3e7B02697A84363Eef6DcE8DfBdF333", Permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", + CirclePaymaster: "0x0578cFB241215b77442a541325d6A4E6dFE700Ec", UniswapV4PositionManager: "0x3c3ea4b57a46241e54610e5f022e5c45859a1017", UniswapV4PoolManager: "0x9a13f98cb987694c9f086b1f5eb990eea8264ec3", }, @@ -766,7 +768,7 @@ export const chainConfigs: Record = { TokenGateway: "0xFcDa26cA021d5535C3059547390E6cCd8De7acA6", Host: "0x3435bD7e5895356535459D6087D1eB982DAd90e7", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", + SimplexPaymaster: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", }, defaultRpcUrl: "https://sepolia-rollup.arbitrum.io/rpc", consensusStateId: "ETH0", @@ -791,7 +793,7 @@ export const chainConfigs: Record = { TokenGateway: "0xFcDa26cA021d5535C3059547390E6cCd8De7acA6", Host: "0x6d51b678836d8060d980605d2999eF211809f3C2", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", + SimplexPaymaster: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", }, defaultRpcUrl: "https://sepolia.optimism.io", consensusStateId: "ETH0", @@ -816,7 +818,7 @@ export const chainConfigs: Record = { TokenGateway: "0xFcDa26cA021d5535C3059547390E6cCd8De7acA6", Host: "0xD198c01839dd4843918617AfD1e4DDf44Cc3BB4a", EntryPointV08: "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108", - CirclePaymasterV08: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", + SimplexPaymaster: "0x3BA9A96eE3eFf3A69E2B18886AcF52027EFF8966", }, defaultRpcUrl: "https://sepolia.base.org", consensusStateId: "ETH0", diff --git a/sdk/packages/sdk/src/protocols/intents/BidManager.ts b/sdk/packages/sdk/src/protocols/intents/BidManager.ts index 1da5fbd6b..a5fc58566 100644 --- a/sdk/packages/sdk/src/protocols/intents/BidManager.ts +++ b/sdk/packages/sdk/src/protocols/intents/BidManager.ts @@ -74,8 +74,8 @@ export class BidManager { this.ctx.dest.client.chain?.id ?? Number.parseInt(this.ctx.dest.config.stateMachineId.split("-")[1]), ) - const accountGasLimits = this.crypto.packGasLimits(verificationGasLimit, callGasLimit) - const gasFees = this.crypto.packGasFees(maxPriorityFeePerGas, maxFeePerGas) + const accountGasLimits = CryptoUtils.packGasLimits(verificationGasLimit, callGasLimit) + const gasFees = CryptoUtils.packGasFees(maxPriorityFeePerGas, maxFeePerGas) const userOp: PackedUserOperation = { sender: solverAccount, @@ -89,7 +89,7 @@ export class BidManager { signature: "0x" as HexString, } - const userOpHash = this.crypto.computeUserOpHash(userOp, entryPointAddress, chainId) + const userOpHash = CryptoUtils.computeUserOpHash(userOp, entryPointAddress, chainId) const sessionKey = order.session const messageHash = keccak256(concat([userOpHash, order.id as HexString, sessionKey as HexString])) @@ -154,7 +154,7 @@ export class BidManager { hexToString(order.destination as HexString), ) - const domainSeparator = this.crypto.getDomainSeparator( + const domainSeparator = CryptoUtils.getDomainSeparator( "IntentGateway", "2", BigInt( @@ -172,7 +172,7 @@ export class BidManager { const solverAddress = bidWithOptions.bid.userOp.sender console.log(`[BidManager] Simulating bid ${idx + 1}/${sortedBids.length} from solver=${solverAddress}`) - const signature = await this.crypto.signSolverSelection( + const signature = await CryptoUtils.signSolverSelection( commitment, solverAddress, domainSeparator, @@ -229,7 +229,7 @@ export class BidManager { ) const bundlerResult = await this.crypto.sendBundler(BundlerMethod.ETH_SEND_USER_OPERATION, [ - this.crypto.prepareBundlerCall(signedUserOp), + CryptoUtils.prepareBundlerCall(signedUserOp), entryPointAddress, ]) diff --git a/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts b/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts index 330fe0d96..009e597c4 100644 --- a/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts +++ b/sdk/packages/sdk/src/protocols/intents/CryptoUtils.ts @@ -77,7 +77,7 @@ export class CryptoUtils { * @param contractAddress - Address of the verifying contract. * @returns The 32-byte domain separator as a hex string. */ - getDomainSeparator(contractName: string, version: string, chainId: bigint, contractAddress: HexString): HexString { + static getDomainSeparator(contractName: string, version: string, chainId: bigint, contractAddress: HexString): HexString { return keccak256( encodeAbiParameters(parseAbiParameters("bytes32, bytes32, bytes32, uint256, address"), [ DOMAIN_TYPEHASH, @@ -102,7 +102,7 @@ export class CryptoUtils { * @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. */ - async signSolverSelection( + static async signSolverSelection( commitment: HexString, solverAddress: HexString, domainSeparator: HexString, @@ -132,9 +132,9 @@ export class CryptoUtils { * @param chainId - Chain ID of the network on which the operation will execute. * @returns The UserOperation hash as a hex string. */ - computeUserOpHash(userOp: PackedUserOperation, entryPoint: Hex, chainId: bigint): Hex { - const structHash = this.getPackedUserStructHash(userOp) - const domainSeparator = this.getDomainSeparator("ERC4337", "1", chainId, entryPoint as HexString) + static computeUserOpHash(userOp: PackedUserOperation, entryPoint: Hex, chainId: bigint): Hex { + const structHash = CryptoUtils.getPackedUserStructHash(userOp) + const domainSeparator = CryptoUtils.getDomainSeparator("ERC4337", "1", chainId, entryPoint as HexString) return keccak256( encodePacked(["bytes1", "bytes1", "bytes32", "bytes32"], ["0x19", "0x01", domainSeparator, structHash]), @@ -150,7 +150,7 @@ export class CryptoUtils { * @param userOp - The packed UserOperation to hash. * @returns The struct hash as a 32-byte hex string. */ - getPackedUserStructHash(userOp: PackedUserOperation): HexString { + static getPackedUserStructHash(userOp: PackedUserOperation): HexString { return keccak256( encodeAbiParameters( parseAbiParameters("bytes32, address, uint256, bytes32, bytes32, bytes32, uint256, bytes32, bytes32"), @@ -180,7 +180,7 @@ export class CryptoUtils { * @param callGasLimit - Gas limit for the main execution call. * @returns A 32-byte hex string with both limits packed. */ - packGasLimits(verificationGasLimit: bigint, callGasLimit: bigint): HexString { + static packGasLimits(verificationGasLimit: bigint, callGasLimit: bigint): HexString { const verificationGasHex = pad(toHex(verificationGasLimit), { size: 16 }) const callGasHex = pad(toHex(callGasLimit), { size: 16 }) return concat([verificationGasHex, callGasHex]) as HexString @@ -197,7 +197,7 @@ export class CryptoUtils { * @param maxFeePerGas - Maximum total fee per gas (EIP-1559). * @returns A 32-byte hex string with both fee values packed. */ - packGasFees(maxPriorityFeePerGas: bigint, maxFeePerGas: bigint): HexString { + static packGasFees(maxPriorityFeePerGas: bigint, maxFeePerGas: bigint): HexString { const priorityFeeHex = pad(toHex(maxPriorityFeePerGas), { size: 16 }) const maxFeeHex = pad(toHex(maxFeePerGas), { size: 16 }) return concat([priorityFeeHex, maxFeeHex]) as HexString @@ -210,7 +210,7 @@ export class CryptoUtils { * @param accountGasLimits - The packed 32-byte gas limits field from a `PackedUserOperation`. * @returns Object with `verificationGasLimit` and `callGasLimit` as bigints. */ - unpackGasLimits(accountGasLimits: HexString): { verificationGasLimit: bigint; callGasLimit: bigint } { + static unpackGasLimits(accountGasLimits: HexString): { verificationGasLimit: bigint; callGasLimit: bigint } { const hex = accountGasLimits.slice(2) const verificationGasLimit = BigInt(`0x${hex.slice(0, 32)}`) const callGasLimit = BigInt(`0x${hex.slice(32, 64)}`) @@ -223,7 +223,7 @@ export class CryptoUtils { * @param gasFees - The packed 32-byte gas fees field from a `PackedUserOperation`. * @returns Object with `maxPriorityFeePerGas` and `maxFeePerGas` as bigints. */ - unpackGasFees(gasFees: HexString): { maxPriorityFeePerGas: bigint; maxFeePerGas: bigint } { + static unpackGasFees(gasFees: HexString): { maxPriorityFeePerGas: bigint; maxFeePerGas: bigint } { const hex = gasFees.slice(2) const maxPriorityFeePerGas = BigInt(`0x${hex.slice(0, 32)}`) const maxFeePerGas = BigInt(`0x${hex.slice(32, 64)}`) @@ -240,9 +240,9 @@ export class CryptoUtils { * @param userOp - The packed UserOperation to convert. * @returns A plain object safe to pass as the first element of bundler RPC params. */ - prepareBundlerCall(userOp: PackedUserOperation): Record { - const { verificationGasLimit, callGasLimit } = this.unpackGasLimits(userOp.accountGasLimits) - const { maxPriorityFeePerGas, maxFeePerGas } = this.unpackGasFees(userOp.gasFees) + static prepareBundlerCall(userOp: PackedUserOperation): Record { + const { verificationGasLimit, callGasLimit } = CryptoUtils.unpackGasLimits(userOp.accountGasLimits) + const { maxPriorityFeePerGas, maxFeePerGas } = CryptoUtils.unpackGasFees(userOp.gasFees) const hasFactory = userOp.initCode && userOp.initCode !== "0x" && userOp.initCode.length > 2 const factory = hasFactory ? (`0x${userOp.initCode.slice(2, 42)}` as HexString) : undefined diff --git a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts index a6fbb1cd6..87645eb9f 100644 --- a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts +++ b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts @@ -157,7 +157,7 @@ export class GasEstimator { let callGasLimit: bigint = 500_000n let verificationGasLimit: bigint = 100_000n let preVerificationGas: bigint = 100_000n - // Circle Paymaster v0.8 caps used as fallback when the bundler doesn't return paymaster gas fields. + // SimplexPaymaster v0.8 caps used as fallback when the bundler doesn't return paymaster gas fields. let paymasterVerificationGasLimit: bigint = 0n let paymasterPostOpGasLimit: bigint = 0n @@ -168,8 +168,8 @@ export class GasEstimator { { target: intentGatewayV2Address, value: totalNativeValue, data: fillOrderCalldata }, ]) - const accountGasLimits = this.crypto.packGasLimits(100_000n, callGasLimit) - const gasFees = this.crypto.packGasFees(maxPriorityFeePerGas, maxFeePerGas) + const accountGasLimits = CryptoUtils.packGasLimits(100_000n, callGasLimit) + const gasFees = CryptoUtils.packGasFees(maxPriorityFeePerGas, maxFeePerGas) const nonce = 0n @@ -185,7 +185,7 @@ export class GasEstimator { signature: "0x" as HexString, } - const userOpHash = this.crypto.computeUserOpHash(preliminaryUserOp, entryPointAddress, chainId) + const userOpHash = CryptoUtils.computeUserOpHash(preliminaryUserOp, entryPointAddress, chainId) const messageHash = keccak256( concat([userOpHash, commitment as HexString, solverAccountAddress as import("viem").Hex]), ) @@ -194,13 +194,13 @@ export class GasEstimator { }) const solverSig = concat([commitment as HexString, solverSignature as import("viem").Hex]) as HexString - const domainSeparator = this.crypto.getDomainSeparator( + const domainSeparator = CryptoUtils.getDomainSeparator( "IntentGateway", "2", chainId, intentGatewayV2Address, ) - const sessionSignature = await this.crypto.signSolverSelection( + const sessionSignature = await CryptoUtils.signSolverSelection( commitment as HexString, solverAccountAddress, domainSeparator, @@ -212,7 +212,7 @@ export class GasEstimator { sessionSignature as import("viem").Hex, ]) as HexString - const bundlerUserOp = this.crypto.prepareBundlerCall(preliminaryUserOp) + const bundlerUserOp = CryptoUtils.prepareBundlerCall(preliminaryUserOp) const bundlerUrlLower = this.ctx.bundlerUrl.toLowerCase() const isPimlico = bundlerUrlLower.includes("pimlico.io") const isAlchemy = bundlerUrlLower.includes("alchemy.com") diff --git a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts index 8fe7c4de7..61874ace5 100644 --- a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts +++ b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts @@ -3,6 +3,7 @@ import type { IntentOrderStatusUpdate, ExecuteIntentOrderOptions, FillerBid, Sel import { sleep, DEFAULT_POLL_INTERVAL, hexToString } from "@/utils" import type { IntentGatewayContext } from "./types" import { BidManager } from "./BidManager" +import { CryptoUtils } from "./CryptoUtils" // @ts-ignore import mergeRace from "@async-generator/merge-race" @@ -95,7 +96,7 @@ export class OrderExecutor { const chainId = BigInt( this.ctx.dest.client.chain?.id ?? Number.parseInt(this.ctx.dest.config.stateMachineId.split("-")[1]), ) - return (userOp) => this.crypto.computeUserOpHash(userOp, entryPointAddress, chainId) + return (userOp) => CryptoUtils.computeUserOpHash(userOp, entryPointAddress, chainId) } /** diff --git a/sdk/packages/sdk/src/protocols/intents/index.ts b/sdk/packages/sdk/src/protocols/intents/index.ts index 8295d0ed9..f9abf17a1 100644 --- a/sdk/packages/sdk/src/protocols/intents/index.ts +++ b/sdk/packages/sdk/src/protocols/intents/index.ts @@ -1,7 +1,7 @@ export { IntentGateway } from "./IntentGateway" export { OrderStatusChecker } from "./OrderStatusChecker" export { encodeERC7821ExecuteBatch, transformOrderForContract, fetchSourceProof, orderCommitment } from "./utils" -export { SELECT_SOLVER_TYPEHASH, PACKED_USEROP_TYPEHASH, DOMAIN_TYPEHASH } from "./CryptoUtils" +export { CryptoUtils, SELECT_SOLVER_TYPEHASH, PACKED_USEROP_TYPEHASH, DOMAIN_TYPEHASH } from "./CryptoUtils" export { DEFAULT_GRAFFITI, ERC7821_BATCH_MODE, diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index f5a4d9346..a601faa9a 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1187,7 +1187,7 @@ export interface SigningAccount { /** Signs a raw 32-byte hash, returning split signature components for EIP-7702 etc. */ signRawHash: (hash: HexString) => Promise<{ r: HexString; s: HexString; yParity: number }> /** - * Signs an EIP-712 typed-data payload (e.g. an EIP-2612 USDC permit for the Circle paymaster). + * Signs an EIP-712 typed-data payload (e.g. an EIP-2612 USDC permit for the SimplexPaymaster). * The shape of `typedData` matches viem's `TypedDataDefinition` (domain + types + message). */ signTypedData: (typedData: unknown, chainId?: number) => Promise @@ -1248,9 +1248,9 @@ export interface FillOrderEstimate { callGasLimit: bigint verificationGasLimit: bigint preVerificationGas: bigint - /** Paymaster verification gas limit from bundler estimate, or Circle's cap if absent. 0n when no paymaster. */ + /** Paymaster verification gas limit from bundler estimate, or SimplexPaymaster cap if absent. 0n when no paymaster. */ paymasterVerificationGasLimit: bigint - /** Paymaster postOp gas limit from bundler estimate, or Circle's cap if absent. 0n when no paymaster. */ + /** Paymaster postOp gas limit from bundler estimate, or SimplexPaymaster cap if absent. 0n when no paymaster. */ paymasterPostOpGasLimit: bigint maxFeePerGas: bigint maxPriorityFeePerGas: bigint diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index 53bc0c5a9..0236d28a7 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -7,8 +7,7 @@ import { parse } from "toml" import { IntentFiller } from "@/core/filler" import { BasicFiller } from "@/strategies/basic" import { FXFiller } from "@/strategies/fx" -import type { AerodromePoolConfig, FundingVenue, UniswapV4PositionConfig } from "@/funding/types" -import { AerodromeFundingPlanner } from "@/funding/aerodrome/AerodromeFundingPlanner" +import type { FundingVenue, UniswapV4PositionConfig } from "@/funding/types" import { UniswapV4FundingPlanner } from "@/funding/uniswapV4/UniswapV4FundingPlanner" import { ConfirmationPolicy, FillerBpsPolicy, FillerPricePolicy } from "@/config/interpolated-curve" import { ChainConfig, FillerConfig, HexString } from "@hyperbridge/sdk" @@ -72,11 +71,6 @@ interface BasicStrategyConfig { confirmationPolicies?: Record } -/** TOML row for an Aerodrome pool; `chain` is the state machine id e.g. `EVM-8453`. */ -interface AerodromePoolToml extends AerodromePoolConfig { - chain: string -} - /** TOML row for a Uniswap V4 position; only chain + tokenId needed. */ interface UniswapV4PositionToml { chain: string @@ -122,9 +116,6 @@ interface FxStrategyConfig { confirmationPolicies?: Record /** Optional on-chain liquidity funding for destination-chain outputs */ vault?: { - aerodrome?: { - pools?: AerodromePoolToml[] - } uniswapV4?: { positions?: UniswapV4PositionToml[] } @@ -338,20 +329,6 @@ program } const fxConfirmationPolicy = new ConfirmationPolicy(mergedFxPolicies) const fundingVenues: FundingVenue[] = [] - if (strategyConfig.vault?.aerodrome?.pools?.length) { - const poolsByChain: Record = {} - for (const row of strategyConfig.vault.aerodrome.pools) { - const chain = row.chain - if (!poolsByChain[chain]) poolsByChain[chain] = [] - poolsByChain[chain].push({ - pair: row.pair, - gauge: row.gauge, - }) - } - fundingVenues.push( - new AerodromeFundingPlanner(chainClientManager, { poolsByChain }, configService, strategyConfig.spreadBps), - ) - } if (strategyConfig.vault?.uniswapV4?.positions?.length) { const positionsByChain: Record = {} for (const row of strategyConfig.vault.uniswapV4.positions) { @@ -580,9 +557,6 @@ function validateConfig(config: FillerTomlConfig): void { } if (strategy.type === "hyperfx") { - if (strategy.vault?.aerodrome?.pools?.length) { - AerodromeFundingPlanner.validateConfig(strategy.vault.aerodrome.pools) - } if (strategy.vault?.uniswapV4?.positions?.length) { UniswapV4FundingPlanner.validateConfig(strategy.vault.uniswapV4.positions) } diff --git a/sdk/packages/simplex/src/config/abis/Aerodrome.ts b/sdk/packages/simplex/src/config/abis/Aerodrome.ts deleted file mode 100644 index 6fa39ce39..000000000 --- a/sdk/packages/simplex/src/config/abis/Aerodrome.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Minimal Aerodrome / Solidly-style router, pair, and gauge ABIs. - * Deployments may differ slightly; extend fragments if a chain adds functions. - */ - -export const AERODROME_PAIR_ABI = [ - { - name: "token0", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{ type: "address" }], - }, - { - name: "token1", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{ type: "address" }], - }, - { - name: "stable", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{ type: "bool" }], - }, - { - name: "totalSupply", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{ type: "uint256" }], - }, - { - name: "getReserves", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [ - { name: "reserve0", type: "uint256" }, - { name: "reserve1", type: "uint256" }, - { name: "blockTimestampLast", type: "uint256" }, - ], - }, - { - name: "balanceOf", - type: "function", - stateMutability: "view", - inputs: [{ name: "account", type: "address" }], - outputs: [{ type: "uint256" }], - }, - { - name: "approve", - type: "function", - stateMutability: "nonpayable", - inputs: [ - { name: "spender", type: "address" }, - { name: "amount", type: "uint256" }, - ], - outputs: [{ type: "bool" }], - }, -] as const - -export const AERODROME_ROUTER_ABI = [ - { - name: "quoteRemoveLiquidity", - type: "function", - stateMutability: "view", - inputs: [ - { name: "tokenA", type: "address" }, - { name: "tokenB", type: "address" }, - { name: "stable", type: "bool" }, - { name: "liquidity", type: "uint256" }, - ], - outputs: [ - { name: "amountA", type: "uint256" }, - { name: "amountB", type: "uint256" }, - ], - }, - { - name: "removeLiquidity", - type: "function", - stateMutability: "nonpayable", - inputs: [ - { name: "tokenA", type: "address" }, - { name: "tokenB", type: "address" }, - { name: "stable", type: "bool" }, - { name: "liquidity", type: "uint256" }, - { name: "amountAMin", type: "uint256" }, - { name: "amountBMin", type: "uint256" }, - { name: "to", type: "address" }, - { name: "deadline", type: "uint256" }, - ], - outputs: [ - { name: "amountA", type: "uint256" }, - { name: "amountB", type: "uint256" }, - ], - }, -] as const - -export const AERODROME_GAUGE_ABI = [ - { - name: "withdraw", - type: "function", - stateMutability: "nonpayable", - inputs: [{ name: "amount", type: "uint256" }], - outputs: [], - }, - { - name: "balanceOf", - type: "function", - stateMutability: "view", - inputs: [{ name: "account", type: "address" }], - outputs: [{ type: "uint256" }], - }, - { - name: "stakingToken", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{ type: "address" }], - }, -] as const diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index b3aa3a45c..730f33c3f 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -20,6 +20,7 @@ import { import { FillerConfigService } from "@/services/FillerConfigService" import { getLogger } from "@/services/Logger" import type { SigningAccount } from "@/services/wallet" +import { hasPaymaster } from "@/services/paymaster" import { Decimal } from "decimal.js" export class IntentFiller { @@ -126,20 +127,13 @@ export class IntentFiller { } // Ensure EntryPoint deposit covers target gas units on chains - // that do NOT have a Circle Paymaster configured (e.g. BSC). - // Chains with a paymaster address pay gas in USDC instead. + // that do NOT have any paymaster (Circle or Simplex) configured. + // Chains with a paymaster address pay gas in ERC-20 tokens instead. + // Paymaster approval is handled per-order inside buildPaymasterData/buildCirclePaymasterData. const targetGasUnits = this.configService.getTargetGasUnits() for (const chain of chainsWithSolverSelection) { - const paymasterAddress = this.configService.getCirclePaymasterV08Address(chain) - if (paymasterAddress) { - this.logger.info({ chain }, "Skipping EntryPoint deposit — Circle Paymaster available") - // Ensure solver has max USDC allowance to the paymaster so that - // per-bid permit signing is never needed (saves ~2-10s for MPC signers). - try { - await this.contractService.ensurePaymasterApproval(chain, paymasterAddress) - } catch (err) { - this.logger.warn({ chain, err }, "Failed to ensure paymaster USDC approval") - } + if (hasPaymaster(chain, this.configService)) { + this.logger.info({ chain }, "Skipping EntryPoint deposit — paymaster available") continue } try { @@ -592,10 +586,10 @@ export class IntentFiller { private handleOrderFilledOnChain(commitment: HexString, filler: string, chainId: number): void { // Top up EntryPoint deposit if we were the filler, but only on chains - // without Circle Paymaster (paymaster chains pay gas in USDC). + // without any paymaster (paymaster chains pay gas in ERC-20 tokens). if (filler.toLowerCase() === this.fillerAddress.toLowerCase()) { const chain = `EVM-${chainId}` - if (!this.configService.getCirclePaymasterV08Address(chain)) { + if (!hasPaymaster(chain, this.configService)) { const targetGasUnits = this.configService.getTargetGasUnits() this.contractService.topUpEntryPointDeposit(chain, targetGasUnits, 1_000_000n).catch((err) => { this.logger.error({ commitment, chain, err }, "Post-fill EntryPoint deposit top-up failed") diff --git a/sdk/packages/simplex/src/funding/aerodrome/AerodromeFundingPlanner.ts b/sdk/packages/simplex/src/funding/aerodrome/AerodromeFundingPlanner.ts deleted file mode 100644 index 1f70458e9..000000000 --- a/sdk/packages/simplex/src/funding/aerodrome/AerodromeFundingPlanner.ts +++ /dev/null @@ -1,419 +0,0 @@ -import type { HexString } from "@hyperbridge/sdk" -import { type ERC7821Call, encodeERC7821ExecuteBatch } from "@hyperbridge/sdk" -import { encodeFunctionData, maxUint256 } from "viem" -import { Mutex } from "async-mutex" -import type { Decimal } from "decimal.js" -import type { ChainClientManager } from "@/services/ChainClientManager" -import type { FillerConfigService } from "@/services/FillerConfigService" -import type { FundingPlanResult, FundingVenue, AerodromeOutputFundingConfig, HydratedPool } from "@/funding/types" -import { AerodromeLiquidityState } from "@/funding/aerodrome/AerodromeLiquidityState" -import { AERODROME_GAUGE_ABI, AERODROME_ROUTER_ABI } from "@/config/abis/Aerodrome" -import { ERC20_ABI } from "@/config/abis/ERC20" -import { getLogger } from "@/services/Logger" - -const logger = getLogger("aerodrome-funding") - -/** Default slippage for `amount0Min` / `amount1Min` (50 bps). */ -const DEFAULT_MIN_AMOUNT_OUT_BPS = 9950 -const MAX_BS_ITER = 48 - -// ============================================================================ -// Helpers -// ============================================================================ - -function sortTokens(t0: HexString, t1: HexString): [HexString, HexString] { - return t0.toLowerCase() < t1.toLowerCase() ? [t0, t1] : [t1, t0] -} - -/** - * Map router (tokenA, tokenB) amounts → pair (token0, token1) order. - * `tokenA` is whichever address was passed first to the router call. - */ -function mapRouterAmountsToToken01( - token0: HexString, - tokenA: HexString, - amountA: bigint, - amountB: bigint, -): { amount0: bigint; amount1: bigint } { - if (tokenA.toLowerCase() === token0.toLowerCase()) { - return { amount0: amountA, amount1: amountB } - } - return { amount0: amountB, amount1: amountA } -} - -/** - * Reverse of above: token0/token1 mins → router's tokenA/tokenB order. - */ -function mapToken01MinsToRouter( - token0: HexString, - tokenA: HexString, - amount0Min: bigint, - amount1Min: bigint, -): { amountAMin: bigint; amountBMin: bigint } { - if (tokenA.toLowerCase() === token0.toLowerCase()) { - return { amountAMin: amount0Min, amountBMin: amount1Min } - } - return { amountAMin: amount1Min, amountBMin: amount0Min } -} - -/** - * Closed-form LP needed for a volatile pool. - * `LP = ceil(deficit * totalSupply / reserveOut)` - */ -function liquidityForVolatileDeficit(deficit: bigint, reserveOut: bigint, totalSupply: bigint): bigint { - if (deficit <= 0n || reserveOut === 0n || totalSupply === 0n) return 0n - return (deficit * totalSupply + reserveOut - 1n) / reserveOut -} - -// ============================================================================ -// Planner -// ============================================================================ - -export class AerodromeFundingPlanner implements FundingVenue { - name = "Aerodrome" - /** Long-lived state per chain, keyed by chain identifier. */ - private stateByChain = new Map() - /** Per-chain mutex serialising planWithdrawalForToken to prevent concurrent state races. */ - private mutexByChain = new Map() - - constructor( - private readonly clientManager: ChainClientManager, - private readonly config: AerodromeOutputFundingConfig, - private readonly configService: FillerConfigService, - spreadBps?: number, - ) { - this._minAmountOutBps = spreadBps !== undefined ? 10000 - spreadBps : DEFAULT_MIN_AMOUNT_OUT_BPS - } - - private readonly _minAmountOutBps: number - - /** - * Validates raw TOML pool entries before constructing the planner. - * Throws on missing/invalid required fields. Address availability - * is checked later in `initialise()` when configService is available. - */ - static validateConfig(pools: { chain?: string; pair?: string; gauge?: string }[]): void { - for (const pool of pools) { - if (!pool.chain?.trim()) { - throw new Error("Each Aerodrome vault pool must have a non-empty 'chain' (e.g. EVM-8453)") - } - if (!pool.pair) { - throw new Error("Each Aerodrome pool must include a 'pair' address") - } - } - } - - get minAmountOutBps(): number { - return this._minAmountOutBps - } - - // ========================================================================= - // Lifecycle - // ========================================================================= - - /** - * Call once at startup. Hydrates every configured chain's pools - * (reads token0/token1/stable, validates gauge, fetches initial reserves - * and LP balances). Replaces the old `validateAerodromePoolsOnChain`. - */ - async initialise(solver: HexString): Promise { - const approvalsByChain = new Map() - for (const [chain, pools] of Object.entries(this.config.poolsByChain)) { - const router = this.configService.getAerodromeRouterAddress(chain) - const z = router.toLowerCase() - if (!z || z === "0x" || z === "0x0000000000000000000000000000000000000000") { - throw new Error(`Aerodrome router not configured for chain ${chain}`) - } - const state = new AerodromeLiquidityState(chain, pools, router, solver, this.clientManager) - await state.hydrate() - this.stateByChain.set(chain, state) - this.mutexByChain.set(chain, new Mutex()) - - const client = this.clientManager.getPublicClient(chain) - const calls: ERC7821Call[] = [] - for (const pool of state.allPools()) { - const allowance = (await client.readContract({ - address: pool.pair, - abi: ERC20_ABI, - functionName: "allowance", - args: [solver, pool.router], - })) as bigint - if (allowance < maxUint256 / 2n) { - calls.push({ - target: pool.pair, - value: 0n, - data: encodeFunctionData({ - abi: ERC20_ABI, - functionName: "approve", - args: [pool.router, maxUint256], - }) as HexString, - }) - } - } - if (calls.length > 0) { - approvalsByChain.set(chain, calls) - } - } - for (const [chain, calls] of approvalsByChain) { - const walletClient = this.clientManager.getWalletClient(chain) - const callData = encodeERC7821ExecuteBatch(calls) - await walletClient.sendTransaction({ - to: solver, - data: callData, - value: 0n, - chain: walletClient.chain, - }) - logger.info({ chain, approvalCount: calls.length }, "Batched LP token approvals via ERC-7821") - } - } - - /** - * Refresh live data (reserves, LP balances) for one or all chains. - * Called automatically at the start of planWithdrawalForToken for the - * relevant chain. - */ - async refresh(chain?: string): Promise { - if (chain) { - const state = this.stateByChain.get(chain) - if (state) await state.refresh() - } else { - await Promise.all(Array.from(this.stateByChain.values()).map((s) => s.refresh())) - } - } - - /** Retrieve the liquidity state for a chain. Returns undefined if not configured. */ - getState(chain: string): AerodromeLiquidityState | undefined { - return this.stateByChain.get(chain) - } - - async getExoticTokenPrice(_chain: string, _exoticToken: string): Promise { - return null - } - - // ========================================================================= - // Planning - // ========================================================================= - - /** - * Produces ERC-7821 calls to withdraw LP and remove liquidity so that at - * least `amountNeeded` of `tokenOut` is credited to the solver. - * - * Refreshes on-chain state (reserves, LP balances) for the destination - * chain immediately before planning to ensure calculations use the latest - * data. Only stable-pool binary search still needs additional RPC calls - * (quoteRemoveLiquidity). - */ - async planWithdrawalForToken( - destChain: string, - solver: HexString, - tokenOutLower: string, - amountNeeded: bigint, - deadlineTimestamp?: bigint, - ): Promise { - const noopResult: FundingPlanResult = { calls: [], credited: 0n } - - if (amountNeeded <= 0n) return noopResult - - const state = this.stateByChain.get(destChain) - if (!state || !state.isHydrated()) return noopResult - - const mutex = this.mutexByChain.get(destChain)! - return mutex.runExclusive(async () => { - // Refresh on-chain state for this chain right before planning so - // reserves and LP balances are as fresh as possible. - await state.refresh() - - const tokenNeed = tokenOutLower.toLowerCase() - const candidatePools = state.poolsForToken(tokenNeed) - - const slippageBps = 10000n - BigInt(this.minAmountOutBps) - const bufferedAmount = amountNeeded + (amountNeeded * slippageBps) / 10000n - - for (const pool of candidatePools) { - const lpAvail = state.remaining(pool.pair) - if (lpAvail === 0n) continue - - const [tokenA, tokenB] = sortTokens(pool.token0, pool.token1) - - // --- solve LP amount --- - const liquidity = - this.solveLiquidityForDeficit(pool, tokenNeed, bufferedAmount, lpAvail) ?? - (await this.solveLiquidityForDeficitStable( - destChain, - pool, - tokenA, - tokenB, - tokenNeed, - bufferedAmount, - lpAvail, - )) - if (liquidity <= 0n) continue - - const cappedL = liquidity > lpAvail ? lpAvail : liquidity - - // --- quote expected output --- - const expected = await this.quoteExpectedAmounts(destChain, pool, tokenA, tokenB, cappedL) - if (!expected) continue - - const { amount0, amount1 } = mapRouterAmountsToToken01( - pool.token0, - tokenA, - expected.amountA, - expected.amountB, - ) - const credit = tokenNeed === pool.token0.toLowerCase() ? amount0 : amount1 - if (credit === 0n) continue - - // --- slippage --- - // By the time the tx lands, reserves can change (other swaps, liquidity moves, ordering in the block). - const bps = BigInt(this.minAmountOutBps) - const amount0Min = (amount0 * bps) / 10000n - const amount1Min = (amount1 * bps) / 10000n - const { amountAMin, amountBMin } = mapToken01MinsToRouter(pool.token0, tokenA, amount0Min, amount1Min) - - // --- build ERC-7821 calls --- - const deadline = deadlineTimestamp ?? BigInt(Math.floor(Date.now() / 1000) + 30 * 60) - const calls: ERC7821Call[] = [] - - // 1. Gauge withdraw (if needed) - if (pool.gauge) { - // Only withdraw the shortfall beyond what's already in the wallet. - const shortfall = cappedL > pool.walletLp ? cappedL - pool.walletLp : 0n - const withdrawAmt = shortfall > pool.gaugeLp ? pool.gaugeLp : shortfall - if (withdrawAmt > 0n) { - calls.push({ - target: pool.gauge, - value: 0n, - data: encodeFunctionData({ - abi: AERODROME_GAUGE_ABI, - functionName: "withdraw", - args: [withdrawAmt], - }) as HexString, - }) - } - } - - // 2. Remove liquidity - calls.push({ - target: pool.router, - value: 0n, - data: encodeFunctionData({ - abi: AERODROME_ROUTER_ABI, - functionName: "removeLiquidity", - args: [tokenA, tokenB, pool.stable, cappedL, amountAMin, amountBMin, solver, deadline], - }) as HexString, - }) - - logger.debug( - { - pair: pool.pair, - liquidity: cappedL.toString(), - tokenOut: tokenNeed, - credited: credit.toString(), - }, - "Aerodrome funding planned", - ) - - state.consume(pool.pair, cappedL) - return { calls, credited: credit } - } - - return noopResult - }) // mutex.runExclusive - } - - // ========================================================================= - // Private — LP solving - // ========================================================================= - - /** - * Volatile pool: closed-form solve from cached reserves + totalSupply. - * Returns `null` for stable pools (caller falls through to binary search). - */ - private solveLiquidityForDeficit( - pool: HydratedPool, - tokenNeedLower: string, - deficit: bigint, - lpMax: bigint, - ): bigint | null { - if (pool.stable) return null - if (lpMax === 0n || deficit <= 0n) return 0n - - const reserveOut = tokenNeedLower === pool.token0.toLowerCase() ? pool.reserve0 : pool.reserve1 - - const L = liquidityForVolatileDeficit(deficit, reserveOut, pool.totalSupply) - return L > lpMax ? lpMax : L - } - - /** - * Stable pool: binary search over `quoteRemoveLiquidity`. - * This is the only path that still needs RPC calls during planning. - */ - private async solveLiquidityForDeficitStable( - chain: string, - pool: HydratedPool, - tokenA: HexString, - tokenB: HexString, - tokenNeedLower: string, - deficit: bigint, - lpMax: bigint, - ): Promise { - if (lpMax === 0n || deficit <= 0n) return 0n - - let lo = 0n - let hi = lpMax - let best = 0n - - for (let i = 0; i < MAX_BS_ITER; i++) { - if (lo > hi) break - const mid = (lo + hi) / 2n - if (mid === 0n) { - lo = 1n - continue - } - - const q = await this.quoteExpectedAmounts(chain, pool, tokenA, tokenB, mid) - if (!q) { - hi = mid - 1n - continue - } - - const { amount0, amount1 } = mapRouterAmountsToToken01(pool.token0, tokenA, q.amountA, q.amountB) - const out = tokenNeedLower === pool.token0.toLowerCase() ? amount0 : amount1 - - if (out >= deficit) { - best = mid - hi = mid - 1n - } else { - lo = mid + 1n - } - } - - return best - } - - // ========================================================================= - // Private — Router quote - // ========================================================================= - - private async quoteExpectedAmounts( - chain: string, - pool: HydratedPool, - tokenA: HexString, - tokenB: HexString, - liquidity: bigint, - ): Promise<{ amountA: bigint; amountB: bigint } | null> { - try { - const client = this.clientManager.getPublicClient(chain) - const [amountA, amountB] = await client.readContract({ - address: pool.router, - abi: AERODROME_ROUTER_ABI, - functionName: "quoteRemoveLiquidity", - args: [tokenA, tokenB, pool.stable, liquidity], - }) - return { amountA, amountB } - } catch { - return null - } - } -} diff --git a/sdk/packages/simplex/src/funding/aerodrome/AerodromeLiquidityState.ts b/sdk/packages/simplex/src/funding/aerodrome/AerodromeLiquidityState.ts deleted file mode 100644 index 340c04151..000000000 --- a/sdk/packages/simplex/src/funding/aerodrome/AerodromeLiquidityState.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { HexString } from "@hyperbridge/sdk" -import type { PublicClient } from "viem" -import type { ChainClientManager } from "@/services/ChainClientManager" -import type { AerodromePoolConfig, HydratedPool } from "@/funding/types" -import { AERODROME_PAIR_ABI, AERODROME_GAUGE_ABI } from "@/config/abis/Aerodrome" -import { getLogger } from "@/services/Logger" - -const logger = getLogger("aerodrome-state") - -/** - * Long-lived liquidity state for Aerodrome pools on a single destination chain. - * - * Hydrates pool metadata (token0, token1, stable) once, then periodically - * refreshes live data (reserves, totalSupply, LP balances). The planner - * reads from this cache instead of hitting the chain per-order. - * - * Concurrent access is serialised by the planner's per-chain mutex. - */ -export class AerodromeLiquidityState { - /** Keyed by `pair` address (lower-cased). */ - private pools = new Map() - private hydrated = false - private consumed = new Map() - private lastOnChainLp = new Map() - - constructor( - private readonly chain: string, - private readonly configs: AerodromePoolConfig[], - private readonly router: HexString, - private readonly solver: HexString, - private readonly clientManager: ChainClientManager, - ) {} - - // ========================================================================= - // Initialisation & refresh - // ========================================================================= - - /** - * One-time hydration: reads immutable pool metadata (token0, token1, stable) - * and then calls `refresh()` for live data. Throws on mismatches (acts as - * the old `validatePools` step). - */ - async hydrate(): Promise { - const client = this.clientManager.getPublicClient(this.chain) - - for (const cfg of this.configs) { - const key = cfg.pair.toLowerCase() - - const [token0, token1, stable] = await Promise.all([ - client.readContract({ - address: cfg.pair, - abi: AERODROME_PAIR_ABI, - functionName: "token0", - }) as Promise, - client.readContract({ - address: cfg.pair, - abi: AERODROME_PAIR_ABI, - functionName: "token1", - }) as Promise, - client.readContract({ - address: cfg.pair, - abi: AERODROME_PAIR_ABI, - functionName: "stable", - }) as Promise, - ]) - - // Validate gauge staking token matches the pair (smoke-check). - if (cfg.gauge) { - const stakingToken = (await client.readContract({ - address: cfg.gauge, - abi: AERODROME_GAUGE_ABI, - functionName: "stakingToken", - })) as HexString - - if (stakingToken.toLowerCase() !== cfg.pair.toLowerCase()) { - throw new Error( - `Aerodrome gauge ${cfg.gauge}: stakingToken ${stakingToken} does not match pair ${cfg.pair}`, - ) - } - } - - this.pools.set(key, { - pair: cfg.pair, - token0, - token1, - stable, - router: this.router, - gauge: cfg.gauge, - // Zeroed until refresh(). - reserve0: 0n, - reserve1: 0n, - totalSupply: 0n, - remainingLp: 0n, - walletLp: 0n, - gaugeLp: 0n, - }) - } - - await this.refresh() - this.hydrated = true - - logger.info({ chain: this.chain, pools: this.configs.length }, "Aerodrome liquidity state hydrated") - } - - /** - * Refreshes live data: reserves, totalSupply, and LP balances. - * Called on-demand by the planner before each withdrawal plan. - */ - async refresh(): Promise { - const client = this.clientManager.getPublicClient(this.chain) - - const refreshPromises = Array.from(this.pools.values()).map((pool) => this.refreshOnePool(client, pool)) - await Promise.all(refreshPromises) - } - - private async refreshOnePool(client: PublicClient, pool: HydratedPool): Promise { - const [reserves, totalSupply, walletLp, gaugeLp] = await Promise.all([ - client.readContract({ - address: pool.pair, - abi: AERODROME_PAIR_ABI, - functionName: "getReserves", - }) as Promise<[bigint, bigint, bigint]>, - client.readContract({ - address: pool.pair, - abi: AERODROME_PAIR_ABI, - functionName: "totalSupply", - }) as Promise, - client.readContract({ - address: pool.pair, - abi: AERODROME_PAIR_ABI, - functionName: "balanceOf", - args: [this.solver], - }) as Promise, - pool.gauge - ? (client.readContract({ - address: pool.gauge, - abi: AERODROME_GAUGE_ABI, - functionName: "balanceOf", - args: [this.solver], - }) as Promise) - : Promise.resolve(0n), - ]) - - pool.reserve0 = reserves[0] - pool.reserve1 = reserves[1] - pool.totalSupply = totalSupply - - pool.walletLp = walletLp - pool.gaugeLp = gaugeLp - - const key = pool.pair.toLowerCase() - const onChain = walletLp + gaugeLp - const prevOnChain = this.lastOnChainLp.get(key) ?? onChain - const decrease = prevOnChain > onChain ? prevOnChain - onChain : 0n - const prevConsumed = this.consumed.get(key) ?? 0n - const newConsumed = prevConsumed > decrease ? prevConsumed - decrease : 0n - this.consumed.set(key, newConsumed) - this.lastOnChainLp.set(key, onChain) - pool.remainingLp = onChain > newConsumed ? onChain - newConsumed : 0n - } - - // ========================================================================= - // Pool lookups - // ========================================================================= - - isHydrated(): boolean { - return this.hydrated - } - - /** All hydrated pools for this chain. */ - allPools(): HydratedPool[] { - return Array.from(this.pools.values()) - } - - /** Pools that contain `tokenLower` as either token0 or token1. */ - poolsForToken(tokenLower: string): HydratedPool[] { - const t = tokenLower.toLowerCase() - return this.allPools().filter((p) => p.token0.toLowerCase() === t || p.token1.toLowerCase() === t) - } - - getPool(pair: HexString): HydratedPool | undefined { - return this.pools.get(pair.toLowerCase()) - } - - /** Remaining LP available for a given pair. */ - remaining(pair: HexString): bigint { - return this.pools.get(pair.toLowerCase())?.remainingLp ?? 0n - } - - consume(pair: HexString, amount: bigint): void { - const key = pair.toLowerCase() - const pool = this.pools.get(key) - if (pool) { - pool.remainingLp = pool.remainingLp > amount ? pool.remainingLp - amount : 0n - } - this.consumed.set(key, (this.consumed.get(key) ?? 0n) + amount) - } -} diff --git a/sdk/packages/simplex/src/funding/index.ts b/sdk/packages/simplex/src/funding/index.ts index 28ff50ea5..f6e2556b2 100644 --- a/sdk/packages/simplex/src/funding/index.ts +++ b/sdk/packages/simplex/src/funding/index.ts @@ -1,16 +1,11 @@ export type { FundingVenue, FundingPlanResult, - AerodromePoolConfig, - AerodromeOutputFundingConfig, - HydratedPool, UniswapV4PositionConfig, UniswapV4PositionInit, UniswapV4OutputFundingConfig, HydratedV4Position, OutputFundingConfig, } from "@/funding/types" -export { AerodromeLiquidityState } from "@/funding/aerodrome/AerodromeLiquidityState" -export { AerodromeFundingPlanner } from "@/funding/aerodrome/AerodromeFundingPlanner" export { UniswapV4LiquidityState } from "@/funding/uniswapV4/UniswapV4LiquidityState" export { UniswapV4FundingPlanner } from "@/funding/uniswapV4/UniswapV4FundingPlanner" diff --git a/sdk/packages/simplex/src/funding/types.ts b/sdk/packages/simplex/src/funding/types.ts index 4df4ab041..d27824a5e 100644 --- a/sdk/packages/simplex/src/funding/types.ts +++ b/sdk/packages/simplex/src/funding/types.ts @@ -9,7 +9,7 @@ import type { Decimal } from "decimal.js" * A liquidity source that can atomically withdraw tokens and make them * available for order fills within a single ERC-7821 batched call. * - * Implementations: AerodromeFundingPlanner, UniswapV4FundingPlanner. + * Implementations: UniswapV4FundingPlanner. */ export interface FundingVenue { name: string @@ -48,55 +48,6 @@ export interface FundingPlanResult { credited: bigint } -// ========================================================================= -// Aerodrome Types -// ========================================================================= - -/** - * Minimal per-pool config. Everything else (token0, token1, stable) is read - * from the pair contract at initialisation time. - * - * If `gauge` is provided the LP is assumed to be staked there; otherwise we - * look for LP directly in the solver wallet. - */ -export interface AerodromePoolConfig { - pair: HexString - /** If present, LP is staked in this gauge and must be withdrawn first. */ - gauge?: HexString -} - -/** - * Runtime representation of a pool after on-chain hydration. - * Created once at startup / refresh — never serialised to config. - */ -export interface HydratedPool { - pair: HexString - token0: HexString - token1: HexString - stable: boolean - router: HexString - gauge?: HexString - - // --- live state (updated on refresh) --- - reserve0: bigint - reserve1: bigint - totalSupply: bigint - /** Wallet LP + gauge-staked LP that can still be scheduled to burn. */ - remainingLp: bigint - /** LP sitting in the solver wallet right now (subset of remainingLp). */ - walletLp: bigint - /** LP staked in the gauge (subset of remainingLp). Zero when no gauge. */ - gaugeLp: bigint -} - -/** - * Top-level Aerodrome funding config. - */ -export interface AerodromeOutputFundingConfig { - /** Chain identifier → list of pool configs to source liquidity from. */ - poolsByChain: Record -} - // ========================================================================= // Uniswap V4 Types // ========================================================================= @@ -171,6 +122,5 @@ export interface UniswapV4OutputFundingConfig { // ========================================================================= export interface OutputFundingConfig { - aerodrome?: AerodromeOutputFundingConfig uniswapV4?: UniswapV4OutputFundingConfig } diff --git a/sdk/packages/simplex/src/index.ts b/sdk/packages/simplex/src/index.ts index a33d3bcac..b6b95c4b6 100644 --- a/sdk/packages/simplex/src/index.ts +++ b/sdk/packages/simplex/src/index.ts @@ -15,6 +15,6 @@ export type { CurvePoint, CurveConfig } from "@/config/interpolated-curve" // Service exports export { ChainClientManager, ContractInteractionService } from "@/services" -// Output funding (Aerodrome LP → fillOrder batch) -export type { AerodromePoolConfig, AerodromeOutputFundingConfig, OutputFundingConfig } from "@/funding/types" -export { AerodromeFundingPlanner, AerodromeLiquidityState } from "@/funding" +// Output funding +export type { OutputFundingConfig } from "@/funding/types" +export { UniswapV4LiquidityState, UniswapV4FundingPlanner } from "@/funding" diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index d2f911f51..63a4e5193 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -1,4 +1,4 @@ -import { toHex, formatUnits, encodeFunctionData, maxUint256, formatEther } from "viem" +import { toHex, formatUnits, encodeFunctionData, formatEther } from "viem" import { ADDRESS_ZERO, HexString, @@ -27,7 +27,7 @@ import { Decimal } from "decimal.js" import { INTENT_GATEWAY_V2_ABI } from "@/config/abis/IntentGatewayV2" import { ENTRYPOINT_ABI } from "@/config/abis/Entrypoint" import type { SigningAccount } from "@/services/wallet" -import { buildCirclePaymasterData, packPaymasterAndData } from "@/services/paymaster/circle" +import { buildPaymasterAndData } from "@/services/paymaster" // Configure for financial precision Decimal.config({ precision: 28, rounding: 4 }) @@ -397,50 +397,6 @@ export class ContractInteractionService { await this.depositToEntryPoint(chain, deficit) } - /** - * Ensures the solver account has max USDC allowance to the Circle Paymaster. - * This is a one-time approval that eliminates per-bid permit signing. - * No-ops if the allowance is already sufficient. - */ - async ensurePaymasterApproval(chain: string, paymasterAddress: HexString): Promise { - const usdcAddress = this.configService.getUsdcAsset(chain) - const publicClient = this.clientManager.getPublicClient(chain) - const walletClient = this.clientManager.getWalletClient(chain) - - const currentAllowance = (await publicClient.readContract({ - address: usdcAddress, - abi: ERC20_ABI, - functionName: "allowance", - args: [this.solverAccountAddress, paymasterAddress], - })) as bigint - - // Consider sufficient if allowance covers at least $50 of USDC - const usdcDecimals = this.configService.getUsdcDecimals(chain) - const sufficientThreshold = 50n * 10n ** BigInt(usdcDecimals) - - if (currentAllowance >= sufficientThreshold) { - this.logger.info({ chain, allowance: currentAllowance.toString() }, "USDC paymaster allowance sufficient") - return - } - - this.logger.info( - { chain, paymasterAddress, usdcAddress, currentAllowance: currentAllowance.toString() }, - "Approving USDC to Circle Paymaster (one-time)", - ) - - const hash = await walletClient.writeContract({ - address: usdcAddress, - abi: ERC20_ABI, - functionName: "approve", - args: [paymasterAddress, maxUint256], - chain: walletClient.chain, - account: walletClient.account!, - }) - - const receipt = await publicClient.waitForTransactionReceipt({ hash, confirmations: 1 }) - this.logger.info({ chain, txHash: hash, blockNumber: receipt.blockNumber }, "USDC paymaster approval confirmed") - } - /** * Calculates the total USD value of an order's inputs. * Only stable (USDC/USDT) inputs contribute; non-stables contribute 0. @@ -654,24 +610,18 @@ export class ContractInteractionService { const commitment = orderCommitment(order) - // Build paymasterAndData when the destination chain has a Circle Paymaster configured. - // Chains without a paymaster address (e.g. BSC) fall through to "0x", - // retaining the existing EntryPoint deposit behaviour. - let paymasterAndData: HexString = "0x" as HexString - const paymasterAddress = this.configService.getCirclePaymasterV08Address(order.destination) - if (paymasterAddress) { - const chainId = getChainId(order.destination)! - const usdcAddress = this.configService.getUsdcAsset(order.destination) - const usdcDecimals = this.configService.getUsdcDecimals(order.destination) - const publicClient = this.clientManager.getPublicClient(order.destination) - const pm = await buildCirclePaymasterData(publicClient, this.signer, solverAccountAddress, { - usdcAddress, - paymasterAddress, - chainId, - usdcDecimals, - }) - paymasterAndData = packPaymasterAndData(pm) - this.logger.info({ chainId, paymaster: pm.paymaster }, "Using Circle Paymaster for bid UserOp") + // Build paymasterAndData — Circle (USDC permit) → Simplex → EntryPoint deposit + const pmResult = await buildPaymasterAndData({ + chain: order.destination, + solverAccount: solverAccountAddress, + publicClient: this.clientManager.getPublicClient(order.destination), + walletClient: this.clientManager.getWalletClient(order.destination), + signer: this.signer, + configService: this.configService, + }) + const paymasterAndData = pmResult.paymasterAndData + if (pmResult.type !== "none") { + this.logger.info({ paymaster: pmResult.address, type: pmResult.type }, "Using paymaster for bid UserOp") } const userOp = await sdkHelper.prepareSubmitBid({ @@ -758,7 +708,7 @@ export class ContractInteractionService { data: encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", - args: [intentGatewayV2Address, maxUint256], + args: [intentGatewayV2Address, required], }) as HexString, }) } diff --git a/sdk/packages/simplex/src/services/DelegationService.ts b/sdk/packages/simplex/src/services/DelegationService.ts index cb890482f..c8d68a56e 100644 --- a/sdk/packages/simplex/src/services/DelegationService.ts +++ b/sdk/packages/simplex/src/services/DelegationService.ts @@ -1,9 +1,12 @@ import type { HexString } from "@hyperbridge/sdk" +import { CryptoUtils, BundlerMethod } from "@hyperbridge/sdk" import { concat, keccak256, toHex, toRlp, zeroAddress } from "viem" import { ChainClientManager } from "./ChainClientManager" import { FillerConfigService } from "./FillerConfigService" import { getLogger } from "./Logger" import type { SigningAccount } from "./wallet" +import { buildPaymasterAndData, hasPaymaster } from "./paymaster" +import { ENTRYPOINT_ABI } from "@/config/abis/Entrypoint" /** EIP-7702 delegation indicator prefix */ const DELEGATION_INDICATOR_PREFIX = "0xef0100" @@ -14,6 +17,11 @@ const DELEGATION_TX_GAS_FLOOR = 350_000n /** * Service for managing EIP-7702 delegation of the filler's EOA to the SolverAccount contract. * This enables the filler to participate in solver selection mode. + * + * When a SimplexPaymaster is configured, delegation is performed via a no-op UserOp + * sent through the bundler — the paymaster pays gas in ERC-20 tokens, so the solver + * never needs native tokens. Falls back to a direct type-0x04 tx if the bundler + * path is unavailable. */ export class DelegationService { private logger = getLogger("delegation-service") @@ -29,9 +37,14 @@ export class DelegationService { return keccak256(concat(["0x05", encoded])) as HexString } + /** + * @param viaBundler When true, uses the current nonce (bundler submits the tx). + * When false, uses nonce+1 (EOA submits the type-0x04 tx itself). + */ private async buildAuthorization( chain: string, contractAddress: HexString, + viaBundler = false, ): Promise<{ chainId: number address: HexString @@ -45,10 +58,9 @@ export class DelegationService { const authorityAddress = this.signer.account.address as HexString const currentNonce = await publicClient.getTransactionCount({ address: authorityAddress, - blockTag: "pending", + blockTag: "latest", }) - // EIP-7702 auth nonce must be authority tx nonce + 1 when authority submits the type-0x04 tx. - const authorizationNonce = currentNonce + 1 + const authorizationNonce = viaBundler ? currentNonce : currentNonce + 1 const authHash = this.computeAuthorizationHash(chainId, contractAddress, Number(authorizationNonce)) const { r, s, yParity } = await this.signer.signRawHash(authHash) @@ -89,9 +101,6 @@ export class DelegationService { /** * Checks if the filler's EOA is already delegated to the SolverAccount contract on a specific chain. - * - * @param chain - The chain identifier (e.g., "EVM-1") - * @returns True if delegated, false otherwise */ async isDelegated(chain: string): Promise { const client = this.clientManager.getPublicClient(chain) @@ -103,17 +112,14 @@ export class DelegationService { } try { - // Get the code at the filler's EOA address const code = await client.getCode({ address: account.address }) if (!code || code === "0x") { return false } - // Check if code starts with delegation indicator (0xef0100 + address) - // EIP-7702 sets code to: 0xef0100 || delegate_address (23 bytes total) if (code.toLowerCase().startsWith(DELEGATION_INDICATOR_PREFIX)) { - const delegatedTo = ("0x" + code.slice(8)) as HexString // Skip "0xef0100" + const delegatedTo = ("0x" + code.slice(8)) as HexString const isCorrectDelegate = delegatedTo.toLowerCase() === solverAccountContract.toLowerCase() this.logger.debug( @@ -131,12 +137,172 @@ export class DelegationService { } } + /** + * Sets up EIP-7702 delegation via the bundler with a no-op UserOp. + * Prefers Circle Paymaster (USDC permit) when available and filler has USDC balance. + * Falls back to SimplexPaymaster with forceApproveMode otherwise. + */ + private async setupDelegationViaBundler(chain: string): Promise { + const solverAccountContract = this.configService.getSolverAccountContractAddress(chain) + const entryPointAddress = this.configService.getEntryPointAddress(chain) + const bundlerUrl = this.configService.getBundlerUrl(chain) + + if (!solverAccountContract || !hasPaymaster(chain, this.configService) || !entryPointAddress || !bundlerUrl) { + this.logger.warn({ chain }, "Missing config for bundler-based delegation, falling back to direct tx") + return false + } + + const publicClient = this.clientManager.getPublicClient(chain) + const walletClient = this.clientManager.getWalletClient(chain) + const solverAccount = this.signer.account.address as HexString + const chainId = this.configService.getChainId(chain) + + try { + // Build EIP-7702 authorization (bundler submits tx, so use current nonce) + const authorization = await this.buildAuthorization(chain, solverAccountContract, true) + + this.logger.info( + { chain, solverAccount, solverAccountContract, mode: "bundler" }, + "Setting up EIP-7702 delegation via bundler with paymaster", + ) + + // Build paymaster data — Circle (USDC permit) → Simplex (approve) → none + const pmResult = await buildPaymasterAndData({ + chain, + solverAccount, + publicClient, + walletClient, + signer: this.signer, + configService: this.configService, + forceApproveMode: true, + }) + if (pmResult.type === "none") { + this.logger.warn({ chain }, "No paymaster available for delegation") + return false + } + const paymasterAndData = pmResult.paymasterAndData + this.logger.info( + { chain, paymaster: pmResult.address, type: pmResult.type }, + "Using paymaster for delegation UserOp", + ) + + // Get gas prices — detect bundler type and use appropriate RPC method + let maxFeePerGas: bigint + let maxPriorityFeePerGas: bigint + + const bundlerUrlLower = bundlerUrl.toLowerCase() + const isPimlico = bundlerUrlLower.includes("pimlico.io") + const isAlchemy = bundlerUrlLower.includes("alchemy.com") + + if (isPimlico) { + const gasPriceResult = await this.sendBundlerRpc<{ + fast: { maxFeePerGas: string; maxPriorityFeePerGas: string } + }>(bundlerUrl, BundlerMethod.PIMLICO_GET_USER_OPERATION_GAS_PRICE, []) + maxFeePerGas = BigInt(gasPriceResult.fast.maxFeePerGas) + maxPriorityFeePerGas = BigInt(gasPriceResult.fast.maxPriorityFeePerGas) + } else if (isAlchemy) { + const [rundlerPriorityFee, latestBlock] = await Promise.all([ + this.sendBundlerRpc(bundlerUrl, BundlerMethod.RUNDLER_MAX_PRIORITY_FEE_PER_GAS, []), + publicClient.getBlock({ blockTag: "latest" }), + ]) + const baseFeePerGas = latestBlock.baseFeePerGas ?? (await publicClient.getGasPrice()) + const chainIdBigInt = BigInt(chainId) + const isArbitrum = chainIdBigInt === 42161n + const alchemyPrioBump = isArbitrum ? 0n : 25n + maxPriorityFeePerGas = + BigInt(rundlerPriorityFee) + (BigInt(rundlerPriorityFee) * alchemyPrioBump) / 100n + const bufferedBaseFee = baseFeePerGas + (baseFeePerGas * 50n) / 100n + maxFeePerGas = bufferedBaseFee + maxPriorityFeePerGas + } else { + const gasPrice = await publicClient.getGasPrice() + maxPriorityFeePerGas = gasPrice + (gasPrice * 8n) / 100n + maxFeePerGas = gasPrice + (gasPrice * 10n) / 100n + } + + // Get nonce from EntryPoint (key = 0 for delegation UserOps) + const nonce = (await publicClient.readContract({ + address: entryPointAddress, + abi: ENTRYPOINT_ABI, + functionName: "getNonce", + args: [solverAccount, 0n], + })) as bigint + + // 5. Build minimal no-op UserOp + const verificationGasLimit = 150_000n + const callGasLimit = 50_000n + const preVerificationGas = 100_000n + const accountGasLimits = CryptoUtils.packGasLimits(verificationGasLimit, callGasLimit) + const gasFees = CryptoUtils.packGasFees(maxPriorityFeePerGas, maxFeePerGas) + + const userOp = { + sender: solverAccount, + nonce, + initCode: "0x" as HexString, + callData: "0x" as HexString, + accountGasLimits, + preVerificationGas, + gasFees, + paymasterAndData, + signature: "0x" as HexString, + } + + // Compute UserOp hash (EIP-712) and sign with raw ECDSA (no eth prefix) + // SolverAccount._rawSignatureValidation expects ECDSA.recover(userOpHash, sig) == address(this) + const userOpHash = CryptoUtils.computeUserOpHash( + userOp, + entryPointAddress as `0x${string}`, + BigInt(chainId), + ) + const { r, s, yParity } = await this.signer.signRawHash(userOpHash as HexString) + const v = yParity === 0 ? 27 : 28 + userOp.signature = concat([r, s, toHex(v)]) as HexString + + // Prepare bundler call format using CryptoUtils + const bundlerUserOp = CryptoUtils.prepareBundlerCall(userOp) + + // Attach EIP-7702 authorization inside the UserOp object for Pimlico + bundlerUserOp.eip7702Auth = { + address: authorization.address, + chainId: toHex(authorization.chainId), + nonce: toHex(authorization.nonce), + r: authorization.r, + s: authorization.s, + yParity: toHex(authorization.yParity), + } + + // Send to bundler: eth_sendUserOperation(userOp, entryPoint) + const userOpHashResult = await this.sendBundlerRpc( + bundlerUrl, + BundlerMethod.ETH_SEND_USER_OPERATION, + [bundlerUserOp, entryPointAddress], + ) + + this.logger.info({ chain, userOpHash: userOpHashResult }, "Delegation UserOp submitted to bundler") + + // Wait for receipt + const receipt = await this.waitForUserOpReceipt(bundlerUrl, userOpHashResult) + + if (receipt) { + this.logger.info( + { chain, txHash: receipt.receipt?.transactionHash }, + "Delegation via bundler successful — paymaster paid gas", + ) + return true + } + + this.logger.warn({ chain }, "Delegation UserOp receipt not received, checking on-chain status") + return this.isDelegated(chain) + } catch (error) { + this.logger.warn({ chain, error }, "Bundler delegation failed, will fall back to direct tx") + return false + } + } + /** * Sets up EIP-7702 delegation from the filler's EOA to the SolverAccount contract. - * This is required for solver selection mode to work. * - * @param chain - The chain identifier to set up delegation on - * @returns True if delegation was successful or already in place + * Tries bundler path first (paymaster pays gas in ERC-20). + * Falls back to direct type-0x04 tx if bundler path fails. */ async setupDelegation(chain: string): Promise { const solverAccountContract = this.configService.getSolverAccountContractAddress(chain) @@ -146,24 +312,25 @@ export class DelegationService { return false } - // Check if already delegated if (await this.isDelegated(chain)) { this.logger.info({ chain }, "EOA already delegated to SolverAccount") return true } + // Try bundler path first (paymaster pays gas) + if (hasPaymaster(chain, this.configService)) { + const success = await this.setupDelegationViaBundler(chain) + if (success) return true + this.logger.info({ chain }, "Falling back to direct delegation tx") + } + + // Fallback: direct type-0x04 transaction (requires native token) const publicClient = this.clientManager.getPublicClient(chain) - const authority = this.signer.account try { this.logger.info( - { - chain, - authority: authority.address, - solverAccountContract, - mode: this.signer.mode, - }, - "Setting up EIP-7702 delegation", + { chain, authority: this.signer.account.address, solverAccountContract, mode: this.signer.mode }, + "Setting up EIP-7702 delegation via direct tx", ) const authorization = await this.buildAuthorization(chain, solverAccountContract) @@ -171,7 +338,6 @@ export class DelegationService { this.logger.info({ chain, txHash: hash }, "Delegation transaction sent") - // Wait for confirmation const receipt = await publicClient.waitForTransactionReceipt({ hash }) if (receipt.status === "success") { @@ -189,9 +355,6 @@ export class DelegationService { /** * Sets up delegation on the specified chains where solver selection is active. - * - * @param chains - Array of chain identifiers (e.g., ["EVM-1", "EVM-137"]) - * @returns Object with results per chain */ async setupDelegationOnChains(chains: string[]): Promise<{ success: boolean; results: Record }> { const results: Record = {} @@ -215,18 +378,13 @@ export class DelegationService { /** * Revokes delegation by delegating to the zero address. - * This restores the EOA to a normal (non-delegated) state. - * - * @param chain - The chain identifier - * @returns True if revocation was successful */ async revokeDelegation(chain: string): Promise { - const authority = this.signer.account const publicClient = this.clientManager.getPublicClient(chain) try { this.logger.info( - { chain, authority: authority.address, mode: this.signer.mode }, + { chain, authority: this.signer.account.address, mode: this.signer.mode }, "Revoking EIP-7702 delegation", ) @@ -246,4 +404,44 @@ export class DelegationService { return false } } + + // ── Helpers ────────────────────────────────────────────────────── + + private async sendBundlerRpc(bundlerUrl: string, method: string, params: unknown[]): Promise { + const response = await fetch(bundlerUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }) + + const result = (await response.json()) as { result?: T; error?: { message?: string } } + + if (result.error) { + throw new Error(`Bundler RPC error (${method}): ${result.error.message || JSON.stringify(result.error)}`) + } + + return result.result as T + } + + private async waitForUserOpReceipt( + bundlerUrl: string, + userOpHash: HexString, + maxAttempts = 30, + intervalMs = 2000, + ): Promise<{ receipt?: { transactionHash: HexString } } | null> { + for (let i = 0; i < maxAttempts; i++) { + try { + const receipt = await this.sendBundlerRpc<{ receipt: { transactionHash: HexString } } | null>( + bundlerUrl, + BundlerMethod.ETH_GET_USER_OPERATION_RECEIPT, + [userOpHash], + ) + if (receipt) return receipt + } catch { + // Not found yet + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + return null + } } diff --git a/sdk/packages/simplex/src/services/FillerConfigService.ts b/sdk/packages/simplex/src/services/FillerConfigService.ts index 18300df51..3b5559128 100644 --- a/sdk/packages/simplex/src/services/FillerConfigService.ts +++ b/sdk/packages/simplex/src/services/FillerConfigService.ts @@ -141,8 +141,12 @@ export class FillerConfigService { return this.chainConfigService.getUsdcDecimals(chain) } - getCirclePaymasterV08Address(chain: string): HexString | undefined { - return this.chainConfigService.getCirclePaymasterV08Address(chain) + getSimplexPaymasterAddress(chain: string): HexString | undefined { + return this.chainConfigService.getSimplexPaymasterAddress(chain) + } + + getCirclePaymasterAddress(chain: string): HexString | undefined { + return this.chainConfigService.getCirclePaymasterAddress(chain) } getUsdtDecimals(chain: string): number { @@ -191,10 +195,6 @@ export class FillerConfigService { return this.chainConfigService.getUniswapRouterV2Address(chain) } - getAerodromeRouterAddress(chain: string): HexString { - return this.chainConfigService.getAerodromeRouterAddress(chain) - } - getUniswapV2FactoryAddress(chain: string): HexString { return this.chainConfigService.getUniswapV2FactoryAddress(chain) } @@ -308,8 +308,6 @@ export class FillerConfigService { return this.fillerConfig?.gasFeeBump } - - /** * Get the LayerZero Endpoint ID for the chain * Used for USDT0 cross-chain transfers via LayerZero OFT diff --git a/sdk/packages/simplex/src/services/paymaster/circle.ts b/sdk/packages/simplex/src/services/paymaster/circle.ts deleted file mode 100644 index e5be2f78f..000000000 --- a/sdk/packages/simplex/src/services/paymaster/circle.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { encodePacked, maxUint256, getContract, erc20Abi, type PublicClient } from "viem" -import type { HexString } from "@hyperbridge/sdk" -import { EIP2612_ABI } from "@/config/abis/EIP2612" - -// ── Constants ──────────────────────────────────────────────────────── - -/** - * Recommended gas limits from Circle's documentation. - * These are safe upper bounds — actual usage is typically lower. - * Unused gas is not charged. - */ -export const PAYMASTER_VERIFICATION_GAS_LIMIT = 200_000n -export const PAYMASTER_POST_OP_GAS_LIMIT = 100_000n - -// ── Types ──────────────────────────────────────────────────────────── - -export interface PaymasterResult { - /** Circle Paymaster contract address */ - paymaster: HexString - /** ABI-packed paymaster data (mode + token + permitAmount + permitSig) */ - paymasterData: HexString - /** Gas limit for paymaster verification phase */ - paymasterVerificationGasLimit: bigint - /** Gas limit for paymaster postOp phase */ - paymasterPostOpGasLimit: bigint -} - -export interface CirclePaymasterConfig { - /** USDC contract address on the destination chain (from config service) */ - usdcAddress: HexString - /** Circle Paymaster contract address on the destination chain (from config service) */ - paymasterAddress: HexString - /** Chain ID of the destination chain */ - chainId: number - /** USDC decimal count on this chain (from config service) */ - usdcDecimals: number - /** Max USDC the paymaster may pull (human units $10). Computed from decimals if omitted. */ - permitAmount?: bigint -} - -// ── Core integration ───────────────────────────────────────────────── - -/** - * Computes the default permit amount ($5 worth of USDC) for the given decimals. - * This is a max-spend cap per UserOp, not the actual charge. - * The paymaster charges actual gas cost and refunds the rest. - */ -function defaultPermitAmount(usdcDecimals: number): bigint { - return 5n * 10n ** BigInt(usdcDecimals) -} - -/** - * Builds the paymaster fields for a PackedUserOperation using Circle Paymaster v0.8. - * - * Flow: - * 1. Signs an EIP-2612 permit granting the Circle Paymaster an allowance - * to pull up to `permitAmount` USDC from the solver's smart account. - * 2. Encodes the paymaster data as: - * `abi.encodePacked(uint8(0), address(usdc), uint256(permitAmount), bytes(permitSig))` - * 3. Returns the paymaster address, encoded data, and gas limits. - * - * The permit uses `deadline = maxUint256` because the paymaster contract - * cannot access `block.timestamp` due to ERC-4337 opcode restrictions. - * - * Use `packPaymasterAndData(result)` to produce the final `paymasterAndData` - * bytes for the PackedUserOperation. - * - * @param client - Public client for reading USDC contract state (nonces, name, version). - * @param signer - The solver's signer, capable of EIP-712 signTypedData. - * @param solverAccount - The solver's smart account address (the "owner" in permit terms). - * @param config - Chain-specific addresses, decimals, chain ID, and optional permit amount. - */ -export async function buildCirclePaymasterData( - client: PublicClient, - signer: { signTypedData: (typedData: unknown, chainId?: number) => Promise }, - solverAccount: HexString, - config: CirclePaymasterConfig, -): Promise { - const { - usdcAddress, - paymasterAddress, - chainId, - usdcDecimals, - permitAmount = defaultPermitAmount(usdcDecimals), - } = config - - // The paymaster contract skips the permit section when paymasterData is - // shorter than PAYMASTER_PERMIT_SIGNATURE_OFFSET and goes straight to transferFrom. - const existingAllowance = (await client.readContract({ - address: usdcAddress, - abi: erc20Abi, - functionName: "allowance", - args: [solverAccount, paymasterAddress], - })) as bigint - - if (existingAllowance >= permitAmount) { - // No permit data needed — paymaster will use existing allowance - const paymasterData = encodePacked(["uint8"], [0]) as HexString - return { - paymaster: paymasterAddress, - paymasterData, - paymasterVerificationGasLimit: PAYMASTER_VERIFICATION_GAS_LIMIT, - paymasterPostOpGasLimit: PAYMASTER_POST_OP_GAS_LIMIT, - } - } - - const permitSignature = await signUsdcPermit( - client, - signer, - solverAccount, - paymasterAddress, - usdcAddress, - permitAmount, - chainId, - ) - - // Encode paymasterData: mode(0) + token + permitAmount + permitSignature - const paymasterData = encodePacked( - ["uint8", "address", "uint256", "bytes"], - [0, usdcAddress, permitAmount, permitSignature], - ) as HexString - - return { - paymaster: paymasterAddress, - paymasterData, - paymasterVerificationGasLimit: PAYMASTER_VERIFICATION_GAS_LIMIT, - paymasterPostOpGasLimit: PAYMASTER_POST_OP_GAS_LIMIT, - } -} - -// ── EIP-2612 Permit signing ────────────────────────────────────────── - -async function signUsdcPermit( - client: PublicClient, - signer: { signTypedData: (typedData: unknown, chainId?: number) => Promise }, - owner: HexString, - spender: HexString, - usdcAddress: HexString, - value: bigint, - chainId: number, -): Promise { - const token = getContract({ - client, - address: usdcAddress, - abi: EIP2612_ABI, - }) - - const [name, version, nonce] = await Promise.all([ - token.read.name(), - token.read.version(), - token.read.nonces([owner]), - ]) - - const typedData = { - types: { - EIP712Domain: [ - { name: "name", type: "string" }, - { name: "version", type: "string" }, - { name: "chainId", type: "uint256" }, - { name: "verifyingContract", type: "address" }, - ], - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" }, - ], - }, - primaryType: "Permit" as const, - domain: { - name, - version, - chainId, - verifyingContract: usdcAddress, - }, - message: { - owner, - spender, - value, - nonce, - // maxUint256: paymaster can't read block.timestamp (ERC-4337 opcode restriction) - deadline: maxUint256, - }, - } - - return signer.signTypedData(typedData, chainId) -} - -// ── Helper: pack paymaster fields into paymasterAndData ────────────── - -/** - * For EntryPoint v0.8, the `paymasterAndData` field in PackedUserOperation - * is encoded as: - * paymaster (20 bytes) || paymasterVerificationGasLimit (uint128, 16 bytes) - * || paymasterPostOpGasLimit (uint128, 16 bytes) || paymasterData (variable) - * - * Use this helper to produce the final packed bytes from a PaymasterResult, - * then pass the result as `paymasterAndData` in `SubmitBidOptions`. - */ -export function packPaymasterAndData(pm: PaymasterResult): HexString { - return encodePacked( - ["address", "uint128", "uint128", "bytes"], - [pm.paymaster, pm.paymasterVerificationGasLimit, pm.paymasterPostOpGasLimit, pm.paymasterData], - ) as HexString -} diff --git a/sdk/packages/simplex/src/services/paymaster/index.ts b/sdk/packages/simplex/src/services/paymaster/index.ts new file mode 100644 index 000000000..07590e1d0 --- /dev/null +++ b/sdk/packages/simplex/src/services/paymaster/index.ts @@ -0,0 +1,98 @@ +import { erc20Abi } from "viem" +import type { HexString } from "@hyperbridge/sdk" +import type { FillerConfigService } from "@/services/FillerConfigService" +import { buildCirclePaymasterData } from "./provider/circle" +import { buildPaymasterData as buildSimplexPaymasterData } from "./provider/simplex" +import { packPaymasterAndData } from "./types" +import type { PaymasterOptions, PaymasterDataResult } from "./types" + +export type { PaymasterOptions, PaymasterDataResult } from "./types" + +/** + * Returns true if the chain has any paymaster (Circle or Simplex) configured. + * Used by filler.ts to decide whether to skip EntryPoint deposits. + */ +export function hasPaymaster(chain: string, configService: FillerConfigService): boolean { + return !!(configService.getCirclePaymasterAddress(chain) || configService.getSimplexPaymasterAddress(chain)) +} + +/** + * Unified paymaster data builder. + * + * Selection priority: + * 1. Circle Paymaster — when configured AND solver has ≥1 USDC balance + * 2. Simplex Paymaster — when configured (supports USDC/USDT, permit or approve) + * 3. None — returns "0x" (caller falls back to EntryPoint deposit) + */ +export async function buildPaymasterAndData(options: PaymasterOptions): Promise { + const { chain, solverAccount, publicClient, walletClient, signer, configService, forceApproveMode } = options + + const circleAddr = configService.getCirclePaymasterAddress(chain) + const simplexAddr = configService.getSimplexPaymasterAddress(chain) + + // 1. Try Circle Paymaster (USDC only) + if (circleAddr) { + const usdcAddress = configService.getUsdcAsset(chain) + const usdcDecimals = configService.getUsdcDecimals(chain) + + if (usdcAddress && (await hasSufficientBalance(publicClient, solverAccount, usdcAddress, usdcDecimals))) { + const pm = await buildCirclePaymasterData( + publicClient, + signer, + solverAccount, + circleAddr, + chain, + configService, + ) + return { + paymasterAndData: packPaymasterAndData(pm), + type: "circle", + address: circleAddr, + } + } + } + + // 2. Try Simplex Paymaster (USDC/USDT, permit or approve) + if (simplexAddr) { + const pm = await buildSimplexPaymasterData( + publicClient, + walletClient, + signer, + solverAccount, + simplexAddr, + chain, + configService, + forceApproveMode, + ) + return { + paymasterAndData: packPaymasterAndData(pm), + type: "simplex", + address: simplexAddr, + } + } + + // 3. No paymaster available + return { + paymasterAndData: "0x" as HexString, + type: "none", + } +} + +// ── Helpers ────────────────────────────────────────────────────────── + +async function hasSufficientBalance( + publicClient: PaymasterOptions["publicClient"], + account: HexString, + tokenAddress: HexString, + tokenDecimals: number, +): Promise { + const balance = (await publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "balanceOf", + args: [account], + })) as bigint + + const minBalance = 10n ** BigInt(tokenDecimals) + return balance >= minBalance +} diff --git a/sdk/packages/simplex/src/services/paymaster/permit.ts b/sdk/packages/simplex/src/services/paymaster/permit.ts new file mode 100644 index 000000000..dde18dd76 --- /dev/null +++ b/sdk/packages/simplex/src/services/paymaster/permit.ts @@ -0,0 +1,69 @@ +import { maxUint256, getContract, type PublicClient } from "viem" +import type { HexString } from "@hyperbridge/sdk" +import { EIP2612_ABI } from "@/config/abis/EIP2612" + +/** + * Signs an EIP-2612 permit for a token that supports it. + * + * Used by both Circle Paymaster (USDC) and Simplex Paymaster (USDC/USDT) + * to grant a spending allowance via off-chain signature rather than an + * on-chain approve transaction. + * + * Uses `deadline = maxUint256` because paymaster contracts cannot access + * `block.timestamp` due to ERC-4337 opcode restrictions. + */ +export async function signEip2612Permit( + client: PublicClient, + signer: { signTypedData: (typedData: unknown, chainId?: number) => Promise }, + owner: HexString, + spender: HexString, + tokenAddress: HexString, + value: bigint, + chainId: number, +): Promise { + const token = getContract({ + client, + address: tokenAddress, + abi: EIP2612_ABI, + }) + + const [name, version, nonce] = await Promise.all([ + token.read.name(), + token.read.version(), + token.read.nonces([owner]), + ]) + + const typedData = { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "Permit" as const, + domain: { + name, + version, + chainId, + verifyingContract: tokenAddress, + }, + message: { + owner, + spender, + value, + nonce, + deadline: maxUint256, + }, + } + + return signer.signTypedData(typedData, chainId) +} diff --git a/sdk/packages/simplex/src/services/paymaster/provider/circle.ts b/sdk/packages/simplex/src/services/paymaster/provider/circle.ts new file mode 100644 index 000000000..c64b234d4 --- /dev/null +++ b/sdk/packages/simplex/src/services/paymaster/provider/circle.ts @@ -0,0 +1,77 @@ +import { encodePacked, erc20Abi, type PublicClient } from "viem" +import type { HexString } from "@hyperbridge/sdk" +import type { FillerConfigService } from "@/services/FillerConfigService" +import { RECOMMENDED_AMOUNT_USD, THRESHOLD_USD, VERIFICATION_GAS_LIMIT_CIRCLE, POST_OP_GAS_LIMIT, type PaymasterResult } from "../types" +import { signEip2612Permit } from "../permit" + +/** + * Builds the paymaster fields for a PackedUserOperation using Circle Paymaster v0.8. + * + * Flow: + * 1. Signs an EIP-2612 permit granting the Circle Paymaster an allowance + * to pull up to the recommended amount of USDC from the solver's smart account. + * 2. Encodes the paymaster data as: + * `abi.encodePacked(uint8(0), address(usdc), uint256(permitAmount), bytes(permitSig))` + * 3. Returns the paymaster address, encoded data, and gas limits. + * + * The permit uses `deadline = maxUint256` because the paymaster contract + * cannot access `block.timestamp` due to ERC-4337 opcode restrictions. + */ +export async function buildCirclePaymasterData( + client: PublicClient, + signer: { signTypedData: (typedData: unknown, chainId?: number) => Promise }, + solverAccount: HexString, + paymasterAddress: HexString, + chain: string, + configService: FillerConfigService, +): Promise { + const usdcAddress = configService.getUsdcAsset(chain) + const usdcDecimals = configService.getUsdcDecimals(chain) + const chainId = configService.getChainId(chain) + + const threshold = THRESHOLD_USD * 10n ** BigInt(usdcDecimals) + const recommended = RECOMMENDED_AMOUNT_USD * 10n ** BigInt(usdcDecimals) + + // The paymaster contract skips the permit section when paymasterData is + // shorter than PAYMASTER_PERMIT_SIGNATURE_OFFSET and goes straight to transferFrom. + const existingAllowance = (await client.readContract({ + address: usdcAddress, + abi: erc20Abi, + functionName: "allowance", + args: [solverAccount, paymasterAddress], + })) as bigint + + if (existingAllowance >= threshold) { + // No permit data needed — paymaster will use existing allowance + const paymasterData = encodePacked(["uint8"], [0]) as HexString + return { + paymaster: paymasterAddress, + paymasterData, + paymasterVerificationGasLimit: VERIFICATION_GAS_LIMIT_CIRCLE, + paymasterPostOpGasLimit: POST_OP_GAS_LIMIT, + } + } + + const permitSignature = await signEip2612Permit( + client, + signer, + solverAccount, + paymasterAddress, + usdcAddress, + recommended, + chainId, + ) + + // Encode paymasterData: mode(0) + token + permitAmount + permitSignature + const paymasterData = encodePacked( + ["uint8", "address", "uint256", "bytes"], + [0, usdcAddress, recommended, permitSignature], + ) as HexString + + return { + paymaster: paymasterAddress, + paymasterData, + paymasterVerificationGasLimit: VERIFICATION_GAS_LIMIT_CIRCLE, + paymasterPostOpGasLimit: POST_OP_GAS_LIMIT, + } +} diff --git a/sdk/packages/simplex/src/services/paymaster/provider/simplex.ts b/sdk/packages/simplex/src/services/paymaster/provider/simplex.ts new file mode 100644 index 000000000..e065d95bd --- /dev/null +++ b/sdk/packages/simplex/src/services/paymaster/provider/simplex.ts @@ -0,0 +1,229 @@ +import { encodePacked, maxUint256, erc20Abi, type PublicClient, type WalletClient } from "viem" +import type { HexString } from "@hyperbridge/sdk" +import type { FillerConfigService } from "@/services/FillerConfigService" +import { + RECOMMENDED_AMOUNT_USD, + THRESHOLD_USD, + VERIFICATION_GAS_LIMIT_PERMIT, + VERIFICATION_GAS_LIMIT_APPROVE, + POST_OP_GAS_LIMIT, + type PaymasterResult, +} from "../types" +import { signEip2612Permit } from "../permit" + +// ── Types ──────────────────────────────────────────────────────────── + +interface TokenOption { + address: HexString + decimals: number +} + +// ── Main entry point ───────────────────────────────────────────────── + +/** + * Self-contained paymaster data builder for SimplexPaymaster. + * + * Resolves USDC/USDT token addresses from the config service, then: + * 1. Selects the first token with ≥1 balance (priority: USDC → USDT) + * 2. If the token supports EIP-2612 permit → signs permit, encodes PERMIT mode + * 3. If not → ensures a capped on-chain approval exists, encodes APPROVE mode + * + * @param forceApproveMode When true, skips permit detection and always uses APPROVE mode. + * Useful for delegation UserOps where ERC-1271 may not be ready yet. + */ +export async function buildPaymasterData( + client: PublicClient, + walletClient: WalletClient, + signer: { signTypedData: (typedData: unknown, chainId?: number) => Promise }, + solverAccount: HexString, + paymasterAddress: HexString, + chain: string, + configService: FillerConfigService, + forceApproveMode = false, +): Promise { + const chainId = configService.getChainId(chain) + + const tokens: TokenOption[] = [] + + const usdcAddress = configService.getUsdcAsset(chain) + const usdcDecimals = configService.getUsdcDecimals(chain) + if (usdcAddress) { + tokens.push({ address: usdcAddress, decimals: usdcDecimals }) + } + + const usdtAddress = configService.getUsdtAsset(chain) + const usdtDecimals = configService.getUsdtDecimals(chain) + if (usdtAddress) { + tokens.push({ address: usdtAddress, decimals: usdtDecimals }) + } + + // ── 1. Select token by balance ─────────────────────────────────── + const selected = await selectToken(client, solverAccount, tokens) + if (!selected) { + throw new Error( + `SimplexPaymaster: solver ${solverAccount} has insufficient balance in all configured tokens ` + + `(${tokens.map((t) => t.address).join(", ")}). Need ≥1 token in at least one.`, + ) + } + + const { address: tokenAddress, decimals: tokenDecimals } = selected + const recommended = RECOMMENDED_AMOUNT_USD * 10n ** BigInt(tokenDecimals) + + // ── 2. Check permit support ────────────────────────────────────── + const hasPermit = !forceApproveMode && (await tokenSupportsPermit(client, tokenAddress)) + + if (hasPermit) { + return buildPermitMode(client, signer, solverAccount, paymasterAddress, tokenAddress, recommended, chainId) + } + + // ── 3. Approve mode: ensure capped allowance ───────────────────── + await ensureCappedApproval(client, walletClient, solverAccount, paymasterAddress, tokenAddress, tokenDecimals) + + const paymasterData = encodePacked(["uint8", "address"], [1, tokenAddress]) as HexString + + return { + paymaster: paymasterAddress, + paymasterData, + paymasterVerificationGasLimit: VERIFICATION_GAS_LIMIT_APPROVE, + paymasterPostOpGasLimit: POST_OP_GAS_LIMIT, + } +} + +// ── Token selection ────────────────────────────────────────────────── + +async function selectToken( + client: PublicClient, + solverAccount: HexString, + tokens: TokenOption[], +): Promise { + for (const token of tokens) { + const balance = (await client.readContract({ + address: token.address, + abi: erc20Abi, + functionName: "balanceOf", + args: [solverAccount], + })) as bigint + + const minBalance = 10n ** BigInt(token.decimals) + if (balance >= minBalance) { + return token + } + } + return null +} + +// ── Permit mode ────────────────────────────────────────────────────── + +async function buildPermitMode( + client: PublicClient, + signer: { signTypedData: (typedData: unknown, chainId?: number) => Promise }, + solverAccount: HexString, + paymasterAddress: HexString, + tokenAddress: HexString, + permitAmount: bigint, + chainId: number, +): Promise { + // Check existing allowance — skip permit if already sufficient + const existingAllowance = (await client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "allowance", + args: [solverAccount, paymasterAddress], + })) as bigint + + if (existingAllowance >= permitAmount) { + const paymasterData = encodePacked(["uint8", "address"], [1, tokenAddress]) as HexString + return { + paymaster: paymasterAddress, + paymasterData, + paymasterVerificationGasLimit: VERIFICATION_GAS_LIMIT_APPROVE, + paymasterPostOpGasLimit: POST_OP_GAS_LIMIT, + } + } + + const permitSignature = await signEip2612Permit( + client, + signer, + solverAccount, + paymasterAddress, + tokenAddress, + permitAmount, + chainId, + ) + + const r = `0x${permitSignature.slice(2, 66)}` as HexString + const s = `0x${permitSignature.slice(66, 130)}` as HexString + const v = parseInt(permitSignature.slice(130, 132), 16) + + const paymasterData = encodePacked( + ["uint8", "address", "uint256", "uint256", "uint8", "bytes32", "bytes32"], + [0, tokenAddress, permitAmount, maxUint256, v, r, s], + ) as HexString + + return { + paymaster: paymasterAddress, + paymasterData, + paymasterVerificationGasLimit: VERIFICATION_GAS_LIMIT_PERMIT, + paymasterPostOpGasLimit: POST_OP_GAS_LIMIT, + } +} + +// ── Approve mode: capped on-chain approval ─────────────────────────── + +async function ensureCappedApproval( + client: PublicClient, + walletClient: WalletClient, + solverAccount: HexString, + paymasterAddress: HexString, + tokenAddress: HexString, + tokenDecimals: number, +): Promise { + const currentAllowance = (await client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "allowance", + args: [solverAccount, paymasterAddress], + })) as bigint + + const threshold = THRESHOLD_USD * 10n ** BigInt(tokenDecimals) + + if (currentAllowance >= threshold) { + return + } + + const approvalAmount = RECOMMENDED_AMOUNT_USD * 10n ** BigInt(tokenDecimals) + + const hash = await walletClient.writeContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "approve", + args: [paymasterAddress, approvalAmount], + chain: walletClient.chain, + account: walletClient.account!, + }) + + await client.waitForTransactionReceipt({ hash, confirmations: 1 }) +} + +// ── EIP-2612 support detection ─────────────────────────────────────── + +async function tokenSupportsPermit(client: PublicClient, tokenAddress: HexString): Promise { + try { + await client.readContract({ + address: tokenAddress, + abi: [ + { + inputs: [], + name: "version", + outputs: [{ type: "string" }], + stateMutability: "view", + type: "function", + }, + ], + functionName: "version", + }) + return true + } catch { + return false + } +} diff --git a/sdk/packages/simplex/src/services/paymaster/types.ts b/sdk/packages/simplex/src/services/paymaster/types.ts new file mode 100644 index 000000000..707a16cf6 --- /dev/null +++ b/sdk/packages/simplex/src/services/paymaster/types.ts @@ -0,0 +1,67 @@ +import { encodePacked, type PublicClient, type WalletClient } from "viem" +import type { HexString } from "@hyperbridge/sdk" +import type { FillerConfigService } from "@/services/FillerConfigService" + +// ── Shared paymaster result type ──────────────────────────────────── + +export interface PaymasterResult { + paymaster: HexString + paymasterData: HexString + paymasterVerificationGasLimit: bigint + paymasterPostOpGasLimit: bigint +} + +// ── Unified orchestration types ───────────────────────────────────── + +export interface PaymasterOptions { + chain: string + solverAccount: HexString + publicClient: PublicClient + walletClient: WalletClient + signer: { signTypedData: (typedData: unknown, chainId?: number) => Promise } + configService: FillerConfigService + /** When true, skips permit detection and uses approve mode (for delegation UserOps). */ + forceApproveMode?: boolean +} + +export interface PaymasterDataResult { + /** Packed paymasterAndData bytes, or "0x" when no paymaster is available. */ + paymasterAndData: HexString + /** Which paymaster was selected. */ + type: "circle" | "simplex" | "none" + /** Paymaster contract address (undefined when type is "none"). */ + address?: HexString +} + +// ── Authorization amount constants ────────────────────────────────── + +/** Dollar amount to authorize (permit or approve). Safe upper bound — unused gas is refunded. */ +export const RECOMMENDED_AMOUNT_USD = 5n +/** When existing allowance drops below this, re-authorize. */ +export const THRESHOLD_USD = 2n + +// ── Gas limit constants ───────────────────────────────────────────── + +/** Verification gas limit when using EIP-2612 permit (higher due to permit verification). */ +export const VERIFICATION_GAS_LIMIT_PERMIT = 250_000n +/** Verification gas limit when using on-chain approve (lower, no permit overhead). */ +export const VERIFICATION_GAS_LIMIT_APPROVE = 150_000n +/** Verification gas limit for Circle Paymaster (recommended by Circle docs). */ +export const VERIFICATION_GAS_LIMIT_CIRCLE = 200_000n +/** Post-operation gas limit (shared across all paymaster modes). */ +export const POST_OP_GAS_LIMIT = 100_000n + +// ── Shared helpers ────────────────────────────────────────────────── + +/** + * For EntryPoint v0.8, the `paymasterAndData` field in PackedUserOperation + * is encoded as: + * paymaster (20 bytes) || paymasterVerificationGasLimit (uint128, 16 bytes) + * || paymasterPostOpGasLimit (uint128, 16 bytes) || paymasterData (variable) + */ +export function packPaymasterAndData(pm: PaymasterResult): HexString { + return encodePacked( + ["address", "uint128", "uint128", "bytes"], + [pm.paymaster, pm.paymasterVerificationGasLimit, pm.paymasterPostOpGasLimit, pm.paymasterData], + ) as HexString +} diff --git a/sdk/packages/simplex/src/services/wallet/accounts/privatekey.ts b/sdk/packages/simplex/src/services/wallet/accounts/privatekey.ts index e999a2fbb..b6fef0304 100644 --- a/sdk/packages/simplex/src/services/wallet/accounts/privatekey.ts +++ b/sdk/packages/simplex/src/services/wallet/accounts/privatekey.ts @@ -35,6 +35,7 @@ export function createPrivateKeySigningAccount(privateKey: HexString): SigningAc value: 0n, authorizationList: [args.authorization], chain: args.walletClient.chain, + gas: args.gasFloor, })) as HexString, } } diff --git a/sdk/packages/simplex/src/services/wallet/types.ts b/sdk/packages/simplex/src/services/wallet/types.ts index c68c41b25..90bfd721c 100644 --- a/sdk/packages/simplex/src/services/wallet/types.ts +++ b/sdk/packages/simplex/src/services/wallet/types.ts @@ -82,7 +82,7 @@ export interface SigningAccount extends SdkSigningAccount { account: Account mode: "privateKey" | "mpcVault" | "turnkey" /** - * Signs an EIP-712 typed-data payload (e.g. an EIP-2612 USDC permit for the Circle paymaster). + * Signs an EIP-712 typed-data payload (e.g. an EIP-2612 USDC permit for the SimplexPaymaster). * The shape of `typedData` matches viem's `TypedDataDefinition`. * MPC adapter must JSON.stringify before delegating to MpcVaultService.signTypedData. */ diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 3be050743..3c6bd0c7c 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -318,7 +318,7 @@ export class FXFiller implements FillerStrategy { const { usdUsed, policyMaxOutput } = legResult remainingUsd = remainingUsd.minus(usdUsed) - // Cap by wallet balance on the destination chain, optionally topped up via Aerodrome LP removal. + // Cap by wallet balance on the destination chain, optionally topped up via LP removal. const tokenAddress = bytes32ToBytes20(output.token).toLowerCase() const balance = await this.getAndCacheBalance(tokenAddress, walletAddress, destClient, balanceCache) diff --git a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts index 3b0721b47..5d24b9e55 100644 --- a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts +++ b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts @@ -303,12 +303,14 @@ describe.skip("Filler V2 FX - Base mainnet same-chain swap", () => { console.error(`[TIMEOUT] order.id: ${order.id}`) console.error(`[TIMEOUT] On-chain fill status: ${onChainFilled}`) - reject(new Error( - `FILLED event not received within 2 minutes. ` + - `Statuses seen: [${allStatuses.join(", ")}]. ` + - `On-chain filled: ${onChainFilled}. ` + - `userOpHash: ${userOpHash}, selectedSolver: ${selectedSolver}` - )) + reject( + new Error( + `FILLED event not received within 2 minutes. ` + + `Statuses seen: [${allStatuses.join(", ")}]. ` + + `On-chain filled: ${onChainFilled}. ` + + `userOpHash: ${userOpHash}, selectedSolver: ${selectedSolver}`, + ), + ) }, FILL_TIMEOUT_MS) }) @@ -1298,6 +1300,186 @@ describe.skip("Filler V2 FX - Base mainnet same-chain USDC→cNGN with V4 fundin }, 600_000) }) +describe.skip("Filler V2 FX - BSC mainnet same-chain swap", () => { + it("Should place USDC->EXT order on BSC and fill on BSC using FX strategy only", async () => { + const { + bscIntentGatewayV2, + bscPublicClient, + bscWalletClient, + chainConfigs, + fillerConfig, + chainConfigService, + bscMainnetId, + contractService, + } = await setUpMainnetFxBsc() + + const intentFiller = await createFxOnlyIntentFiller( + chainConfigs, + fillerConfig, + chainConfigService, + contractService, + bscMainnetId, + ) + await intentFiller.initialize() + intentFiller.start() + + const sourceUsdc = chainConfigService.getUsdcAsset(bscMainnetId) + const destExt = chainConfigService.getExtAsset(bscMainnetId)! + + const sourceUsdcDecimals = await contractService.getTokenDecimals(sourceUsdc, bscMainnetId) + const destExtDecimals = await contractService.getTokenDecimals(destExt, bscMainnetId) + // BSC USDC is 18 decimals + const amountIn = parseUnits("0.01", sourceUsdcDecimals) + + const inputs: TokenInfo[] = [{ token: bytes20ToBytes32(sourceUsdc), amount: amountIn }] + + const requestedExtOut = parseUnits("0.006", destExtDecimals) + const outputs: TokenInfo[] = [{ token: bytes20ToBytes32(destExt), amount: requestedExtOut }] + + const beneficiaryAddress = "0xdab14BdBF23d10F062eAA1a527cE2e9354E9e07F" + const beneficiary = bytes20ToBytes32(beneficiaryAddress) + const user = privateKeyToAccount(process.env.PRIVATE_KEY as HexString).address + + // ~200 blocks ≈ 10 min on BSC (3s blocks) + const currentBlock = await bscPublicClient.getBlockNumber() + const deadline = currentBlock + 200n + + let order: Order = { + user: bytes20ToBytes32(user), + source: toHex(bscMainnetId), + destination: toHex(bscMainnetId), + deadline, + nonce: 0n, + fees: 0n, + session: "0x0000000000000000000000000000000000000000" as HexString, + predispatch: { assets: [], call: "0x" as HexString }, + inputs, + output: { beneficiary, assets: outputs, call: "0x" as HexString }, + } + + const intentsCoprocessor = await IntentsCoprocessor.connect( + process.env.HYPERBRIDGE_NEXUS!, + process.env.SECRET_PHRASE!, + ) + + const destBundlerUrl = chainConfigService.getBundlerUrl(bscMainnetId) + const bscEvmChain = EvmChain.fromParams({ + chainId: 56, + host: chainConfigService.getHostAddress(bscMainnetId), + rpcUrl: chainConfigService.getRpcUrl(bscMainnetId), + bundlerUrl: destBundlerUrl, + }) + + const feeToken = await contractService.getFeeTokenWithDecimals(bscMainnetId) + await approveTokens(bscWalletClient, bscPublicClient, feeToken.address, bscIntentGatewayV2.address) + await approveTokens(bscWalletClient, bscPublicClient, sourceUsdc, bscIntentGatewayV2.address) + + const userSdkHelper = await IntentGateway.create(bscEvmChain, bscEvmChain, intentsCoprocessor) + + const gen = userSdkHelper.execute(order, DEFAULT_GRAFFITI, { + auctionTimeMs: 15_000, + pollIntervalMs: 5_000, + }) + + let result = await gen.next() + if (result.value?.status === "AWAITING_PLACE_ORDER") { + const { to, data, value } = result.value + + const signedTx = (await bscWalletClient.signTransaction( + (await bscPublicClient.prepareTransactionRequest({ + to, + data, + value: value ?? 0n, + account: bscWalletClient.account!, + chain: bscWalletClient.chain, + })) as any, + )) as HexString + result = await gen.next(signedTx) + } + + let userOpHash: HexString | undefined + let selectedSolver: HexString | undefined + let sawFilled = false + const FILL_TIMEOUT_MS = 2 * 60 * 1000 // 2 minutes + const orderPlacedAt = Date.now() + const allStatuses: string[] = [] + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(async () => { + const onChainFilled = await checkIfOrderFilled( + order.id as HexString, + bscPublicClient, + chainConfigService.getIntentGatewayV2Address(bscMainnetId), + ).catch(() => false) + + console.error(`\n[TIMEOUT] FILLED event not received within ${FILL_TIMEOUT_MS / 1000}s`) + console.error(`[TIMEOUT] Statuses seen so far: ${JSON.stringify(allStatuses)}`) + console.error(`[TIMEOUT] userOpHash: ${userOpHash}`) + console.error(`[TIMEOUT] selectedSolver: ${selectedSolver}`) + console.error(`[TIMEOUT] order.id: ${order.id}`) + console.error(`[TIMEOUT] On-chain fill status: ${onChainFilled}`) + + reject( + new Error( + `FILLED event not received within 2 minutes. ` + + `Statuses seen: [${allStatuses.join(", ")}]. ` + + `On-chain filled: ${onChainFilled}. ` + + `userOpHash: ${userOpHash}, selectedSolver: ${selectedSolver}`, + ), + ) + }, FILL_TIMEOUT_MS) + }) + + const drainGenerator = async () => { + while (!result.done) { + if (result.value && "status" in result.value) { + const status = result.value + allStatuses.push(status.status) + console.log(`[${((Date.now() - orderPlacedAt) / 1000).toFixed(1)}s] status:`, status.status, status) + + if (status.status === "BID_SELECTED") { + selectedSolver = status.selectedSolver as HexString + userOpHash = status.userOpHash as HexString + if (status.transactionHash) { + console.log("Transaction hash:", status.transactionHash) + } + } + if (status.status === "FILLED") { + sawFilled = true + console.log("[FILLED] Order filled successfully!", { + commitment: status.commitment, + userOpHash: status.userOpHash, + transactionHash: status.transactionHash, + selectedSolver: status.selectedSolver, + }) + } + if (status.status === "FAILED") { + throw new Error(`Order execution failed: ${status.error}`) + } + } + result = await gen.next() + } + } + + await Promise.race([drainGenerator(), timeoutPromise]) + + expect(userOpHash).toBeDefined() + expect(selectedSolver).toBeDefined() + expect(sawFilled).toBe(true) + const isFilled = await pollForOrderFilled( + order.id as HexString, + bscPublicClient, + chainConfigService.getIntentGatewayV2Address(bscMainnetId), + ) + expect(isFilled).toBe(true) + + console.log(`[DONE] Order lifecycle completed in ${((Date.now() - orderPlacedAt) / 1000).toFixed(1)}s`) + + await intentFiller.stop() + await intentsCoprocessor.disconnect() + }, 600_000) +}) + describe.skip("Filler V2 FX - Arbitrum mainnet same-chain swap", () => { it("Should place EXT->USDC order on Arbitrum and fill on Arbitrum using FX strategy only", async () => { const { @@ -1708,6 +1890,58 @@ async function setUpMainnetFxBase() { } } +async function setUpMainnetFxBsc() { + const bscMainnetId = "EVM-56" + const chains = [bscMainnetId] + + const testChainConfigs: ResolvedChainConfig[] = [ + { chainId: 56, rpcUrl: process.env.BSC_MAINNET!, bundlerUrl: bundlerUrl(56) }, + ] + + const fillerConfigForService: FillerServiceConfig = { + maxConcurrentOrders: 5, + hyperbridgeWsUrl: process.env.HYPERBRIDGE_NEXUS, + substratePrivateKey: process.env.SECRET_PHRASE, + } + + const chainConfigService = new FillerConfigService(testChainConfigs, fillerConfigForService) + const chainConfigs: ChainConfig[] = chains.map((chain) => chainConfigService.getChainConfig(chain)) + + const fillerConfig: FillerConfig = { + maxConcurrentOrders: 5, + pendingQueueConfig: { + maxRechecks: 10, + recheckDelayMs: 30_000, + }, + } + + const privateKey = process.env.PRIVATE_KEY as HexString + const signer = await createSimplexSigner({ type: SignerType.PrivateKey, key: privateKey }) + const cacheService = new CacheService() + const chainClientManager = new ChainClientManager(chainConfigService, signer) + const contractService = new ContractInteractionService(chainClientManager, chainConfigService, signer, cacheService) + + const bscWalletClient = chainClientManager.getWalletClient(bscMainnetId) + const bscPublicClient = chainClientManager.getPublicClient(bscMainnetId) + + const bscIntentGatewayV2 = getContract({ + address: chainConfigService.getIntentGatewayV2Address(bscMainnetId), + abi: INTENT_GATEWAY_V2_ABI, + client: { public: bscPublicClient, wallet: bscWalletClient }, + }) + + return { + bscWalletClient, + bscPublicClient, + bscIntentGatewayV2, + contractService, + bscMainnetId, + chainConfigService, + fillerConfig, + chainConfigs, + } +} + async function setUpMainnetFxArbitrum() { const arbitrumMainnetId = "EVM-42161" const chains = [arbitrumMainnetId] @@ -2030,13 +2264,19 @@ async function approveTokens( account: walletClient.account, }) + const decimals = await publicClient.readContract({ + abi: ERC20_ABI, + address: tokenAddress, + functionName: "decimals", + }) + if (approval === 0n) { console.log(`Approving token ${tokenAddress} for ${spender}`) const tx = await walletClient.writeContract({ abi: ERC20_ABI, address: tokenAddress, functionName: "approve", - args: [spender, maxUint256], + args: [spender, 10n ** BigInt(decimals)], chain: walletClient.chain, account: walletClient.account!, })