diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 394140ad3a..1c447dd116 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -5,8 +5,7 @@ import '@openzeppelin/contracts/access/AccessControl.sol'; import '@openzeppelin/contracts/security/Pausable.sol'; import '@openzeppelin/contracts/security/ReentrancyGuard.sol'; import '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; -import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; -import '@openzeppelin/contracts/access/Ownable.sol'; +import '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; import './interfaces/ERC20FeeProxy.sol'; import './lib/SafeERC20.sol'; @@ -14,50 +13,92 @@ import './lib/SafeERC20.sol'; * @title ERC20RecurringPaymentProxy * @notice Triggers recurring ERC20 payments based on predefined schedules. */ -contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, ReentrancyGuard, Ownable { +contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, ReentrancyGuard { using SafeERC20 for IERC20; - using ECDSA for bytes32; error ERC20RecurringPaymentProxy__BadSignature(); error ERC20RecurringPaymentProxy__SignatureExpired(); - error ERC20RecurringPaymentProxy__IndexTooLarge(); error ERC20RecurringPaymentProxy__PaymentOutOfOrder(); error ERC20RecurringPaymentProxy__IndexOutOfBounds(); error ERC20RecurringPaymentProxy__NotDueYet(); error ERC20RecurringPaymentProxy__AlreadyPaid(); error ERC20RecurringPaymentProxy__ZeroAddress(); + error ERC20RecurringPaymentProxy__TransferFailed(); + error ERC20RecurringPaymentProxy__ShortPull(); + error ERC20RecurringPaymentProxy__UnexpectedBalance(); + error ERC20RecurringPaymentProxy__ZeroScheduleId(); + error ERC20RecurringPaymentProxy__InvalidDueTimes(); + error ERC20RecurringPaymentProxy__TooManyLegs(); + error ERC20RecurringPaymentProxy__EmptyLegs(); + error ERC20RecurringPaymentProxy__DuplicatePaymentReference(); + error ERC20RecurringPaymentProxy__ZeroAmount(); + error ERC20RecurringPaymentProxy__NotSubscriber(); + error ERC20RecurringPaymentProxy__Cancelled(); + error ERC20RecurringPaymentProxy__NotAdmitted(); + error ERC20RecurringPaymentProxy__NotRelayer(); + uint8 public constant MAX_LEGS = 8; + + /// @notice Relayers may trigger any due cycle. Extra holders of this role compete + /// for `relayerFee` because `_payRelayer` pays `msg.sender`. bytes32 public constant RELAYER_ROLE = keccak256('RELAYER_ROLE'); - /* keccak256 of the typed-data struct with relayerFee field */ - bytes32 private constant _PERMIT_TYPEHASH = + bytes32 private constant _LEG_TYPEHASH = + keccak256('Leg(address recipient,uint128 amount,bytes8 paymentReference)'); + + /* Nested Leg is appended once, in EIP-712 referenced-type order. */ + bytes32 private constant _BATCH_TYPEHASH = keccak256( - 'SchedulePermit(address subscriber,address token,address recipient,' - 'address feeAddress,uint128 amount,uint128 feeAmount,uint128 relayerFee,' - 'uint32 periodSeconds,uint32 firstPayment,uint8 totalPayments,' - 'uint256 nonce,uint256 deadline,bool strictOrder)' + 'SchedulePermitBatch(address subscriber,address token,uint128 relayerFee,' + 'uint8 totalPayments,uint256 nonce,uint256 deadline,bool strictOrder,' + 'bytes32 scheduleId,uint32[] dueTimes,Leg[] initialLegs,Leg[] recurringLegs)' + 'Leg(address recipient,uint128 amount,bytes8 paymentReference)' ); - /* replay defence */ - mapping(bytes32 => uint256) public triggeredPaymentsBitmap; - mapping(bytes32 => uint8) public lastPaymentIndex; + struct ScheduleState { + uint256 bitmap; + uint256 admitted; + uint8 lastIndex; + bool cancelled; + } + + /// @dev Key includes every signed term except nonce and deadline. Changing any + /// of those terms yields a new key with a virgin bitmap and cancelled flag — + /// cancelling schedule A does not cancel an amended variant B. + mapping(bytes32 => ScheduleState) public schedules; + + event PaymentTriggered( + bytes32 indexed scheduleKey, + address indexed subscriber, + address token, + uint8 index, + uint256 payerTotal + ); + event ScheduleCancelled(bytes32 indexed scheduleKey, address indexed subscriber); + event CyclesAdmitted(bytes32 indexed scheduleKey, uint256 mask); + event CyclesRevoked(bytes32 indexed scheduleKey, uint256 mask); + event FeeProxyUpdated(address indexed oldProxy, address indexed newProxy); IERC20FeeProxy public erc20FeeProxy; - struct SchedulePermit { - address subscriber; - address token; + struct Leg { address recipient; - address feeAddress; uint128 amount; - uint128 feeAmount; + bytes8 paymentReference; + } + + struct SchedulePermitBatch { + address subscriber; + address token; uint128 relayerFee; - uint32 periodSeconds; - uint32 firstPayment; uint8 totalPayments; uint256 nonce; uint256 deadline; bool strictOrder; + bytes32 scheduleId; + uint32[] dueTimes; + Leg[] initialLegs; + Leg[] recurringLegs; } constructor( @@ -70,89 +111,415 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } _grantRole(DEFAULT_ADMIN_ROLE, adminSafe); _grantRole(RELAYER_ROLE, relayerEOA); - transferOwnership(adminSafe); erc20FeeProxy = IERC20FeeProxy(erc20FeeProxyAddress); } - function _hashSchedule(SchedulePermit calldata p) private view returns (bytes32) { - bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, p)); + function _hashUint32Array(uint32[] calldata values) private pure returns (bytes32) { + bytes32[] memory words = new bytes32[](values.length); + for (uint256 i = 0; i < values.length; ++i) { + words[i] = bytes32(uint256(values[i])); + } + return keccak256(abi.encodePacked(words)); + } + + function _hashLeg(Leg calldata leg) private pure returns (bytes32) { + return keccak256(abi.encode(_LEG_TYPEHASH, leg.recipient, leg.amount, leg.paymentReference)); + } + + function _hashLegs(Leg[] calldata legs) private pure returns (bytes32) { + bytes32[] memory words = new bytes32[](legs.length); + for (uint256 i = 0; i < legs.length; ++i) { + words[i] = _hashLeg(legs[i]); + } + return keccak256(abi.encodePacked(words)); + } + + function _hashPermitParts(SchedulePermitBatch calldata p) + private + pure + returns ( + bytes32 dueTimesHash, + bytes32 initialLegsHash, + bytes32 recurringLegsHash + ) + { + dueTimesHash = _hashUint32Array(p.dueTimes); + initialLegsHash = _hashLegs(p.initialLegs); + recurringLegsHash = _hashLegs(p.recurringLegs); + } + + function _hashScheduleBatch( + SchedulePermitBatch calldata p, + bytes32 dueTimesHash, + bytes32 initialLegsHash, + bytes32 recurringLegsHash + ) private view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + _BATCH_TYPEHASH, + p.subscriber, + p.token, + p.relayerFee, + p.totalPayments, + p.nonce, + p.deadline, + p.strictOrder, + p.scheduleId, + dueTimesHash, + initialLegsHash, + recurringLegsHash + ) + ); return _hashTypedDataV4(structHash); } - function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { - erc20FeeProxy.transferFromWithReferenceAndFee( - p.token, - p.recipient, - p.amount, - paymentReference, - p.feeAmount, - p.feeAddress + function hashScheduleBatch(SchedulePermitBatch calldata p) public view returns (bytes32) { + (bytes32 dueTimesHash, bytes32 initialLegsHash, bytes32 recurringLegsHash) = _hashPermitParts( + p ); + return _hashScheduleBatch(p, dueTimesHash, initialLegsHash, recurringLegsHash); } - function triggerRecurringPayment( - SchedulePermit calldata p, - bytes calldata signature, + function _assertSigner( + address subscriber, + bytes32 digest, + bytes calldata signature + ) private view { + if (!SignatureChecker.isValidSignatureNow(subscriber, digest, signature)) { + revert ERC20RecurringPaymentProxy__BadSignature(); + } + } + + function _scheduleKeyFromBatch( + SchedulePermitBatch calldata p, + bytes32 dueTimesHash, + bytes32 initialLegsHash, + bytes32 recurringLegsHash + ) private pure returns (bytes32) { + if (p.scheduleId == bytes32(0)) revert ERC20RecurringPaymentProxy__ZeroScheduleId(); + return + keccak256( + abi.encode( + p.subscriber, + p.scheduleId, + p.token, + p.relayerFee, + p.totalPayments, + p.strictOrder, + dueTimesHash, + initialLegsHash, + recurringLegsHash + ) + ); + } + + function scheduleKeyFromBatch(SchedulePermitBatch calldata p) public pure returns (bytes32) { + (bytes32 dueTimesHash, bytes32 initialLegsHash, bytes32 recurringLegsHash) = _hashPermitParts( + p + ); + return _scheduleKeyFromBatch(p, dueTimesHash, initialLegsHash, recurringLegsHash); + } + + function _keyAndDigest(SchedulePermitBatch calldata p) + private + view + returns (bytes32 scheduleKey, bytes32 digest) + { + (bytes32 dueTimesHash, bytes32 initialLegsHash, bytes32 recurringLegsHash) = _hashPermitParts( + p + ); + scheduleKey = _scheduleKeyFromBatch(p, dueTimesHash, initialLegsHash, recurringLegsHash); + digest = _hashScheduleBatch(p, dueTimesHash, initialLegsHash, recurringLegsHash); + } + + function triggeredPaymentsBitmap(bytes32 scheduleKey) external view returns (uint256) { + return schedules[scheduleKey].bitmap; + } + + function lastPaymentIndex(bytes32 scheduleKey) external view returns (uint8) { + return schedules[scheduleKey].lastIndex; + } + + function cancelledSchedules(bytes32 scheduleKey) external view returns (bool) { + return schedules[scheduleKey].cancelled; + } + + function admittedCycles(bytes32 scheduleKey) external view returns (uint256) { + return schedules[scheduleKey].admitted; + } + + function _assertSubscriber(address subscriber) private view { + if (msg.sender != subscriber) revert ERC20RecurringPaymentProxy__NotSubscriber(); + } + + function _assertNotCancelled(ScheduleState storage state) private view { + if (state.cancelled) revert ERC20RecurringPaymentProxy__Cancelled(); + } + + function _cancel(ScheduleState storage state) private { + state.cancelled = true; + } + + function _assertRelayerOrAdmitted( + address subscriber, + ScheduleState storage state, + uint8 index + ) private view { + if (hasRole(RELAYER_ROLE, msg.sender)) { + return; + } + if (msg.sender != subscriber) revert ERC20RecurringPaymentProxy__NotSubscriber(); + if (state.admitted & (1 << index) == 0) { + revert ERC20RecurringPaymentProxy__NotAdmitted(); + } + } + + function admitCycles(bytes32 scheduleKey, uint256 mask) external onlyRole(RELAYER_ROLE) { + schedules[scheduleKey].admitted |= mask; + emit CyclesAdmitted(scheduleKey, mask); + } + + /** + * @notice Clears bits so a previously admitted cycle can no longer be self-triggered. + * Relayer-initiated triggers are unaffected. + */ + function revokeCycles(bytes32 scheduleKey, uint256 mask) external onlyRole(RELAYER_ROLE) { + schedules[scheduleKey].admitted &= ~mask; + emit CyclesRevoked(scheduleKey, mask); + } + + function _assertUnpaid(ScheduleState storage state, uint8 index) private view { + if (state.bitmap & (1 << index) != 0) { + revert ERC20RecurringPaymentProxy__AlreadyPaid(); + } + } + + function _assertOrder( + ScheduleState storage state, uint8 index, - bytes calldata paymentReference - ) external whenNotPaused onlyRole(RELAYER_ROLE) nonReentrant { - bytes32 digest = _hashSchedule(p); + bool strictOrder + ) private view { + if (strictOrder && uint256(index) != uint256(state.lastIndex) + 1) { + revert ERC20RecurringPaymentProxy__PaymentOutOfOrder(); + } + } - if (digest.recover(signature) != p.subscriber) - revert ERC20RecurringPaymentProxy__BadSignature(); - if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); + function _markPaid( + ScheduleState storage state, + uint8 index, + bool strictOrder + ) private { + state.bitmap |= (1 << index); + if (strictOrder) { + state.lastIndex = index; + } + } - if (index >= 256) revert ERC20RecurringPaymentProxy__IndexTooLarge(); + function _pullExact( + IERC20 token, + address from, + uint256 amount + ) private returns (uint256 balanceBefore) { + balanceBefore = token.balanceOf(address(this)); + if (!token.safeTransferFrom(from, address(this), amount)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); + } + if (token.balanceOf(address(this)) - balanceBefore != amount) { + revert ERC20RecurringPaymentProxy__ShortPull(); + } + } - if (p.strictOrder) { - if (index != lastPaymentIndex[digest] + 1) - revert ERC20RecurringPaymentProxy__PaymentOutOfOrder(); - lastPaymentIndex[digest] = index; + function _approveFeeProxy( + IERC20 token, + IERC20FeeProxy proxy, + uint256 amount + ) private { + if (!token.safeApprove(address(proxy), 0)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); } + if (!token.safeApprove(address(proxy), amount)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); + } + } - if (index > p.totalPayments) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); + function _payRelayer(IERC20 token, uint256 amount) private { + if (amount == 0) { + return; + } + if (!token.safeTransfer(msg.sender, amount)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); + } + } - uint256 execTime = uint256(p.firstPayment) + uint256(index - 1) * p.periodSeconds; - if (block.timestamp < execTime) revert ERC20RecurringPaymentProxy__NotDueYet(); + function _assertLegs(Leg[] calldata legs) private pure { + if (legs.length == 0) revert ERC20RecurringPaymentProxy__EmptyLegs(); + for (uint256 i = 0; i < legs.length; ++i) { + if (legs[i].recipient == address(0)) { + revert ERC20RecurringPaymentProxy__ZeroAddress(); + } + if (legs[i].amount == 0) { + revert ERC20RecurringPaymentProxy__ZeroAmount(); + } + for (uint256 j = 0; j < i; ++j) { + if (legs[i].paymentReference == legs[j].paymentReference) { + revert ERC20RecurringPaymentProxy__DuplicatePaymentReference(); + } + } + } + } - uint256 mask = 1 << index; - uint256 word = triggeredPaymentsBitmap[digest]; - if (word & mask != 0) revert ERC20RecurringPaymentProxy__AlreadyPaid(); - triggeredPaymentsBitmap[digest] = word | mask; + function _assertScheduleLegs(SchedulePermitBatch calldata p) private pure { + if (p.initialLegs.length > MAX_LEGS || p.recurringLegs.length > MAX_LEGS) { + revert ERC20RecurringPaymentProxy__TooManyLegs(); + } + if (p.initialLegs.length != 0) { + _assertLegs(p.initialLegs); + } + if (p.recurringLegs.length != 0 || p.totalPayments > 1 || p.initialLegs.length == 0) { + _assertLegs(p.recurringLegs); + } + } - uint256 total = p.amount + p.feeAmount + p.relayerFee; + function _sumLegs(Leg[] calldata legs) private pure returns (uint256 sum) { + for (uint256 i = 0; i < legs.length; ++i) { + sum += legs[i].amount; + } + } - IERC20 token = IERC20(p.token); - token.safeTransferFrom(p.subscriber, address(this), total); + function _settleLegs( + IERC20FeeProxy proxy, + address token, + Leg[] calldata legs + ) private { + for (uint256 i = 0; i < legs.length; ++i) { + proxy.transferFromWithReferenceAndFee( + token, + legs[i].recipient, + legs[i].amount, + abi.encodePacked(legs[i].paymentReference), + 0, + address(0) + ); + } + } + + function triggerRecurringPaymentBatch( + SchedulePermitBatch calldata p, + bytes calldata signature, + uint8 index + ) external whenNotPaused nonReentrant { + if (p.token == address(0) || p.subscriber == address(0)) { + revert ERC20RecurringPaymentProxy__ZeroAddress(); + } + if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); + + (bytes32 scheduleKey, bytes32 digest) = _keyAndDigest(p); + ScheduleState storage state = schedules[scheduleKey]; + _assertRelayerOrAdmitted(p.subscriber, state, index); + + _assertSigner(p.subscriber, digest, signature); + if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); + + if (p.totalPayments == 0 || index > p.totalPayments) { + revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); + } + if (p.dueTimes.length != p.totalPayments) { + revert ERC20RecurringPaymentProxy__InvalidDueTimes(); + } + for (uint256 i = 1; i < p.dueTimes.length; ++i) { + if (p.dueTimes[i] <= p.dueTimes[i - 1]) { + revert ERC20RecurringPaymentProxy__InvalidDueTimes(); + } + } + if (block.timestamp < p.dueTimes[index - 1]) { + revert ERC20RecurringPaymentProxy__NotDueYet(); + } - /* USDT-safe zero-approve then set allowance */ - token.safeApprove(address(erc20FeeProxy), 0); - token.safeApprove(address(erc20FeeProxy), p.amount + p.feeAmount); + _assertScheduleLegs(p); - _proxyTransfer(p, paymentReference); + _assertNotCancelled(state); + _assertOrder(state, index, p.strictOrder); + _assertUnpaid(state, index); - if (p.relayerFee != 0) { - token.safeTransfer(msg.sender, p.relayerFee); + bool useInitial = p.initialLegs.length != 0 && index == 1; + uint256 legsSum = _sumLegs(useInitial ? p.initialLegs : p.recurringLegs); + uint256 payerTotal = legsSum + p.relayerFee; + + _markPaid(state, index, p.strictOrder); + + IERC20 token = IERC20(p.token); + IERC20FeeProxy proxy = erc20FeeProxy; + uint256 baseline = _pullExact(token, p.subscriber, payerTotal); + _approveFeeProxy(token, proxy, legsSum); + if (useInitial) { + _settleLegs(proxy, p.token, p.initialLegs); + } else { + _settleLegs(proxy, p.token, p.recurringLegs); + } + _payRelayer(token, p.relayerFee); + if (token.balanceOf(address(this)) != baseline) { + revert ERC20RecurringPaymentProxy__UnexpectedBalance(); } + emit PaymentTriggered(scheduleKey, p.subscriber, p.token, index, payerTotal); + } + + /** + * @notice Blocks further triggers for this batch schedule. + * @dev Does not revoke the subscriber's ERC-20 allowance to this contract. A relayer can + * still collect a due cycle if they include a trigger in the same block ahead of + * cancel. Also `approve` this proxy to 0 (or decrease) in the same wallet batch. + */ + function cancelScheduleBatch(SchedulePermitBatch calldata p) external { + _assertSubscriber(p.subscriber); + bytes32 scheduleKey = scheduleKeyFromBatch(p); + _cancel(schedules[scheduleKey]); + emit ScheduleCancelled(scheduleKey, p.subscriber); + } + + /** + * @notice Grants `RELAYER_ROLE`. Every holder can collect `relayerFee` on trigger. + */ + function grantRelayer(address relayer) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (relayer == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); + _grantRole(RELAYER_ROLE, relayer); } - function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { - if (newRelayer == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); - _revokeRole(RELAYER_ROLE, oldRelayer); - _grantRole(RELAYER_ROLE, newRelayer); + /** + * @notice Revokes `RELAYER_ROLE`. Reverts if `relayer` does not hold the role. + */ + function revokeRelayer(address relayer) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (!hasRole(RELAYER_ROLE, relayer)) { + revert ERC20RecurringPaymentProxy__NotRelayer(); + } + _revokeRole(RELAYER_ROLE, relayer); } - function setFeeProxy(address newProxy) external onlyOwner { + function setFeeProxy(address newProxy) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newProxy == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); + address oldProxy = address(erc20FeeProxy); erc20FeeProxy = IERC20FeeProxy(newProxy); + emit FeeProxyUpdated(oldProxy, newProxy); } - function pause() external onlyOwner { + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { _pause(); } - function unpause() external onlyOwner { + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); } + + function rescueTokens( + address token, + address to, + uint256 amount + ) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant { + if (token == address(0) || to == address(0)) { + revert ERC20RecurringPaymentProxy__ZeroAddress(); + } + if (!IERC20(token).safeTransfer(to, amount)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); + } + } } diff --git a/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol b/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol new file mode 100644 index 0000000000..0013d01cbf --- /dev/null +++ b/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import '@openzeppelin/contracts/token/ERC20/ERC20.sol'; + +/** + * @notice ERC-20 that returns false on a failed transferFrom instead of reverting. + */ +contract ERC20SilentFail is ERC20 { + constructor(uint256 initialSupply) ERC20('Silent Fail', 'SFL') { + _mint(msg.sender, initialSupply); + } + + function transferFrom( + address from, + address to, + uint256 amount + ) public override returns (bool) { + uint256 currentAllowance = allowance(from, _msgSender()); + if (balanceOf(from) < amount || currentAllowance < amount) { + return false; + } + _transfer(from, to, amount); + _approve(from, _msgSender(), currentAllowance - amount); + return true; + } +} + +/** + * @notice ERC-20 that under-delivers on transferFrom (fee-on-transfer). + */ +contract ERC20FeeOnTransfer is ERC20 { + constructor(uint256 initialSupply) ERC20('Fee On Transfer', 'FOT') { + _mint(msg.sender, initialSupply); + } + + function transferFrom( + address from, + address to, + uint256 amount + ) public override returns (bool) { + require(amount > 1, 'ERC20FeeOnTransfer: amount'); + address spender = _msgSender(); + _spendAllowance(from, spender, amount); + _transfer(from, to, amount - 1); + _transfer(from, address(this), 1); + return true; + } +} + +/** + * @notice ERC-20 whose transfer() returns false so a relayer-fee payout can fail. + */ +contract ERC20FailTransfer is ERC20 { + constructor(uint256 initialSupply) ERC20('Fail Transfer', 'FLT') { + _mint(msg.sender, initialSupply); + } + + function transfer(address, uint256) public pure override returns (bool) { + return false; + } +} + +/** + * @notice ERC-20 that reverts transfers to a chosen recipient so a batch leg can fail. + */ +contract ERC20BlockRecipient is ERC20 { + address public blocked; + + constructor(uint256 initialSupply) ERC20('Block Recipient', 'BLK') { + _mint(msg.sender, initialSupply); + } + + function setBlocked(address account) external { + blocked = account; + } + + function _beforeTokenTransfer( + address, + address to, + uint256 + ) internal view override { + require(to != blocked, 'ERC20BlockRecipient: blocked'); + } +} diff --git a/packages/smart-contracts/src/contracts/test/MockERC1271.sol b/packages/smart-contracts/src/contracts/test/MockERC1271.sol new file mode 100644 index 0000000000..810da72204 --- /dev/null +++ b/packages/smart-contracts/src/contracts/test/MockERC1271.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +/** + * @notice Minimal ERC-1271 wallet for recurring-proxy signature tests. + */ +contract MockERC1271 { + bytes4 private constant _MAGICVALUE = 0x1626ba7e; + + address public immutable owner; + + constructor(address _owner) { + owner = _owner; + } + + function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4) { + if (signature.length != 65) { + return 0xffffffff; + } + + bytes32 r; + bytes32 s; + uint8 v; + // solhint-disable-next-line no-inline-assembly + assembly { + r := mload(add(signature, 32)) + s := mload(add(signature, 64)) + v := byte(0, mload(add(signature, 96))) + } + + address recovered = ecrecover(hash, v, r, s); + if (recovered != address(0) && recovered == owner) { + return _MAGICVALUE; + } + return 0xffffffff; + } + + function approveToken( + address token, + address spender, + uint256 amount + ) external { + require(msg.sender == owner, 'MockERC1271: not owner'); + IERC20(token).approve(spender, amount); + } +} diff --git a/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.2.0.json b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.2.0.json new file mode 100644 index 0000000000..b267f7a73d --- /dev/null +++ b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.2.0.json @@ -0,0 +1,531 @@ +{ + "abi": [ + { + "inputs": [ + { "internalType": "address", "name": "adminSafe", "type": "address" }, + { "internalType": "address", "name": "relayerEOA", "type": "address" }, + { "internalType": "address", "name": "erc20FeeProxyAddress", "type": "address" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__AlreadyPaid", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__BadSignature", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__Cancelled", "type": "error" }, + { + "inputs": [], + "name": "ERC20RecurringPaymentProxy__DuplicatePaymentReference", + "type": "error" + }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__EmptyLegs", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__IndexOutOfBounds", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__InvalidDueTimes", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__NotAdmitted", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__NotDueYet", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__NotRelayer", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__NotSubscriber", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__PaymentOutOfOrder", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__ShortPull", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__SignatureExpired", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__TooManyLegs", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__TransferFailed", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__UnexpectedBalance", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__ZeroAddress", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__ZeroAmount", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__ZeroScheduleId", "type": "error" }, + { "inputs": [], "name": "InvalidShortString", "type": "error" }, + { + "inputs": [{ "internalType": "string", "name": "str", "type": "string" }], + "name": "StringTooLong", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }, + { "indexed": false, "internalType": "uint256", "name": "mask", "type": "uint256" } + ], + "name": "CyclesAdmitted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }, + { "indexed": false, "internalType": "uint256", "name": "mask", "type": "uint256" } + ], + "name": "CyclesRevoked", + "type": "event" + }, + { "anonymous": false, "inputs": [], "name": "EIP712DomainChanged", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "oldProxy", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "newProxy", "type": "address" } + ], + "name": "FeeProxyUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "internalType": "address", "name": "account", "type": "address" } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }, + { "indexed": true, "internalType": "address", "name": "subscriber", "type": "address" }, + { "indexed": false, "internalType": "address", "name": "token", "type": "address" }, + { "indexed": false, "internalType": "uint8", "name": "index", "type": "uint8" }, + { "indexed": false, "internalType": "uint256", "name": "payerTotal", "type": "uint256" } + ], + "name": "PaymentTriggered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { "indexed": true, "internalType": "bytes32", "name": "newAdminRole", "type": "bytes32" } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "sender", "type": "address" } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "sender", "type": "address" } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }, + { "indexed": true, "internalType": "address", "name": "subscriber", "type": "address" } + ], + "name": "ScheduleCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "internalType": "address", "name": "account", "type": "address" } + ], + "name": "Unpaused", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_LEGS", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAYER_ROLE", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }, + { "internalType": "uint256", "name": "mask", "type": "uint256" } + ], + "name": "admitCycles", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }], + "name": "admittedCycles", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { "internalType": "address", "name": "subscriber", "type": "address" }, + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "uint128", "name": "relayerFee", "type": "uint128" }, + { "internalType": "uint8", "name": "totalPayments", "type": "uint8" }, + { "internalType": "uint256", "name": "nonce", "type": "uint256" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" }, + { "internalType": "bool", "name": "strictOrder", "type": "bool" }, + { "internalType": "bytes32", "name": "scheduleId", "type": "bytes32" }, + { "internalType": "uint32[]", "name": "dueTimes", "type": "uint32[]" }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "initialLegs", + "type": "tuple[]" + }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "recurringLegs", + "type": "tuple[]" + } + ], + "internalType": "struct ERC20RecurringPaymentProxy.SchedulePermitBatch", + "name": "p", + "type": "tuple" + } + ], + "name": "cancelScheduleBatch", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }], + "name": "cancelledSchedules", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "eip712Domain", + "outputs": [ + { "internalType": "bytes1", "name": "fields", "type": "bytes1" }, + { "internalType": "string", "name": "name", "type": "string" }, + { "internalType": "string", "name": "version", "type": "string" }, + { "internalType": "uint256", "name": "chainId", "type": "uint256" }, + { "internalType": "address", "name": "verifyingContract", "type": "address" }, + { "internalType": "bytes32", "name": "salt", "type": "bytes32" }, + { "internalType": "uint256[]", "name": "extensions", "type": "uint256[]" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "erc20FeeProxy", + "outputs": [{ "internalType": "contract IERC20FeeProxy", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "role", "type": "bytes32" }], + "name": "getRoleAdmin", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "relayer", "type": "address" }], + "name": "grantRelayer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "hasRole", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { "internalType": "address", "name": "subscriber", "type": "address" }, + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "uint128", "name": "relayerFee", "type": "uint128" }, + { "internalType": "uint8", "name": "totalPayments", "type": "uint8" }, + { "internalType": "uint256", "name": "nonce", "type": "uint256" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" }, + { "internalType": "bool", "name": "strictOrder", "type": "bool" }, + { "internalType": "bytes32", "name": "scheduleId", "type": "bytes32" }, + { "internalType": "uint32[]", "name": "dueTimes", "type": "uint32[]" }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "initialLegs", + "type": "tuple[]" + }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "recurringLegs", + "type": "tuple[]" + } + ], + "internalType": "struct ERC20RecurringPaymentProxy.SchedulePermitBatch", + "name": "p", + "type": "tuple" + } + ], + "name": "hashScheduleBatch", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }], + "name": "lastPaymentIndex", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" } + ], + "name": "rescueTokens", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }, + { "internalType": "uint256", "name": "mask", "type": "uint256" } + ], + "name": "revokeCycles", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "relayer", "type": "address" }], + "name": "revokeRelayer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { "internalType": "address", "name": "subscriber", "type": "address" }, + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "uint128", "name": "relayerFee", "type": "uint128" }, + { "internalType": "uint8", "name": "totalPayments", "type": "uint8" }, + { "internalType": "uint256", "name": "nonce", "type": "uint256" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" }, + { "internalType": "bool", "name": "strictOrder", "type": "bool" }, + { "internalType": "bytes32", "name": "scheduleId", "type": "bytes32" }, + { "internalType": "uint32[]", "name": "dueTimes", "type": "uint32[]" }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "initialLegs", + "type": "tuple[]" + }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "recurringLegs", + "type": "tuple[]" + } + ], + "internalType": "struct ERC20RecurringPaymentProxy.SchedulePermitBatch", + "name": "p", + "type": "tuple" + } + ], + "name": "scheduleKeyFromBatch", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "name": "schedules", + "outputs": [ + { "internalType": "uint256", "name": "bitmap", "type": "uint256" }, + { "internalType": "uint256", "name": "admitted", "type": "uint256" }, + { "internalType": "uint8", "name": "lastIndex", "type": "uint8" }, + { "internalType": "bool", "name": "cancelled", "type": "bool" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "newProxy", "type": "address" }], + "name": "setFeeProxy", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes4", "name": "interfaceId", "type": "bytes4" }], + "name": "supportsInterface", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { "internalType": "address", "name": "subscriber", "type": "address" }, + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "uint128", "name": "relayerFee", "type": "uint128" }, + { "internalType": "uint8", "name": "totalPayments", "type": "uint8" }, + { "internalType": "uint256", "name": "nonce", "type": "uint256" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" }, + { "internalType": "bool", "name": "strictOrder", "type": "bool" }, + { "internalType": "bytes32", "name": "scheduleId", "type": "bytes32" }, + { "internalType": "uint32[]", "name": "dueTimes", "type": "uint32[]" }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "initialLegs", + "type": "tuple[]" + }, + { + "components": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "bytes8", "name": "paymentReference", "type": "bytes8" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.Leg[]", + "name": "recurringLegs", + "type": "tuple[]" + } + ], + "internalType": "struct ERC20RecurringPaymentProxy.SchedulePermitBatch", + "name": "p", + "type": "tuple" + }, + { "internalType": "bytes", "name": "signature", "type": "bytes" }, + { "internalType": "uint8", "name": "index", "type": "uint8" } + ], + "name": "triggerRecurringPaymentBatch", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "scheduleKey", "type": "bytes32" }], + "name": "triggeredPaymentsBitmap", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} diff --git a/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/index.ts b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/index.ts index e2fd59afda..70984cc93e 100644 --- a/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/index.ts +++ b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/index.ts @@ -1,10 +1,10 @@ +import type { Contract } from 'ethers'; import { ContractArtifact } from '../../ContractArtifact'; import { abi as ABI_0_1_0 } from './0.1.0.json'; +import { abi as ABI_0_2_0 } from './0.2.0.json'; -import type { ERC20RecurringPaymentProxy } from '../../../types'; - -export const erc20RecurringPaymentProxyArtifact = new ContractArtifact( +export const erc20RecurringPaymentProxyArtifact = new ContractArtifact( { '0.1.0': { abi: ABI_0_1_0, @@ -43,6 +43,10 @@ export const erc20RecurringPaymentProxyArtifact = new ContractArtifact(), +): boolean => { + if (typeof value === 'string') return value.startsWith(selector); + if (typeof value !== 'object' || value === null || seen.has(value)) return false; + + seen.add(value); + const error = value as Record; + return ['data', 'error', 'result'].some((key) => + containsRevertSelector(error[key], selector, seen), + ); +}; + +const expectCustomError = async (call: Promise, errorName: string): Promise => { + let thrown: unknown; + try { + await call; + } catch (error) { + thrown = error; + } + + expect(thrown, `Expected transaction to revert with ${errorName}`).to.not.equal(undefined); + const selector = ethers.utils.id(`${errorName}()`).slice(0, 10); + expect( + containsRevertSelector(thrown, selector), + `Expected revert data to contain ${errorName} selector ${selector}`, + ).to.be.true; +}; + describe('ERC20RecurringPaymentProxy', () => { + let chainSnapshot: unknown; let erc20RecurringPaymentProxy: Contract; let erc20FeeProxy: ERC20FeeProxy; let testERC20: TestERC20; @@ -26,6 +58,16 @@ describe('ERC20RecurringPaymentProxy', () => { let recipientAddress: string; let feeAddressString: string; + // warpTo advances the shared Hardhat/Ganache clock. Snapshot/revert so later + // files (SwapToPay, SwapToConversion) still see a current block.timestamp. + before(async () => { + chainSnapshot = await ethers.provider.send('evm_snapshot', []); + }); + + after(async () => { + await ethers.provider.send('evm_revert', [chainSnapshot]); + }); + beforeEach(async () => { [owner, relayer, user, newRelayer, newOwner, subscriber, recipient, feeAddress] = await ethers.getSigners(); @@ -60,59 +102,40 @@ describe('ERC20RecurringPaymentProxy', () => { await testERC20.deployed(); }); - // Helper function to create a valid SchedulePermit - const createSchedulePermit = (overrides: any = {}) => { - const now = Math.floor(Date.now() / 1000); - return { - subscriber: subscriberAddress, - token: testERC20.address, - recipient: recipientAddress, - feeAddress: feeAddressString, - amount: 100, - feeAmount: 10, - relayerFee: 5, - periodSeconds: 3600, - firstPayment: now, - totalPayments: 3, - nonce: 0, - deadline: now + 86400, // 24 hours from now - strictOrder: false, - ...overrides, - }; + const schedulePermitBatchTypes = { + SchedulePermitBatch: [ + { name: 'subscriber', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'relayerFee', type: 'uint128' }, + { name: 'totalPayments', type: 'uint8' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'strictOrder', type: 'bool' }, + { name: 'scheduleId', type: 'bytes32' }, + { name: 'dueTimes', type: 'uint32[]' }, + { name: 'initialLegs', type: 'Leg[]' }, + { name: 'recurringLegs', type: 'Leg[]' }, + ], + Leg: [ + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint128' }, + { name: 'paymentReference', type: 'bytes8' }, + ], }; - // Helper function to create EIP712 signature - const createSignature = async (permit: any, signer: Signer) => { - const domain = { - name: 'ERC20RecurringPaymentProxy', - version: '1', - chainId: await signer.getChainId(), - verifyingContract: erc20RecurringPaymentProxy.address, - }; - - const types = { - SchedulePermit: [ - { name: 'subscriber', type: 'address' }, - { name: 'token', type: 'address' }, - { name: 'recipient', type: 'address' }, - { name: 'feeAddress', type: 'address' }, - { name: 'amount', type: 'uint128' }, - { name: 'feeAmount', type: 'uint128' }, - { name: 'relayerFee', type: 'uint128' }, - { name: 'periodSeconds', type: 'uint32' }, - { name: 'firstPayment', type: 'uint32' }, - { name: 'totalPayments', type: 'uint8' }, - { name: 'nonce', type: 'uint256' }, - { name: 'deadline', type: 'uint256' }, - { name: 'strictOrder', type: 'bool' }, - ], - }; + const eip712Domain = async () => ({ + name: 'ERC20RecurringPaymentProxy', + version: '1', + chainId: await subscriber.getChainId(), + verifyingContract: erc20RecurringPaymentProxy.address, + }); - // Some providers (Hardhat in-process) happily accept the string-encoded data (what - // ethers' _signTypedData sends). Others (Hardhat JSON-RPC, Ganache) expect the object - // version. To work everywhere we try the object version first and fall back to - // the built-in helper if the call is rejected. + const hashBatchOffchain = async (permit: any) => + ethers.utils._TypedDataEncoder.hash(await eip712Domain(), schedulePermitBatchTypes, permit); + const createBatchSignature = async (permit: any, signer: Signer) => { + const domain = await eip712Domain(); + const address = await signer.getAddress(); const typedDataObject = { types: { EIP712Domain: [ @@ -121,28 +144,52 @@ describe('ERC20RecurringPaymentProxy', () => { { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], - ...types, + ...schedulePermitBatchTypes, }, - primaryType: 'SchedulePermit', + primaryType: 'SchedulePermitBatch', domain, message: permit, }; - - const address = await signer.getAddress(); try { - // This matches the spec used by Hardhat JSON-RPC & Ganache return await (signer.provider as any).send('eth_signTypedData', [address, typedDataObject]); } catch (_) { - // Fallback to ethers helper (works in most in-process Hardhat environments) - return await (signer as any)._signTypedData(domain, types, permit); + return await (signer as any)._signTypedData(domain, schedulePermitBatchTypes, permit); } }; + const paymentRef = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + + const latestBlockTs = async () => (await ethers.provider.getBlock('latest')).timestamp; + + const dueBatchPermit = async (overrides: Record = {}) => { + const now = await latestBlockTs(); + return { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 5, + totalPayments: 1, + nonce: 0, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0808080808080808080808080808080808080808080808080808080808080808', + dueTimes: [now - 1], + initialLegs: [] as { recipient: string; amount: number; paymentReference: string }[], + recurringLegs: [ + { recipient: recipientAddress, amount: 100, paymentReference: paymentRef(0x61) }, + ], + ...overrides, + }; + }; + + const fundSubscriber = async (amount = 500) => { + await testERC20.transfer(subscriberAddress, amount); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, amount); + }; + describe('Deployment', () => { it('should be deployed with correct initial values', async () => { expect(erc20RecurringPaymentProxy.address).to.not.equal(ethers.constants.AddressZero); expect(await erc20RecurringPaymentProxy.erc20FeeProxy()).to.equal(erc20FeeProxy.address); - expect(await erc20RecurringPaymentProxy.owner()).to.equal(ownerAddress); expect( await erc20RecurringPaymentProxy.hasRole( await erc20RecurringPaymentProxy.RELAYER_ROLE(), @@ -160,6 +207,30 @@ describe('ERC20RecurringPaymentProxy', () => { it('should be unpaused by default', async () => { expect(await erc20RecurringPaymentProxy.paused()).to.be.false; }); + + it('reverts deploy when admin is the zero address', async () => { + const Factory = await ethers.getContractFactory('ERC20RecurringPaymentProxy'); + await expectCustomError( + Factory.deploy(ethers.constants.AddressZero, relayerAddress, erc20FeeProxy.address), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + }); + + it('reverts deploy when relayer is the zero address', async () => { + const Factory = await ethers.getContractFactory('ERC20RecurringPaymentProxy'); + await expectCustomError( + Factory.deploy(ownerAddress, ethers.constants.AddressZero, erc20FeeProxy.address), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + }); + + it('reverts deploy when fee proxy is the zero address', async () => { + const Factory = await ethers.getContractFactory('ERC20RecurringPaymentProxy'); + await expectCustomError( + Factory.deploy(ownerAddress, relayerAddress, ethers.constants.AddressZero), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + }); }); describe('Access Control', () => { @@ -192,16 +263,16 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); - describe('setRelayer', () => { - it('should allow owner to set new relayer', async () => { - await erc20RecurringPaymentProxy.setRelayer(relayerAddress, newRelayerAddress); + describe('grantRelayer and revokeRelayer', () => { + it('grants RELAYER_ROLE to a new address', async () => { + await erc20RecurringPaymentProxy.grantRelayer(newRelayerAddress); expect( await erc20RecurringPaymentProxy.hasRole( await erc20RecurringPaymentProxy.RELAYER_ROLE(), relayerAddress, ), - ).to.be.false; + ).to.be.true; expect( await erc20RecurringPaymentProxy.hasRole( await erc20RecurringPaymentProxy.RELAYER_ROLE(), @@ -210,18 +281,83 @@ describe('ERC20RecurringPaymentProxy', () => { ).to.be.true; }); - it('should revert when non-owner tries to set relayer', async () => { + it('revokes RELAYER_ROLE from a current relayer', async () => { + await erc20RecurringPaymentProxy.revokeRelayer(relayerAddress); + + expect( + await erc20RecurringPaymentProxy.hasRole( + await erc20RecurringPaymentProxy.RELAYER_ROLE(), + relayerAddress, + ), + ).to.be.false; + }); + + it('reverts when a non-admin tries to grant or revoke', async () => { await expect( - erc20RecurringPaymentProxy.connect(user).setRelayer(relayerAddress, newRelayerAddress), - ).to.be.revertedWith('Ownable: caller is not the owner'); + erc20RecurringPaymentProxy.connect(user).grantRelayer(newRelayerAddress), + ).to.be.revertedWith('AccessControl: account'); + await expect( + erc20RecurringPaymentProxy.connect(user).revokeRelayer(relayerAddress), + ).to.be.revertedWith('AccessControl: account'); }); - it('should emit RoleRevoked and RoleGranted events', async () => { - await expect(erc20RecurringPaymentProxy.setRelayer(relayerAddress, newRelayerAddress)) - .to.emit(erc20RecurringPaymentProxy, 'RoleRevoked') - .withArgs(await erc20RecurringPaymentProxy.RELAYER_ROLE(), relayerAddress, ownerAddress) - .and.to.emit(erc20RecurringPaymentProxy, 'RoleGranted') + it('emits RoleGranted and RoleRevoked', async () => { + await expect(erc20RecurringPaymentProxy.grantRelayer(newRelayerAddress)) + .to.emit(erc20RecurringPaymentProxy, 'RoleGranted') .withArgs(await erc20RecurringPaymentProxy.RELAYER_ROLE(), newRelayerAddress, ownerAddress); + + await expect(erc20RecurringPaymentProxy.revokeRelayer(relayerAddress)) + .to.emit(erc20RecurringPaymentProxy, 'RoleRevoked') + .withArgs(await erc20RecurringPaymentProxy.RELAYER_ROLE(), relayerAddress, ownerAddress); + }); + + it('reverts grant of the zero address', async () => { + await expectCustomError( + erc20RecurringPaymentProxy.grantRelayer(ethers.constants.AddressZero), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + }); + + it('reverts revoke when the address does not hold RELAYER_ROLE and leaves holders unchanged', async () => { + const relayerRole = await erc20RecurringPaymentProxy.RELAYER_ROLE(); + + await expectCustomError( + erc20RecurringPaymentProxy.revokeRelayer(userAddress), + 'ERC20RecurringPaymentProxy__NotRelayer', + ); + + expect(await erc20RecurringPaymentProxy.hasRole(relayerRole, relayerAddress)).to.be.true; + expect(await erc20RecurringPaymentProxy.hasRole(relayerRole, userAddress)).to.be.false; + }); + + it('pays relayerFee to a second granted relayer that triggers', async () => { + await erc20RecurringPaymentProxy.grantRelayer(newRelayerAddress); + await fundSubscriber(); + const permit = await dueBatchPermit({ relayerFee: 5 }); + const signature = await createBatchSignature(permit, subscriber); + const constructorRelayerBefore = await testERC20.balanceOf(relayerAddress); + const newRelayerBefore = await testERC20.balanceOf(newRelayerAddress); + + await erc20RecurringPaymentProxy + .connect(newRelayer) + .triggerRecurringPaymentBatch(permit, signature, 1); + + expect(await testERC20.balanceOf(newRelayerAddress)).to.equal(newRelayerBefore.add(5)); + expect(await testERC20.balanceOf(relayerAddress)).to.equal(constructorRelayerBefore); + }); + + it('blocks trigger after revokeRelayer of the calling address', async () => { + await fundSubscriber(); + const permit = await dueBatchPermit(); + const signature = await createBatchSignature(permit, subscriber); + await erc20RecurringPaymentProxy.revokeRelayer(relayerAddress); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__NotSubscriber', + ); }); }); @@ -230,7 +366,9 @@ describe('ERC20RecurringPaymentProxy', () => { const newERC20FeeProxy = await (await ethers.getContractFactory('ERC20FeeProxy')).deploy(); await newERC20FeeProxy.deployed(); - await erc20RecurringPaymentProxy.setFeeProxy(newERC20FeeProxy.address); + await expect(erc20RecurringPaymentProxy.setFeeProxy(newERC20FeeProxy.address)) + .to.emit(erc20RecurringPaymentProxy, 'FeeProxyUpdated') + .withArgs(erc20FeeProxy.address, newERC20FeeProxy.address); expect(await erc20RecurringPaymentProxy.erc20FeeProxy()).to.equal(newERC20FeeProxy.address); }); @@ -240,7 +378,7 @@ describe('ERC20RecurringPaymentProxy', () => { await expect( erc20RecurringPaymentProxy.connect(user).setFeeProxy(newERC20FeeProxy.address), - ).to.be.revertedWith('Ownable: caller is not the owner'); + ).to.be.revertedWith('AccessControl: account'); }); it('should revert when trying to set zero address as fee proxy', async () => { @@ -265,7 +403,7 @@ describe('ERC20RecurringPaymentProxy', () => { it('should revert when non-owner tries to pause', async () => { await expect(erc20RecurringPaymentProxy.connect(user).pause()).to.be.revertedWith( - 'Ownable: caller is not the owner', + 'AccessControl: account', ); }); @@ -273,7 +411,7 @@ describe('ERC20RecurringPaymentProxy', () => { await erc20RecurringPaymentProxy.pause(); await expect(erc20RecurringPaymentProxy.connect(user).unpause()).to.be.revertedWith( - 'Ownable: caller is not the owner', + 'AccessControl: account', ); }); @@ -290,366 +428,1383 @@ describe('ERC20RecurringPaymentProxy', () => { .to.emit(erc20RecurringPaymentProxy, 'Unpaused') .withArgs(ownerAddress); }); + + it('blocks a relayer trigger while paused', async () => { + await fundSubscriber(); + const permit = await dueBatchPermit(); + const signature = await createBatchSignature(permit, subscriber); + await erc20RecurringPaymentProxy.pause(); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('Pausable: paused'); + }); }); - describe('Ownership', () => { - it('should allow owner to transfer ownership', async () => { - await erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress); - expect(await erc20RecurringPaymentProxy.owner()).to.equal(newOwnerAddress); + describe('Admin role', () => { + it('lets the admin grant and revoke DEFAULT_ADMIN_ROLE', async () => { + const adminRole = await erc20RecurringPaymentProxy.DEFAULT_ADMIN_ROLE(); + await erc20RecurringPaymentProxy.grantRole(adminRole, newOwnerAddress); + expect(await erc20RecurringPaymentProxy.hasRole(adminRole, newOwnerAddress)).to.be.true; + + await erc20RecurringPaymentProxy.connect(newOwner).revokeRole(adminRole, ownerAddress); + expect(await erc20RecurringPaymentProxy.hasRole(adminRole, ownerAddress)).to.be.false; }); - it('should revert when non-owner tries to transfer ownership', async () => { + it('reverts when a non-admin tries to grant admin', async () => { await expect( - erc20RecurringPaymentProxy.connect(user).transferOwnership(newOwnerAddress), - ).to.be.revertedWith('Ownable: caller is not the owner'); + erc20RecurringPaymentProxy + .connect(user) + .grantRole(await erc20RecurringPaymentProxy.DEFAULT_ADMIN_ROLE(), userAddress), + ).to.be.revertedWith('AccessControl: account'); }); + }); + + describe('Fee destination and rescue', () => { + it('allows the owner to rescue a residual balance', async () => { + await testERC20.transfer(erc20RecurringPaymentProxy.address, 40); + const ownerBalanceBefore = await testERC20.balanceOf(ownerAddress); - it('should emit OwnershipTransferred event', async () => { - await expect(erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress)) - .to.emit(erc20RecurringPaymentProxy, 'OwnershipTransferred') - .withArgs(ownerAddress, newOwnerAddress); + await erc20RecurringPaymentProxy.rescueTokens(testERC20.address, ownerAddress, 40); + + expect(await testERC20.balanceOf(erc20RecurringPaymentProxy.address)).to.equal(0); + expect(await testERC20.balanceOf(ownerAddress)).to.equal(ownerBalanceBefore.add(40)); }); - it('should allow new owner to renounce ownership', async () => { - await erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress); + it('reverts when a non-owner tries to rescue tokens', async () => { + await testERC20.transfer(erc20RecurringPaymentProxy.address, 10); + + await expect( + erc20RecurringPaymentProxy.connect(user).rescueTokens(testERC20.address, userAddress, 10), + ).to.be.revertedWith('AccessControl: account'); + }); - await expect(erc20RecurringPaymentProxy.connect(newOwner).renounceOwnership()) - .to.emit(erc20RecurringPaymentProxy, 'OwnershipTransferred') - .withArgs(newOwnerAddress, ethers.constants.AddressZero); + it('reverts rescue to the zero address', async () => { + await testERC20.transfer(erc20RecurringPaymentProxy.address, 10); - expect(await erc20RecurringPaymentProxy.owner()).to.equal(ethers.constants.AddressZero); + await expectCustomError( + erc20RecurringPaymentProxy.rescueTokens( + testERC20.address, + ethers.constants.AddressZero, + 10, + ), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); }); - it('should revert when non-owner tries to renounce ownership', async () => { - await expect(erc20RecurringPaymentProxy.connect(user).renounceOwnership()).to.be.revertedWith( - 'Ownable: caller is not the owner', + it('reverts rescue when the token is the zero address', async () => { + await expectCustomError( + erc20RecurringPaymentProxy.rescueTokens(ethers.constants.AddressZero, ownerAddress, 10), + 'ERC20RecurringPaymentProxy__ZeroAddress', ); }); }); - describe('Trigger Recurring Payment', () => { - beforeEach(async () => { - // Transfer tokens to subscriber and approve the recurring payment proxy - await testERC20.transfer(subscriberAddress, 500); - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + describe('Schedule key replay', () => { + it('keeps the batch schedule key stable across nonce and deadline re-sign', async () => { + const permit = { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 1, + totalPayments: 1, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 86400, + strictOrder: false, + scheduleId: '0x0101010101010101010101010101010101010101010101010101010101010101', + dueTimes: [Math.floor(Date.now() / 1000)], + initialLegs: [], + recurringLegs: [], + }; + const resigned = { ...permit, nonce: 9, deadline: permit.deadline + 1 }; + expect(await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit)).to.equal( + await erc20RecurringPaymentProxy.scheduleKeyFromBatch(resigned), + ); }); - it('should trigger a valid recurring payment', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('changes the batch schedule key when signed terms change', async () => { + const permit = { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 1, + totalPayments: 1, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 86400, + strictOrder: false, + scheduleId: '0x0101010101010101010101010101010101010101010101010101010101010101', + dueTimes: [Math.floor(Date.now() / 1000)], + initialLegs: [], + recurringLegs: [], + }; + const key = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + expect( + await erc20RecurringPaymentProxy.scheduleKeyFromBatch({ + ...permit, + token: ethers.constants.AddressZero, + }), + ).to.not.equal(key); + expect( + await erc20RecurringPaymentProxy.scheduleKeyFromBatch({ ...permit, strictOrder: true }), + ).to.not.equal(key); + expect( + await erc20RecurringPaymentProxy.scheduleKeyFromBatch({ + ...permit, + recurringLegs: [ + { + recipient: recipientAddress, + amount: 1, + paymentReference: ethers.utils.hexZeroPad('0x01', 8), + }, + ], + }), + ).to.not.equal(key); + }); - const subscriberBalanceBefore = await testERC20.balanceOf(subscriberAddress); - const recipientBalanceBefore = await testERC20.balanceOf(recipientAddress); - const feeAddressBalanceBefore = await testERC20.balanceOf(feeAddressString); - const relayerBalanceBefore = await testERC20.balanceOf(relayerAddress); + it('rejects a zero batch scheduleId', async () => { + const permit = { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 0, + totalPayments: 1, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 86400, + strictOrder: false, + scheduleId: ethers.constants.HashZero, + dueTimes: [Math.floor(Date.now() / 1000)], + initialLegs: [], + recurringLegs: [], + }; + await expectCustomError( + erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit), + 'ERC20RecurringPaymentProxy__ZeroScheduleId', + ); + }); + }); + + describe('triggerRecurringPaymentBatch', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + const t0 = Math.floor(Date.UTC(2026, 8, 1) / 1000); + const oct1 = Math.floor(Date.UTC(2026, 9, 1) / 1000); + + const workedExample = (tokenAddress: string) => ({ + subscriber: subscriberAddress, + token: tokenAddress, + relayerFee: 1_000_000, + totalPayments: 4, + nonce: 0, + deadline: Math.floor(Date.UTC(2027, 0, 1) / 1000), + strictOrder: false, + scheduleId: '0x0101010101010101010101010101010101010101010101010101010101010101', + dueTimes: [ + t0, + oct1, + Math.floor(Date.UTC(2026, 10, 1) / 1000), + Math.floor(Date.UTC(2026, 11, 1) / 1000), + ], + initialLegs: [ + { recipient: recipientAddress, amount: 30_000_000, paymentReference: ref(0x0a) }, + { recipient: feeAddressString, amount: 3_000_000, paymentReference: ref(0x0b) }, + ], + recurringLegs: [ + { recipient: recipientAddress, amount: 99_000_000, paymentReference: ref(0x0c) }, + { recipient: feeAddressString, amount: 5_000_000, paymentReference: ref(0x0d) }, + { recipient: userAddress, amount: 4_000_000, paymentReference: ref(0x0e) }, + { recipient: newRelayerAddress, amount: 2_000_000, paymentReference: ref(0x0f) }, + ], + }); + + const warpTo = async (timestamp: number) => { + const clientVersion: string = await ethers.provider.send('web3_clientVersion', []); + if (clientVersion.toLowerCase().includes('ganache')) { + await ethers.provider.send('evm_setTime', [timestamp * 1000]); + } else { + await ethers.provider.send('evm_setNextBlockTimestamp', [timestamp]); + } + await ethers.provider.send('evm_mine', []); + }; + + it('settles the worked-example initial and first recurring cycles atomically', async () => { + const TestERC20Factory = await ethers.getContractFactory('TestERC20'); + const token = await TestERC20Factory.deploy(200_000_000); + await token.deployed(); + await token.transfer(subscriberAddress, 160_000_000); + await token.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 160_000_000); + const permit = workedExample(token.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await warpTo(t0); + const subscriberBefore = await token.balanceOf(subscriberAddress); + const relayerBefore = await token.balanceOf(relayerAddress); await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), + .triggerRecurringPaymentBatch(permit, signature, 1), ) .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') .withArgs( - testERC20.address, + token.address, recipientAddress, - permit.amount, - ethers.utils.keccak256(paymentReference), - permit.feeAmount, + 30_000_000, + ethers.utils.keccak256(ref(0x0a)), + 0, + ethers.constants.AddressZero, + ) + .and.to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + token.address, feeAddressString, + 3_000_000, + ethers.utils.keccak256(ref(0x0b)), + 0, + ethers.constants.AddressZero, + ) + .and.to.emit(erc20RecurringPaymentProxy, 'PaymentTriggered') + .withArgs(scheduleKey, subscriberAddress, token.address, 1, 34_000_000); + + expect(await token.balanceOf(subscriberAddress)).to.equal(subscriberBefore.sub(34_000_000)); + expect(await token.balanceOf(recipientAddress)).to.equal(30_000_000); + expect(await token.balanceOf(feeAddressString)).to.equal(3_000_000); + expect(await token.balanceOf(relayerAddress)).to.equal(relayerBefore.add(1_000_000)); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(2); + + await warpTo(oct1); + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 2), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + token.address, + recipientAddress, + 99_000_000, + ethers.utils.keccak256(ref(0x0c)), + 0, + ethers.constants.AddressZero, ); - // Check balance changes - const subscriberBalanceAfter = await testERC20.balanceOf(subscriberAddress); - const recipientBalanceAfter = await testERC20.balanceOf(recipientAddress); - const feeAddressBalanceAfter = await testERC20.balanceOf(feeAddressString); - const relayerBalanceAfter = await testERC20.balanceOf(relayerAddress); - - expect(subscriberBalanceAfter).to.equal(subscriberBalanceBefore.sub(115)); // amount + fee + gas - expect(recipientBalanceAfter).to.equal(recipientBalanceBefore.add(100)); // amount - expect(feeAddressBalanceAfter).to.equal(feeAddressBalanceBefore.add(10)); // fee - expect(relayerBalanceAfter).to.equal(relayerBalanceBefore.add(5)); // gas fee + expect(await token.balanceOf(subscriberAddress)).to.equal( + subscriberBefore.sub(34_000_000 + 111_000_000), + ); + expect(await token.balanceOf(recipientAddress)).to.equal(129_000_000); }); - it('should revert when called by non-relayer', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts a zero token without moving balances', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - await expect( + const permit = workedExample(ethers.constants.AddressZero); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + const subscriberBefore = await testERC20.balanceOf(subscriberAddress); + + await expectCustomError( erc20RecurringPaymentProxy - .connect(user) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('AccessControl: account'); + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); }); - it('should revert when contract is paused', async () => { - await erc20RecurringPaymentProxy.pause(); + it('reverts when index is greater than totalPayments', async () => { + const permit = workedExample(testERC20.address); + const signature = await createBatchSignature(permit, subscriber); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, permit.totalPayments + 1), + 'ERC20RecurringPaymentProxy__IndexOutOfBounds', + ); + }); - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts when dueTimes are not strictly increasing', async () => { + const permit = workedExample(testERC20.address); + const decreasing = { + ...permit, + dueTimes: [permit.dueTimes[1], permit.dueTimes[0], permit.dueTimes[2], permit.dueTimes[3]], + }; + const equal = { + ...permit, + dueTimes: [permit.dueTimes[0], permit.dueTimes[0], permit.dueTimes[2], permit.dueTimes[3]], + }; - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('Pausable: paused'); + .triggerRecurringPaymentBatch( + decreasing, + await createBatchSignature(decreasing, subscriber), + 1, + ), + 'ERC20RecurringPaymentProxy__InvalidDueTimes', + ); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(equal, await createBatchSignature(equal, subscriber), 1), + 'ERC20RecurringPaymentProxy__InvalidDueTimes', + ); }); - it('should revert with bad signature', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, user); // Wrong signer - const paymentReference = '0x1234567890abcdef'; + it('rejects index 256 before the call is encoded', async () => { + const permit = workedExample(testERC20.address); + const signature = await createBatchSignature(permit, subscriber); await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), + .triggerRecurringPaymentBatch(permit, signature, 256), ).to.be.reverted; }); - it('should revert when signature is expired', async () => { - const permit = createSchedulePermit({ - deadline: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago - }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts a zero subscriber without moving balances', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - await expect( + const permit = { + ...workedExample(testERC20.address), + subscriber: ethers.constants.AddressZero, + }; + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + const subscriberBefore = await testERC20.balanceOf(subscriberAddress); + + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); + }); + + const failingLegPermit = ( + tokenAddress: string, + first: string, + middle: string, + last: string, + ) => ({ + subscriber: subscriberAddress, + token: tokenAddress, + relayerFee: 0, + totalPayments: 1, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 86400, + strictOrder: false, + scheduleId: '0x0202020202020202020202020202020202020202020202020202020202020202', + dueTimes: [Math.floor(Date.now() / 1000) - 1], + initialLegs: [], + recurringLegs: [ + { recipient: first, amount: 10, paymentReference: ref(0x11) }, + { recipient: middle, amount: 10, paymentReference: ref(0x12) }, + { recipient: last, amount: 10, paymentReference: ref(0x13) }, + ], }); - it('should revert when index is too large (>= 256)', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + const expectFailedLegUnchanged = async ( + blockedRecipient: string, + first: string, + middle: string, + last: string, + ) => { + const BlockFactory = await ethers.getContractFactory('ERC20BlockRecipient'); + const token = await BlockFactory.deploy(1000); + await token.deployed(); + await token.setBlocked(blockedRecipient); + await token.transfer(subscriberAddress, 500); + await token.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = failingLegPermit(token.address, first, middle, last); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + const subscriberBefore = await token.balanceOf(subscriberAddress); await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 256, paymentReference), + .triggerRecurringPaymentBatch(permit, signature, 1), ).to.be.reverted; + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await token.balanceOf(subscriberAddress)).to.equal(subscriberBefore); + expect(await token.balanceOf(first)).to.equal(0); + expect(await token.balanceOf(middle)).to.equal(0); + expect(await token.balanceOf(last)).to.equal(0); + }; + + it('reverts a failing first leg with balances and bitmap unchanged', async () => { + await expectFailedLegUnchanged( + recipientAddress, + recipientAddress, + userAddress, + feeAddressString, + ); }); - it('should revert when execution is out of order', async () => { - const permit = createSchedulePermit({ strictOrder: true, periodSeconds: 1 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts a failing middle leg with balances and bitmap unchanged', async () => { + await expectFailedLegUnchanged(userAddress, recipientAddress, userAddress, feeAddressString); + }); - // Advance time so payment #2 is due, ensuring the only failure reason is order. - await ethers.provider.send('evm_increaseTime', [1]); - await ethers.provider.send('evm_mine', []); + it('reverts a failing last leg with balances and bitmap unchanged', async () => { + await expectFailedLegUnchanged( + feeAddressString, + recipientAddress, + userAddress, + feeAddressString, + ); + }); - // Try to execute index 2 before index 1 - await expect( + it('reverts a zero-amount leg without emitting a fee-proxy transfer', async () => { + const now = (await ethers.provider.getBlock('latest')).timestamp; + const permit = { + ...workedExample(testERC20.address), + deadline: now + 86400, + dueTimes: [now - 1, now + 86400, now + 2 * 86400, now + 3 * 86400], + initialLegs: [ + { recipient: recipientAddress, amount: 30_000_000, paymentReference: ref(0x0a) }, + { recipient: feeAddressString, amount: 0, paymentReference: ref(0x0b) }, + ], + }; + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + const subscriberBefore = await testERC20.balanceOf(subscriberAddress); + const recipientBefore = await testERC20.balanceOf(recipientAddress); + const feeBefore = await testERC20.balanceOf(feeAddressString); + + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__ZeroAmount', + ); + + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); + expect(await testERC20.balanceOf(recipientAddress)).to.equal(recipientBefore); + expect(await testERC20.balanceOf(feeAddressString)).to.equal(feeBefore); }); - it('should allow out of order trigger if strictOrder is false', async () => { - const permit = createSchedulePermit({ strictOrder: false, periodSeconds: 1 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts index 1 when recurring legs are invalid even if initial legs are valid', async () => { + const now = (await ethers.provider.getBlock('latest')).timestamp; + const permit = { + ...workedExample(testERC20.address), + deadline: now + 86400, + dueTimes: [now - 1, now + 86400, now + 2 * 86400, now + 3 * 86400], + initialLegs: [ + { recipient: recipientAddress, amount: 30_000_000, paymentReference: ref(0x0a) }, + { recipient: feeAddressString, amount: 3_000_000, paymentReference: ref(0x0b) }, + ], + recurringLegs: [{ recipient: recipientAddress, amount: 0, paymentReference: ref(0x0c) }], + }; + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + const subscriberBefore = await testERC20.balanceOf(subscriberAddress); + const recipientBefore = await testERC20.balanceOf(recipientAddress); - // Fast forward time to make multiple payments due - await ethers.provider.send('evm_increaseTime', [5]); - await ethers.provider.send('evm_mine', []); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__ZeroAmount', + ); + + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); + expect(await testERC20.balanceOf(recipientAddress)).to.equal(recipientBefore); + }); + + it('settles a one-cycle schedule that has only initialLegs', async () => { + await fundSubscriber(); + const permit = await dueBatchPermit({ + initialLegs: [ + { recipient: recipientAddress, amount: 100, paymentReference: paymentRef(0x61) }, + ], + recurringLegs: [], + }); + const signature = await createBatchSignature(permit, subscriber); - // Execute index 2 before index 1, which should be allowed await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.not.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testERC20.address, + recipientAddress, + 100, + ethers.utils.keccak256(paymentRef(0x61)), + 0, + ethers.constants.AddressZero, + ); + expect(await testERC20.balanceOf(recipientAddress)).to.equal(100); + }); + + it('reverts when index is 0', async () => { + const permit = await dueBatchPermit(); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 0), + 'ERC20RecurringPaymentProxy__IndexOutOfBounds', + ); }); - it('should revert when index is out of bounds', async () => { - const permit = createSchedulePermit({ totalPayments: 1 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts when totalPayments is 0', async () => { + const permit = await dueBatchPermit({ totalPayments: 0, dueTimes: [] }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__IndexOutOfBounds', + ); + }); - await expect( + it('reverts when dueTimes length does not match totalPayments', async () => { + const now = await latestBlockTs(); + const permit = await dueBatchPermit({ + totalPayments: 2, + dueTimes: [now - 1], + }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__InvalidDueTimes', + ); }); - it('should revert when payment is not due yet', async () => { - const permit = createSchedulePermit({ - firstPayment: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + it('reverts when both leg arrays are empty', async () => { + const permit = await dueBatchPermit({ initialLegs: [], recurringLegs: [] }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__EmptyLegs', + ); + }); + + it('reverts when recurringLegs are empty and totalPayments is greater than 1', async () => { + const now = await latestBlockTs(); + const permit = await dueBatchPermit({ + totalPayments: 2, + dueTimes: [now - 2, now - 1], + initialLegs: [ + { recipient: recipientAddress, amount: 100, paymentReference: paymentRef(0x61) }, + ], + recurringLegs: [], }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__EmptyLegs', + ); + }); - await expect( + const overflowLegs = () => + Array.from({ length: 9 }, (_, i) => ({ + recipient: recipientAddress, + amount: 10, + paymentReference: paymentRef(0x80 + i), + })); + + it('reverts when initialLegs exceed MAX_LEGS', async () => { + const permit = await dueBatchPermit({ initialLegs: overflowLegs() }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__TooManyLegs', + ); + }); + + it('reverts when recurringLegs exceed MAX_LEGS', async () => { + const permit = await dueBatchPermit({ recurringLegs: overflowLegs() }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__TooManyLegs', + ); }); - it('should revert when payment is already triggered', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('settles a cycle with exactly MAX_LEGS recurring legs', async () => { + await fundSubscriber(); + const maxLegs = Number(await erc20RecurringPaymentProxy.MAX_LEGS()); + const recurringLegs = Array.from({ length: maxLegs }, (_, i) => ({ + recipient: recipientAddress, + amount: 10, + paymentReference: paymentRef(0x90 + i), + })); + const permit = await dueBatchPermit({ relayerFee: 0, recurringLegs }); + const signature = await createBatchSignature(permit, subscriber); - // Trigger first time await erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); + .triggerRecurringPaymentBatch(permit, signature, 1); - // Try to trigger the same index again - await expect( + expect(await testERC20.balanceOf(recipientAddress)).to.equal(10 * maxLegs); + }); + + it('reverts when two recurring legs share a paymentReference', async () => { + const permit = await dueBatchPermit({ + recurringLegs: [ + { recipient: recipientAddress, amount: 50, paymentReference: paymentRef(0x61) }, + { recipient: feeAddressString, amount: 50, paymentReference: paymentRef(0x61) }, + ], + }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__DuplicatePaymentReference', + ); }); - it('should allow sequential trigger of multiple payments', async () => { - const permit = createSchedulePermit({ totalPayments: 3, periodSeconds: 1 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts when two initial legs share a paymentReference', async () => { + const permit = await dueBatchPermit({ + initialLegs: [ + { recipient: recipientAddress, amount: 50, paymentReference: paymentRef(0x61) }, + { recipient: feeAddressString, amount: 50, paymentReference: paymentRef(0x61) }, + ], + recurringLegs: [], + }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__DuplicatePaymentReference', + ); + }); - // Trigger first payment + it('reverts a zero-address leg recipient', async () => { + const permit = await dueBatchPermit({ + recurringLegs: [ + { + recipient: ethers.constants.AddressZero, + amount: 100, + paymentReference: paymentRef(0x61), + }, + ], + }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + }); + + it('reverts when the EOA signer is not the subscriber', async () => { + const permit = await dueBatchPermit(); + const signature = await createBatchSignature(permit, user); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__BadSignature', + ); + }); + + it('reverts when the deadline has passed', async () => { + const now = await latestBlockTs(); + const permit = await dueBatchPermit({ deadline: now - 1, dueTimes: [now - 2] }); + const signature = await createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__SignatureExpired', + ); + }); + + it('reverts a second trigger of the same index', async () => { + await fundSubscriber(); + const permit = await dueBatchPermit(); + const signature = await createBatchSignature(permit, subscriber); await erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); + .triggerRecurringPaymentBatch(permit, signature, 1); - // Advance time by periodSeconds to allow second payment - await ethers.provider.send('evm_increaseTime', [permit.periodSeconds]); - await ethers.provider.send('evm_mine', []); + const subscriberBefore = await testERC20.balanceOf(subscriberAddress); + const recipientBefore = await testERC20.balanceOf(recipientAddress); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__AlreadyPaid', + ); + expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); + expect(await testERC20.balanceOf(recipientAddress)).to.equal(recipientBefore); + }); + + it('leaves the proxy token balance at the pre-pull baseline after a successful trigger', async () => { + await testERC20.transfer(erc20RecurringPaymentProxy.address, 40); + await fundSubscriber(); + const permit = await dueBatchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const baseline = await testERC20.balanceOf(erc20RecurringPaymentProxy.address); - // Trigger second payment await erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference); + .triggerRecurringPaymentBatch(permit, signature, 1); - // Advance time by periodSeconds to allow third payment - await ethers.provider.send('evm_increaseTime', [permit.periodSeconds]); - await ethers.provider.send('evm_mine', []); + expect(await testERC20.balanceOf(erc20RecurringPaymentProxy.address)).to.equal(baseline); + }); + }); + + describe('strictOrder', () => { + const twoCyclePermit = async (strictOrder: boolean) => { + const now = await latestBlockTs(); + return dueBatchPermit({ + relayerFee: 0, + totalPayments: 2, + strictOrder, + scheduleId: '0x0909090909090909090909090909090909090909090909090909090909090909', + dueTimes: [now - 2, now - 1], + recurringLegs: [ + { recipient: recipientAddress, amount: 10, paymentReference: paymentRef(0x71) }, + ], + }); + }; + + it('reverts jumping to index 2 when strictOrder is true', async () => { + await fundSubscriber(); + const permit = await twoCyclePermit(true); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 2), + 'ERC20RecurringPaymentProxy__PaymentOutOfOrder', + ); + expect(await erc20RecurringPaymentProxy.lastPaymentIndex(scheduleKey)).to.equal(0); + }); + + it('accepts index 1 then 2 when strictOrder is true and lastPaymentIndex becomes 2', async () => { + await fundSubscriber(); + const permit = await twoCyclePermit(true); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); - // Trigger third payment await erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 3, paymentReference); + .triggerRecurringPaymentBatch(permit, signature, 1); + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 2); + + expect(await erc20RecurringPaymentProxy.lastPaymentIndex(scheduleKey)).to.equal(2); + }); + + it('allows index 2 before index 1 when strictOrder is false', async () => { + await fundSubscriber(); + const permit = await twoCyclePermit(false); + const signature = await createBatchSignature(permit, subscriber); - // Verify all payments were triggered - // Note: We can't directly call _hashSchedule as it's private, but we can verify through the bitmap - // The bitmap should have bits 1, 2, and 3 set (2^1 + 2^2 + 2^3 = 14) - // We'll check this by trying to trigger the same indices again, which should fail await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; // Should fail because already triggered + .triggerRecurringPaymentBatch(permit, signature, 2), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + expect(await testERC20.balanceOf(recipientAddress)).to.equal(10); + }); + + it('does not advance lastPaymentIndex when strictOrder is false', async () => { + await fundSubscriber(); + const permit = await twoCyclePermit(false); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1); + + expect(await erc20RecurringPaymentProxy.lastPaymentIndex(scheduleKey)).to.equal(0); + }); + }); + + describe('Pull assertions', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + + const pullPermit = async (tokenAddress: string, overrides: Record = {}) => { + const now = (await ethers.provider.getBlock('latest')).timestamp; + return { + subscriber: subscriberAddress, + token: tokenAddress, + relayerFee: 5, + totalPayments: 1, + nonce: 0, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0606060606060606060606060606060606060606060606060606060606060606', + dueTimes: [now - 1], + initialLegs: [], + recurringLegs: [{ recipient: recipientAddress, amount: 100, paymentReference: ref(0x41) }], + ...overrides, + }; + }; + + it('reverts an under-funded pull, leaves the bitmap unset, and stays collectable after funding', async () => { + await testERC20.transfer(subscriberAddress, 50); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = await pullPermit(testERC20.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.be.reverted; // Should fail because already triggered + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.reverted; + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); - await expect( + await testERC20.transfer(subscriberAddress, 500); + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.not.equal(0); + expect(await testERC20.balanceOf(erc20RecurringPaymentProxy.address)).to.equal(0); + }); + + it('cannot settle an unfunded subscriber from a residual proxy balance', async () => { + const SilentFailFactory = await ethers.getContractFactory('ERC20SilentFail'); + const silentFail = await SilentFailFactory.deploy(1000); + await silentFail.deployed(); + + await silentFail.transfer(erc20RecurringPaymentProxy.address, 500); + + const permit = await pullPermit(silentFail.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 3, paymentReference), - ).to.be.reverted; // Should fail because already triggered + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__TransferFailed', + ); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await silentFail.balanceOf(erc20RecurringPaymentProxy.address)).to.equal(500); + expect(await silentFail.balanceOf(recipientAddress)).to.equal(0); }); - it('should handle zero relayer fee correctly', async () => { - const permit = createSchedulePermit({ relayerFee: 0 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts a fee-on-transfer token that under-delivers', async () => { + const FeeOnTransferFactory = await ethers.getContractFactory('ERC20FeeOnTransfer'); + const feeOnTransfer = await FeeOnTransferFactory.deploy(1000); + await feeOnTransfer.deployed(); - const relayerBalanceBefore = await testERC20.balanceOf(relayerAddress); + await feeOnTransfer.transfer(subscriberAddress, 500); + await feeOnTransfer.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); + const permit = await pullPermit(feeOnTransfer.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); - const relayerBalanceAfter = await testERC20.balanceOf(relayerAddress); - expect(relayerBalanceAfter).to.equal(relayerBalanceBefore); // No relayer fee transferred + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__ShortPull', + ); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); }); - it('should handle zero fee amount correctly', async () => { - const permit = createSchedulePermit({ feeAmount: 0 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('reverts when the token returns false without reverting', async () => { + const SilentFailFactory = await ethers.getContractFactory('ERC20SilentFail'); + const silentFail = await SilentFailFactory.deploy(1000); + await silentFail.deployed(); - const feeAddressBalanceBefore = await testERC20.balanceOf(feeAddressString); + await silentFail.transfer(subscriberAddress, 500); + // No approve: transferFrom returns false instead of reverting. - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); + const permit = await pullPermit(silentFail.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__TransferFailed', + ); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + }); - const feeAddressBalanceAfter = await testERC20.balanceOf(feeAddressString); - expect(feeAddressBalanceAfter).to.equal(feeAddressBalanceBefore); // No fee transferred + it('does not mark the cycle paid when the relayer-fee transfer fails', async () => { + const FailTransferFactory = await ethers.getContractFactory('ERC20FailTransfer'); + const failTransfer = await FailTransferFactory.deploy(1000); + await failTransfer.deployed(); + + await failTransfer.transfer(subscriberAddress, 500); + await failTransfer.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = await pullPermit(failTransfer.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__TransferFailed', + ); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await failTransfer.balanceOf(recipientAddress)).to.equal(0); }); + }); - it('should revert when subscriber has insufficient balance', async () => { - const permit = createSchedulePermit({ amount: 1000 }); // More than subscriber has - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + describe('EIP-1271 signatures', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + + const walletPermit = async (wallet: string) => { + const now = (await ethers.provider.getBlock('latest')).timestamp; + return { + subscriber: wallet, + token: testERC20.address, + relayerFee: 5, + totalPayments: 1, + nonce: 0, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0707070707070707070707070707070707070707070707070707070707070707', + dueTimes: [now - 1], + initialLegs: [], + recurringLegs: [{ recipient: recipientAddress, amount: 100, paymentReference: ref(0x51) }], + }; + }; + + it('accepts a valid smart-account signature', async () => { + const MockERC1271Factory = await ethers.getContractFactory('MockERC1271'); + const mockWallet = await MockERC1271Factory.deploy(subscriberAddress); + await mockWallet.deployed(); + + await testERC20.transfer(mockWallet.address, 500); + await mockWallet + .connect(subscriber) + .approveToken(testERC20.address, erc20RecurringPaymentProxy.address, 500); + + const permit = await walletPermit(mockWallet.address); + const signature = await createBatchSignature(permit, subscriber); await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testERC20.address, + recipientAddress, + 100, + ethers.utils.keccak256(ref(0x51)), + 0, + ethers.constants.AddressZero, + ); }); - it('should revert when subscriber has insufficient allowance', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; + it('rejects a malformed smart-account signature', async () => { + const MockERC1271Factory = await ethers.getContractFactory('MockERC1271'); + const mockWallet = await MockERC1271Factory.deploy(subscriberAddress); + await mockWallet.deployed(); - // Revoke approval - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 0); + await testERC20.transfer(mockWallet.address, 500); + await mockWallet + .connect(subscriber) + .approveToken(testERC20.address, erc20RecurringPaymentProxy.address, 500); - await expect( + const permit = await walletPermit(mockWallet.address); + const signature = '0x' + '11'.repeat(65); + + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__BadSignature', + ); }); }); - describe('Integration: Paused state affects execution', () => { - it('should revert trigger when contract is paused', async () => { - await erc20RecurringPaymentProxy.pause(); + describe('cancelSchedule', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); - // Create a minimal SchedulePermit for testing - const schedulePermit = { - subscriber: userAddress, + const latestTs = async () => (await ethers.provider.getBlock('latest')).timestamp; + + const simpleBatch = async () => { + const now = await latestTs(); + return { + subscriber: subscriberAddress, token: testERC20.address, - recipient: userAddress, - feeAddress: userAddress, - amount: 100, - feeAmount: 10, - relayerFee: 5, - periodSeconds: 3600, - firstPayment: Math.floor(Date.now() / 1000), + relayerFee: 0, totalPayments: 1, nonce: 0, - deadline: Math.floor(Date.now() / 1000) + 3600, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0303030303030303030303030303030303030303030303030303030303030303', + dueTimes: [now], + initialLegs: [], + recurringLegs: [{ recipient: recipientAddress, amount: 10, paymentReference: ref(0x21) }], }; + }; + + it('blocks the batch entry point after the subscriber cancels', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = await simpleBatch(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await expect(erc20RecurringPaymentProxy.connect(subscriber).cancelScheduleBatch(permit)) + .to.emit(erc20RecurringPaymentProxy, 'ScheduleCancelled') + .withArgs(scheduleKey, subscriberAddress); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__Cancelled', + ); + }); + + it('reverts when a non-subscriber tries to cancel', async () => { + await expectCustomError( + erc20RecurringPaymentProxy.connect(user).cancelScheduleBatch(await simpleBatch()), + 'ERC20RecurringPaymentProxy__NotSubscriber', + ); + }); + + it('reports cancelledSchedules true after the subscriber cancels', async () => { + const permit = await simpleBatch(); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(subscriber).cancelScheduleBatch(permit); + expect(await erc20RecurringPaymentProxy.cancelledSchedules(scheduleKey)).to.be.true; + }); + }); + + describe('admitCycles', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + const latestTs = async () => (await ethers.provider.getBlock('latest')).timestamp; + const bit = (index: number) => ethers.BigNumber.from(1).shl(index); + + const batchPermit = async (overrides: Record = {}) => { + const now = await latestTs(); + return { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 0, + totalPayments: 4, + nonce: 0, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0404040404040404040404040404040404040404040404040404040404040404', + dueTimes: [now - 3, now - 2, now - 1, now], + initialLegs: [], + recurringLegs: [{ recipient: recipientAddress, amount: 10, paymentReference: ref(0x31) }], + ...overrides, + }; + }; + + const fundSubscriber = async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + }; + + it('rejects a subscriber-initiated call when the cycle was never admitted', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); - const signature = '0x' + '0'.repeat(130); // Dummy signature - const paymentReference = '0x1234'; + await expectCustomError( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__NotAdmitted', + ); + }); + + it('does not let admitting index 3 admit index 4', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await expect(erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(3))) + .to.emit(erc20RecurringPaymentProxy, 'CyclesAdmitted') + .withArgs(scheduleKey, bit(3)); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 4), + 'ERC20RecurringPaymentProxy__NotAdmitted', + ); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 3), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + }); + + it('lets the relayer trigger a cycle that was never admitted', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(schedulePermit, signature, 1, paymentReference), + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + }); + + it("reverts when a subscriber tries to trigger another subscriber's admitted schedule", async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + + await expectCustomError( + erc20RecurringPaymentProxy.connect(user).triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__NotSubscriber', + ); + }); + + it('still enforces NotDueYet on the self-trigger path', async () => { + await fundSubscriber(); + const now = await latestTs(); + const permit = await batchPermit({ + dueTimes: [now + 3600, now + 7200, now + 10800, now + 14400], + }); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__NotDueYet', + ); + }); + + it('still enforces pause on the self-trigger path', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + await erc20RecurringPaymentProxy.pause(); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 1), ).to.be.revertedWith('Pausable: paused'); }); + + it('reverts when a non-relayer tries to admit cycles', async () => { + const permit = await batchPermit(); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await expect( + erc20RecurringPaymentProxy.connect(subscriber).admitCycles(scheduleKey, bit(1)), + ).to.be.revertedWith('AccessControl: account'); + }); + + it('ORs admitted bits so admitting 1 then 2 leaves both set', async () => { + const permit = await batchPermit(); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(2)); + expect(await erc20RecurringPaymentProxy.admittedCycles(scheduleKey)).to.equal( + bit(1).or(bit(2)), + ); + }); + }); + + describe('revokeCycles', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + const latestTs = async () => (await ethers.provider.getBlock('latest')).timestamp; + const bit = (index: number) => ethers.BigNumber.from(1).shl(index); + + const batchPermit = async (overrides: Record = {}) => { + const now = await latestTs(); + return { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 0, + totalPayments: 4, + nonce: 0, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0505050505050505050505050505050505050505050505050505050505050505', + dueTimes: [now - 3, now - 2, now - 1, now], + initialLegs: [], + recurringLegs: [{ recipient: recipientAddress, amount: 10, paymentReference: ref(0x32) }], + ...overrides, + }; + }; + + const fundSubscriber = async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + }; + + it('blocks a subscriber self-trigger after the admitted bit is revoked', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(3)); + await expect(erc20RecurringPaymentProxy.connect(relayer).revokeCycles(scheduleKey, bit(3))) + .to.emit(erc20RecurringPaymentProxy, 'CyclesRevoked') + .withArgs(scheduleKey, bit(3)); + + await expectCustomError( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 3), + 'ERC20RecurringPaymentProxy__NotAdmitted', + ); + }); + + it('lets the relayer trigger a cycle after its admitted bit is revoked', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(3)); + await erc20RecurringPaymentProxy.connect(relayer).revokeCycles(scheduleKey, bit(3)); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 3), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + }); + + it('reverts when a non-relayer tries to revoke cycles', async () => { + const permit = await batchPermit(); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + await expect( + erc20RecurringPaymentProxy.connect(subscriber).revokeCycles(scheduleKey, bit(1)), + ).to.be.revertedWith('AccessControl: account'); + }); + + it('leaves an unrevoked admitted bit self-triggerable', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1).or(bit(3))); + await erc20RecurringPaymentProxy.connect(relayer).revokeCycles(scheduleKey, bit(3)); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + }); + + it('lets the subscriber self-trigger after a revoked bit is re-admitted', async () => { + await fundSubscriber(); + const permit = await batchPermit(); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + await erc20RecurringPaymentProxy.connect(relayer).revokeCycles(scheduleKey, bit(1)); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + }); + }); + + describe('EIP-712 digest parity', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + + const workedExample = () => { + const t0 = Math.floor(Date.UTC(2026, 8, 1) / 1000); + const oct1 = Math.floor(Date.UTC(2026, 9, 1) / 1000); + const nov1 = Math.floor(Date.UTC(2026, 10, 1) / 1000); + const dec1 = Math.floor(Date.UTC(2026, 11, 1) / 1000); + return { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 1_000_000, + totalPayments: 4, + nonce: 0, + deadline: Math.floor(Date.UTC(2027, 0, 1) / 1000), + strictOrder: false, + scheduleId: '0x0101010101010101010101010101010101010101010101010101010101010101', + dueTimes: [t0, oct1, nov1, dec1], + initialLegs: [ + { recipient: recipientAddress, amount: 30_000_000, paymentReference: ref(0x0a) }, + { recipient: feeAddressString, amount: 3_000_000, paymentReference: ref(0x0b) }, + ], + recurringLegs: [ + { recipient: recipientAddress, amount: 99_000_000, paymentReference: ref(0x0c) }, + { recipient: feeAddressString, amount: 5_000_000, paymentReference: ref(0x0d) }, + { recipient: userAddress, amount: 4_000_000, paymentReference: ref(0x0e) }, + { recipient: newRelayerAddress, amount: 2_000_000, paymentReference: ref(0x0f) }, + ], + }; + }; + + it('matches ethers _TypedDataEncoder for the worked-example SchedulePermitBatch', async () => { + const permit = workedExample(); + expect(await erc20RecurringPaymentProxy.hashScheduleBatch(permit)).to.equal( + await hashBatchOffchain(permit), + ); + }); + + it('matches ethers _TypedDataEncoder when initialLegs is empty', async () => { + const permit = { ...workedExample(), initialLegs: [] }; + expect(await erc20RecurringPaymentProxy.hashScheduleBatch(permit)).to.equal( + await hashBatchOffchain(permit), + ); + }); + + it('createBatchSignature is a valid typed-data payload for the worked example', async () => { + const permit = workedExample(); + const signature = await createBatchSignature(permit, subscriber); + expect(signature).to.match(/^0x[0-9a-fA-F]{130}$/); + }); + + it('uses an 8-byte payment reference whose fee-proxy topic is not the 32-byte pad', () => { + const ref8 = ref(0x0a); + const ref32 = ethers.utils.hexZeroPad(ref8, 32); + expect(ref8).to.equal('0x000000000000000a'); + expect(ethers.utils.keccak256(ref8)).to.equal(ethers.utils.keccak256('0x000000000000000a')); + expect(ethers.utils.keccak256(ref8)).to.not.equal(ethers.utils.keccak256(ref32)); + }); }); }); diff --git a/packages/smart-contracts/test/lib/artifact.test.ts b/packages/smart-contracts/test/lib/artifact.test.ts index dd98e3475e..4f2eb7c07e 100644 --- a/packages/smart-contracts/test/lib/artifact.test.ts +++ b/packages/smart-contracts/test/lib/artifact.test.ts @@ -1,6 +1,10 @@ import { BigNumber, providers } from 'ethers'; import { RequestOpenHashSubmitter } from '../../src/types'; -import { erc20FeeProxyArtifact, erc20ProxyArtifact } from '../../src/lib'; +import { + erc20FeeProxyArtifact, + erc20ProxyArtifact, + erc20RecurringPaymentProxyArtifact, +} from '../../src/lib'; import { CurrencyTypes } from '@requestnetwork/types'; describe('Artifact', () => { @@ -55,6 +59,22 @@ describe('Artifact', () => { ); }); + it('keeps the deployed recurring proxy ABI as default while exposing version 0.2.0', () => { + expect( + erc20RecurringPaymentProxyArtifact + .getContractAbi() + .some(({ name }) => name === 'triggerRecurringPayment'), + ).toBe(true); + expect( + erc20RecurringPaymentProxyArtifact + .getContractAbi('0.2.0') + .some(({ name }) => name === 'triggerRecurringPaymentBatch'), + ).toBe(true); + expect( + erc20RecurringPaymentProxyArtifact.getOptionalDeploymentInformation('mainnet', '0.2.0'), + ).toBeNull(); + }); + it('throws for a non-existing network', () => { expect(() => erc20ProxyArtifact.getDeploymentInformation('fakenetwork' as CurrencyTypes.EvmChainName),