Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions documents/CaveatEnforcers.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,76 @@ Enforcers can target specific call type modes: **single** or **batch**, and exec

---

### MetaSwapFlexibleSettlementEnforcer

Authorizes one successful MetaSwap settlement with a redeemer-selected route. It binds the input, approval behavior,
output asset, recipient, and minimum output while keeping route discovery flexible. A successful settlement is
permanently consumed; a reverted fill, including insufficient output, rolls back the consumed state and remains
retryable.

It accepts one direct `BATCH_DEFAULT_MODE` redemption with:

- Native input: `MetaSwap.swap{ value: tokenInAmount }(...)`
- ERC-20 skipping approval: `MetaSwap.swap(...)`
- ERC-20 approval: `approve(metaSwap, tokenInAmount)`, then `MetaSwap.swap(...)`
- ERC-20 reset approval: `approve(metaSwap, 0)`, `approve(metaSwap, tokenInAmount)`, then `MetaSwap.swap(...)`

Terms are packed as:

```text
metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) |
tokenOut(20) | recipient(20) | tokenOutMin(32)
```

`address(0)` represents the native token. The one-byte `ApprovalMode` enum selects exactly one execution shape:

- `0`: `None`, required for native input
- `1`: `SkipApproval`
- `2`: `Approve`
- `3`: `ResetApprove`

ERC-20 input requires modes `1` through `3`. The execution count must match the signed mode; caveat args are not used.
`SkipApproval` does not inspect allowance; the swap must have sufficient allowance to execute successfully.

Example terms for an ERC-20 settlement requiring `approve(amount)`:

```solidity
bytes memory terms = abi.encodePacked(
metaSwap,
tokenIn,
tokenInAmount,
uint8(MetaSwapFlexibleSettlementEnforcer.ApprovalMode.Approve),
tokenOut,
recipient,
tokenOutMin
);
```

The enforcer permanently records successful use in a boolean mapping and temporarily caches the recipient's raw
pre-execution output balance in a separate mapping. Both mappings are keyed by the DelegationManager and delegation hash.
The balance snapshot is deleted after validation for a storage refund. Any reverted settlement atomically rolls back the
consumed flag and remains retryable.

Approval spender and swap input token arguments must use canonical 32-byte ABI address words, including zeroed upper
bytes. Swap calldata must be at least 196 bytes: the selector, four-word static head, and two dynamic length words required
by `swap(string,address,uint256,bytes)`.

#### Trust Assumptions

MetaSwap's `aggregatorId` and route `data` remain unrestricted. The delegator trusts the delegate to provide safe route
data and trusts the configured MetaSwap contract and its adapters. The enforcer fixes the input token, input amount,
approval spender, approval amounts, output token, output recipient, and minimum net balance increase, but it cannot
prevent arbitrary route side effects or protect unrelated assets already approved to MetaSwap or its adapters.

The minimum output may be satisfied by any balance increase during the execution, including unrelated transfers or token
rebases. A malicious or non-standard output token may report misleading balances. A residual input allowance may remain
if MetaSwap spends less than the approved amount.

Deployment uses `script/DeployCaveatEnforcers.s.sol`. After recording deployed addresses, verification uses the shared
`script/verification/verify-enforcer-contracts.sh` flow.

---

## Enforcer Details

