Skip to content

Commit 6cbd97e

Browse files
authored
Add LimitingPaymaster (#26)
* Add limiting paymaster * Add limit per spender * Add bundler allowlist * Removed unused constant * Move tx.origin validation to postOp * Add optional support for any bundler * Ensure spending params are in the signed hash * Add via_ir=true * Fix tests broken by via_ir=true
1 parent 6711e15 commit 6cbd97e

File tree

3 files changed

+141
-2
lines changed

3 files changed

+141
-2
lines changed

foundry.toml

+1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ remappings = [
1212
'@account-abstraction/=lib/account-abstraction/contracts',
1313
'@solady/=lib/solady/src',
1414
]
15+
via_ir = true
1516

src/LimitingPaymaster.sol

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// SPDX-License-Identifier: GPL-3.0
2+
pragma solidity 0.8.20;
3+
4+
/* solhint-disable reason-string */
5+
6+
import "@account-abstraction/core/BasePaymaster.sol";
7+
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
8+
9+
/**
10+
* A paymaster that uses external service to decide whether to pay for the UserOp.
11+
* Also limits spending to "spendMax" per "spentKey", passed in via paymaster data.
12+
* The paymaster trusts an external signer to sign the transaction.
13+
* The calling user must pass the UserOp to that external signer first, which performs
14+
* whatever off-chain verification before signing the UserOp.
15+
* Note that this signature is NOT a replacement for the account-specific signature:
16+
* - the paymaster checks a signature to agree to PAY for GAS.
17+
* - the account checks a signature to prove identity and account ownership.
18+
*/
19+
contract LimitingPaymaster is BasePaymaster {
20+
using UserOperationLib for UserOperation;
21+
22+
address public immutable verifyingSigner;
23+
24+
mapping (uint32 => uint96) public spent;
25+
mapping (address => bool) public bundlerAllowed;
26+
27+
constructor(IEntryPoint _entryPoint, address _verifyingSigner) BasePaymaster(_entryPoint) Ownable() {
28+
require(address(_entryPoint).code.length > 0, "Paymaster: passed _entryPoint is not currently a contract");
29+
require(_verifyingSigner != address(0), "Paymaster: verifyingSigner cannot be address(0)");
30+
require(_verifyingSigner != msg.sender, "Paymaster: verifyingSigner cannot be the owner");
31+
verifyingSigner = _verifyingSigner;
32+
}
33+
34+
/**
35+
* return the hash we're going to sign off-chain (and validate on-chain)
36+
* this method is called by the off-chain service, to sign the request.
37+
* it is called on-chain from the validatePaymasterUserOp, to validate the signature.
38+
* note that this signature covers all fields of the UserOperation, except the "paymasterAndData",
39+
* which will carry the signature itself.
40+
*/
41+
function getHash(UserOperation calldata userOp, uint48 validUntil, uint48 validAfter, uint32 spentKey, uint96 spentMax, bool allowAnyBundler)
42+
public view returns (bytes32) {
43+
// can't use userOp.hash(), since it contains also the paymasterAndData itself.
44+
return keccak256(
45+
abi.encode(
46+
userOp.getSender(),
47+
userOp.nonce,
48+
calldataKeccak(userOp.initCode),
49+
calldataKeccak(userOp.callData),
50+
userOp.callGasLimit,
51+
userOp.verificationGasLimit,
52+
userOp.preVerificationGas,
53+
userOp.maxFeePerGas,
54+
userOp.maxPriorityFeePerGas,
55+
block.chainid,
56+
address(this),
57+
validUntil,
58+
validAfter,
59+
spentKey,
60+
spentMax,
61+
allowAnyBundler
62+
)
63+
);
64+
}
65+
66+
/**
67+
* verify our external signer signed this request.
68+
* the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params
69+
* paymasterAndData[:20] : address(this)
70+
* paymasterAndData[20:26] : validUntil
71+
* paymasterAndData[26:32] : validAfter
72+
* paymasterAndData[32:36] : spendKey
73+
* paymasterAndData[36:48] : spendMax
74+
* paymasterAndData[48] : allowAnyBundler
75+
* paymasterAndData[49:114] : signature
76+
*/
77+
function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
78+
internal view override returns (bytes memory context, uint256 validationData) {
79+
(uint48 validUntil, uint48 validAfter, uint32 spentKey, uint96 spentMax, bool allowAnyBundler, bytes calldata signature) = parsePaymasterAndData(userOp.paymasterAndData);
80+
require(spent[spentKey] + requiredPreFund <= spentMax, "Paymaster: spender funds are depleted");
81+
// Only support 65-byte signatures, to avoid potential replay attacks.
82+
require(signature.length == 65, "Paymaster: invalid signature length in paymasterAndData");
83+
bytes32 hash = ECDSA.toEthSignedMessageHash(getHash(userOp, validUntil, validAfter, spentKey, spentMax, allowAnyBundler));
84+
85+
// don't revert on signature failure: return SIG_VALIDATION_FAILED
86+
if (verifyingSigner != ECDSA.recover(hash, signature)) {
87+
return ("", _packValidationData(true, validUntil, validAfter));
88+
}
89+
90+
// no need for other on-chain validation: entire UserOp should have been checked
91+
// by the external service prior to signing it.
92+
return (abi.encode(spentKey, allowAnyBundler), _packValidationData(false, validUntil, validAfter));
93+
}
94+
95+
function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) internal override {
96+
(uint32 spentKey, bool allowAnyBundler) = abi.decode(context, (uint32, bool));
97+
// unfortunately tx.origin is not allowed in validation, so we check here
98+
require(allowAnyBundler || bundlerAllowed[tx.origin], "Paymaster: bundler not allowed");
99+
100+
if (mode != PostOpMode.postOpReverted) {
101+
spent[spentKey] += uint96(actualGasCost);
102+
}
103+
}
104+
105+
function parsePaymasterAndData(bytes calldata paymasterAndData)
106+
internal pure returns(uint48 validUntil, uint48 validAfter, uint32 spentKey, uint96 spentMax, bool allowAnyBundler, bytes calldata signature) {
107+
validUntil = uint48(bytes6(paymasterAndData[20:26]));
108+
validAfter = uint48(bytes6(paymasterAndData[26:32]));
109+
spentKey = uint32(bytes4(paymasterAndData[32:36]));
110+
spentMax = uint96(bytes12(paymasterAndData[36:48]));
111+
allowAnyBundler = paymasterAndData[48] > 0;
112+
signature = paymasterAndData[49:];
113+
}
114+
115+
function renounceOwnership() public override view onlyOwner {
116+
revert("Paymaster: renouncing ownership is not allowed");
117+
}
118+
119+
function transferOwnership(address newOwner) public override onlyOwner {
120+
require(newOwner != address(0), "Paymaster: owner cannot be address(0)");
121+
require(newOwner != verifyingSigner, "Paymaster: owner cannot be the verifyingSigner");
122+
_transferOwnership(newOwner);
123+
}
124+
125+
function addBundler(address bundler) public onlyOwner {
126+
bundlerAllowed[bundler] = true;
127+
}
128+
129+
function removeBundler(address bundler) public onlyOwner {
130+
bundlerAllowed[bundler] = false;
131+
}
132+
133+
receive() external payable {
134+
// use address(this).balance rather than msg.value in case of force-send
135+
(bool callSuccess, ) = payable(address(entryPoint)).call{value: address(this).balance}("");
136+
require(callSuccess, "Deposit failed");
137+
}
138+
}

test/Paymaster.t.sol

+2-2
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ contract PaymasterTest is Test {
7474
UserOperation memory userOp = createUserOp();
7575
signUserOp(userOp);
7676

77-
vm.expectRevert(createEncodedValidationResult(false, 55014));
77+
vm.expectRevert(createEncodedValidationResult(false, 53025));
7878
entrypoint.simulateValidation(userOp);
7979
}
8080

@@ -84,7 +84,7 @@ contract PaymasterTest is Test {
8484
userOp.paymasterAndData = abi.encodePacked(address(paymaster), abi.encode(MOCK_VALID_UNTIL, MOCK_VALID_AFTER), r, s, v);
8585
signUserOp(userOp);
8686

87-
vm.expectRevert(createEncodedValidationResult(true, 55020));
87+
vm.expectRevert(createEncodedValidationResult(true, 53035));
8888
entrypoint.simulateValidation(userOp);
8989
}
9090

0 commit comments

Comments
 (0)