Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7a62763
feat: add ERC-3009 support, modify DepositRecorded to support various…
pali101 Aug 15, 2025
8970f8d
refactor: generalize error to SignerMustBeMsgSender for self-recipien…
pali101 Aug 16, 2025
a8a76ef
refactor: replace PermitRecipientMustBeMsgSender check with SignerMus…
pali101 Aug 16, 2025
eb19764
feat: add ERC-3009 support to Mock ERC-20
pali101 Aug 16, 2025
16c0567
refactor: updated error selector for PermitRecipient
pali101 Aug 16, 2025
1a0733a
test: add tests for deposit with authorization
pali101 Aug 16, 2025
272a083
feat: add depositWithAuthorizationAndApproveOperator and depositWithA…
pali101 Aug 16, 2025
19cf5ba
test: add tests for deposit with authorization + operator operations
pali101 Aug 16, 2025
a15dc32
feat(erc3009): switch to receiveWithAuthorization
pali101 Aug 20, 2025
4b1f3dd
fix: removed hardcoded hashed name, _chainID() and explicitly importe…
pali101 Aug 24, 2025
96724de
fix: modified tests to reflect updated MockERC20
pali101 Aug 24, 2025
58ad21c
fix: updated DepositRecorded event to remove auth type and nonce
pali101 Aug 24, 2025
10c77a7
feat: allow relayer to perform deposit with authorization on behalf o…
pali101 Sep 3, 2025
8418eb0
test: add test to simulate different signer and relayer
pali101 Sep 3, 2025
b1704e5
test: add tests checking only to address can call deposit and operato…
pali101 Sep 3, 2025
d775697
refactor: moved IERC3009 to separate file
pali101 Sep 5, 2025
b7f573a
style: fix formatting
pali101 Sep 5, 2025
c6c443a
fix: use parameter type IERC3009 instead of explicit casting
pali101 Sep 5, 2025
e7773ed
refactor: MockERC20 inherit ERC20 explicitly and add IERC3009 interfa…
pali101 Sep 5, 2025
6b025a9
fix: shifted to SignerMustBeMsgSender custom error for newer tests af…
pali101 Sep 7, 2025
5d30d18
style: fix formatting
pali101 Sep 7, 2025
61e7c24
fix: passed IERC3009 token instead of just address in depositWithAuth…
pali101 Sep 11, 2025
588e3ba
refactor: update _increaseOperatorApproval to use IERC20 interface
pali101 Sep 12, 2025
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
5 changes: 3 additions & 2 deletions src/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,9 @@ library Errors {
/// @param sent The amount of native token sent with the transaction
error InsufficientNativeTokenForBurn(uint256 required, uint256 sent);

/// @notice The 'to' address in permit functions must be the message sender
/// @notice The 'to' address must equal the transaction sender (self-recipient enforcement)
/// @dev Used by flows like permit and transfer-with-authorization to ensure only self-deposits
/// @param expected The expected address (msg.sender)
/// @param actual The actual 'to' address provided
error PermitRecipientMustBeMsgSender(address expected, address actual);
error SignerMustBeMsgSender(address expected, address actual);
}
176 changes: 161 additions & 15 deletions src/Payments.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

import "./Errors.sol";
import "./RateChangeQueue.sol";
import "./interfaces/IERC3009.sol";