### NativeTokenPaymentEnforcer
Expand Down
4 changes: 4 additions & 0 deletions script/DeployCaveatEnforcers.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ExactExecutionEnforcer } from "../src/enforcers/ExactExecutionEnforcer.
import { IdEnforcer } from "../src/enforcers/IdEnforcer.sol";
import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol";
import { LogicalOrWrapperEnforcer } from "../src/enforcers/LogicalOrWrapperEnforcer.sol";
import { MetaSwapFlexibleSettlementEnforcer } from "../src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol";
import { MultiTokenPeriodEnforcer } from "../src/enforcers/MultiTokenPeriodEnforcer.sol";
import { NativeBalanceChangeEnforcer } from "../src/enforcers/NativeBalanceChangeEnforcer.sol";
import { NativeTokenPaymentEnforcer } from "../src/enforcers/NativeTokenPaymentEnforcer.sol";
Expand Down Expand Up @@ -133,6 +134,9 @@ contract DeployCaveatEnforcers is Script {
deployedAddress = address(new LogicalOrWrapperEnforcer{ salt: salt }(delegationManager));
console2.log("LogicalOrWrapperEnforcer: %s", deployedAddress);

deployedAddress = address(new MetaSwapFlexibleSettlementEnforcer{ salt: salt }());
console2.log("MetaSwapFlexibleSettlementEnforcer: %s", deployedAddress);

deployedAddress = address(new MultiTokenPeriodEnforcer{ salt: salt }());
console2.log("MultiTokenPeriodEnforcer: %s", deployedAddress);

Expand Down
241 changes: 241 additions & 0 deletions src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// 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 { CaveatEnforcer } from "./CaveatEnforcer.sol";
import { IMetaSwap } from "../helpers/interfaces/IMetaSwap.sol";
import { Execution, ModeCode } from "../utils/Types.sol";

/**
* @title MetaSwapFlexibleSettlementEnforcer
* @notice Authorizes one MetaSwap settlement with a redeemer-selected route, exact input, and minimum output.
* @dev The settlement combines batch validation, one-shot consumption, and output enforcement. It accepts:
* - Native: `[swap{ value: tokenInAmount }(...)]`
* - ERC-20 without approval: `[swap(...)]`
* - ERC-20 with approval: `[approve(metaSwap, tokenInAmount), swap(...)]`
* - ERC-20 with reset: `[approve(metaSwap, 0), approve(metaSwap, tokenInAmount), swap(...)]`
*
* The signed approval mode selects one exact ERC-20 shape. MetaSwap's dynamic `aggregatorId` and route `data`
* remain unrestricted. The configured MetaSwap contract and its adapters must therefore be trusted.
*/
contract MetaSwapFlexibleSettlementEnforcer is CaveatEnforcer {
using ExecutionLib for bytes;

enum ApprovalMode {
None,
SkipApproval,
Approve,
ResetApprove
}

struct Terms {
address metaSwap;
address tokenIn;
uint256 tokenInAmount;
ApprovalMode approvalMode;
address tokenOut;
address recipient;
uint256 tokenOutMin;
}

uint256 private constant TERMS_LENGTH = 145;
uint256 private constant APPROVE_CALL_LENGTH = 68;
// Selector + four-word head + two dynamic length words.
uint256 private constant SWAP_CALL_MIN_LENGTH = 196;

/// @notice Records settlements that have already been used.
mapping(bytes32 settlementKey => bool isUsed) public consumedSettlements;

/// @dev Caches the recipient's balance between the DelegationManager's before and after hooks.
mapping(bytes32 settlementKey => uint256 balanceBefore) private balanceSnapshots;

/**
* @notice Emitted after a settlement satisfies its minimum output and is permanently consumed.
* @param delegationManager DelegationManager that redeemed the settlement.
* @param delegationHash Hash identifying the signed delegation.
* @param redeemer Address that submitted the redemption.
*/
event SettlementConsumed(address indexed delegationManager, bytes32 indexed delegationHash, address indexed redeemer);

/**
* @notice Returns the storage key used to isolate a settlement.
* @param delegationManager_ DelegationManager that redeems the delegation.
* @param delegationHash_ Hash identifying the delegation.
*/
function getSettlementKey(address delegationManager_, bytes32 delegationHash_) external pure returns (bytes32) {
return _getSettlementKey(delegationManager_, delegationHash_);
}

/**
* @notice Validates the batch, caches the output balance, and locks the settlement against reuse.
* @param terms_ Packed settlement constraints.
* @param mode_ Execution mode; must be batch/default.
* @param executionCallData_ ABI-encoded `Execution[]`.
* @param delegationHash_ Hash identifying the signed delegation.
*/
function beforeHook(
bytes calldata terms_,
bytes calldata,
ModeCode mode_,
bytes calldata executionCallData_,
bytes32 delegationHash_,
address,
address
)
public
override
onlyBatchCallTypeMode(mode_)
onlyDefaultExecutionMode(mode_)
{
Terms memory termsInfo_ = getTermsInfo(terms_);
Execution[] calldata executions_ = executionCallData_.decodeBatch();
_validateExecutions(executions_, termsInfo_);

bytes32 settlementKey_ = _getSettlementKey(msg.sender, delegationHash_);
require(!consumedSettlements[settlementKey_], "MetaSwapFlexibleSettlementEnforcer:settlement-already-used");

consumedSettlements[settlementKey_] = true;
balanceSnapshots[settlementKey_] = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient);
}

/**
* @notice Enforces the minimum output and permanently consumes the successful settlement.
* @param terms_ Packed settlement constraints.
* @param delegationHash_ Hash identifying the signed delegation.
* @param redeemer_ Address that submitted the redemption.
*/
function afterHook(
bytes calldata terms_,
bytes calldata,
ModeCode,
bytes calldata,
bytes32 delegationHash_,
address,
address redeemer_
)
public
override
{
require(terms_.length == TERMS_LENGTH, "MetaSwapFlexibleSettlementEnforcer:invalid-terms");

bytes32 settlementKey_ = _getSettlementKey(msg.sender, delegationHash_);
address tokenOut_ = address(bytes20(terms_[73:93]));
address recipient_ = address(bytes20(terms_[93:113]));
uint256 tokenOutMin_ = uint256(bytes32(terms_[113:145]));
uint256 balanceBefore_ = balanceSnapshots[settlementKey_];
delete balanceSnapshots[settlementKey_];

uint256 balanceAfter_ = _balanceOf(tokenOut_, recipient_);

require(
balanceAfter_ >= balanceBefore_ && balanceAfter_ - balanceBefore_ >= tokenOutMin_,
"MetaSwapFlexibleSettlementEnforcer:insufficient-output"
);

emit SettlementConsumed(msg.sender, delegationHash_, redeemer_);
}

/**
* @notice Decodes and validates signed settlement terms.
* @param terms_ Packed as
* `metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) | tokenOut(20) | recipient(20) | tokenOutMin(32)`.
*/
function getTermsInfo(bytes calldata terms_) public pure returns (Terms memory termsInfo_) {
require(terms_.length == TERMS_LENGTH, "MetaSwapFlexibleSettlementEnforcer:invalid-terms");

termsInfo_.metaSwap = address(bytes20(terms_[0:20]));
termsInfo_.tokenIn = address(bytes20(terms_[20:40]));
termsInfo_.tokenInAmount = uint256(bytes32(terms_[40:72]));
uint8 approvalMode_ = uint8(terms_[72]);
termsInfo_.tokenOut = address(bytes20(terms_[73:93]));
termsInfo_.recipient = address(bytes20(terms_[93:113]));
termsInfo_.tokenOutMin = uint256(bytes32(terms_[113:145]));

require(
termsInfo_.metaSwap != address(0) && termsInfo_.tokenInAmount != 0 && termsInfo_.recipient != address(0)
&& termsInfo_.tokenOutMin != 0 && termsInfo_.tokenIn != termsInfo_.tokenOut,
"MetaSwapFlexibleSettlementEnforcer:invalid-terms"
);

require(approvalMode_ <= uint8(ApprovalMode.ResetApprove), "MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode");
termsInfo_.approvalMode = ApprovalMode(approvalMode_);
}

function _validateExecutions(Execution[] calldata executions_, Terms memory termsInfo_) private pure {
ApprovalMode approvalMode_ = termsInfo_.approvalMode;

if (termsInfo_.tokenIn == address(0)) {
require(approvalMode_ == ApprovalMode.None, "MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode");
require(executions_.length == 1, "MetaSwapFlexibleSettlementEnforcer:invalid-batch-length");
_validateSwap(executions_[0], termsInfo_.metaSwap, address(0), termsInfo_.tokenInAmount, termsInfo_.tokenInAmount);
return;
}

if (approvalMode_ == ApprovalMode.SkipApproval) {
require(executions_.length == 1, "MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed");
_validateSwap(executions_[0], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0);
} else if (approvalMode_ == ApprovalMode.Approve) {
require(executions_.length == 2, "MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed");
_validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount);
_validateSwap(executions_[1], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0);
} else if (approvalMode_ == ApprovalMode.ResetApprove) {
require(executions_.length == 3, "MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed");
_validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, 0);
_validateApproval(executions_[1], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount);
_validateSwap(executions_[2], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0);
} else {
revert("MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode");
}
}

