Skip to content

Commit 5a34645

Browse files
authored
feat: add support for ERC-3009 (#214)
## Summary Adds `ERC-3009` deposit flows, event extensions, and tests. ## Changes - Introduce `IERC3009`. - Payments - Add `AuthType` enum; extend `DepositRecorded(token, from, to, amount, authType, nonce)`. - Generalize self-recipient check: `PermitRecipientMustBeMsgSender` → `SignerMustBeMsgSender`; `validatePermitRecipient` → `validateSignerIsRecipient`. - New methods: `depositWithAuthorization(...)`, `depositWithAuthorizationAndApproveOperator(...)`, `depositWithAuthorizationAndIncreaseOperatorApproval(...)` - Tests - Comprehensive coverage for happy path, replay, signature errors, time windows, sender mismatch, insufficient balance, domain mismatch. - Mock - `MockERC20`: implements ERC-3009. ## Notes - `authorizationState` used for replay protection - `domain separation` enforced by signatures. Closes #205
1 parent 29726a3 commit 5a34645

File tree

9 files changed

+1319
-27
lines changed

9 files changed

+1319
-27
lines changed

src/Errors.sol

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,9 @@ library Errors {
286286
/// @param sent The amount of native token sent with the transaction
287287
error InsufficientNativeTokenForBurn(uint256 required, uint256 sent);
288288

289-
/// @notice The 'to' address in permit functions must be the message sender
289+
/// @notice The 'to' address must equal the transaction sender (self-recipient enforcement)
290+
/// @dev Used by flows like permit and transfer-with-authorization to ensure only self-deposits
290291
/// @param expected The expected address (msg.sender)
291292
/// @param actual The actual 'to' address provided
292-
error PermitRecipientMustBeMsgSender(address expected, address actual);
293+
error SignerMustBeMsgSender(address expected, address actual);
293294
}

src/Payments.sol

Lines changed: 161 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
66
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
77
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
88
import "@openzeppelin/contracts/utils/Strings.sol";
9-
109
import "./Errors.sol";
1110
import "./RateChangeQueue.sol";
11+
import "./interfaces/IERC3009.sol";
1212

1313
interface IValidator {
1414
struct ValidationResult {
@@ -91,9 +91,7 @@ contract Payments is ReentrancyGuard {
9191
event RailTerminated(uint256 indexed railId, address indexed by, uint256 endEpoch);
9292
event RailFinalized(uint256 indexed railId);
9393

94-
event DepositRecorded(
95-
address indexed token, address indexed from, address indexed to, uint256 amount, bool usedPermit
96-
);
94+
event DepositRecorded(address indexed token, address indexed from, address indexed to, uint256 amount);
9795
event WithdrawRecorded(address indexed token, address indexed from, address indexed to, uint256 amount);
9896

9997
struct Account {
@@ -225,8 +223,8 @@ contract Payments is ReentrancyGuard {
225223
_;
226224
}
227225

228-
modifier validatePermitRecipient(address to) {
229-
require(to == msg.sender, Errors.PermitRecipientMustBeMsgSender(msg.sender, to));
226+
modifier validateSignerIsRecipient(address to) {
227+
require(to == msg.sender, Errors.SignerMustBeMsgSender(msg.sender, to));
230228
_;
231229
}
232230

@@ -365,16 +363,16 @@ contract Payments is ReentrancyGuard {
365363
uint256 rateAllowanceIncrease,
366364
uint256 lockupAllowanceIncrease
367365
) external nonReentrant validateNonZeroAddress(operator, "operator") {
368-
_increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease);
366+
_increaseOperatorApproval(IERC20(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease);
369367
}
370368

371369
function _increaseOperatorApproval(
372-
address token,
370+
IERC20 token,
373371
address operator,
374372
uint256 rateAllowanceIncrease,
375373
uint256 lockupAllowanceIncrease
376374
) internal {
377-
OperatorApproval storage approval = operatorApprovals[token][msg.sender][operator];
375+
OperatorApproval storage approval = operatorApprovals[address(token)][msg.sender][operator];
378376

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

386384
emit OperatorApprovalUpdated(
387-
token,
385+
address(token),
388386
msg.sender,
389387
operator,
390388
approval.isApproved,
@@ -475,7 +473,7 @@ contract Payments is ReentrancyGuard {
475473

476474
account.funds += actualAmount;
477475

478-
emit DepositRecorded(token, msg.sender, to, actualAmount, false);
476+
emit DepositRecorded(token, msg.sender, to, actualAmount);
479477
}
480478

481479
/**
@@ -524,7 +522,7 @@ contract Payments is ReentrancyGuard {
524522

525523
account.funds += actualAmount;
526524

527-
emit DepositRecorded(token, to, to, actualAmount, true);
525+
emit DepositRecorded(token, to, to, actualAmount);
528526
}
529527

530528
/**
@@ -563,7 +561,7 @@ contract Payments is ReentrancyGuard {
563561
nonReentrant
564562
validateNonZeroAddress(operator, "operator")
565563
validateNonZeroAddress(to, "to")
566-
validatePermitRecipient(to)
564+
validateSignerIsRecipient(to)
567565
settleAccountLockupBeforeAndAfter(token, to, false)
568566
{
569567
_setOperatorApproval(token, operator, true, rateAllowance, lockupAllowance, maxLockupPeriod);
@@ -600,13 +598,161 @@ contract Payments is ReentrancyGuard {
600598
nonReentrant
601599
validateNonZeroAddress(operator, "operator")
602600
validateNonZeroAddress(to, "to")
603-
validatePermitRecipient(to)
601+
validateSignerIsRecipient(to)
604602
settleAccountLockupBeforeAndAfter(token, to, false)
605603
{
606-
_increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease);
604+
_increaseOperatorApproval(IERC20(token), operator, rateAllowanceIncrease, lockupAllowanceIncrease);
607605
_depositWithPermit(token, to, amount, deadline, v, r, s);
608606
}
609607

608+
/**
609+
* @notice Deposits tokens using an ERC-3009 authorization in a single transaction.
610+
* @param token The ERC-3009-compliant token contract.
611+
* @param to The address whose account within the contract will be credited.
612+
* @param amount The amount of tokens to deposit.
613+
* @param validAfter The timestamp after which the authorization is valid.
614+
* @param validBefore The timestamp before which the authorization is valid.
615+
* @param nonce A unique nonce for the authorization, used to prevent replay attacks.
616+
* @param v,r,s The signature of the authorization.
617+
*/
618+
function depositWithAuthorization(
619+
IERC3009 token,
620+
address to,
621+
uint256 amount,
622+
uint256 validAfter,
623+
uint256 validBefore,
624+
bytes32 nonce,
625+
uint8 v,
626+
bytes32 r,
627+
bytes32 s
628+
)
629+
external
630+
nonReentrant
631+
validateNonZeroAddress(to, "to")
632+
settleAccountLockupBeforeAndAfter(address(token), to, false)
633+
{
634+
_depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s);
635+
}
636+
637+
/**
638+
* @notice Deposits tokens using an ERC-3009 authorization in a single transaction.
639+
* while also setting operator approval.
640+
* @param token The ERC-3009-compliant token contract.
641+
* @param to The address whose account within the contract will be credited.
642+
* @param amount The amount of tokens to deposit.
643+
* @param validAfter The timestamp after which the authorization is valid.
644+
* @param validBefore The timestamp before which the authorization is valid.
645+
* @param nonce A unique nonce for the authorization, used to prevent replay attacks.
646+
* @param v,r,s The signature of the authorization.
647+
* @param operator The address of the operator whose approval is being modified.
648+
* @param rateAllowance The maximum payment rate the operator can set across all rails created by the operator
649+
* on behalf of the message sender. If this is less than the current payment rate, the operator will
650+
* only be able to reduce rates until they fall below the target.
651+
* @param lockupAllowance The maximum amount of funds the operator can lock up on behalf of the message sender
652+
* towards future payments. If this exceeds the current total amount of funds locked towards future payments,
653+
* the operator will only be able to reduce future lockup.
654+
* @param maxLockupPeriod The maximum number of epochs (blocks) the operator can lock funds for. If this is less than
655+
* the current lockup period for a rail, the operator will only be able to reduce the lockup period.
656+
*/
657+
function depositWithAuthorizationAndApproveOperator(
658+
IERC3009 token,
659+
address to,
660+
uint256 amount,
661+
uint256 validAfter,
662+
uint256 validBefore,
663+
bytes32 nonce,
664+
uint8 v,
665+
bytes32 r,
666+
bytes32 s,
667+
address operator,
668+
uint256 rateAllowance,
669+
uint256 lockupAllowance,
670+
uint256 maxLockupPeriod
671+
)
672+
external
673+
nonReentrant
674+
validateNonZeroAddress(operator, "operator")
675+
validateNonZeroAddress(to, "to")
676+
validateSignerIsRecipient(to)
677+
settleAccountLockupBeforeAndAfter(address(token), to, false)
678+
{
679+
_setOperatorApproval(address(token), operator, true, rateAllowance, lockupAllowance, maxLockupPeriod);
680+
_depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s);
681+
}
682+
683+
/**
684+
* @notice Deposits tokens using an ERC-3009 authorization in a single transaction.
685+
* while also setting operator approval.
686+
* @param token The ERC-3009-compliant token contract.
687+
* @param to The address whose account within the contract will be credited.
688+
* @param amount The amount of tokens to deposit.
689+
* @param validAfter The timestamp after which the authorization is valid.
690+
* @param validBefore The timestamp before which the authorization is valid.
691+
* @param nonce A unique nonce for the authorization, used to prevent replay attacks.
692+
* @param v,r,s The signature of the authorization.
693+
* @param operator The address of the operator whose allowances are being increased.
694+
* @param rateAllowanceIncrease The amount to increase the rate allowance by.
695+
* @param lockupAllowanceIncrease The amount to increase the lockup allowance by.
696+
* @custom:constraint Operator must already be approved.
697+
*/
698+
function depositWithAuthorizationAndIncreaseOperatorApproval(
699+
IERC3009 token,
700+
address to,
701+
uint256 amount,
702+
uint256 validAfter,
703+
uint256 validBefore,
704+
bytes32 nonce,
705+
uint8 v,
706+
bytes32 r,
707+
bytes32 s,
708+
address operator,
709+
uint256 rateAllowanceIncrease,
710+
uint256 lockupAllowanceIncrease
711+
)
712+
external
713+
nonReentrant
714+
validateNonZeroAddress(operator, "operator")
715+
validateNonZeroAddress(to, "to")
716+
validateSignerIsRecipient(to)
717+
settleAccountLockupBeforeAndAfter(address(token), to, false)
718+
{
719+
_increaseOperatorApproval(token, operator, rateAllowanceIncrease, lockupAllowanceIncrease);
720+
_depositWithAuthorization(token, to, amount, validAfter, validBefore, nonce, v, r, s);
721+
}
722+
723+
function _depositWithAuthorization(
724+
IERC3009 token,
725+
address to,
726+
uint256 amount,
727+
uint256 validAfter,
728+
uint256 validBefore,
729+
bytes32 nonce,
730+
uint8 v,
731+
bytes32 r,
732+
bytes32 s
733+
) internal {
734+
// Revert if token is address(0) as authorization is not supported for native tokens
735+
require(address(token) != address(0), Errors.NativeTokenNotSupported());
736+
737+
// Use balance-before/balance-after accounting to correctly handle fee-on-transfer tokens
738+
uint256 balanceBefore = token.balanceOf(address(this));
739+
740+
// Call ERC-3009 receiveWithAuthorization.
741+
// This will transfer 'amount' from 'to' to this contract.
742+
// The token contract itself verifies the signature.
743+
token.receiveWithAuthorization(to, address(this), amount, validAfter, validBefore, nonce, v, r, s);
744+
745+
uint256 balanceAfter = token.balanceOf(address(this));
746+
uint256 actualAmount = balanceAfter - balanceBefore;
747+
748+
// Credit the beneficiary's internal account
749+
Account storage account = accounts[address(token)][to];
750+
account.funds += actualAmount;
751+
752+
// Emit an event to record the deposit, marking it as made via an off-chain signature.
753+
emit DepositRecorded(address(token), to, to, actualAmount);
754+
}
755+
610756
/// @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).
611757
/// @param token The ERC20 token address to withdraw.
612758
/// @param amount The amount of tokens to withdraw.

src/interfaces/IERC3009.sol

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-License-Identifier: Apache-2.0 OR MIT
2+
pragma solidity ^0.8.27;
3+
4+
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
6+
interface IERC3009 is IERC20 {
7+
/**
8+
* @notice Receive a transfer with a signed authorization from the payer
9+
* @dev This has an additional check to ensure that the payee's address matches
10+
* the caller of this function to prevent front-running attacks.
11+
* @param from Payer's address (Authorizer)
12+
* @param to Payee's address
13+
* @param value Amount to be transferred
14+
* @param validAfter The time after which this is valid (unix time)
15+
* @param validBefore The time before which this is valid (unix time)
16+
* @param nonce Unique nonce
17+
* @param v v of the signature
18+
* @param r r of the signature
19+
* @param s s of the signature
20+
*/
21+
function receiveWithAuthorization(
22+
address from,
23+
address to,
24+
uint256 value,
25+
uint256 validAfter,
26+
uint256 validBefore,
27+
bytes32 nonce,
28+
uint8 v,
29+
bytes32 r,
30+
bytes32 s
31+
) external;
32+
33+
function authorizationState(address user, bytes32 nonce) external view returns (bool used);
34+
}

0 commit comments

Comments
 (0)