Skip to content
Merged
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
83 changes: 42 additions & 41 deletions .gas-snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -6,52 +6,53 @@ DelegatecallGuardTest:test_direct_call_reverts_NotDelegateCall() (gas: 8420)
DelegatecallGuardTest:test_multiple_delegatecall_guards() (gas: 298825)
DelegatecallGuardTest:test_onlyDelegatecall_modifier_usage() (gas: 98867)
DelegatecallGuardTest:test_self_address_immutable() (gas: 2877)
TrailsIntentEntrypointDeploymentTest:test_DeployIntentEntrypoint_SameAddress() (gas: 850262)
TrailsIntentEntrypointDeploymentTest:test_DeployIntentEntrypoint_Success() (gas: 843491)
TrailsIntentEntrypointDeploymentTest:test_DeployedIntentEntrypoint_HasCorrectConfiguration() (gas: 846367)
TrailsIntentEntrypointTest:testAssemblyCodeExecution() (gas: 90651)
TrailsIntentEntrypointTest:testConstructor() (gas: 3375)
TrailsIntentEntrypointTest:testConstructorAndDomainSeparator() (gas: 8493)
TrailsIntentEntrypointDeploymentTest:test_DeployIntentEntrypoint_SameAddress() (gas: 840130)
TrailsIntentEntrypointDeploymentTest:test_DeployIntentEntrypoint_Success() (gas: 833384)
TrailsIntentEntrypointDeploymentTest:test_DeployedIntentEntrypoint_HasCorrectConfiguration() (gas: 836260)
TrailsIntentEntrypointTest:testAssemblyCodeExecution() (gas: 90673)
TrailsIntentEntrypointTest:testConstructor() (gas: 3419)
TrailsIntentEntrypointTest:testConstructorAndDomainSeparator() (gas: 8515)
TrailsIntentEntrypointTest:testDepositToIntentAlreadyUsed() (gas: 115475)
TrailsIntentEntrypointTest:testDepositToIntentCannotReuseDigest() (gas: 96181)
TrailsIntentEntrypointTest:testDepositToIntentExpiredDeadline() (gas: 56739)
TrailsIntentEntrypointTest:testDepositToIntentCannotReuseDigest() (gas: 96203)
TrailsIntentEntrypointTest:testDepositToIntentExpiredDeadline() (gas: 56783)
TrailsIntentEntrypointTest:testDepositToIntentReentrancyProtection() (gas: 115453)
TrailsIntentEntrypointTest:testDepositToIntentRequiresNonZeroAmount() (gas: 28059)
TrailsIntentEntrypointTest:testDepositToIntentRequiresValidToken() (gas: 31464)
TrailsIntentEntrypointTest:testDepositToIntentTransferFromFails() (gas: 59749)
TrailsIntentEntrypointTest:testDepositToIntentWithFeeAmountButNoCollector_Reverts() (gas: 113407)
TrailsIntentEntrypointTest:testDepositToIntentRequiresNonZeroAmount() (gas: 28081)
TrailsIntentEntrypointTest:testDepositToIntentRequiresValidToken() (gas: 31486)
TrailsIntentEntrypointTest:testDepositToIntentTransferFromFails() (gas: 59771)
TrailsIntentEntrypointTest:testDepositToIntentWithFeeAmountButNoCollector_Reverts() (gas: 113429)
TrailsIntentEntrypointTest:testDepositToIntentWithFeeCollectorButNoAmount_Reverts() (gas: 91375)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitAlreadyUsed() (gas: 134736)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitExpiredDeadline() (gas: 37181)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitReentrancyProtection() (gas: 125513)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitRequiresNonZeroAmount() (gas: 35895)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitRequiresPermitAmount() (gas: 62086)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitRequiresValidToken() (gas: 36621)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitTransferFromFails() (gas: 61293)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitWithFeeAmountButNoCollector_Reverts() (gas: 141991)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitWithFeeCollectorButNoAmount_Reverts() (gas: 121570)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitWrongSigner() (gas: 41794)
TrailsIntentEntrypointTest:testDepositToIntentWithoutPermit_RequiresIntentAddress() (gas: 32173)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitAlreadyUsed() (gas: 134746)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitExpiredDeadline() (gas: 37222)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitReentrancyProtection() (gas: 125501)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitRequiresNonZeroAmount() (gas: 35914)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitRequiresPermitAmount() (gas: 112450)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitRequiresValidToken() (gas: 36640)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitTransferFromFails() (gas: 111613)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitWithFeeAmountButNoCollector_Reverts() (gas: 141960)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitWithFeeCollectorButNoAmount_Reverts() (gas: 121561)
TrailsIntentEntrypointTest:testDepositToIntentWithPermitWrongSigner() (gas: 41835)
TrailsIntentEntrypointTest:testDepositToIntentWithoutPermit_RequiresIntentAddress() (gas: 32195)
TrailsIntentEntrypointTest:testDepositToIntentWrongSigner() (gas: 58611)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20AndFee_Success() (gas: 810878)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20_InsufficientAllowance_Reverts() (gas: 773627)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20_InsufficientBalance_Reverts() (gas: 773586)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20_Success() (gas: 781776)
TrailsIntentEntrypointTest:testExactApprovalFlow() (gas: 152588)
TrailsIntentEntrypointTest:testExecuteIntentWithFee() (gas: 154994)
TrailsIntentEntrypointTest:testExecuteIntentWithPermit() (gas: 124636)
TrailsIntentEntrypointTest:testExecuteIntentWithPermitExpired() (gas: 37100)
TrailsIntentEntrypointTest:testExecuteIntentWithPermitInvalidSignature() (gas: 39016)
TrailsIntentEntrypointTest:testExecuteIntentWithPermit_permit_frontrun() (gas: 129498)
TrailsIntentEntrypointTest:testFeeCollectorReceivesFees() (gas: 149146)
TrailsIntentEntrypointTest:testFeeCollectorReceivesFeesWithoutPermit() (gas: 118796)
TrailsIntentEntrypointTest:testInfiniteApprovalFlow() (gas: 146872)
TrailsIntentEntrypointTest:testIntentTypehashConstant() (gas: 5732)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20AndFee_Success() (gas: 810900)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20_InsufficientAllowance_Reverts() (gas: 773649)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20_InsufficientBalance_Reverts() (gas: 773630)
TrailsIntentEntrypointTest:testDepositToIntent_WithNonStandardERC20_Success() (gas: 781820)
TrailsIntentEntrypointTest:testExactApprovalFlow() (gas: 152574)
TrailsIntentEntrypointTest:testExecuteIntentWithFee() (gas: 155007)
TrailsIntentEntrypointTest:testExecuteIntentWithPermit() (gas: 124649)
TrailsIntentEntrypointTest:testExecuteIntentWithPermitExpired() (gas: 37141)
TrailsIntentEntrypointTest:testExecuteIntentWithPermitInvalidSignature() (gas: 39013)
TrailsIntentEntrypointTest:testExecuteIntentWithPermit_permit_frontrun() (gas: 129489)
TrailsIntentEntrypointTest:testFeeCollectorReceivesFees() (gas: 149159)
TrailsIntentEntrypointTest:testFeeCollectorReceivesFeesWithoutPermit() (gas: 118818)
TrailsIntentEntrypointTest:testInfiniteApprovalFlow() (gas: 146864)
TrailsIntentEntrypointTest:testIntentTypehashConstant() (gas: 5754)
TrailsIntentEntrypointTest:testInvalidNonceReverts() (gas: 55344)
TrailsIntentEntrypointTest:testNonceIncrementsOnDeposit() (gas: 91830)
TrailsIntentEntrypointTest:testPermitAmountExcessiveWithFee() (gas: 61879)
TrailsIntentEntrypointTest:testPermitAmountInsufficientWithFee() (gas: 60889)
TrailsIntentEntrypointTest:testVersionConstant() (gas: 10506)
TrailsIntentEntrypointTest:testNonceIncrementsOnDeposit() (gas: 91852)
TrailsIntentEntrypointTest:testPermitAmountExcessiveThenUseRemainingAllowance() (gas: 178584)
TrailsIntentEntrypointTest:testPermitAmountExcessiveWithFeeLeavesAllowance() (gas: 174910)
TrailsIntentEntrypointTest:testPermitAmountInsufficientWithFee() (gas: 142114)
TrailsIntentEntrypointTest:testVersionConstant() (gas: 10550)
TrailsRouterDeploymentTest:test_DeployTrailsRouter_SameAddress() (gas: 1864438)
TrailsRouterDeploymentTest:test_DeployTrailsRouter_Success() (gas: 1852254)
TrailsRouterDeploymentTest:test_DeployedRouter_HasCorrectConfiguration() (gas: 1852050)
Expand Down
34 changes: 9 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ Intent(
address token, // ERC20 token to deposit
uint256 amount, // Amount to deposit
address intentAddress, // Destination intent wallet
uint256 deadline // Intent expiration timestamp
uint256 deadline, // Intent expiration timestamp
uint256 chainId, // Chain the intent is authorized for
uint256 nonce, // Intent nonce for replay protection
uint256 feeAmount, // Fee to collect alongside the deposit
address feeCollector // Recipient of the fee
)
```

Expand Down Expand Up @@ -129,55 +133,35 @@ flowchart TD
dest_sweep --> done
```

