diff --git a/contracts/base/BasePaymaster.sol b/contracts/base/BasePaymaster.sol new file mode 100644 index 0000000..63eafc3 --- /dev/null +++ b/contracts/base/BasePaymaster.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.24; + +/* solhint-disable reason-string */ + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IPaymaster } from "account-abstraction/contracts/interfaces/IPaymaster.sol"; +import { IEntryPoint } from "account-abstraction/contracts/interfaces/IEntryPoint.sol"; +import "account-abstraction/contracts/core/UserOperationLib.sol"; +/** + * Helper class for creating a paymaster. + * provides helper methods for staking. + * Validates that the postOp is called only by the entryPoint. + */ +abstract contract BasePaymaster is IPaymaster, Ownable { + IEntryPoint public immutable entryPoint; + + uint256 internal constant PAYMASTER_VALIDATION_GAS_OFFSET = UserOperationLib.PAYMASTER_VALIDATION_GAS_OFFSET; + uint256 internal constant PAYMASTER_POSTOP_GAS_OFFSET = UserOperationLib.PAYMASTER_POSTOP_GAS_OFFSET; + uint256 internal constant PAYMASTER_DATA_OFFSET = UserOperationLib.PAYMASTER_DATA_OFFSET; + + constructor(IEntryPoint _entryPoint) Ownable(msg.sender) { + _validateEntryPointInterface(_entryPoint); + entryPoint = _entryPoint; + } + + //sanity check: make sure this EntryPoint was compiled against the same + // IEntryPoint of this paymaster + function _validateEntryPointInterface(IEntryPoint _entryPoint) internal virtual { + require(IERC165(address(_entryPoint)).supportsInterface(type(IEntryPoint).interfaceId), "IEntryPoint interface mismatch"); + } + + /// @inheritdoc IPaymaster + function validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) external override returns (bytes memory context, uint256 validationData) { + _requireFromEntryPoint(); + return _validatePaymasterUserOp(userOp, userOpHash, maxCost); + } + + /** + * Validate a user operation. + * @param userOp - The user operation. + * @param userOpHash - The hash of the user operation. + * @param maxCost - The maximum cost of the user operation. + */ + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) internal virtual returns (bytes memory context, uint256 validationData); + + /// @inheritdoc IPaymaster + function postOp( + PostOpMode mode, + bytes calldata context, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas + ) external override { + _requireFromEntryPoint(); + _postOp(mode, context, actualGasCost, actualUserOpFeePerGas); + } + + /** + * Post-operation handler. + * (verified to be called only through the entryPoint) + * @dev If subclass returns a non-empty context from validatePaymasterUserOp, + * it must also implement this method. + * @param mode - Enum with the following options: + * opSucceeded - User operation succeeded. + * opReverted - User op reverted. The paymaster still has to pay for gas. + * postOpReverted - never passed in a call to postOp(). + * @param context - The context value returned by validatePaymasterUserOp + * @param actualGasCost - Actual gas used so far (without this postOp call). + * @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas + * and maxPriorityFee (and basefee) + * It is not the same as tx.gasprice, which is what the bundler pays. + */ + function _postOp( + PostOpMode mode, + bytes calldata context, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas + ) internal virtual { + (mode, context, actualGasCost, actualUserOpFeePerGas); // unused params + // subclass must override this method if validatePaymasterUserOp returns a context + revert("must override"); + } + + /** + * Add a deposit for this paymaster, used for paying for transaction fees. + */ + function deposit() public payable { + entryPoint.depositTo{value: msg.value}(address(this)); + } + + /** + * Withdraw value from the deposit. + * @param withdrawAddress - Target to send to. + * @param amount - Amount to withdraw. + */ + function withdrawTo( + address payable withdrawAddress, + uint256 amount + ) public onlyOwner { + entryPoint.withdrawTo(withdrawAddress, amount); + } + + /** + * Add stake for this paymaster. + * This method can also carry eth value to add to the current stake. + * @param unstakeDelaySec - The unstake delay for this paymaster. Can only be increased. + */ + function addStake(uint32 unstakeDelaySec) external payable onlyOwner { + entryPoint.addStake{value: msg.value}(unstakeDelaySec); + } + + /** + * Return current paymaster's deposit on the entryPoint. + */ + function getDeposit() public view returns (uint256) { + return entryPoint.balanceOf(address(this)); + } + + /** + * Unlock the stake, in order to withdraw it. + * The paymaster can't serve requests once unlocked, until it calls addStake again + */ + function unlockStake() external onlyOwner { + entryPoint.unlockStake(); + } + + /** + * Withdraw the entire paymaster's stake. + * stake must be unlocked first (and then wait for the unstakeDelay to be over) + * @param withdrawAddress - The address to send withdrawn value. + */ + function withdrawStake(address payable withdrawAddress) external onlyOwner { + entryPoint.withdrawStake(withdrawAddress); + } + + /** + * Validate the call is made from a valid entrypoint + */ + function _requireFromEntryPoint() internal virtual { + require(msg.sender == address(entryPoint), "Sender not EntryPoint"); + } +} \ No newline at end of file diff --git a/contracts/references/SampleVerifyingPaymaster.sol b/contracts/references/SampleVerifyingPaymaster.sol new file mode 100644 index 0000000..3fdce99 --- /dev/null +++ b/contracts/references/SampleVerifyingPaymaster.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.24; + +/* solhint-disable reason-string */ +/* solhint-disable no-inline-assembly */ + +import "account-abstraction/contracts/core/BasePaymaster.sol"; +import "account-abstraction/contracts/core/UserOperationLib.sol"; +import "account-abstraction/contracts/core/Helpers.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/** + * A sample paymaster that uses external service to decide whether to pay for the UserOp. + * The paymaster trusts an external signer to sign the transaction. + * The calling user must pass the UserOp to that external signer first, which performs + * whatever off-chain verification before signing the UserOp. + * Note that this signature is NOT a replacement for the account-specific signature: + * - the paymaster checks a signature to agree to PAY for GAS. + * - the account checks a signature to prove identity and account ownership. + */ +contract VerifyingPaymaster is BasePaymaster { + + using UserOperationLib for PackedUserOperation; + + address public immutable verifyingSigner; + + uint256 private constant VALID_TIMESTAMP_OFFSET = PAYMASTER_DATA_OFFSET; + + uint256 private constant SIGNATURE_OFFSET = VALID_TIMESTAMP_OFFSET + 64; + + constructor(IEntryPoint _entryPoint, address _verifyingSigner) BasePaymaster(_entryPoint) { + verifyingSigner = _verifyingSigner; + } + + /** + * return the hash we're going to sign off-chain (and validate on-chain) + * this method is called by the off-chain service, to sign the request. + * it is called on-chain from the validatePaymasterUserOp, to validate the signature. + * note that this signature covers all fields of the UserOperation, except the "paymasterAndData", + * which will carry the signature itself. + */ + function getHash(PackedUserOperation calldata userOp, uint48 validUntil, uint48 validAfter) + public view returns (bytes32) { + //can't use userOp.hash(), since it contains also the paymasterAndData itself. + address sender = userOp.getSender(); + return + keccak256( + abi.encode( + sender, + userOp.nonce, + keccak256(userOp.initCode), + keccak256(userOp.callData), + userOp.accountGasLimits, + uint256(bytes32(userOp.paymasterAndData[PAYMASTER_VALIDATION_GAS_OFFSET : PAYMASTER_DATA_OFFSET])), + userOp.preVerificationGas, + userOp.gasFees, + block.chainid, + address(this), + validUntil, + validAfter + ) + ); + } + + /** + * verify our external signer signed this request. + * the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params + * paymasterAndData[:20] : address(this) + * paymasterAndData[20:84] : abi.encode(validUntil, validAfter) + * paymasterAndData[84:] : signature + */ + function _validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) + internal view override returns (bytes memory context, uint256 validationData) { + (requiredPreFund); + + (uint48 validUntil, uint48 validAfter, bytes calldata signature) = parsePaymasterAndData(userOp.paymasterAndData); + //ECDSA library supports both 64 and 65-byte long signatures. + // we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA" + require(signature.length == 64 || signature.length == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData"); + bytes32 hash = MessageHashUtils.toEthSignedMessageHash(getHash(userOp, validUntil, validAfter)); + + //don't revert on signature failure: return SIG_VALIDATION_FAILED + if (verifyingSigner != ECDSA.recover(hash, signature)) { + return ("", _packValidationData(true, validUntil, validAfter)); + } + + //no need for other on-chain validation: entire UserOp should have been checked + // by the external service prior to signing it. + return ("", _packValidationData(false, validUntil, validAfter)); + } + + function parsePaymasterAndData(bytes calldata paymasterAndData) public pure returns (uint48 validUntil, uint48 validAfter, bytes calldata signature) { + (validUntil, validAfter) = abi.decode(paymasterAndData[VALID_TIMESTAMP_OFFSET :], (uint48, uint48)); + signature = paymasterAndData[SIGNATURE_OFFSET :]; + } +} \ No newline at end of file diff --git a/contracts/sponsorship/SponsorshipPaymasterWithPremium.sol b/contracts/sponsorship/SponsorshipPaymasterWithPremium.sol new file mode 100644 index 0000000..45d6c39 --- /dev/null +++ b/contracts/sponsorship/SponsorshipPaymasterWithPremium.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.24; + +/* solhint-disable reason-string */ + +import "../base/BasePaymaster.sol"; +import "account-abstraction/contracts/core/UserOperationLib.sol"; +import "account-abstraction/contracts/core/Helpers.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { SignatureCheckerLib } from "solady/src/utils/SignatureCheckerLib.sol"; +import { ECDSA as ECDSA_solady } from "solady/src/utils/ECDSA.sol"; \ No newline at end of file diff --git a/contracts/Foo.sol b/contracts/test/Foo.sol similarity index 100% rename from contracts/Foo.sol rename to contracts/test/Foo.sol diff --git a/contracts/Lock.sol b/contracts/test/Lock.sol similarity index 100% rename from contracts/Lock.sol rename to contracts/test/Lock.sol diff --git a/hardhat.config.ts b/hardhat.config.ts index a722fc6..7209aa4 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -17,8 +17,8 @@ const config: HardhatUserConfig = { }, }, docgen: { - projectName: "Biconomy", - projectDescription: "Sc-Template Description", + projectName: "Biconomy Paymasters", + projectDescription: "Account Abstraction (v0.7.0) Paymasters", }, }; diff --git a/package.json b/package.json index 528073a..83e06b5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "ethers": "^6.11.1", "forge-std": "github:foundry-rs/forge-std#v1.7.6", "modulekit": "github:rhinestonewtf/modulekit", + "solady": "github:vectorized/solady", + "account-abstraction": "github:eth-infinitism/account-abstraction#develop", "hardhat-gas-reporter": "^1.0.10", "hardhat-storage-layout": "^0.1.7", "prettier": "^3.2.5", diff --git a/scripts/foundry/Deploy.s.sol b/scripts/foundry/Deploy.s.sol index 2c5a4a6..5a55826 100644 --- a/scripts/foundry/Deploy.s.sol +++ b/scripts/foundry/Deploy.s.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.23 <0.9.0; -import { Foo } from "../../contracts/Foo.sol"; +import { Foo } from "../../contracts/test/Foo.sol"; import { BaseScript } from "./Base.s.sol"; diff --git a/test/foundry/Foo.t.sol b/test/foundry/Foo.t.sol deleted file mode 100644 index 5ff564e..0000000 --- a/test/foundry/Foo.t.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.23 <0.9.0; - -import { PRBTest } from "@prb/test/src/PRBTest.sol"; -import { console2 } from "forge-std/src/console2.sol"; -import { StdCheats } from "forge-std/src/StdCheats.sol"; -import { Foo } from "../../contracts/Foo.sol"; - -interface IERC20 { - function balanceOf(address account) external view returns (uint256); -} - -/// @dev If this is your first time with Forge, read this tutorial in the Foundry Book: -/// https://book.getfoundry.sh/forge/writing-tests -contract FooTest is PRBTest, StdCheats { - Foo internal foo; - - /// @dev A function invoked before each test case is run. - function setUp() public virtual { - // Instantiate the contract-under-test. - foo = new Foo(); - } - - /// @dev Basic test. Run it with `forge test -vvv` to see the console log. - function testExample() public { - console2.log("Hello World"); - uint256 x = 42; - assertEq(foo.id(x), x, "value mismatch"); - } - - /// @dev Fuzz test that provides random values for an unsigned integer, but which rejects zero as an input. - /// If you need more sophisticated input validation, you should use the `bound` utility instead. - /// See https://twitter.com/PaulRBerg/status/1622558791685242880 - function testFuzzExample(uint256 x) public { - vm.assume(x != 0); // or x = bound(x, 1, 100) - assertEq(foo.id(x), x, "value mismatch"); - } - - /// @dev Fork test that runs against an Ethereum Mainnet fork. For this to work, you need to set `API_KEY_ALCHEMY` - /// in your environment You can get an API key for free at https://alchemy.com. - function testForkExample() public { - // Silently pass this test if there is no API key. - string memory alchemyApiKey = vm.envOr("API_KEY_ALCHEMY", string("")); - if (bytes(alchemyApiKey).length == 0) { - return; - } - - // Otherwise, run the test against the mainnet fork. - vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 16_428_000 }); - address usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address holder = 0x7713974908Be4BEd47172370115e8b1219F4A5f0; - uint256 actualBalance = IERC20(usdc).balanceOf(holder); - uint256 expectedBalance = 196_307_713.810457e6; - assertEq(actualBalance, expectedBalance); - } -} diff --git a/test/foundry/Lock.t.sol b/test/foundry/Lock.t.sol index 858622f..5782e2d 100644 --- a/test/foundry/Lock.t.sol +++ b/test/foundry/Lock.t.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.24 <0.9.0; import { PRBTest } from "@prb/test/src/PRBTest.sol"; -import { Lock } from "../../contracts/Lock.sol"; +import { Lock } from "../../contracts/test/Lock.sol"; import { StdCheats } from "forge-std/src/StdCheats.sol"; contract LockTest is PRBTest, StdCheats { diff --git a/test/foundry/mocks/Counter.sol b/test/foundry/mocks/Counter.sol new file mode 100644 index 0000000..5807161 --- /dev/null +++ b/test/foundry/mocks/Counter.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +contract Counter { + uint256 private _number; + + function incrementNumber() public { + _number++; + } + + function decrementNumber() public { + _number--; + } + + function getNumber() public view returns (uint256) { + return _number; + } + + function revertOperation() public pure { + revert("Counter: Revert operation"); + } + + function test_() public pure { + // This function is used to ignore file in coverage report + } +} diff --git a/test/hardhat/Foo.ts b/test/hardhat/Foo.ts deleted file mode 100644 index badb63c..0000000 --- a/test/hardhat/Foo.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect } from "chai"; -import { ethers } from "hardhat"; -import { Foo } from "../../typechain-types"; - -describe("Foo contract", function () { - let foo: Foo; - - beforeEach(async function () { - // Deploy the Foo contract before each test - const Foo = await ethers.getContractFactory("Foo"); - foo = await Foo.deploy(); - }); - - // Test case for the id function - it("should return the same value passed", async function () { - const testValue = 123; - expect(await foo.id(testValue)).to.equal(testValue); - }); -});