diff --git a/contracts/interfaces/IBiconomyTokenPaymaster.sol b/contracts/interfaces/IBiconomyTokenPaymaster.sol index 7c777d4..ade1f13 100644 --- a/contracts/interfaces/IBiconomyTokenPaymaster.sol +++ b/contracts/interfaces/IBiconomyTokenPaymaster.sol @@ -22,9 +22,9 @@ interface IBiconomyTokenPaymaster { event UpdatedVerifyingSigner(address indexed oldSigner, address indexed newSigner, address indexed actor); event UpdatedFeeCollector(address indexed oldFeeCollector, address indexed newFeeCollector, address indexed actor); event UpdatedPriceExpiryDuration(uint256 indexed oldValue, uint256 indexed newValue); - event TokensRefunded(address indexed userOpSender, uint256 refundAmount, bytes32 indexed userOpHash); + event TokensRefunded(address indexed userOpSender, address indexed token, uint256 refundAmount, bytes32 indexed userOpHash); event PaidGasInTokens( - address indexed userOpSender, uint256 charge, uint256 dynamicAdjustment, bytes32 indexed userOpHash + address indexed userOpSender, address indexed token, uint256 nativeCharge, uint256 tokenCharge, uint256 dynamicAdjustment, bytes32 indexed userOpHash ); event Received(address indexed sender, uint256 value); event TokensWithdrawn(address indexed token, address indexed to, uint256 indexed amount, address actor); diff --git a/contracts/libraries/PaymasterParser.sol b/contracts/libraries/TokenPaymasterParserLib.sol similarity index 88% rename from contracts/libraries/PaymasterParser.sol rename to contracts/libraries/TokenPaymasterParserLib.sol index 4763996..67a7fb8 100644 --- a/contracts/libraries/PaymasterParser.sol +++ b/contracts/libraries/TokenPaymasterParserLib.sol @@ -5,7 +5,7 @@ import { IBiconomyTokenPaymaster } from "../interfaces/IBiconomyTokenPaymaster.s import "@account-abstraction/contracts/core/UserOperationLib.sol"; // A helper library to parse paymaster and data -library PaymasterParser { +library TokenPaymasterParserLib { // Start offset of mode in PND uint256 private constant PAYMASTER_MODE_OFFSET = UserOperationLib.PAYMASTER_DATA_OFFSET; @@ -15,10 +15,8 @@ library PaymasterParser { returns (IBiconomyTokenPaymaster.PaymasterMode mode, bytes memory modeSpecificData) { unchecked { - mode = IBiconomyTokenPaymaster.PaymasterMode( - uint8(bytes1(paymasterAndData[PAYMASTER_MODE_OFFSET:PAYMASTER_MODE_OFFSET + 8])) - ); - modeSpecificData = paymasterAndData[PAYMASTER_MODE_OFFSET + 8:]; + mode = IBiconomyTokenPaymaster.PaymasterMode(uint8(bytes1(paymasterAndData[PAYMASTER_MODE_OFFSET]))); + modeSpecificData = paymasterAndData[PAYMASTER_MODE_OFFSET + 1:]; } } diff --git a/contracts/token/BiconomyTokenPaymaster.sol b/contracts/token/BiconomyTokenPaymaster.sol index 6aa3a68..fef1213 100644 --- a/contracts/token/BiconomyTokenPaymaster.sol +++ b/contracts/token/BiconomyTokenPaymaster.sol @@ -11,7 +11,7 @@ import { BasePaymaster } from "../base/BasePaymaster.sol"; import { BiconomyTokenPaymasterErrors } from "../common/BiconomyTokenPaymasterErrors.sol"; import { IBiconomyTokenPaymaster } from "../interfaces/IBiconomyTokenPaymaster.sol"; import { IOracle } from "../interfaces/oracles/IOracle.sol"; -import { PaymasterParser } from "../libraries/PaymasterParser.sol"; +import { TokenPaymasterParserLib } from "../libraries/TokenPaymasterParserLib.sol"; import { SignatureCheckerLib } from "@solady/src/utils/SignatureCheckerLib.sol"; import { ECDSA as ECDSA_solady } from "@solady/src/utils/ECDSA.sol"; import "@account-abstraction/contracts/core/Helpers.sol"; @@ -40,7 +40,7 @@ contract BiconomyTokenPaymaster is IBiconomyTokenPaymaster { using UserOperationLib for PackedUserOperation; - using PaymasterParser for bytes; + using TokenPaymasterParserLib for bytes; using SignatureCheckerLib for address; // State variables @@ -423,9 +423,8 @@ contract BiconomyTokenPaymaster is // Transfer full amount to this address. Unused amount will be refunded in postOP SafeTransferLib.safeTransferFrom(tokenAddress, userOp.sender, address(this), tokenAmount); - context = abi.encode( - userOp.sender, tokenAddress, tokenAmount, tokenPrice, uint256(externalDynamicAdjustment), userOpHash - ); + context = + abi.encode(userOp.sender, tokenAddress, tokenAmount, tokenPrice, externalDynamicAdjustment, userOpHash); validationData = _packValidationData(false, validUntil, validAfter); } else if (mode == PaymasterMode.INDEPENDENT) { // Use only oracles for the token specified in modeSpecificData @@ -488,16 +487,19 @@ contract BiconomyTokenPaymaster is if (prechargedAmount > actualTokenAmount) { uint256 refundAmount = prechargedAmount - actualTokenAmount; SafeTransferLib.safeTransfer(tokenAddress, userOpSender, refundAmount); - emit TokensRefunded(userOpSender, refundAmount, userOpHash); + emit TokensRefunded(userOpSender, tokenAddress, refundAmount, userOpHash); } // Emit an event for post-operation completion (optional) - emit PaidGasInTokens(userOpSender, actualGasCost, appliedDynamicAdjustment, userOpHash); + emit PaidGasInTokens( + userOpSender, tokenAddress, actualGasCost, actualTokenAmount, appliedDynamicAdjustment, userOpHash + ); } function _withdrawERC20(IERC20 token, address target, uint256 amount) private { if (target == address(0)) revert CanNotWithdrawToZeroAddress(); SafeTransferLib.safeTransfer(address(token), target, amount); + emit TokensWithdrawn(address(token), target, amount, msg.sender); } /// @notice Fetches the latest token price. diff --git a/test/base/TestBase.sol b/test/base/TestBase.sol index 5e57f04..8a369c3 100644 --- a/test/base/TestBase.sol +++ b/test/base/TestBase.sol @@ -17,6 +17,13 @@ import { BaseEventsAndErrors } from "./BaseEventsAndErrors.sol"; import { BiconomySponsorshipPaymaster } from "../../contracts/sponsorship/BiconomySponsorshipPaymaster.sol"; +import { + BiconomyTokenPaymaster, + IBiconomyTokenPaymaster, + BiconomyTokenPaymasterErrors, + IOracle +} from "../../../contracts/token/BiconomyTokenPaymaster.sol"; + abstract contract TestBase is CheatCodes, TestHelper, BaseEventsAndErrors { address constant ENTRYPOINT_ADDRESS = address(0x0000000071727De22E5E9d8BAf0edAc6f37da032); @@ -207,6 +214,70 @@ abstract contract TestBase is CheatCodes, TestHelper, BaseEventsAndErrors { ); } + /// @notice Generates and signs the paymaster data for a user operation. + /// @dev This function prepares the `paymasterAndData` field for a `PackedUserOperation` with the correct signature. + /// @param userOp The user operation to be signed. + /// @param signer The wallet that will sign the paymaster hash. + /// @param paymaster The paymaster contract. + /// @return finalPmData Full Pm Data. + /// @return signature Pm Signature on Data. + function generateAndSignTokenPaymasterData( + PackedUserOperation memory userOp, + Vm.Wallet memory signer, + BiconomyTokenPaymaster paymaster, + uint128 paymasterValGasLimit, + uint128 paymasterPostOpGasLimit, + IBiconomyTokenPaymaster.PaymasterMode mode, + uint48 validUntil, + uint48 validAfter, + address tokenAddress, + uint128 tokenPrice, + uint32 externalDynamicAdjustment + ) + internal + view + returns (bytes memory finalPmData, bytes memory signature) + { + // Initial paymaster data with zero signature + bytes memory initialPmData = abi.encodePacked( + address(paymaster), + paymasterValGasLimit, + paymasterPostOpGasLimit, + uint8(mode), + validUntil, + validAfter, + tokenAddress, + tokenPrice, + externalDynamicAdjustment, + new bytes(65) // Zero signature + ); + + // Update user operation with initial paymaster data + userOp.paymasterAndData = initialPmData; + + // Generate hash to be signed + bytes32 paymasterHash = + paymaster.getHash(userOp, validUntil, validAfter, tokenAddress, tokenPrice, externalDynamicAdjustment); + + // Sign the hash + signature = signMessage(signer, paymasterHash); + require(signature.length == 65, "Invalid Paymaster Signature length"); + + // Final paymaster data with the actual signature + finalPmData = abi.encodePacked( + address(paymaster), + paymasterValGasLimit, + paymasterPostOpGasLimit, + uint8(mode), + validUntil, + validAfter, + tokenAddress, + tokenPrice, + externalDynamicAdjustment, + signature + ); + } + function excludeLastNBytes(bytes memory data, uint256 n) internal pure returns (bytes memory) { require(data.length > n, "Input data is too short"); bytes memory result = new bytes(data.length - n); @@ -268,4 +339,16 @@ abstract contract TestBase is CheatCodes, TestHelper, BaseEventsAndErrors { // paymaster) assertApproxEqRel(totalGasFeePaid + actualDynamicAdjustment, gasPaidByDapp, 0.01e18); } + + function _toSingletonArray(address addr) internal pure returns (address[] memory) { + address[] memory array = new address[](1); + array[0] = addr; + return array; + } + + function _toSingletonArray(IOracle oracle) internal pure returns (IOracle[] memory) { + IOracle[] memory array = new IOracle[](1); + array[0] = oracle; + return array; + } } diff --git a/test/mocks/MockOracle.sol b/test/mocks/MockOracle.sol new file mode 100644 index 0000000..ce572e6 --- /dev/null +++ b/test/mocks/MockOracle.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "contracts/interfaces/oracles/IOracle.sol"; + +contract MockOracle is IOracle { + int256 public price; + uint8 public priceDecimals; + uint256 public updatedAtDelay; + + constructor(int256 _initialPrice, uint8 _decimals) { + price = _initialPrice; + priceDecimals = _decimals; + updatedAtDelay = 0; + } + + /** + * @dev Allows setting a new price manually for testing purposes. + * @param _price The new price to be set. + */ + function setPrice(int256 _price) external { + price = _price; + } + + /** + * @dev Allows setting the delay for the `updatedAt` timestamp. + * @param _updatedAtDelay The delay in seconds to simulate a stale price. + */ + function setUpdatedAtDelay(uint256 _updatedAtDelay) external { + updatedAtDelay = _updatedAtDelay; + } + + /** + * @dev Returns the number of decimals for the oracle price feed. + */ + function decimals() external view override returns (uint8) { + return priceDecimals; + } + + /** + * @dev Mocks a random price within a given range. + * @param minPrice The minimum price range (inclusive). + * @param maxPrice The maximum price range (inclusive). + */ + function setRandomPrice(int256 minPrice, int256 maxPrice) external { + require(minPrice <= maxPrice, "Min price must be less than or equal to max price"); + + // Generate a random price within the range [minPrice, maxPrice] + price = minPrice + int256(uint256(keccak256(abi.encodePacked(block.timestamp, block.difficulty))) % uint256(maxPrice - minPrice + 1)); + } + + /** + * @dev Returns mocked data for the latest round of the price feed. + * @return _roundId The round ID. + * @return answer The current price. + * @return startedAt The timestamp when the round started. + * @return _updatedAt The timestamp when the round was last updated. + * @return answeredInRound The round ID in which the answer was computed. + */ + function latestRoundData() + external + view + override + returns ( + uint80 _roundId, + int256 answer, + uint256 startedAt, + uint256 _updatedAt, + uint80 answeredInRound + ) + { + return ( + 73786976294838215802, // Mock round ID + price, // The current price + block.timestamp, // Simulate round started at the current block timestamp + block.timestamp - updatedAtDelay, // Simulate price last updated with delay + 73786976294838215802 // Mock round ID for answeredInRound + ); + } +} diff --git a/test/unit/concrete/TestTokenPaymaster.t.sol b/test/unit/concrete/TestTokenPaymaster.t.sol new file mode 100644 index 0000000..512ef55 --- /dev/null +++ b/test/unit/concrete/TestTokenPaymaster.t.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity ^0.8.26; + +import "../../base/TestBase.sol"; +import { + BiconomyTokenPaymaster, + IBiconomyTokenPaymaster, + BiconomyTokenPaymasterErrors, + IOracle +} from "../../../contracts/token/BiconomyTokenPaymaster.sol"; +import { MockOracle } from "../../mocks/MockOracle.sol"; +import { MockToken } from "@nexus/contracts/mocks/MockToken.sol"; + +contract TestTokenPaymaster is TestBase { + BiconomyTokenPaymaster public tokenPaymaster; + MockOracle public nativeOracle; + MockToken public testToken; + MockOracle public tokenOracle; + + function setUp() public { + setupPaymasterTestEnvironment(); + + // Deploy mock oracles and tokens + nativeOracle = new MockOracle(100_000_000, 8); // Oracle with 8 decimals for ETH + tokenOracle = new MockOracle(100_000_000, 8); // Oracle with 8 decimals for ERC20 token + testToken = new MockToken("Test Token", "TKN"); + + // Deploy the token paymaster + tokenPaymaster = new BiconomyTokenPaymaster( + PAYMASTER_OWNER.addr, + PAYMASTER_SIGNER.addr, + ENTRYPOINT, + 5000, // unaccounted gas + 1e6, // dynamic adjustment + nativeOracle, + 1 days, // price expiry duration + _toSingletonArray(address(testToken)), + _toSingletonArray(IOracle(address(tokenOracle))) + ); + } + + function test_Deploy() external { + BiconomyTokenPaymaster testArtifact = new BiconomyTokenPaymaster( + PAYMASTER_OWNER.addr, + PAYMASTER_SIGNER.addr, + ENTRYPOINT, + 5000, + 1e6, + nativeOracle, + 1 days, + _toSingletonArray(address(testToken)), + _toSingletonArray(IOracle(address(tokenOracle))) + ); + + assertEq(testArtifact.owner(), PAYMASTER_OWNER.addr); + assertEq(address(testArtifact.entryPoint()), ENTRYPOINT_ADDRESS); + assertEq(testArtifact.verifyingSigner(), PAYMASTER_SIGNER.addr); + assertEq(address(testArtifact.nativeOracle()), address(nativeOracle)); + assertEq(testArtifact.unaccountedGas(), 5000); + assertEq(testArtifact.dynamicAdjustment(), 1e6); + } + + function test_RevertIf_DeployWithSignerSetToZero() external { + vm.expectRevert(BiconomyTokenPaymasterErrors.VerifyingSignerCanNotBeZero.selector); + new BiconomyTokenPaymaster( + PAYMASTER_OWNER.addr, + address(0), + ENTRYPOINT, + 5000, + 1e6, + nativeOracle, + 1 days, + _toSingletonArray(address(testToken)), + _toSingletonArray(IOracle(address(tokenOracle))) + ); + } + + function test_RevertIf_DeployWithSignerAsContract() external { + vm.expectRevert(BiconomyTokenPaymasterErrors.VerifyingSignerCanNotBeContract.selector); + new BiconomyTokenPaymaster( + PAYMASTER_OWNER.addr, + address(ENTRYPOINT), + ENTRYPOINT, + 5000, + 1e6, + nativeOracle, + 1 days, + _toSingletonArray(address(testToken)), + _toSingletonArray(IOracle(address(tokenOracle))) + ); + } + + function test_RevertIf_UnaccountedGasTooHigh() external { + vm.expectRevert(BiconomyTokenPaymasterErrors.UnaccountedGasTooHigh.selector); + new BiconomyTokenPaymaster( + PAYMASTER_OWNER.addr, + PAYMASTER_SIGNER.addr, + ENTRYPOINT, + 50_001, // too high unaccounted gas + 1e6, + nativeOracle, + 1 days, + _toSingletonArray(address(testToken)), + _toSingletonArray(IOracle(address(tokenOracle))) + ); + } + + function test_RevertIf_InvalidDynamicAdjustment() external { + vm.expectRevert(BiconomyTokenPaymasterErrors.InvalidDynamicAdjustment.selector); + new BiconomyTokenPaymaster( + PAYMASTER_OWNER.addr, + PAYMASTER_SIGNER.addr, + ENTRYPOINT, + 5000, + 2e6 + 1, // too high dynamic adjustment + nativeOracle, + 1 days, + _toSingletonArray(address(testToken)), + _toSingletonArray(IOracle(address(tokenOracle))) + ); + } + + function test_SetVerifyingSigner() external prankModifier(PAYMASTER_OWNER.addr) { + vm.expectEmit(true, true, true, true, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.UpdatedVerifyingSigner(PAYMASTER_SIGNER.addr, BOB_ADDRESS, PAYMASTER_OWNER.addr); + tokenPaymaster.setSigner(BOB_ADDRESS); + assertEq(tokenPaymaster.verifyingSigner(), BOB_ADDRESS); + } + + function test_RevertIf_SetVerifyingSignerToZero() external prankModifier(PAYMASTER_OWNER.addr) { + vm.expectRevert(BiconomyTokenPaymasterErrors.VerifyingSignerCanNotBeZero.selector); + tokenPaymaster.setSigner(address(0)); + } + + function test_SetFeeCollector() external prankModifier(PAYMASTER_OWNER.addr) { + // Set the expected fee collector change and expect the event to be emitted + vm.expectEmit(true, true, true, true, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.UpdatedFeeCollector( + address(tokenPaymaster), BOB_ADDRESS, PAYMASTER_OWNER.addr + ); + + // Call the function to set the fee collector + tokenPaymaster.setFeeCollector(BOB_ADDRESS); + + // Assert the change has been applied correctly + assertEq(tokenPaymaster.feeCollector(), BOB_ADDRESS); + } + + function test_Deposit() external prankModifier(PAYMASTER_OWNER.addr) { + uint256 depositAmount = 10 ether; + assertEq(tokenPaymaster.getDeposit(), 0); + + tokenPaymaster.deposit{ value: depositAmount }(); + assertEq(tokenPaymaster.getDeposit(), depositAmount); + } + + function test_WithdrawTo() external prankModifier(PAYMASTER_OWNER.addr) { + uint256 depositAmount = 10 ether; + tokenPaymaster.deposit{ value: depositAmount }(); + uint256 initialBalance = BOB_ADDRESS.balance; + + // Withdraw ETH to BOB_ADDRESS and verify the balance changes + tokenPaymaster.withdrawTo(payable(BOB_ADDRESS), depositAmount); + + assertEq(BOB_ADDRESS.balance, initialBalance + depositAmount); + assertEq(tokenPaymaster.getDeposit(), 0); + } + + function test_WithdrawERC20() external prankModifier(PAYMASTER_OWNER.addr) { + uint256 mintAmount = 10 * (10 ** testToken.decimals()); + testToken.mint(address(tokenPaymaster), mintAmount); + + // Ensure that the paymaster has the tokens + assertEq(testToken.balanceOf(address(tokenPaymaster)), mintAmount); + assertEq(testToken.balanceOf(ALICE_ADDRESS), 0); + + // Expect the `TokensWithdrawn` event to be emitted with the correct values + vm.expectEmit(true, true, true, true, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.TokensWithdrawn( + address(testToken), ALICE_ADDRESS, mintAmount, PAYMASTER_OWNER.addr + ); + + // Withdraw tokens and validate balances + tokenPaymaster.withdrawERC20(testToken, ALICE_ADDRESS, mintAmount); + + assertEq(testToken.balanceOf(address(tokenPaymaster)), 0); + assertEq(testToken.balanceOf(ALICE_ADDRESS), mintAmount); + } + + function test_RevertIf_InvalidOracleDecimals() external { + MockOracle invalidOracle = new MockOracle(100_000_000, 18); // invalid oracle with 18 decimals + vm.expectRevert(BiconomyTokenPaymasterErrors.InvalidOracleDecimals.selector); + new BiconomyTokenPaymaster( + PAYMASTER_OWNER.addr, + PAYMASTER_SIGNER.addr, + ENTRYPOINT, + 5000, + 1e6, + invalidOracle, // incorrect oracle decimals + 1 days, + _toSingletonArray(address(testToken)), + _toSingletonArray(IOracle(address(tokenOracle))) + ); + } + + function test_SetNativeOracle() external prankModifier(PAYMASTER_OWNER.addr) { + MockOracle newOracle = new MockOracle(100_000_000, 8); + + vm.expectEmit(true, true, false, true, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.UpdatedNativeAssetOracle(nativeOracle, newOracle); + tokenPaymaster.setNativeOracle(newOracle); + + assertEq(address(tokenPaymaster.nativeOracle()), address(newOracle)); + } + + function test_ValidatePaymasterUserOp_ExternalMode() external { + tokenPaymaster.deposit{ value: 10 ether }(); + testToken.mint(address(ALICE_ACCOUNT), 100_000 * (10 ** testToken.decimals())); + vm.startPrank(address(ALICE_ACCOUNT)); + testToken.approve(address(tokenPaymaster), testToken.balanceOf(address(ALICE_ACCOUNT))); + vm.stopPrank(); + + // Build the user operation for external mode + PackedUserOperation memory userOp = buildUserOpWithCalldata(ALICE, "", address(VALIDATOR_MODULE)); + uint48 validUntil = uint48(block.timestamp + 1 days); + uint48 validAfter = uint48(block.timestamp); + uint128 tokenPrice = 1e8; // Assume 1 token = 1 USD + uint32 externalDynamicAdjustment = 1e6; + + // Generate and sign the token paymaster data + (bytes memory paymasterAndData,) = generateAndSignTokenPaymasterData( + userOp, + PAYMASTER_SIGNER, + tokenPaymaster, + 3e6, // assumed gas limit for test + 3e6, // assumed verification gas for test + IBiconomyTokenPaymaster.PaymasterMode.EXTERNAL, + validUntil, + validAfter, + address(testToken), + tokenPrice, + externalDynamicAdjustment + ); + + userOp.paymasterAndData = paymasterAndData; + userOp.signature = signUserOp(ALICE, userOp); + + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = userOp; + + vm.expectEmit(true, true, false, false, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.TokensRefunded(address(ALICE_ACCOUNT), address(testToken), 0, bytes32(0)); + + vm.expectEmit(true, true, false, false, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.PaidGasInTokens(address(ALICE_ACCOUNT), address(testToken), 0, 0, 1e6, bytes32(0)); + + // Execute the operation + ENTRYPOINT.handleOps(ops, payable(BUNDLER.addr)); + } + + function test_ValidatePaymasterUserOp_IndependentMode() external { + tokenPaymaster.deposit{ value: 10 ether }(); + testToken.mint(address(ALICE_ACCOUNT), 100_000 * (10 ** testToken.decimals())); + vm.startPrank(address(ALICE_ACCOUNT)); + testToken.approve(address(tokenPaymaster), testToken.balanceOf(address(ALICE_ACCOUNT))); + vm.stopPrank(); + + PackedUserOperation memory userOp = buildUserOpWithCalldata(ALICE, "", address(VALIDATOR_MODULE)); + + // Encode paymasterAndData for independent mode + bytes memory paymasterAndData = abi.encodePacked( + address(tokenPaymaster), + uint128(3e6), // assumed gas limit for test + uint128(3e6), // assumed verification gas for test + uint8(IBiconomyTokenPaymaster.PaymasterMode.INDEPENDENT), + address(testToken) + ); + + userOp.paymasterAndData = paymasterAndData; + userOp.signature = signUserOp(ALICE, userOp); + + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = userOp; + + vm.expectEmit(true, true, false, false, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.TokensRefunded(address(ALICE_ACCOUNT), address(testToken), 0, bytes32(0)); + + vm.expectEmit(true, true, false, false, address(tokenPaymaster)); + emit IBiconomyTokenPaymaster.PaidGasInTokens(address(ALICE_ACCOUNT), address(testToken), 0, 0, 1e6, bytes32(0)); + + ENTRYPOINT.handleOps(ops, payable(BUNDLER.addr)); + } +}