From b0a1ffcaf3aa3bc25be471f5a5797642dc971270 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 9 Oct 2024 00:40:30 -0700 Subject: [PATCH] Add merkle proof hash generation --- src/SpendPermissions.sol | 50 +++++++++++++++---- src/SpendPermissionsPaymaster.sol | 9 ++-- test/src/SpendPermissions/Debug.t.sol | 1 + .../src/SpendPermissionsPaymaster/Debug.t.sol | 9 +++- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/SpendPermissions.sol b/src/SpendPermissions.sol index 570ffbd..a6dcbdd 100644 --- a/src/SpendPermissions.sol +++ b/src/SpendPermissions.sol @@ -31,6 +31,16 @@ contract SpendPermissions { uint160 allowance; } + /// @notice A signed permit to approve a recurring allowance. + struct SignedPermit { + /// @dev Recurring allowance parameters. + RecurringAllowance recurringAllowance; + /// @dev User signature to validate via EIP-1271. + bytes signature; + /// @dev Optional Merkle proof from batch signing of many recurring allowances. + bytes32[] proof; + } + /// @notice Cycle parameters and spend usage. struct CycleUsage { /// @dev Start time of the cycle (unix seconds). @@ -150,26 +160,25 @@ contract SpendPermissions { /// @param recipient Address to withdraw tokens to. /// @param value Amount of token attempting to withdraw (wei). function withdraw(bytes calldata context, address recipient, uint160 value) external { - (RecurringAllowance memory recurringAllowance, bytes memory signature) = - abi.decode(context, (RecurringAllowance, bytes)); - permit(recurringAllowance, signature); - withdraw(recurringAllowance, recipient, value); + SignedPermit memory signedPermit = abi.decode(context, (SignedPermit)); + permit(signedPermit); + withdraw(signedPermit.recurringAllowance, recipient, value); } /// @notice Approve a recurring allowance via a signature from the account. /// - /// @param recurringAllowance Details of the recurring allowance. - /// @param signature Signed hash of the recurring allowance data. - function permit(RecurringAllowance memory recurringAllowance, bytes memory signature) public { + /// @param signedPermit user-approved recurring allowance with optional Merkle proof if batch signed. + function permit(SignedPermit memory signedPermit) public { // validate signature over recurring allowance data if ( - IERC1271(recurringAllowance.account).isValidSignature(getHash(recurringAllowance), signature) - != IERC1271.isValidSignature.selector + IERC1271(signedPermit.recurringAllowance.account).isValidSignature( + getRoot(signedPermit.recurringAllowance, signedPermit.proof), signedPermit.signature + ) != IERC1271.isValidSignature.selector ) { revert UnauthorizedRecurringAllowance(); } - _approve(recurringAllowance); + _approve(signedPermit.recurringAllowance); } /// @notice Withdraw tokens using a recurring allowance. @@ -197,6 +206,27 @@ contract SpendPermissions { return keccak256(abi.encode(recurringAllowance, block.chainid, address(this))); } + /// @notice Hash a RecurringAllowance struct for signing. + /// + /// @dev Prevent phishing permits by making the hash incompatible with EIP-191/712. + /// @dev Include chainId and contract address in hash for cross-chain and cross-contract replay protection. + /// + /// @param recurringAllowance Details of the recurring allowance. + /// @param proof Merkle branch matching this recurring allowance to reconstruct root. + /// + /// @return root Merkle root of a batch of recurring allowances. + function getRoot(RecurringAllowance memory recurringAllowance, bytes32[] memory proof) + public + view + returns (bytes32 root) + { + root = getHash(recurringAllowance); + uint256 len = proof.length; + for (uint256 i = 0; i < len; i++) { + root = keccak256(abi.encode(root, proof[i])); + } + } + /// @notice Return if recurring allowance is authorized i.e. approved and not revoked. /// /// @param recurringAllowance Details of the recurring allowance. diff --git a/src/SpendPermissionsPaymaster.sol b/src/SpendPermissionsPaymaster.sol index 72eddf0..b998956 100644 --- a/src/SpendPermissionsPaymaster.sol +++ b/src/SpendPermissionsPaymaster.sol @@ -56,8 +56,9 @@ contract SpendPermissionsPaymaster is SpendPermissions, Ownable2Step, IPaymaster returns (bytes memory postOpContext, uint256 validationData) { // todo allow passing signature for first-time allowance use - (RecurringAllowance memory recurringAllowance, bytes memory signature, uint256 withdrawAmount) = - abi.decode(userOp.paymasterAndData[20:], (RecurringAllowance, bytes, uint256)); + (SignedPermit memory signedPermit, uint256 withdrawAmount) = + abi.decode(userOp.paymasterAndData[20:], (SignedPermit, uint256)); + RecurringAllowance memory recurringAllowance = signedPermit.recurringAllowance; // require withdraw amount not less than max gas cost if (withdrawAmount < maxGasCost) { @@ -75,8 +76,8 @@ contract SpendPermissionsPaymaster is SpendPermissions, Ownable2Step, IPaymaster } // apply permit if signature length non-zero - if (signature.length > 0) { - permit(recurringAllowance, signature); + if (signedPermit.signature.length > 0) { + permit(signedPermit); } // check total spend value does not overflow max value diff --git a/test/src/SpendPermissions/Debug.t.sol b/test/src/SpendPermissions/Debug.t.sol index f1a3728..5d01ccd 100644 --- a/test/src/SpendPermissions/Debug.t.sol +++ b/test/src/SpendPermissions/Debug.t.sol @@ -29,6 +29,7 @@ contract DebugTest is Test, Base { } function test_withdraw(address recipient) public { + vm.assume(recipient != address(spendPermissions)); SpendPermissions.RecurringAllowance memory recurringAllowance = _createRecurringAllowance(); vm.prank(address(account)); diff --git a/test/src/SpendPermissionsPaymaster/Debug.t.sol b/test/src/SpendPermissionsPaymaster/Debug.t.sol index e1725e1..b6114d6 100644 --- a/test/src/SpendPermissionsPaymaster/Debug.t.sol +++ b/test/src/SpendPermissionsPaymaster/Debug.t.sol @@ -49,7 +49,9 @@ contract DebugTest is Test, Base { vm.assertEq(account.isValidSignature(hash, signature), IERC1271.isValidSignature.selector); - bytes memory paymasterData = abi.encode(recurringAllowance, signature, allowance); + SpendPermissions.SignedPermit memory signedPermit = + SpendPermissions.SignedPermit(recurringAllowance, signature, new bytes32[](0)); + bytes memory paymasterData = abi.encode(signedPermit, allowance); userOp.paymasterAndData = abi.encodePacked(address(spendPermissions), paymasterData); @@ -79,7 +81,10 @@ contract DebugTest is Test, Base { vm.assertEq(account.isValidSignature(hash, signature), IERC1271.isValidSignature.selector); - spendPermissions.permit(recurringAllowance, signature); + SpendPermissions.SignedPermit memory signedPermit = + SpendPermissions.SignedPermit(recurringAllowance, signature, new bytes32[](0)); + + spendPermissions.permit(signedPermit); vm.assertTrue(spendPermissions.isAuthorized(recurringAllowance));