#### TrailsIntentEntrypoint – Fee Payment

The entrypoint supports decoupled fee payments that can leverage existing allowances (e.g., leftover permit allowance from a prior deposit) or set allowance on the fly via ERC-2612 permit.

##### API

- **payFee**
- Signature: `payFee(address user, address feeToken, uint256 feeAmount, address feeCollector)`
- Preconditions: `feeAmount > 0`, `feeToken != address(0)`, `feeCollector != address(0)`, and sufficient allowance for the entrypoint
- Effects: Transfers `feeAmount` of `feeToken` from `user` to `feeCollector`
- Emits: `FeePaid(user, feeToken, feeAmount, feeCollector)`

- **payFeeWithPermit**
- Signature: `payFeeWithPermit(address user, address feeToken, uint256 feeAmount, address feeCollector, uint256 deadline, uint8 v, bytes32 r, bytes32 s)`
- Preconditions: `feeAmount > 0`, `feeToken != address(0)`, `feeCollector != address(0)`, `block.timestamp <= deadline`
- Effects: Executes ERC-2612 permit for `feeAmount` and then transfers tokens from `user` to `feeCollector`
- Emits: `FeePaid(user, feeToken, feeAmount, feeCollector)`

##### Typical Flow

- Use `depositToIntentWithPermit` with a `permitAmount` greater than the deposit `amount` to leave leftover allowance.
- Call `payFee` to consume the leftover allowance for fee collection.