interface IValidator {
struct ValidationResult {
Expand Down Expand Up @@ -91,9 +91,7 @@ contract Payments is ReentrancyGuard {
event RailTerminated(uint256 indexed railId, address indexed by, uint256 endEpoch);
event RailFinalized(uint256 indexed railId);

event DepositRecorded(
address indexed token, address indexed from, address indexed to, uint256 amount, bool usedPermit
);
event DepositRecorded(address indexed token, address indexed from, address indexed to, uint256 amount);
event WithdrawRecorded(address indexed token, address indexed from, address indexed to, uint256 amount);

struct Account {
Expand Down Expand Up @@ -225,8 +223,8 @@ contract Payments is ReentrancyGuard {
_;
}

modifier validatePermitRecipient(address to) {
require(to == msg.sender, Errors.PermitRecipientMustBeMsgSender(msg.sender, to));
modifier validateSignerIsRecipient(address to) {
require(to == msg.sender, Errors.SignerMustBeMsgSender(msg.sender, to));
_;
}

Expand Down Expand Up @@ -365,16 +363,16 @@ contract Payments is ReentrancyGuard {
uint256 rateAllowanceIncrease,
uint256 lockupAllowanceIncrease
) external nonReentrant validateNonZeroAddress(operator, "operator") {
_increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease);
_increaseOperatorApproval(IERC20(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease);
}

function _increaseOperatorApproval(
address token,
IERC20 token,
address operator,
uint256 rateAllowanceIncrease,
uint256 lockupAllowanceIncrease
) internal {
OperatorApproval storage approval = operatorApprovals[token][msg.sender][operator];
OperatorApproval storage approval = operatorApprovals[address(token)][msg.sender][operator];

// Operator must already be approved
require(approval.isApproved, Errors.OperatorNotApproved(msg.sender, operator));
Expand All @@ -384,7 +382,7 @@ contract Payments is ReentrancyGuard {
approval.lockupAllowance += lockupAllowanceIncrease;

emit OperatorApprovalUpdated(
token,
address(token),
msg.sender,
operator,
approval.isApproved,
Expand Down Expand Up @@ -475,7 +473,7 @@ contract Payments is ReentrancyGuard {

account.funds += actualAmount;

emit DepositRecorded(token, msg.sender, to, actualAmount, false);
emit DepositRecorded(token, msg.sender, to, actualAmount);
}

/**
Expand Down Expand Up @@ -524,7 +522,7 @@ contract Payments is ReentrancyGuard {

account.funds += actualAmount;

emit DepositRecorded(token, to, to, actualAmount, true);
emit DepositRecorded(token, to, to, actualAmount);
}

/**
Expand Down Expand Up @@ -563,7 +561,7 @@ contract Payments is ReentrancyGuard {
nonReentrant
validateNonZeroAddress(operator, "operator")
validateNonZeroAddress(to, "to")
validatePermitRecipient(to)
validateSignerIsRecipient(to)
settleAccountLockupBeforeAndAfter(token, to, false)
{
_setOperatorApproval(token, operator, true, rateAllowance, lockupAllowance, maxLockupPeriod);
Expand Down Expand Up @@ -600,13 +598,161 @@ contract Payments is ReentrancyGuard {
nonReentrant
validateNonZeroAddress(operator, "operator")
validateNonZeroAddress(to, "to")
validatePermitRecipient(to)
validateSignerIsRecipient(to)
settleAccountLockupBeforeAndAfter(token, to, false)
{
_increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease);
_increaseOperatorApproval(IERC20(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease);
_depositWithPermit(token, to, amount, deadline, v, r, s);
}

/**
* @notice Deposits tokens using an ERC-3009 authorization in a single transaction.
* @param token The ERC-3009-compliant token contract.
* @param to The address whose account within the contract will be credited.
* @param amount The amount of tokens to deposit.
* @param validAfter The timestamp after which the authorization is valid.
* @param validBefore The timestamp before which the authorization is valid.
* @param nonce A unique nonce for the authorization, used to prevent replay attacks.
* @param v,r,s The signature of the authorization.
*/
function depositWithAuthorization(
IERC3009 token,
address to,
uint256 amount,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
)
external
nonReentrant
validateNonZeroAddress(to, "to")
settleAccountLockupBeforeAndAfter(address(token), to, false)
{
_depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s);
}

/**
* @notice Deposits tokens using an ERC-3009 authorization in a single transaction.
* while also setting operator approval.
* @param token The ERC-3009-compliant token contract.
* @param to The address whose account within the contract will be credited.
* @param amount The amount of tokens to deposit.
* @param validAfter The timestamp after which the authorization is valid.
* @param validBefore The timestamp before which the authorization is valid.
* @param nonce A unique nonce for the authorization, used to prevent replay attacks.
* @param v,r,s The signature of the authorization.
* @param operator The address of the operator whose approval is being modified.
* @param rateAllowance The maximum payment rate the operator can set across all rails created by the operator
* on behalf of the message sender. If this is less than the current payment rate, the operator will
* only be able to reduce rates until they fall below the target.
* @param lockupAllowance The maximum amount of funds the operator can lock up on behalf of the message sender
* towards future payments. If this exceeds the current total amount of funds locked towards future payments,
* the operator will only be able to reduce future lockup.
* @param maxLockupPeriod The maximum number of epochs (blocks) the operator can lock funds for. If this is less than
* the current lockup period for a rail, the operator will only be able to reduce the lockup period.
*/
function depositWithAuthorizationAndApproveOperator(
IERC3009 token,
address to,
uint256 amount,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s,
address operator,
uint256 rateAllowance,
uint256 lockupAllowance,
uint256 maxLockupPeriod
)
external
nonReentrant
validateNonZeroAddress(operator, "operator")
validateNonZeroAddress(to, "to")
validateSignerIsRecipient(to)
settleAccountLockupBeforeAndAfter(address(token), to, false)
{
_setOperatorApproval(address(token), operator, true, rateAllowance, lockupAllowance, maxLockupPeriod);
_depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s);
}

/**
* @notice Deposits tokens using an ERC-3009 authorization in a single transaction.
* while also setting operator approval.
* @param token The ERC-3009-compliant token contract.
* @param to The address whose account within the contract will be credited.
* @param amount The amount of tokens to deposit.
* @param validAfter The timestamp after which the authorization is valid.
* @param validBefore The timestamp before which the authorization is valid.
* @param nonce A unique nonce for the authorization, used to prevent replay attacks.
* @param v,r,s The signature of the authorization.
* @param operator The address of the operator whose allowances are being increased.
* @param rateAllowanceIncrease The amount to increase the rate allowance by.
* @param lockupAllowanceIncrease The amount to increase the lockup allowance by.
* @custom:constraint Operator must already be approved.
*/
function depositWithAuthorizationAndIncreaseOperatorApproval(
IERC3009 token,
address to,
uint256 amount,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s,
address operator,
uint256 rateAllowanceIncrease,
uint256 lockupAllowanceIncrease
)
external
nonReentrant
validateNonZeroAddress(operator, "operator")
validateNonZeroAddress(to, "to")
validateSignerIsRecipient(to)
settleAccountLockupBeforeAndAfter(address(token), to, false)
{
_increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease);
_depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s);
}

function _depositWithAuthorization(
IERC3009 token,
address to,
uint256 amount,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) internal {
// Revert if token is address(0) as authorization is not supported for native tokens
require(address(token) != address(0), Errors.NativeTokenNotSupported());

// Use balance-before/balance-after accounting to correctly handle fee-on-transfer tokens
uint256 balanceBefore = token.balanceOf(address(this));

// Call ERC-3009 receiveWithAuthorization.
// This will transfer 'amount' from 'to' to this contract.
// The token contract itself verifies the signature.
token.receiveWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s);

uint256 balanceAfter = token.balanceOf(address(this));
uint256 actualAmount = balanceAfter - balanceBefore;

// Credit the beneficiary's internal account
Account storage account = accounts[address(token)][to];
account.funds += actualAmount;

// Emit an event to record the deposit, marking it as made via an off-chain signature.
emit DepositRecorded(address(token), to, to, actualAmount);
}

/// @notice Withdraws tokens from the caller's account to the caller's account, up to the amount of currently available tokens (the tokens not currently locked in rails).
/// @param token The ERC20 token address to withdraw.
/// @param amount The amount of tokens to withdraw.
Expand Down
34 changes: 34 additions & 0 deletions src/interfaces/IERC3009.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.27;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IERC3009 is IERC20 {
/**
* @notice Receive a transfer with a signed authorization from the payer
* @dev This has an additional check to ensure that the payee's address matches
* the caller of this function to prevent front-running attacks.
* @param from Payer's address (Authorizer)
* @param to Payee's address
* @param value Amount to be transferred
* @param validAfter The time after which this is valid (unix time)
* @param validBefore The time before which this is valid (unix time)
* @param nonce Unique nonce
* @param v v of the signature
* @param r r of the signature
* @param s s of the signature
*/
function receiveWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external;

function authorizationState(address user, bytes32 nonce) external view returns (bool used);
}
Loading