diff --git a/src/enforcers/SwapOfferEnforcer.sol b/src/enforcers/SwapOfferEnforcer.sol new file mode 100644 index 0000000..c70dae4 --- /dev/null +++ b/src/enforcers/SwapOfferEnforcer.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode, Delegation } from "../utils/Types.sol"; +import { IDelegationManager } from "../interfaces/IDelegationManager.sol"; + +/** + * @title SwapOfferEnforcer + * @dev This contract enforces a swap offer, allowing partial transfers if the order is not filled in a single transaction. + * @dev This caveat enforcer only works when the execution is in single mode. + * @dev The redeemer must include an allowance delegation when executing the swap to ensure payment. + */ +contract SwapOfferEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + using ModeLib for ModeCode; + + struct SwapOffer { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 amountOut; + uint256 amountInFilled; + uint256 amountOutFilled; + address recipient; + } + + ////////////////////////////// State ////////////////////////////// + + mapping(address delegationManager => mapping(bytes32 delegationHash => SwapOffer)) public swapOffers; + + ////////////////////////////// Events ////////////////////////////// + event SwapOfferUpdated( + address indexed sender, + address indexed redeemer, + bytes32 indexed delegationHash, + uint256 amountInFilled, + uint256 amountOutFilled + ); + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Enforces the swap offer before the transaction is performed. + * @param _terms The encoded swap offer terms. + * @param _args The encoded arguments containing the claimed amount and payment delegation. + * @param _mode The mode of the execution. + * @param _executionCallData The transaction the delegate might try to perform. + * @param _delegationHash The hash of the delegation being operated on. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata _args, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address _redeemer + ) + public + override + onlySingleExecutionMode(_mode) + { + (uint256 claimedAmount, IDelegationManager delegationManager,) = abi.decode(_args, (uint256, IDelegationManager, bytes)); + + (uint256 amountInFilled_, uint256 amountOutFilled_) = _validateAndUpdate(_terms, _executionCallData, _delegationHash, claimedAmount); + + // Store the payment info for the afterHook + SwapOffer storage offer = swapOffers[address(delegationManager)][_delegationHash]; + offer.amountInFilled = amountInFilled_; + offer.amountOutFilled = amountOutFilled_; + + emit SwapOfferUpdated(msg.sender, _redeemer, _delegationHash, amountInFilled_, amountOutFilled_); + } + + /** + * @notice Enforces the conditions that should hold after a transaction is performed. + * @param _terms The encoded swap offer terms. + * @param _args The encoded arguments containing the claimed amount and payment delegation. + * @param _delegationHash The hash of the delegation. + */ + function afterHook( + bytes calldata _terms, + bytes calldata _args, + ModeCode, + bytes calldata, + bytes32 _delegationHash, + address, + address _redeemer + ) + public + override + { + (uint256 claimedAmount, IDelegationManager delegationManager, bytes memory permissionContext) = abi.decode(_args, (uint256, IDelegationManager, bytes)); + + (address tokenIn,,,,address recipient) = getTermsInfo(_terms); + + bytes[] memory permissionContexts = new bytes[](1); + permissionContexts[0] = permissionContext; + + bytes[] memory executionCallDatas = new bytes[](1); + executionCallDatas[0] = ExecutionLib.encodeSingle(tokenIn, claimedAmount, abi.encodeWithSelector(IERC20.transfer.selector, address(this), claimedAmount)); + + ModeCode[] memory encodedModes = new ModeCode[](1); + encodedModes[0] = ModeLib.encodeSimpleSingle(); + + uint256 balanceBefore = IERC20(tokenIn).balanceOf(address(this)); + + // Attempt to redeem the delegation and make the payment + delegationManager.redeemDelegations(permissionContexts, encodedModes, executionCallDatas); + + // Ensure the contract received the payment + uint256 balanceAfter = IERC20(tokenIn).balanceOf(address(this)); + require(balanceAfter >= balanceBefore + claimedAmount, "SwapOfferEnforcer:payment-not-received"); + + // Transfer the received tokens to the recipient + require(IERC20(tokenIn).transfer(recipient, claimedAmount), "SwapOfferEnforcer:transfer-to-recipient-failed"); + } + + /** + * @notice Decodes the terms used in this CaveatEnforcer. + * @param _terms encoded data that is used during the execution hooks. + * @return tokenIn_ The address of the token being sold. + * @return tokenOut_ The address of the token being bought. + * @return amountIn_ The total amount of tokens to be sold. + * @return amountOut_ The total amount of tokens to be bought. + * @return recipient_ The address to receive the input tokens. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (address tokenIn_, address tokenOut_, uint256 amountIn_, uint256 amountOut_, address recipient_) { + require(_terms.length == 148, "SwapOfferEnforcer:invalid-terms-length"); + + tokenIn_ = address(bytes20(_terms[:20])); + tokenOut_ = address(bytes20(_terms[20:40])); + amountIn_ = uint256(bytes32(_terms[40:72])); + amountOut_ = uint256(bytes32(_terms[72:104])); + recipient_ = address(bytes20(_terms[104:124])); + } + + /** + * @notice Validates and updates the swap offer. + * @param _terms The encoded swap offer terms. + * @param _executionCallData The transaction the delegate might try to perform. + * @param _delegationHash The hash of the delegation being operated on. + * @param _claimedAmount The amount claimed to be transferred in. + * @return amountInFilled_ The updated amount of input tokens filled. + * @return amountOutFilled_ The updated amount of output tokens filled. + */ + function _validateAndUpdate( + bytes calldata _terms, + bytes calldata _executionCallData, + bytes32 _delegationHash, + uint256 _claimedAmount + ) + internal + returns (uint256 amountInFilled_, uint256 amountOutFilled_) + { + (address target_,, bytes calldata callData_) = _executionCallData.decodeSingle(); + + require(callData_.length == 68, "SwapOfferEnforcer:invalid-execution-length"); + + (address tokenIn_, address tokenOut_, uint256 amountIn_, uint256 amountOut_, address recipient_) = getTermsInfo(_terms); + + SwapOffer storage offer = swapOffers[msg.sender][_delegationHash]; + if (offer.tokenIn == address(0)) { + // Initialize the offer if it doesn't exist + offer.tokenIn = tokenIn_; + offer.tokenOut = tokenOut_; + offer.amountIn = amountIn_; + offer.amountOut = amountOut_; + offer.recipient = recipient_; + } else { + require(offer.tokenIn == tokenIn_ && offer.tokenOut == tokenOut_ && + offer.amountIn == amountIn_ && offer.amountOut == amountOut_ && + offer.recipient == recipient_, + "SwapOfferEnforcer:terms-mismatch"); + } + + require(target_ == tokenOut_, "SwapOfferEnforcer:invalid-token"); + + bytes4 selector = bytes4(callData_[0:4]); + require(selector == IERC20.transfer.selector || selector == IERC20.transferFrom.selector, "SwapOfferEnforcer:invalid-method"); + + uint256 amount = uint256(bytes32(callData_[36:68])); + + require(offer.amountOutFilled + amount <= offer.amountOut, "SwapOfferEnforcer:exceeds-output-amount"); + + amountInFilled_ = offer.amountInFilled + _claimedAmount; + require(amountInFilled_ <= offer.amountIn, "SwapOfferEnforcer:exceeds-input-amount"); + + amountOutFilled_ = offer.amountOutFilled + amount; + } +} diff --git a/src/libraries/Caveats.sol b/src/libraries/Caveats.sol index ac43fcc..b62d8ce 100644 --- a/src/libraries/Caveats.sol +++ b/src/libraries/Caveats.sol @@ -20,6 +20,8 @@ import { NonceEnforcer } from "../enforcers/NonceEnforcer.sol"; import { RedeemerEnforcer } from "../enforcers/RedeemerEnforcer.sol"; import { TimestampEnforcer } from "../enforcers/TimestampEnforcer.sol"; import { ValueLteEnforcer } from "../enforcers/ValueLteEnforcer.sol"; +import { SwapOfferEnforcer } from "../enforcers/SwapOfferEnforcer.sol"; + /** @title Caveats @notice This library aims to export the easier way to create caveats for tests. Its parameters should always be provided in the easiest creator-readable way, even at the cost of gas. @@ -286,4 +288,21 @@ library Caveats { args: "" }); } + + function createSwapOfferCaveat( + address enforcerAddress, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOut, + address recipient + ) internal pure returns (Caveat memory) { + bytes memory terms = abi.encodePacked(tokenIn, tokenOut, amountIn, amountOut, recipient); + + return Caveat({ + enforcer: enforcerAddress, + terms: terms, + args: "" + }); + } } diff --git a/test/enforcers/SwapOfferEnforcer.t.sol b/test/enforcers/SwapOfferEnforcer.t.sol new file mode 100644 index 0000000..aa89ba7 --- /dev/null +++ b/test/enforcers/SwapOfferEnforcer.t.sol @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { Execution, Caveat, Delegation, ModeCode } from "../../src/utils/Types.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { SwapOfferEnforcer } from "../../src/enforcers/SwapOfferEnforcer.sol"; +import { ERC20TransferAmountEnforcer } from "../../src/enforcers/ERC20TransferAmountEnforcer.sol"; +import { IDelegationManager } from "../../src/interfaces/IDelegationManager.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { Caveats } from "../../src/libraries/Caveats.sol"; +import { ArgsEqualityCheckEnforcer } from "../../src/enforcers/ArgsEqualityCheckEnforcer.sol"; + +contract SwapOfferEnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + SwapOfferEnforcer public swapOfferEnforcer; + ERC20TransferAmountEnforcer public erc20TransferAmountEnforcer; + ArgsEqualityCheckEnforcer public argsEqualityCheckEnforcer; + BasicERC20 public tokenIn; + BasicERC20 public tokenOut; + ModeCode public mode = ModeLib.encodeSimpleSingle(); + + uint256 constant AMOUNT_IN = 1000 ether; + uint256 constant AMOUNT_OUT = 500 ether; + + function setUp() public override { + super.setUp(); + swapOfferEnforcer = new SwapOfferEnforcer(); + erc20TransferAmountEnforcer = new ERC20TransferAmountEnforcer(); + argsEqualityCheckEnforcer = new ArgsEqualityCheckEnforcer(); + tokenIn = new BasicERC20(address(users.alice.deleGator), "Token In", "TIN", AMOUNT_IN); + tokenOut = new BasicERC20(address(users.bob.deleGator), "Token Out", "TOUT", AMOUNT_OUT); + + vm.label(address(swapOfferEnforcer), "Swap Offer Enforcer"); + vm.label(address(erc20TransferAmountEnforcer), "ERC20 Transfer Amount Enforcer"); + vm.label(address(argsEqualityCheckEnforcer), "Args Equality Check Enforcer"); + vm.label(address(tokenIn), "Token In"); + vm.label(address(tokenOut), "Token Out"); + } + + function test_swapOfferEnforcer() public { + uint256 initialAliceBalanceIn = tokenIn.balanceOf(address(users.alice.deleGator)); + uint256 initialBobBalanceOut = tokenOut.balanceOf(address(users.bob.deleGator)); + + // Create swap offer caveat + Caveat memory swapOfferCaveat = Caveats.createSwapOfferCaveat( + address(swapOfferEnforcer), + address(tokenIn), + address(tokenOut), + AMOUNT_IN, + AMOUNT_OUT, + address(users.alice.deleGator) + ); + + // Create ERC20 transfer amount caveat for payment + Caveat memory erc20TransferCaveat = Caveats.createERC20TransferAmountCaveat( + address(erc20TransferAmountEnforcer), + address(tokenIn), + AMOUNT_IN + ); + + Caveat[] memory caveats = new Caveat[](2); + caveats[0] = swapOfferCaveat; + caveats[1] = erc20TransferCaveat; + + Delegation memory delegation = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats, + salt: 0, + signature: hex"" + }); + + delegation = signDelegation(users.alice, delegation); + + // Create the execution for token transfer + Execution memory execution = Execution({ + target: address(tokenOut), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.alice.deleGator), AMOUNT_OUT) + }); + + // Create the allowance delegation for payment + Caveat[] memory allowanceCaveats = new Caveat[](1); + allowanceCaveats[0] = Caveat({ + enforcer: address(argsEqualityCheckEnforcer), + terms: hex"", + args: abi.encodePacked(keccak256(abi.encode(delegation)), address(users.bob.deleGator)) + }); + + Delegation memory allowanceDelegation = Delegation({ + delegate: address(delegationManager), + delegator: address(users.bob.deleGator), + authority: ROOT_AUTHORITY, + caveats: allowanceCaveats, + salt: 0, + signature: hex"" + }); + + allowanceDelegation = signDelegation(users.bob, allowanceDelegation); + + // Prepare the arguments for the swap + bytes memory args = abi.encode(AMOUNT_IN, delegationManager, abi.encode(new Delegation[](1))); + + // Execute Bob's UserOp + Delegation[] memory delegations = new Delegation[](1); + delegations[0] = delegation; + + vm.prank(address(users.bob.deleGator)); + invokeDelegation_UserOp(users.bob, delegations, execution, args); + + // Check balances after swap + uint256 finalAliceBalanceIn = tokenIn.balanceOf(address(users.alice.deleGator)); + uint256 finalBobBalanceOut = tokenOut.balanceOf(address(users.bob.deleGator)); + + assertEq(finalAliceBalanceIn, initialAliceBalanceIn + AMOUNT_IN, "Alice should receive the correct amount of tokenIn"); + assertEq(finalBobBalanceOut, initialBobBalanceOut - AMOUNT_OUT, "Bob should send the correct amount of tokenOut"); + } + + function test_swapOfferEnforcer_partialFill() public { + uint256 partialAmountIn = AMOUNT_IN / 2; + uint256 partialAmountOut = AMOUNT_OUT / 2; + + // Create swap offer caveat + Caveat memory swapOfferCaveat = Caveats.createSwapOfferCaveat( + address(swapOfferEnforcer), + address(tokenIn), + address(tokenOut), + AMOUNT_IN, + AMOUNT_OUT, + address(users.alice.deleGator) + ); + + // Create ERC20 transfer amount caveat for payment + Caveat memory erc20TransferCaveat = Caveats.createERC20TransferAmountCaveat( + address(erc20TransferAmountEnforcer), + address(tokenIn), + AMOUNT_IN + ); + + Caveat[] memory caveats = new Caveat[](2); + caveats[0] = swapOfferCaveat; + caveats[1] = erc20TransferCaveat; + + Delegation memory delegation = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats, + salt: 0, + signature: hex"" + }); + + delegation = signDelegation(users.alice, delegation); + + // Create the execution for token transfer (partial amount) + Execution memory execution = Execution({ + target: address(tokenOut), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.alice.deleGator), partialAmountOut) + }); + + // Create the allowance delegation for payment + Caveat[] memory allowanceCaveats = new Caveat[](1); + allowanceCaveats[0] = Caveat({ + enforcer: address(argsEqualityCheckEnforcer), + terms: hex"", + args: abi.encodePacked(keccak256(abi.encode(delegation)), address(users.bob.deleGator)) + }); + + Delegation memory allowanceDelegation = Delegation({ + delegate: address(delegationManager), + delegator: address(users.bob.deleGator), + authority: ROOT_AUTHORITY, + caveats: allowanceCaveats, + salt: 0, + signature: hex"" + }); + + allowanceDelegation = signDelegation(users.bob, allowanceDelegation); + + // Prepare the arguments for the swap (partial amount) + bytes memory args = abi.encode(partialAmountIn, delegationManager, abi.encode(new Delegation[](1))); + + // Execute Bob's UserOp + Delegation[] memory delegations = new Delegation[](1); + delegations[0] = delegation; + + vm.prank(address(users.bob.deleGator)); + invokeDelegation_UserOp(users.bob, delegations, execution, args); + + // Check balances after partial swap + uint256 aliceBalanceIn = tokenIn.balanceOf(address(users.alice.deleGator)); + uint256 bobBalanceOut = tokenOut.balanceOf(address(users.bob.deleGator)); + + assertEq(aliceBalanceIn, AMOUNT_IN + partialAmountIn, "Alice should receive the correct partial amount of tokenIn"); + assertEq(bobBalanceOut, AMOUNT_OUT - partialAmountOut, "Bob should send the correct partial amount of tokenOut"); + + // Execute the remaining part of the swap + vm.prank(address(users.bob.deleGator)); + invokeDelegation_UserOp(users.bob, delegations, execution, args); + + // Check final balances + aliceBalanceIn = tokenIn.balanceOf(address(users.alice.deleGator)); + bobBalanceOut = tokenOut.balanceOf(address(users.bob.deleGator)); + + assertEq(aliceBalanceIn, AMOUNT_IN * 2, "Alice should receive the full amount of tokenIn"); + assertEq(bobBalanceOut, 0, "Bob should send the full amount of tokenOut"); + } + + function test_swapOfferEnforcer_invalidAmount() public { + // Create swap offer caveat + Caveat memory swapOfferCaveat = Caveats.createSwapOfferCaveat( + address(swapOfferEnforcer), + address(tokenIn), + address(tokenOut), + AMOUNT_IN, + AMOUNT_OUT, + address(users.alice.deleGator) + ); + + // Create ERC20 transfer amount caveat for payment + Caveat memory erc20TransferCaveat = Caveats.createERC20TransferAmountCaveat( + address(erc20TransferAmountEnforcer), + address(tokenIn), + AMOUNT_IN + ); + + Caveat[] memory caveats = new Caveat[](2); + caveats[0] = swapOfferCaveat; + caveats[1] = erc20TransferCaveat; + + Delegation memory delegation = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats, + salt: 0, + signature: hex"" + }); + + delegation = signDelegation(users.alice, delegation); + + // Create the execution for token transfer with invalid amount + Execution memory execution = Execution({ + target: address(tokenOut), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.alice.deleGator), AMOUNT_OUT + 1 ether) + }); + + // Create the allowance delegation for payment + Caveat[] memory allowanceCaveats = new Caveat[](1); + allowanceCaveats[0] = Caveat({ + enforcer: address(argsEqualityCheckEnforcer), + terms: hex"", + args: abi.encodePacked(keccak256(abi.encode(delegation)), address(users.bob.deleGator)) + }); + + Delegation memory allowanceDelegation = Delegation({ + delegate: address(delegationManager), + delegator: address(users.bob.deleGator), + authority: ROOT_AUTHORITY, + caveats: allowanceCaveats, + salt: 0, + signature: hex"" + }); + + allowanceDelegation = signDelegation(users.bob, allowanceDelegation); + + // Prepare the arguments for the swap + bytes memory args = abi.encode(AMOUNT_IN, delegationManager, abi.encode(new Delegation[](1))); + + // Execute Bob's UserOp + Delegation[] memory delegations = new Delegation[](1); + delegations[0] = delegation; + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert("SwapOfferEnforcer:exceeds-output-amount"); + invokeDelegation_UserOp(users.bob, delegations, execution, args); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(swapOfferEnforcer)); + } +}