```solidity
// Deposit with a larger permit to leave leftover allowance
uint256 intentNonce = entrypoint.nonces(user);
entrypoint.depositToIntentWithPermit(
user,
token,
depositAmount,
totalPermit, // > depositAmount, leaves leftover allowance
intentAddress,
deadline,
intentNonce,
feeAmount,
feeCollector,
permitV,
permitR,
permitS,
sigV,
sigR,
sigS
);

// Later: collect fee using leftover allowance
entrypoint.payFee(user, token, feeAmount, feeCollector);
```

#### Notes

- `FeePaid(address indexed user, address indexed feeToken, uint256 feeAmount, address indexed feeCollector)` is emitted on successful fee payment.
- `payFee` requires prior allowance (e.g., leftover from `depositToIntentWithPermit`).
- `payFeeWithPermit` sets the allowance atomically using ERC-2612 before transferring.

## Deployment

Expand Down
7 changes: 0 additions & 7 deletions src/TrailsIntentEntrypoint.sol
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ contract TrailsIntentEntrypoint is ReentrancyGuard, ITrailsIntentEntrypoint {
error InvalidIntentSignature();
error InvalidNonce();
error InvalidFeeParameters();
error PermitAmountMismatch();

// -------------------------------------------------------------------------
// State Variables
Expand Down Expand Up @@ -85,12 +84,6 @@ contract TrailsIntentEntrypoint is ReentrancyGuard, ITrailsIntentEntrypoint {
user, token, amount, intentAddress, deadline, nonce, feeAmount, feeCollector, sigV, sigR, sigS
);

// Validate permitAmount exactly matches the total required amount (deposit + fee)
// This prevents permit/approval mismatches that could cause DoS or unexpected behavior
unchecked {
if (permitAmount != amount + feeAmount) revert PermitAmountMismatch();
}

// Execute permit with try-catch to handle potential frontrunning, and scope variables to avoid stack too deep
try IERC20Permit(token).permit(user, address(this), permitAmount, deadline, permitV, permitR, permitS) {
// Permit succeeded
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/ITrailsIntentEntrypoint.sol
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface ITrailsIntentEntrypoint {
/// @param user The user making the deposit
/// @param token The token to deposit (also used for fee payment)
/// @param amount The amount to deposit
/// @param permitAmount The amount to permit for spending (amount + feeAmount if paying fee)
/// @param permitAmount The allowance to set via permit (must cover amount + feeAmount; can be higher to leave leftover)
/// @param intentAddress The intent address to deposit to
/// @param deadline The permit deadline
/// @param nonce The nonce for this user
Expand Down
104 changes: 78 additions & 26 deletions test/TrailsIntentEntrypoint.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {Test} from "forge-std/Test.sol";
import {TrailsIntentEntrypoint} from "../src/TrailsIntentEntrypoint.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {MockNonStandardERC20} from "./mocks/MockNonStandardERC20.sol";

// Mock ERC20 token with permit functionality for testing
Expand Down Expand Up @@ -1989,25 +1990,30 @@ contract TrailsIntentEntrypointTest is Test {
}

// =========================================================================
// SEQ-1: Permit Amount Validation Tests (Additional Safety Check)
// SEQ-1: Permit Amount Flexibility Tests
// =========================================================================

/**
* @notice Test that depositToIntentWithPermit reverts when permit amount is insufficient
* @dev Validates permitAmount != amount + feeAmount check (insufficient case)
* @notice depositToIntentWithPermit reverts when permit amount cannot cover amount + fee
* @dev Now relies on the token's allowance error instead of a custom mismatch check
*/
function testPermitAmountInsufficientWithFee() public {
vm.startPrank(user);
uint256 amt = 50e18;
uint256 fee = 10e18;
uint256 permitAmt = amt + fee - 1; // Insufficient by 1
uint256 permitAmt = amt + fee - 1; // Insufficient by 1 wei
uint256 dl = block.timestamp + 1 hours;
uint256 nonce = entrypoint.nonces(user);

(uint8 pv, bytes32 pr, bytes32 ps) = _signPermit(user, permitAmt, dl);
(uint8 sv, bytes32 sr, bytes32 ss) = _signIntent2(user, amt, address(0x5678), dl, nonce, fee, address(0x9999));

vm.expectRevert(TrailsIntentEntrypoint.PermitAmountMismatch.selector);
uint256 expectedAllowance = fee - 1;
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientAllowance.selector, address(entrypoint), expectedAllowance, fee
)
);
entrypoint.depositToIntentWithPermit(
user,
address(token),
Expand All @@ -2029,38 +2035,84 @@ contract TrailsIntentEntrypointTest is Test {
}

/**
* @notice Test that depositToIntentWithPermit reverts when permit amount exceeds required
* @dev Validates permitAmount != amount + feeAmount check (excess case)
* @notice depositToIntentWithPermit allows over-permitting; only spends amount + fee
* @dev Verifies extra allowance remains available after the deposit completes
*/
function testPermitAmountExcessiveWithFee() public {
function testPermitAmountExcessiveWithFeeLeavesAllowance() public {
vm.startPrank(user);
uint256 amt = 50e18;
uint256 fee = 10e18;
uint256 permitAmt = amt + fee + 1; // Excess by 1
uint256 extra = 5e18;
uint256 permitAmt = amt + fee + extra; // Permit more than required
uint256 dl = block.timestamp + 1 hours;
uint256 nonce = entrypoint.nonces(user);
address intentAddr = address(0x5678);
address feeCollector = address(0x9999);

(uint8 pv, bytes32 pr, bytes32 ps) = _signPermit(user, permitAmt, dl);
(uint8 sv, bytes32 sr, bytes32 ss) = _signIntent2(user, amt, address(0x5678), dl, nonce, fee, address(0x9999));
(uint8 sv, bytes32 sr, bytes32 ss) = _signIntent2(user, amt, intentAddr, dl, nonce, fee, feeCollector);

uint256 userBalanceBefore = token.balanceOf(user);
uint256 intentBalanceBefore = token.balanceOf(intentAddr);
uint256 feeCollectorBalanceBefore = token.balanceOf(feeCollector);

vm.expectRevert(TrailsIntentEntrypoint.PermitAmountMismatch.selector);
entrypoint.depositToIntentWithPermit(
user,
address(token),
amt,
permitAmt,
address(0x5678),
dl,
nonce,
fee,
address(0x9999),
pv,
pr,
ps,
sv,
sr,
ss
user, address(token), amt, permitAmt, intentAddr, dl, nonce, fee, feeCollector, pv, pr, ps, sv, sr, ss
);

assertEq(token.balanceOf(user), userBalanceBefore - (amt + fee));
assertEq(token.balanceOf(intentAddr), intentBalanceBefore + amt);
assertEq(token.balanceOf(feeCollector), feeCollectorBalanceBefore + fee);
assertEq(token.allowance(user, address(entrypoint)), extra);

vm.stopPrank();
}

/**
* @notice Uses leftover allowance from an oversized permit for a second deposit without a new permit
* @dev First call over-permits, second call consumes the remaining allowance via depositToIntent
*/
function testPermitAmountExcessiveThenUseRemainingAllowance() public {
vm.startPrank(user);

uint256 amt1 = 50e18;
uint256 fee1 = 10e18;
uint256 leftover = 20e18;
uint256 permitAmt = amt1 + fee1 + leftover;
uint256 dl = block.timestamp + 1 hours;
address intentAddr = address(0x5678);
address feeCollector = address(0x9999);

uint256 nonce1 = entrypoint.nonces(user);

(uint8 pv, bytes32 pr, bytes32 ps) = _signPermit(user, permitAmt, dl);
(uint8 sv1, bytes32 sr1, bytes32 ss1) = _signIntent2(user, amt1, intentAddr, dl, nonce1, fee1, feeCollector);

entrypoint.depositToIntentWithPermit(
user, address(token), amt1, permitAmt, intentAddr, dl, nonce1, fee1, feeCollector, pv, pr, ps, sv1, sr1, ss1
);

assertEq(token.allowance(user, address(entrypoint)), leftover);

// Use the leftover allowance without another permit
uint256 amt2 = 15e18;
uint256 fee2 = 5e18; // amt2 + fee2 == leftover
uint256 nonce2 = entrypoint.nonces(user);
(uint8 sv2, bytes32 sr2, bytes32 ss2) = _signIntent2(user, amt2, intentAddr, dl, nonce2, fee2, feeCollector);

uint256 userBalBefore = token.balanceOf(user);
uint256 intentBalBefore = token.balanceOf(intentAddr);
uint256 feeCollectorBalBefore = token.balanceOf(feeCollector);

entrypoint.depositToIntent(
user, address(token), amt2, intentAddr, dl, nonce2, fee2, feeCollector, sv2, sr2, ss2
);

assertEq(token.allowance(user, address(entrypoint)), 0);
assertEq(token.balanceOf(user), userBalBefore - (amt2 + fee2));
assertEq(token.balanceOf(intentAddr), intentBalBefore + amt2);
assertEq(token.balanceOf(feeCollector), feeCollectorBalBefore + fee2);

vm.stopPrank();
}

Expand Down