function _validateApproval(
Execution calldata execution_,
address tokenIn_,
address metaSwap_,
uint256 expectedAmount_
)
private
pure
{
bytes calldata callData_ = execution_.callData;
if (
execution_.target != tokenIn_ || execution_.value != 0 || callData_.length != APPROVE_CALL_LENGTH
|| bytes4(callData_[0:4]) != IERC20.approve.selector
|| bytes32(callData_[4:36]) != bytes32(uint256(uint160(metaSwap_)))
|| uint256(bytes32(callData_[36:68])) != expectedAmount_
) {
revert("MetaSwapFlexibleSettlementEnforcer:invalid-approval");
}
}

function _validateSwap(
Execution calldata execution_,
address metaSwap_,
address tokenIn_,
uint256 tokenInAmount_,
uint256 expectedValue_
)
private
pure
{
bytes calldata callData_ = execution_.callData;
if (
execution_.target != metaSwap_ || execution_.value != expectedValue_ || callData_.length < SWAP_CALL_MIN_LENGTH
|| bytes4(callData_[0:4]) != IMetaSwap.swap.selector
|| bytes32(callData_[36:68]) != bytes32(uint256(uint160(tokenIn_)))
|| uint256(bytes32(callData_[68:100])) != tokenInAmount_
) {
revert("MetaSwapFlexibleSettlementEnforcer:invalid-swap");
}
}

function _balanceOf(address token_, address recipient_) private view returns (uint256) {
return token_ == address(0) ? recipient_.balance : IERC20(token_).balanceOf(recipient_);
}

function _getSettlementKey(address delegationManager_, bytes32 delegationHash_) private pure returns (bytes32) {
return keccak256(abi.encode(delegationManager_, delegationHash_));
}
}
Loading
Loading