From babb91967fd9eac4f8412c9329094e665dd5b5f1 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 12:34:03 +0200 Subject: [PATCH 01/17] fix(recurring): emit 8-byte payment references --- .../contracts/ERC20RecurringPaymentProxy.sol | 81 +++++++++ .../ERC20RecurringPaymentProxy.test.ts | 172 +++++++++++++++--- 2 files changed, 227 insertions(+), 26 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 394140ad3a..140daa624c 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -38,6 +38,18 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran 'uint256 nonce,uint256 deadline,bool strictOrder)' ); + 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( + '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; @@ -60,6 +72,26 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran bool strictOrder; } + struct Leg { + address recipient; + uint128 amount; + bytes8 paymentReference; + } + + struct SchedulePermitBatch { + address subscriber; + address token; + uint128 relayerFee; + uint8 totalPayments; + uint256 nonce; + uint256 deadline; + bool strictOrder; + bytes32 scheduleId; + uint32[] dueTimes; + Leg[] initialLegs; + Leg[] recurringLegs; + } + constructor( address adminSafe, address relayerEOA, @@ -80,6 +112,55 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran return _hashTypedDataV4(structHash); } + function hashSchedule(SchedulePermit calldata p) public view returns (bytes32) { + return _hashSchedule(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 _hashScheduleBatch(SchedulePermitBatch calldata p) 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, + _hashUint32Array(p.dueTimes), + _hashLegs(p.initialLegs), + _hashLegs(p.recurringLegs) + ) + ); + + return _hashTypedDataV4(structHash); + } + + function hashScheduleBatch(SchedulePermitBatch calldata p) public view returns (bytes32) { + return _hashScheduleBatch(p); + } + function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { erc20FeeProxy.transferFromWithReferenceAndFee( p.token, diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index ed8ee0d855..155dc3fd03 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -81,32 +81,61 @@ describe('ERC20RecurringPaymentProxy', () => { }; }; + const schedulePermitTypes = { + 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 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' }, + ], + }; + + const eip712Domain = async () => ({ + name: 'ERC20RecurringPaymentProxy', + version: '1', + chainId: await subscriber.getChainId(), + verifyingContract: erc20RecurringPaymentProxy.address, + }); + + const hashPermitOffchain = async (permit: any) => + ethers.utils._TypedDataEncoder.hash(await eip712Domain(), schedulePermitTypes, permit); + + const hashBatchOffchain = async (permit: any) => + ethers.utils._TypedDataEncoder.hash(await eip712Domain(), schedulePermitBatchTypes, permit); + // 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 domain = await eip712Domain(); // Some providers (Hardhat in-process) happily accept the string-encoded data (what // ethers' _signTypedData sends). Others (Hardhat JSON-RPC, Ganache) expect the object @@ -121,7 +150,7 @@ describe('ERC20RecurringPaymentProxy', () => { { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], - ...types, + ...schedulePermitTypes, }, primaryType: 'SchedulePermit', domain, @@ -134,7 +163,31 @@ describe('ERC20RecurringPaymentProxy', () => { 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, schedulePermitTypes, permit); + } + }; + + const createBatchSignature = async (permit: any, signer: Signer) => { + const domain = await eip712Domain(); + const address = await signer.getAddress(); + const typedDataObject = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + ...schedulePermitBatchTypes, + }, + primaryType: 'SchedulePermitBatch', + domain, + message: permit, + }; + try { + return await (signer.provider as any).send('eth_signTypedData', [address, typedDataObject]); + } catch (_) { + return await (signer as any)._signTypedData(domain, schedulePermitBatchTypes, permit); } }; @@ -652,4 +705,71 @@ describe('ERC20RecurringPaymentProxy', () => { ).to.be.revertedWith('Pausable: paused'); }); }); + + 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 SchedulePermit', async () => { + const permit = createSchedulePermit(); + expect(await erc20RecurringPaymentProxy.hashSchedule(permit)).to.equal( + await hashPermitOffchain(permit), + ); + }); + + 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)); + }); + }); }); From b2a429aa72c1e4e0f457d9e9de8086713e3d9e2e Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 12:52:45 +0200 Subject: [PATCH 02/17] feat(recurring): accept EIP-1271 smart-account signatures --- .../contracts/ERC20RecurringPaymentProxy.sol | 16 +++-- .../src/contracts/test/MockERC1271.sol | 48 +++++++++++++ .../ERC20RecurringPaymentProxy.test.ts | 67 +++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 packages/smart-contracts/src/contracts/test/MockERC1271.sol diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 140daa624c..faaabc3cd9 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -5,7 +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/utils/cryptography/SignatureChecker.sol'; import '@openzeppelin/contracts/access/Ownable.sol'; import './interfaces/ERC20FeeProxy.sol'; import './lib/SafeERC20.sol'; @@ -16,7 +16,6 @@ import './lib/SafeERC20.sol'; */ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, ReentrancyGuard, Ownable { using SafeERC20 for IERC20; - using ECDSA for bytes32; error ERC20RecurringPaymentProxy__BadSignature(); error ERC20RecurringPaymentProxy__SignatureExpired(); @@ -161,6 +160,16 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran return _hashScheduleBatch(p); } + function _assertSigner( + address subscriber, + bytes32 digest, + bytes calldata signature + ) private view { + if (!SignatureChecker.isValidSignatureNow(subscriber, digest, signature)) { + revert ERC20RecurringPaymentProxy__BadSignature(); + } + } + function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { erc20FeeProxy.transferFromWithReferenceAndFee( p.token, @@ -180,8 +189,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran ) external whenNotPaused onlyRole(RELAYER_ROLE) nonReentrant { bytes32 digest = _hashSchedule(p); - if (digest.recover(signature) != p.subscriber) - revert ERC20RecurringPaymentProxy__BadSignature(); + _assertSigner(p.subscriber, digest, signature); if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); if (index >= 256) revert ERC20RecurringPaymentProxy__IndexTooLarge(); 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/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index 155dc3fd03..dec7e050c5 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -675,6 +675,73 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); + describe('EIP-1271 signatures', () => { + const paymentReference = '0x1234567890abcdef'; + + 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 = createSchedulePermit({ subscriber: mockWallet.address }); + const signature = await createSignature(permit, subscriber); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testERC20.address, + recipientAddress, + permit.amount, + ethers.utils.keccak256(paymentReference), + permit.feeAmount, + feeAddressString, + ); + }); + + it('rejects a malformed 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 = createSchedulePermit({ subscriber: mockWallet.address }); + const signature = '0x' + '11'.repeat(65); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + }); + + it('still accepts an EOA signature through SignatureChecker', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + }); + }); + describe('Integration: Paused state affects execution', () => { it('should revert trigger when contract is paused', async () => { await erc20RecurringPaymentProxy.pause(); From 3bdf43331060a204431b606fe30d702909117029 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 13:12:59 +0200 Subject: [PATCH 03/17] fix(recurring): revert on a failed or short subscriber pull --- .../contracts/ERC20RecurringPaymentProxy.sol | 47 ++++++-- .../contracts/test/ERC20PullTestTokens.sol | 62 ++++++++++ .../ERC20RecurringPaymentProxy.test.ts | 108 ++++++++++++++++++ 3 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index faaabc3cd9..9d3f2b27bd 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -25,6 +25,8 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__NotDueYet(); error ERC20RecurringPaymentProxy__AlreadyPaid(); error ERC20RecurringPaymentProxy__ZeroAddress(); + error ERC20RecurringPaymentProxy__TransferFailed(); + error ERC20RecurringPaymentProxy__ShortPull(); bytes32 public constant RELAYER_ROLE = keccak256('RELAYER_ROLE'); @@ -170,6 +172,38 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } + function _pullExact( + IERC20 token, + address from, + uint256 amount + ) private { + uint256 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(); + } + } + + function _approveFeeProxy(IERC20 token, uint256 amount) private { + if (!token.safeApprove(address(erc20FeeProxy), 0)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); + } + if (!token.safeApprove(address(erc20FeeProxy), amount)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); + } + } + + function _payRelayer(IERC20 token, uint256 amount) private { + if (amount == 0) { + return; + } + if (!token.safeTransfer(msg.sender, amount)) { + revert ERC20RecurringPaymentProxy__TransferFailed(); + } + } + function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { erc20FeeProxy.transferFromWithReferenceAndFee( p.token, @@ -213,17 +247,10 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran uint256 total = p.amount + p.feeAmount + p.relayerFee; IERC20 token = IERC20(p.token); - token.safeTransferFrom(p.subscriber, address(this), total); - - /* USDT-safe zero-approve then set allowance */ - token.safeApprove(address(erc20FeeProxy), 0); - token.safeApprove(address(erc20FeeProxy), p.amount + p.feeAmount); - + _pullExact(token, p.subscriber, total); + _approveFeeProxy(token, p.amount + p.feeAmount); _proxyTransfer(p, paymentReference); - - if (p.relayerFee != 0) { - token.safeTransfer(msg.sender, p.relayerFee); - } + _payRelayer(token, p.relayerFee); } function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { 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..0d790b7e43 --- /dev/null +++ b/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol @@ -0,0 +1,62 @@ +// 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; + } +} diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index dec7e050c5..c06ed977e2 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -675,6 +675,114 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); + describe('Pull assertions', () => { + const paymentReference = '0x1234567890abcdef'; + + 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 = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + + await testERC20.transfer(subscriberAddress, 500); + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.not.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 = createSchedulePermit({ token: silentFail.address }); + const signature = await createSignature(permit, subscriber); + const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + expect(await silentFail.balanceOf(erc20RecurringPaymentProxy.address)).to.equal(500); + expect(await silentFail.balanceOf(recipientAddress)).to.equal(0); + }); + + 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(); + + await feeOnTransfer.transfer(subscriberAddress, 500); + await feeOnTransfer.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = createSchedulePermit({ token: feeOnTransfer.address }); + const signature = await createSignature(permit, subscriber); + const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__ShortPull'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + }); + + 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(); + + await silentFail.transfer(subscriberAddress, 500); + // No approve: transferFrom returns false instead of reverting. + + const permit = createSchedulePermit({ token: silentFail.address }); + const signature = await createSignature(permit, subscriber); + const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + }); + + 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 = createSchedulePermit({ token: failTransfer.address }); + const signature = await createSignature(permit, subscriber); + const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + expect(await failTransfer.balanceOf(recipientAddress)).to.equal(0); + }); + }); + describe('EIP-1271 signatures', () => { const paymentReference = '0x1234567890abcdef'; From 7e958fc7d1d0daf561e93d03153c1eb6fe6670bc Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 14:04:52 +0200 Subject: [PATCH 04/17] fix(recurring): bind batch schedule key to signed terms --- .../contracts/ERC20RecurringPaymentProxy.sol | 91 +++++++++-- .../ERC20RecurringPaymentProxy.test.ts | 141 ++++++++++++++++-- 2 files changed, 209 insertions(+), 23 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 9d3f2b27bd..17d52686e5 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -27,6 +27,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__ZeroAddress(); error ERC20RecurringPaymentProxy__TransferFailed(); error ERC20RecurringPaymentProxy__ShortPull(); + error ERC20RecurringPaymentProxy__ZeroScheduleId(); bytes32 public constant RELAYER_ROLE = keccak256('RELAYER_ROLE'); @@ -172,6 +173,78 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } + function _scheduleKeyFromPermit(SchedulePermit calldata p) private pure returns (bytes32) { + return + keccak256( + abi.encode( + p.subscriber, + p.token, + p.recipient, + p.feeAddress, + p.amount, + p.feeAmount, + p.relayerFee, + p.periodSeconds, + p.firstPayment, + p.totalPayments, + p.strictOrder + ) + ); + } + + function scheduleKeyFromPermit(SchedulePermit calldata p) public pure returns (bytes32) { + return _scheduleKeyFromPermit(p); + } + + function _scheduleKeyFromBatch(SchedulePermitBatch calldata p) 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, + _hashUint32Array(p.dueTimes), + _hashLegs(p.initialLegs), + _hashLegs(p.recurringLegs) + ) + ); + } + + function scheduleKeyFromBatch(SchedulePermitBatch calldata p) public pure returns (bytes32) { + return _scheduleKeyFromBatch(p); + } + + function _assertUnpaid(bytes32 scheduleKey, uint8 index) private view { + if (triggeredPaymentsBitmap[scheduleKey] & (1 << index) != 0) { + revert ERC20RecurringPaymentProxy__AlreadyPaid(); + } + } + + function _assertOrder( + bytes32 scheduleKey, + uint8 index, + bool strictOrder + ) private view { + if (strictOrder && index != lastPaymentIndex[scheduleKey] + 1) { + revert ERC20RecurringPaymentProxy__PaymentOutOfOrder(); + } + } + + function _markPaid( + bytes32 scheduleKey, + uint8 index, + bool strictOrder + ) private { + triggeredPaymentsBitmap[scheduleKey] |= (1 << index); + if (strictOrder) { + lastPaymentIndex[scheduleKey] = index; + } + } + function _pullExact( IERC20 token, address from, @@ -226,24 +299,17 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _assertSigner(p.subscriber, digest, signature); if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); + if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); if (index >= 256) revert ERC20RecurringPaymentProxy__IndexTooLarge(); - - if (p.strictOrder) { - if (index != lastPaymentIndex[digest] + 1) - revert ERC20RecurringPaymentProxy__PaymentOutOfOrder(); - lastPaymentIndex[digest] = index; - } - if (index > p.totalPayments) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); + bytes32 scheduleKey = _scheduleKeyFromPermit(p); + _assertOrder(scheduleKey, index, p.strictOrder); + _assertUnpaid(scheduleKey, index); + uint256 execTime = uint256(p.firstPayment) + uint256(index - 1) * p.periodSeconds; if (block.timestamp < execTime) revert ERC20RecurringPaymentProxy__NotDueYet(); - uint256 mask = 1 << index; - uint256 word = triggeredPaymentsBitmap[digest]; - if (word & mask != 0) revert ERC20RecurringPaymentProxy__AlreadyPaid(); - triggeredPaymentsBitmap[digest] = word | mask; - uint256 total = p.amount + p.feeAmount + p.relayerFee; IERC20 token = IERC20(p.token); @@ -251,6 +317,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _approveFeeProxy(token, p.amount + p.feeAmount); _proxyTransfer(p, paymentReference); _payRelayer(token, p.relayerFee); + _markPaid(scheduleKey, index, p.strictOrder); } function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index c06ed977e2..79b7925ce4 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -684,20 +684,20 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = createSchedulePermit(); const signature = await createSignature(permit, subscriber); - const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); await expect( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPayment(permit, signature, 1, paymentReference), ).to.be.reverted; - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); await testERC20.transfer(subscriberAddress, 500); await erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPayment(permit, signature, 1, paymentReference); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.not.equal(0); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.not.equal(0); }); it('cannot settle an unfunded subscriber from a residual proxy balance', async () => { @@ -709,14 +709,14 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = createSchedulePermit({ token: silentFail.address }); const signature = await createSignature(permit, subscriber); - const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); await expect( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPayment(permit, signature, 1, paymentReference), ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + 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); }); @@ -731,14 +731,14 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = createSchedulePermit({ token: feeOnTransfer.address }); const signature = await createSignature(permit, subscriber); - const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); await expect( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPayment(permit, signature, 1, paymentReference), ).to.be.revertedWith('ERC20RecurringPaymentProxy__ShortPull'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); }); it('reverts when the token returns false without reverting', async () => { @@ -751,14 +751,14 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = createSchedulePermit({ token: silentFail.address }); const signature = await createSignature(permit, subscriber); - const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); await expect( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPayment(permit, signature, 1, paymentReference), ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); }); it('does not mark the cycle paid when the relayer-fee transfer fails', async () => { @@ -771,18 +771,137 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = createSchedulePermit({ token: failTransfer.address }); const signature = await createSignature(permit, subscriber); - const digest = await erc20RecurringPaymentProxy.hashSchedule(permit); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); await expect( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPayment(permit, signature, 1, paymentReference), ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(digest)).to.equal(0); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); expect(await failTransfer.balanceOf(recipientAddress)).to.equal(0); }); }); + describe('Schedule key replay', () => { + const paymentReference = '0x1234567890abcdef'; + + it('re-signing with a new nonce or deadline does not reset paid indices', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference); + + const resigned = { ...permit, nonce: 1, deadline: permit.deadline + 86400 }; + const resignedSignature = await createSignature(resigned, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); + + expect(await erc20RecurringPaymentProxy.scheduleKeyFromPermit(resigned)).to.equal( + scheduleKey, + ); + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(resigned, resignedSignature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__AlreadyPaid'); + }); + + it('rejects index 0', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 0, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__IndexOutOfBounds'); + }); + + 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('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', 32), + }, + ], + }), + ).to.not.equal(key); + }); + + 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 expect(erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit)).to.be.revertedWith( + 'ERC20RecurringPaymentProxy__ZeroScheduleId', + ); + }); + }); + describe('EIP-1271 signatures', () => { const paymentReference = '0x1234567890abcdef'; From 8b5ecb3792b14de7924601509c81ad232edf74ab Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 14:07:25 +0200 Subject: [PATCH 05/17] fix(recurring): guard rescueTokens with nonReentrant --- .../contracts/ERC20RecurringPaymentProxy.sol | 21 ++++++++ .../ERC20RecurringPaymentProxy.test.ts | 50 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 17d52686e5..f7468f3c98 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -277,6 +277,12 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } + function _assertNonZeroRecipient(address account, uint256 amount) private pure { + if (amount > 0 && account == address(0)) { + revert ERC20RecurringPaymentProxy__ZeroAddress(); + } + } + function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { erc20FeeProxy.transferFromWithReferenceAndFee( p.token, @@ -310,6 +316,8 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran uint256 execTime = uint256(p.firstPayment) + uint256(index - 1) * p.periodSeconds; if (block.timestamp < execTime) revert ERC20RecurringPaymentProxy__NotDueYet(); + _assertNonZeroRecipient(p.feeAddress, p.feeAmount); + uint256 total = p.amount + p.feeAmount + p.relayerFee; IERC20 token = IERC20(p.token); @@ -338,4 +346,17 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran function unpause() external onlyOwner { _unpause(); } + + function rescueTokens( + address token, + address to, + uint256 amount + ) external onlyOwner 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/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index 79b7925ce4..6bf857c928 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -380,6 +380,56 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); + describe('Fee destination and rescue', () => { + const paymentReference = '0x1234567890abcdef'; + + it('reverts when feeAmount is non-zero and feeAddress is zero', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = createSchedulePermit({ feeAddress: ethers.constants.AddressZero }); + const signature = await createSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + }); + + it('allows the owner to rescue a residual balance', async () => { + await testERC20.transfer(erc20RecurringPaymentProxy.address, 40); + const ownerBalanceBefore = await testERC20.balanceOf(ownerAddress); + + 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('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('Ownable: caller is not the owner'); + }); + + it('reverts rescue to the zero address', async () => { + await testERC20.transfer(erc20RecurringPaymentProxy.address, 10); + + await expect( + erc20RecurringPaymentProxy.rescueTokens( + testERC20.address, + ethers.constants.AddressZero, + 10, + ), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); + }); + }); + describe('Trigger Recurring Payment', () => { beforeEach(async () => { // Transfer tokens to subscriber and approve the recurring payment proxy From 7fb81172d261014900299c933f8840a28ff64ece Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 14:29:10 +0200 Subject: [PATCH 06/17] fix(recurring): assert all schedule legs before summing the paid cycle --- .../contracts/ERC20RecurringPaymentProxy.sol | 121 +++++- .../contracts/test/ERC20PullTestTokens.sol | 23 ++ .../ERC20RecurringPaymentProxy.test.ts | 364 +++++++++++++++++- 3 files changed, 489 insertions(+), 19 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index f7468f3c98..5555c81222 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -19,7 +19,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__BadSignature(); error ERC20RecurringPaymentProxy__SignatureExpired(); - error ERC20RecurringPaymentProxy__IndexTooLarge(); error ERC20RecurringPaymentProxy__PaymentOutOfOrder(); error ERC20RecurringPaymentProxy__IndexOutOfBounds(); error ERC20RecurringPaymentProxy__NotDueYet(); @@ -28,6 +27,12 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__TransferFailed(); error ERC20RecurringPaymentProxy__ShortPull(); error ERC20RecurringPaymentProxy__ZeroScheduleId(); + error ERC20RecurringPaymentProxy__InvalidDueTimes(); + error ERC20RecurringPaymentProxy__TooManyLegs(); + error ERC20RecurringPaymentProxy__EmptyLegs(); + error ERC20RecurringPaymentProxy__ZeroAmount(); + + uint8 public constant MAX_LEGS = 8; bytes32 public constant RELAYER_ROLE = keccak256('RELAYER_ROLE'); @@ -259,11 +264,15 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } - function _approveFeeProxy(IERC20 token, uint256 amount) private { - if (!token.safeApprove(address(erc20FeeProxy), 0)) { + function _approveFeeProxy( + IERC20 token, + IERC20FeeProxy proxy, + uint256 amount + ) private { + if (!token.safeApprove(address(proxy), 0)) { revert ERC20RecurringPaymentProxy__TransferFailed(); } - if (!token.safeApprove(address(erc20FeeProxy), amount)) { + if (!token.safeApprove(address(proxy), amount)) { revert ERC20RecurringPaymentProxy__TransferFailed(); } } @@ -283,6 +292,53 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } + 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(); + } + } + } + + 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); + } + } + + function _sumLegs(Leg[] calldata legs) private pure returns (uint256 sum) { + for (uint256 i = 0; i < legs.length; ++i) { + sum += legs[i].amount; + } + } + + 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 _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { erc20FeeProxy.transferFromWithReferenceAndFee( p.token, @@ -306,7 +362,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); - if (index >= 256) revert ERC20RecurringPaymentProxy__IndexTooLarge(); if (index > p.totalPayments) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); bytes32 scheduleKey = _scheduleKeyFromPermit(p); @@ -322,12 +377,66 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran IERC20 token = IERC20(p.token); _pullExact(token, p.subscriber, total); - _approveFeeProxy(token, p.amount + p.feeAmount); + _approveFeeProxy(token, erc20FeeProxy, p.amount + p.feeAmount); _proxyTransfer(p, paymentReference); _payRelayer(token, p.relayerFee); _markPaid(scheduleKey, index, p.strictOrder); } + function triggerRecurringPaymentBatch( + SchedulePermitBatch calldata p, + bytes calldata signature, + uint8 index + ) external whenNotPaused onlyRole(RELAYER_ROLE) nonReentrant { + if (p.token == address(0) || p.subscriber == address(0)) { + revert ERC20RecurringPaymentProxy__ZeroAddress(); + } + + bytes32 digest = _hashScheduleBatch(p); + + _assertSigner(p.subscriber, digest, signature); + if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); + + if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); + 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(); + } + + _assertScheduleLegs(p); + + bytes32 scheduleKey = _scheduleKeyFromBatch(p); + _assertOrder(scheduleKey, index, p.strictOrder); + _assertUnpaid(scheduleKey, index); + + bool useInitial = p.initialLegs.length != 0 && index == 1; + uint256 legsSum = _sumLegs(useInitial ? p.initialLegs : p.recurringLegs); + uint256 payerTotal = legsSum + p.relayerFee; + + _markPaid(scheduleKey, index, p.strictOrder); + + IERC20 token = IERC20(p.token); + IERC20FeeProxy proxy = erc20FeeProxy; + _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); + } + function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { if (newRelayer == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); _revokeRole(RELAYER_ROLE, oldRelayer); diff --git a/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol b/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol index 0d790b7e43..0013d01cbf 100644 --- a/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol +++ b/packages/smart-contracts/src/contracts/test/ERC20PullTestTokens.sol @@ -60,3 +60,26 @@ contract ERC20FailTransfer is ERC20 { 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/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index 6bf857c928..c64eb31aec 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -526,18 +526,6 @@ describe('ERC20RecurringPaymentProxy', () => { ).to.be.reverted; }); - it('should revert when index is too large (>= 256)', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 256, paymentReference), - ).to.be.reverted; - }); - it('should revert when execution is out of order', async () => { const permit = createSchedulePermit({ strictOrder: true, periodSeconds: 1 }); const signature = await createSignature(permit, subscriber); @@ -874,6 +862,17 @@ describe('ERC20RecurringPaymentProxy', () => { ).to.be.revertedWith('ERC20RecurringPaymentProxy__IndexOutOfBounds'); }); + it('rejects index 256 before the call is encoded', async () => { + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 256, paymentReference), + ).to.be.reverted; + }); + it('keeps the batch schedule key stable across nonce and deadline re-sign', async () => { const permit = { subscriber: subscriberAddress, @@ -925,7 +924,7 @@ describe('ERC20RecurringPaymentProxy', () => { { recipient: recipientAddress, amount: 1, - paymentReference: ethers.utils.hexZeroPad('0x01', 32), + paymentReference: ethers.utils.hexZeroPad('0x01', 8), }, ], }), @@ -1050,6 +1049,345 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); + 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) => { + 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) + .triggerRecurringPaymentBatch(permit, signature, 1), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + token.address, + recipientAddress, + 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, + ); + + 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, + ); + + 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('still collects a pre-existing single-fee permit on the same instance', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const now = (await ethers.provider.getBlock('latest')).timestamp; + const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); + const signature = await createSignature(permit, subscriber); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, '0x1234567890abcdef'), + ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + }); + + it('reverts a zero token without moving balances', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + 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 expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); + }); + + it('reverts when index is greater than totalPayments', async () => { + const permit = workedExample(testERC20.address); + const signature = await createBatchSignature(permit, subscriber); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, permit.totalPayments + 1), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__IndexOutOfBounds'); + }); + + 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( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch( + decreasing, + await createBatchSignature(decreasing, subscriber), + 1, + ), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__InvalidDueTimes'); + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(equal, await createBatchSignature(equal, subscriber), 1), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__InvalidDueTimes'); + }); + + 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) + .triggerRecurringPaymentBatch(permit, signature, 256), + ).to.be.reverted; + }); + + it('reverts a zero subscriber without moving balances', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + 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 expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('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) }, + ], + }); + + 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) + .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('reverts a failing middle leg with balances and bitmap unchanged', async () => { + await expectFailedLegUnchanged(userAddress, recipientAddress, userAddress, feeAddressString); + }); + + it('reverts a failing last leg with balances and bitmap unchanged', async () => { + await expectFailedLegUnchanged( + feeAddressString, + recipientAddress, + userAddress, + feeAddressString, + ); + }); + + 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 expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('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('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); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('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); + }); + }); + describe('EIP-712 digest parity', () => { const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); From a844f32618329a899e29fbb066e3f86e09ca819c Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 14:31:25 +0200 Subject: [PATCH 07/17] docs(recurring): warn cancel does not drop allowance --- .../contracts/ERC20RecurringPaymentProxy.sol | 39 ++++++++ .../ERC20RecurringPaymentProxy.test.ts | 95 +++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 5555c81222..5a7a84e921 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -31,6 +31,8 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__TooManyLegs(); error ERC20RecurringPaymentProxy__EmptyLegs(); error ERC20RecurringPaymentProxy__ZeroAmount(); + error ERC20RecurringPaymentProxy__NotSubscriber(); + error ERC20RecurringPaymentProxy__Cancelled(); uint8 public constant MAX_LEGS = 8; @@ -60,6 +62,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran /* replay defence */ mapping(bytes32 => uint256) public triggeredPaymentsBitmap; mapping(bytes32 => uint8) public lastPaymentIndex; + mapping(bytes32 => bool) public cancelledSchedules; IERC20FeeProxy public erc20FeeProxy; @@ -223,6 +226,18 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran return _scheduleKeyFromBatch(p); } + function _assertSubscriber(address subscriber) private view { + if (msg.sender != subscriber) revert ERC20RecurringPaymentProxy__NotSubscriber(); + } + + function _assertNotCancelled(bytes32 scheduleKey) private view { + if (cancelledSchedules[scheduleKey]) revert ERC20RecurringPaymentProxy__Cancelled(); + } + + function _cancel(bytes32 scheduleKey) private { + cancelledSchedules[scheduleKey] = true; + } + function _assertUnpaid(bytes32 scheduleKey, uint8 index) private view { if (triggeredPaymentsBitmap[scheduleKey] & (1 << index) != 0) { revert ERC20RecurringPaymentProxy__AlreadyPaid(); @@ -365,6 +380,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran if (index > p.totalPayments) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); bytes32 scheduleKey = _scheduleKeyFromPermit(p); + _assertNotCancelled(scheduleKey); _assertOrder(scheduleKey, index, p.strictOrder); _assertUnpaid(scheduleKey, index); @@ -416,6 +432,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _assertScheduleLegs(p); bytes32 scheduleKey = _scheduleKeyFromBatch(p); + _assertNotCancelled(scheduleKey); _assertOrder(scheduleKey, index, p.strictOrder); _assertUnpaid(scheduleKey, index); @@ -437,6 +454,28 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _payRelayer(token, p.relayerFee); } + /** + * @notice Blocks further triggers for this single-fee 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 cancelSchedule(SchedulePermit calldata p) external { + _assertSubscriber(p.subscriber); + _cancel(_scheduleKeyFromPermit(p)); + } + + /** + * @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); + _cancel(_scheduleKeyFromBatch(p)); + } + function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { if (newRelayer == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); _revokeRole(RELAYER_ROLE, oldRelayer); diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index c64eb31aec..bc4d12771f 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -1388,6 +1388,101 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); + describe('cancelSchedule', () => { + const paymentReference = '0x1234567890abcdef'; + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + + const latestTs = async () => (await ethers.provider.getBlock('latest')).timestamp; + + const simpleBatch = async () => { + const now = await latestTs(); + return { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 0, + totalPayments: 1, + nonce: 0, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0303030303030303030303030303030303030303030303030303030303030303', + dueTimes: [now], + initialLegs: [], + recurringLegs: [{ recipient: recipientAddress, amount: 10, paymentReference: ref(0x21) }], + }; + }; + + it('blocks the single-fee entry point after the subscriber cancels', async () => { + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const now = await latestTs(); + const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); + const signature = await createSignature(permit, subscriber); + + await erc20RecurringPaymentProxy.connect(subscriber).cancelSchedule(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__Cancelled'); + }); + + 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); + await erc20RecurringPaymentProxy.connect(subscriber).cancelScheduleBatch(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__Cancelled'); + }); + + it('keeps a cancelled single-fee schedule cancelled after a deadline re-sign', async () => { + const now = await latestTs(); + const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); + await erc20RecurringPaymentProxy.connect(subscriber).cancelSchedule(permit); + + const resigned = { ...permit, deadline: now + 86400 * 30 }; + const signature = await createSignature(resigned, subscriber); + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(resigned, signature, 1, paymentReference), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__Cancelled'); + }); + + it('reverts when a non-subscriber tries to cancel', async () => { + const permit = createSchedulePermit(); + await expect( + erc20RecurringPaymentProxy.connect(relayer).cancelSchedule(permit), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotSubscriber'); + await expect( + erc20RecurringPaymentProxy.connect(user).cancelScheduleBatch(await simpleBatch()), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotSubscriber'); + }); + + it("reverts when another subscriber tries to cancel someone else's schedule", async () => { + const permit = createSchedulePermit(); + const hijack = { ...permit, subscriber: userAddress }; + await expect(erc20RecurringPaymentProxy.connect(user).cancelSchedule(hijack)).to.not.be + .reverted; + expect( + await erc20RecurringPaymentProxy.cancelledSchedules( + await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit), + ), + ).to.equal(false); + }); + }); + describe('EIP-712 digest parity', () => { const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); From bc691531e70610c224002fd4e1a8759eaa81599f Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 15:05:02 +0200 Subject: [PATCH 08/17] feat(recurring): add revokeCycles for admitted bits --- .../contracts/ERC20RecurringPaymentProxy.sol | 36 ++- .../ERC20RecurringPaymentProxy.test.ts | 210 ++++++++++++++++++ 2 files changed, 243 insertions(+), 3 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 5a7a84e921..5881be4241 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -33,6 +33,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__ZeroAmount(); error ERC20RecurringPaymentProxy__NotSubscriber(); error ERC20RecurringPaymentProxy__Cancelled(); + error ERC20RecurringPaymentProxy__NotAdmitted(); uint8 public constant MAX_LEGS = 8; @@ -63,6 +64,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran mapping(bytes32 => uint256) public triggeredPaymentsBitmap; mapping(bytes32 => uint8) public lastPaymentIndex; mapping(bytes32 => bool) public cancelledSchedules; + mapping(bytes32 => uint256) public admittedCycles; IERC20FeeProxy public erc20FeeProxy; @@ -238,6 +240,32 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran cancelledSchedules[scheduleKey] = true; } + function _assertRelayerOrAdmitted( + address subscriber, + bytes32 scheduleKey, + uint8 index + ) private view { + if (hasRole(RELAYER_ROLE, msg.sender)) { + return; + } + if (msg.sender != subscriber) revert ERC20RecurringPaymentProxy__NotSubscriber(); + if (admittedCycles[scheduleKey] & (1 << index) == 0) { + revert ERC20RecurringPaymentProxy__NotAdmitted(); + } + } + + function admitCycles(bytes32 scheduleKey, uint256 mask) external onlyRole(RELAYER_ROLE) { + admittedCycles[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) { + admittedCycles[scheduleKey] &= ~mask; + } + function _assertUnpaid(bytes32 scheduleKey, uint8 index) private view { if (triggeredPaymentsBitmap[scheduleKey] & (1 << index) != 0) { revert ERC20RecurringPaymentProxy__AlreadyPaid(); @@ -403,17 +431,20 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran SchedulePermitBatch calldata p, bytes calldata signature, uint8 index - ) external whenNotPaused onlyRole(RELAYER_ROLE) nonReentrant { + ) external whenNotPaused nonReentrant { if (p.token == address(0) || p.subscriber == address(0)) { revert ERC20RecurringPaymentProxy__ZeroAddress(); } + if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); + + bytes32 scheduleKey = _scheduleKeyFromBatch(p); + _assertRelayerOrAdmitted(p.subscriber, scheduleKey, index); bytes32 digest = _hashScheduleBatch(p); _assertSigner(p.subscriber, digest, signature); if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); - if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); if (p.totalPayments == 0 || index > p.totalPayments) { revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); } @@ -431,7 +462,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _assertScheduleLegs(p); - bytes32 scheduleKey = _scheduleKeyFromBatch(p); _assertNotCancelled(scheduleKey); _assertOrder(scheduleKey, index, p.strictOrder); _assertUnpaid(scheduleKey, index); diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index bc4d12771f..35aa6fbaf9 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -1483,6 +1483,216 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); + describe('admitCycles', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 32); + 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); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('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 erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(3)); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 4), + ).to.be.revertedWith('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) + .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 expect( + erc20RecurringPaymentProxy.connect(user).triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('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 expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('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('keeps the single-fee entry point relayer-only', async () => { + const now = await latestTs(); + const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); + const signature = await createSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); + await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPayment(permit, signature, 1, '0x1234567890abcdef'), + ).to.be.revertedWith('AccessControl: account'); + }); + + 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'); + }); + }); + + describe('revokeCycles', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 32); + 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 erc20RecurringPaymentProxy.connect(relayer).revokeCycles(scheduleKey, bit(3)); + + await expect( + erc20RecurringPaymentProxy + .connect(subscriber) + .triggerRecurringPaymentBatch(permit, signature, 3), + ).to.be.revertedWith('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'); + }); + }); + describe('EIP-712 digest parity', () => { const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); From 6cd7e96a85f592766bd3904534c3d144e24ceba7 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 18:01:19 +0200 Subject: [PATCH 09/17] test(recurring): accept and reject EIP-1271 batch signatures --- .../contracts/ERC20RecurringPaymentProxy.sol | 124 +-- .../ERC20RecurringPaymentProxy.test.ts | 960 ++++-------------- 2 files changed, 225 insertions(+), 859 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 5881be4241..a4ddfffd51 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -26,6 +26,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__ZeroAddress(); error ERC20RecurringPaymentProxy__TransferFailed(); error ERC20RecurringPaymentProxy__ShortPull(); + error ERC20RecurringPaymentProxy__UnexpectedBalance(); error ERC20RecurringPaymentProxy__ZeroScheduleId(); error ERC20RecurringPaymentProxy__InvalidDueTimes(); error ERC20RecurringPaymentProxy__TooManyLegs(); @@ -39,15 +40,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran bytes32 public constant RELAYER_ROLE = keccak256('RELAYER_ROLE'); - /* keccak256 of the typed-data struct with relayerFee field */ - bytes32 private constant _PERMIT_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)' - ); - bytes32 private constant _LEG_TYPEHASH = keccak256('Leg(address recipient,uint128 amount,bytes8 paymentReference)'); @@ -68,22 +60,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran IERC20FeeProxy public erc20FeeProxy; - struct 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; - } - struct Leg { address recipient; uint128 amount; @@ -118,16 +94,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran erc20FeeProxy = IERC20FeeProxy(erc20FeeProxyAddress); } - function _hashSchedule(SchedulePermit calldata p) private view returns (bytes32) { - bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, p)); - - return _hashTypedDataV4(structHash); - } - - function hashSchedule(SchedulePermit calldata p) public view returns (bytes32) { - return _hashSchedule(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) { @@ -183,29 +149,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } - function _scheduleKeyFromPermit(SchedulePermit calldata p) private pure returns (bytes32) { - return - keccak256( - abi.encode( - p.subscriber, - p.token, - p.recipient, - p.feeAddress, - p.amount, - p.feeAmount, - p.relayerFee, - p.periodSeconds, - p.firstPayment, - p.totalPayments, - p.strictOrder - ) - ); - } - - function scheduleKeyFromPermit(SchedulePermit calldata p) public pure returns (bytes32) { - return _scheduleKeyFromPermit(p); - } - function _scheduleKeyFromBatch(SchedulePermitBatch calldata p) private pure returns (bytes32) { if (p.scheduleId == bytes32(0)) revert ERC20RecurringPaymentProxy__ZeroScheduleId(); return @@ -297,8 +240,8 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran IERC20 token, address from, uint256 amount - ) private { - uint256 balanceBefore = token.balanceOf(address(this)); + ) private returns (uint256 balanceBefore) { + balanceBefore = token.balanceOf(address(this)); if (!token.safeTransferFrom(from, address(this), amount)) { revert ERC20RecurringPaymentProxy__TransferFailed(); } @@ -382,51 +325,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } - function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { - erc20FeeProxy.transferFromWithReferenceAndFee( - p.token, - p.recipient, - p.amount, - paymentReference, - p.feeAmount, - p.feeAddress - ); - } - - function triggerRecurringPayment( - SchedulePermit calldata p, - bytes calldata signature, - uint8 index, - bytes calldata paymentReference - ) external whenNotPaused onlyRole(RELAYER_ROLE) nonReentrant { - bytes32 digest = _hashSchedule(p); - - _assertSigner(p.subscriber, digest, signature); - if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); - - if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); - if (index > p.totalPayments) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); - - bytes32 scheduleKey = _scheduleKeyFromPermit(p); - _assertNotCancelled(scheduleKey); - _assertOrder(scheduleKey, index, p.strictOrder); - _assertUnpaid(scheduleKey, index); - - uint256 execTime = uint256(p.firstPayment) + uint256(index - 1) * p.periodSeconds; - if (block.timestamp < execTime) revert ERC20RecurringPaymentProxy__NotDueYet(); - - _assertNonZeroRecipient(p.feeAddress, p.feeAmount); - - uint256 total = p.amount + p.feeAmount + p.relayerFee; - - IERC20 token = IERC20(p.token); - _pullExact(token, p.subscriber, total); - _approveFeeProxy(token, erc20FeeProxy, p.amount + p.feeAmount); - _proxyTransfer(p, paymentReference); - _payRelayer(token, p.relayerFee); - _markPaid(scheduleKey, index, p.strictOrder); - } - function triggerRecurringPaymentBatch( SchedulePermitBatch calldata p, bytes calldata signature, @@ -474,7 +372,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran IERC20 token = IERC20(p.token); IERC20FeeProxy proxy = erc20FeeProxy; - _pullExact(token, p.subscriber, payerTotal); + uint256 baseline = _pullExact(token, p.subscriber, payerTotal); _approveFeeProxy(token, proxy, legsSum); if (useInitial) { _settleLegs(proxy, p.token, p.initialLegs); @@ -482,17 +380,9 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _settleLegs(proxy, p.token, p.recurringLegs); } _payRelayer(token, p.relayerFee); - } - - /** - * @notice Blocks further triggers for this single-fee 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 cancelSchedule(SchedulePermit calldata p) external { - _assertSubscriber(p.subscriber); - _cancel(_scheduleKeyFromPermit(p)); + if (token.balanceOf(address(this)) != baseline) { + revert ERC20RecurringPaymentProxy__UnexpectedBalance(); + } } /** diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index 35aa6fbaf9..a5c2f54665 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -60,45 +60,6 @@ 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 schedulePermitTypes = { - 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 schedulePermitBatchTypes = { SchedulePermitBatch: [ { name: 'subscriber', type: 'address' }, @@ -127,46 +88,9 @@ describe('ERC20RecurringPaymentProxy', () => { verifyingContract: erc20RecurringPaymentProxy.address, }); - const hashPermitOffchain = async (permit: any) => - ethers.utils._TypedDataEncoder.hash(await eip712Domain(), schedulePermitTypes, permit); - const hashBatchOffchain = async (permit: any) => ethers.utils._TypedDataEncoder.hash(await eip712Domain(), schedulePermitBatchTypes, permit); - // Helper function to create EIP712 signature - const createSignature = async (permit: any, signer: Signer) => { - const domain = await eip712Domain(); - - // 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 typedDataObject = { - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - ...schedulePermitTypes, - }, - primaryType: 'SchedulePermit', - 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, schedulePermitTypes, permit); - } - }; - const createBatchSignature = async (permit: any, signer: Signer) => { const domain = await eip712Domain(); const address = await signer.getAddress(); @@ -381,24 +305,6 @@ describe('ERC20RecurringPaymentProxy', () => { }); describe('Fee destination and rescue', () => { - const paymentReference = '0x1234567890abcdef'; - - it('reverts when feeAmount is non-zero and feeAddress is zero', async () => { - await testERC20.transfer(subscriberAddress, 500); - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - - const permit = createSchedulePermit({ feeAddress: ethers.constants.AddressZero }); - const signature = await createSignature(permit, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); - }); - it('allows the owner to rescue a residual balance', async () => { await testERC20.transfer(erc20RecurringPaymentProxy.address, 40); const ownerBalanceBefore = await testERC20.balanceOf(ownerAddress); @@ -430,449 +336,7 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); - 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); - }); - - it('should trigger a valid recurring payment', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - const subscriberBalanceBefore = await testERC20.balanceOf(subscriberAddress); - const recipientBalanceBefore = await testERC20.balanceOf(recipientAddress); - const feeAddressBalanceBefore = await testERC20.balanceOf(feeAddressString); - const relayerBalanceBefore = await testERC20.balanceOf(relayerAddress); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ) - .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') - .withArgs( - testERC20.address, - recipientAddress, - permit.amount, - ethers.utils.keccak256(paymentReference), - permit.feeAmount, - feeAddressString, - ); - - // 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 - }); - - it('should revert when called by non-relayer', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - await expect( - erc20RecurringPaymentProxy - .connect(user) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('AccessControl: account'); - }); - - it('should revert when contract is paused', async () => { - await erc20RecurringPaymentProxy.pause(); - - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('Pausable: paused'); - }); - - it('should revert with bad signature', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, user); // Wrong signer - const paymentReference = '0x1234567890abcdef'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).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'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; - }); - - 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'; - - // 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', []); - - // Try to execute index 2 before index 1 - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.be.reverted; - }); - - 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'; - - // Fast forward time to make multiple payments due - await ethers.provider.send('evm_increaseTime', [5]); - await ethers.provider.send('evm_mine', []); - - // Execute index 2 before index 1, which should be allowed - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.not.be.reverted; - }); - - it('should revert when index is out of bounds', async () => { - const permit = createSchedulePermit({ totalPayments: 1 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.be.reverted; - }); - - it('should revert when payment is not due yet', async () => { - const permit = createSchedulePermit({ - firstPayment: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now - }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; - }); - - it('should revert when payment is already triggered', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - // Trigger first time - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); - - // Try to trigger the same index again - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; - }); - - 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'; - - // Trigger first payment - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); - - // Advance time by periodSeconds to allow second payment - await ethers.provider.send('evm_increaseTime', [permit.periodSeconds]); - await ethers.provider.send('evm_mine', []); - - // Trigger second payment - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference); - - // Advance time by periodSeconds to allow third payment - await ethers.provider.send('evm_increaseTime', [permit.periodSeconds]); - await ethers.provider.send('evm_mine', []); - - // Trigger third payment - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 3, paymentReference); - - // 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 - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 2, paymentReference), - ).to.be.reverted; // Should fail because already triggered - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 3, paymentReference), - ).to.be.reverted; // Should fail because already triggered - }); - - it('should handle zero relayer fee correctly', async () => { - const permit = createSchedulePermit({ relayerFee: 0 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - const relayerBalanceBefore = await testERC20.balanceOf(relayerAddress); - - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); - - const relayerBalanceAfter = await testERC20.balanceOf(relayerAddress); - expect(relayerBalanceAfter).to.equal(relayerBalanceBefore); // No relayer fee transferred - }); - - it('should handle zero fee amount correctly', async () => { - const permit = createSchedulePermit({ feeAmount: 0 }); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - const feeAddressBalanceBefore = await testERC20.balanceOf(feeAddressString); - - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); - - const feeAddressBalanceAfter = await testERC20.balanceOf(feeAddressString); - expect(feeAddressBalanceAfter).to.equal(feeAddressBalanceBefore); // No fee transferred - }); - - 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'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; - }); - - it('should revert when subscriber has insufficient allowance', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const paymentReference = '0x1234567890abcdef'; - - // Revoke approval - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 0); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; - }); - }); - - describe('Pull assertions', () => { - const paymentReference = '0x1234567890abcdef'; - - 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 = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); - - await testERC20.transfer(subscriberAddress, 500); - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.not.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 = createSchedulePermit({ token: silentFail.address }); - const signature = await createSignature(permit, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('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('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(); - - await feeOnTransfer.transfer(subscriberAddress, 500); - await feeOnTransfer.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - - const permit = createSchedulePermit({ token: feeOnTransfer.address }); - const signature = await createSignature(permit, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ShortPull'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); - }); - - 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(); - - await silentFail.transfer(subscriberAddress, 500); - // No approve: transferFrom returns false instead of reverting. - - const permit = createSchedulePermit({ token: silentFail.address }); - const signature = await createSignature(permit, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); - }); - - 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 = createSchedulePermit({ token: failTransfer.address }); - const signature = await createSignature(permit, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); - expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); - expect(await failTransfer.balanceOf(recipientAddress)).to.equal(0); - }); - }); - describe('Schedule key replay', () => { - const paymentReference = '0x1234567890abcdef'; - - it('re-signing with a new nonce or deadline does not reset paid indices', async () => { - await testERC20.transfer(subscriberAddress, 500); - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - await erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference); - - const resigned = { ...permit, nonce: 1, deadline: permit.deadline + 86400 }; - const resignedSignature = await createSignature(resigned, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - - expect(await erc20RecurringPaymentProxy.scheduleKeyFromPermit(resigned)).to.equal( - scheduleKey, - ); - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(resigned, resignedSignature, 1, paymentReference), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__AlreadyPaid'); - }); - - it('rejects index 0', async () => { - await testERC20.transfer(subscriberAddress, 500); - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 0, paymentReference), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__IndexOutOfBounds'); - }); - - it('rejects index 256 before the call is encoded', async () => { - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 256, paymentReference), - ).to.be.reverted; - }); - it('keeps the batch schedule key stable across nonce and deadline re-sign', async () => { const permit = { subscriber: subscriberAddress, @@ -919,133 +383,35 @@ describe('ERC20RecurringPaymentProxy', () => { ).to.not.equal(key); expect( await erc20RecurringPaymentProxy.scheduleKeyFromBatch({ - ...permit, - recurringLegs: [ - { - recipient: recipientAddress, - amount: 1, - paymentReference: ethers.utils.hexZeroPad('0x01', 8), - }, - ], - }), - ).to.not.equal(key); - }); - - 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 expect(erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit)).to.be.revertedWith( - 'ERC20RecurringPaymentProxy__ZeroScheduleId', - ); - }); - }); - - describe('EIP-1271 signatures', () => { - const paymentReference = '0x1234567890abcdef'; - - 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 = createSchedulePermit({ subscriber: mockWallet.address }); - const signature = await createSignature(permit, subscriber); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ) - .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') - .withArgs( - testERC20.address, - recipientAddress, - permit.amount, - ethers.utils.keccak256(paymentReference), - permit.feeAmount, - feeAddressString, - ); - }); - - it('rejects a malformed 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 = createSchedulePermit({ subscriber: mockWallet.address }); - const signature = '0x' + '11'.repeat(65); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.reverted; - }); - - it('still accepts an EOA signature through SignatureChecker', async () => { - await testERC20.transfer(subscriberAddress, 500); - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - - const permit = createSchedulePermit(); - const signature = await createSignature(permit, subscriber); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); + ...permit, + recurringLegs: [ + { + recipient: recipientAddress, + amount: 1, + paymentReference: ethers.utils.hexZeroPad('0x01', 8), + }, + ], + }), + ).to.not.equal(key); }); - }); - - describe('Integration: Paused state affects execution', () => { - it('should revert trigger when contract is paused', async () => { - await erc20RecurringPaymentProxy.pause(); - // Create a minimal SchedulePermit for testing - const schedulePermit = { - subscriber: userAddress, + it('rejects a zero batch scheduleId', async () => { + const permit = { + 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: Math.floor(Date.now() / 1000) + 86400, + strictOrder: false, + scheduleId: ethers.constants.HashZero, + dueTimes: [Math.floor(Date.now() / 1000)], + initialLegs: [], + recurringLegs: [], }; - - const signature = '0x' + '0'.repeat(130); // Dummy signature - const paymentReference = '0x1234'; - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(schedulePermit, signature, 1, paymentReference), - ).to.be.revertedWith('Pausable: paused'); + await expect(erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit)).to.be.revertedWith( + 'ERC20RecurringPaymentProxy__ZeroScheduleId', + ); }); }); @@ -1152,21 +518,6 @@ describe('ERC20RecurringPaymentProxy', () => { expect(await token.balanceOf(recipientAddress)).to.equal(129_000_000); }); - it('still collects a pre-existing single-fee permit on the same instance', async () => { - await testERC20.transfer(subscriberAddress, 500); - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); - - const now = (await ethers.provider.getBlock('latest')).timestamp; - const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); - const signature = await createSignature(permit, subscriber); - - await expect( - erc20RecurringPaymentProxy - .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, '0x1234567890abcdef'), - ).to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee'); - }); - it('reverts a zero token without moving balances', async () => { await testERC20.transfer(subscriberAddress, 500); await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); @@ -1388,103 +739,249 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); - describe('cancelSchedule', () => { - const paymentReference = '0x1234567890abcdef'; + describe('Pull assertions', () => { const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); - const latestTs = async () => (await ethers.provider.getBlock('latest')).timestamp; - - const simpleBatch = async () => { - const now = await latestTs(); + const pullPermit = async (tokenAddress: string, overrides: Record = {}) => { + const now = (await ethers.provider.getBlock('latest')).timestamp; return { subscriber: subscriberAddress, - token: testERC20.address, - relayerFee: 0, + token: tokenAddress, + relayerFee: 5, totalPayments: 1, nonce: 0, deadline: now + 86400, strictOrder: false, - scheduleId: '0x0303030303030303030303030303030303030303030303030303030303030303', - dueTimes: [now], + scheduleId: '0x0606060606060606060606060606060606060606060606060606060606060606', + dueTimes: [now - 1], initialLegs: [], - recurringLegs: [{ recipient: recipientAddress, amount: 10, paymentReference: ref(0x21) }], + recurringLegs: [{ recipient: recipientAddress, amount: 100, paymentReference: ref(0x41) }], + ...overrides, }; }; - it('blocks the single-fee entry point after the subscriber cancels', async () => { - await testERC20.transfer(subscriberAddress, 500); + 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 now = await latestTs(); - const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); - const signature = await createSignature(permit, subscriber); + const permit = await pullPermit(testERC20.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); - await erc20RecurringPaymentProxy.connect(subscriber).cancelSchedule(permit); + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.reverted; + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + + 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 expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(permit, signature, 1, paymentReference), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__Cancelled'); + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('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('blocks the batch entry point after the subscriber cancels', async () => { - await testERC20.transfer(subscriberAddress, 500); - await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + 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 permit = await simpleBatch(); + await feeOnTransfer.transfer(subscriberAddress, 500); + await feeOnTransfer.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + + const permit = await pullPermit(feeOnTransfer.address); const signature = await createBatchSignature(permit, subscriber); - await erc20RecurringPaymentProxy.connect(subscriber).cancelScheduleBatch(permit); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); await expect( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__Cancelled'); + ).to.be.revertedWith('ERC20RecurringPaymentProxy__ShortPull'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + }); + + 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(); + + await silentFail.transfer(subscriberAddress, 500); + // No approve: transferFrom returns false instead of reverting. + + const permit = await pullPermit(silentFail.address); + const signature = await createBatchSignature(permit, subscriber); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); }); - it('keeps a cancelled single-fee schedule cancelled after a deadline re-sign', async () => { + 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 expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); + expect(await failTransfer.balanceOf(recipientAddress)).to.equal(0); + }); + }); + + 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) + .triggerRecurringPaymentBatch(permit, signature, 1), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testERC20.address, + recipientAddress, + 100, + ethers.utils.keccak256(ref(0x51)), + 0, + ethers.constants.AddressZero, + ); + }); + + it('rejects a malformed 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 = '0x' + '11'.repeat(65); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + ).to.be.revertedWith('ERC20RecurringPaymentProxy__BadSignature'); + }); + }); + + describe('cancelSchedule', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + + const latestTs = async () => (await ethers.provider.getBlock('latest')).timestamp; + + const simpleBatch = async () => { const now = await latestTs(); - const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); - await erc20RecurringPaymentProxy.connect(subscriber).cancelSchedule(permit); + return { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 0, + totalPayments: 1, + nonce: 0, + deadline: now + 86400, + strictOrder: false, + scheduleId: '0x0303030303030303030303030303030303030303030303030303030303030303', + dueTimes: [now], + initialLegs: [], + recurringLegs: [{ recipient: recipientAddress, amount: 10, paymentReference: ref(0x21) }], + }; + }; - const resigned = { ...permit, deadline: now + 86400 * 30 }; - const signature = await createSignature(resigned, subscriber); + 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); + await erc20RecurringPaymentProxy.connect(subscriber).cancelScheduleBatch(permit); + await expect( erc20RecurringPaymentProxy .connect(relayer) - .triggerRecurringPayment(resigned, signature, 1, paymentReference), + .triggerRecurringPaymentBatch(permit, signature, 1), ).to.be.revertedWith('ERC20RecurringPaymentProxy__Cancelled'); }); it('reverts when a non-subscriber tries to cancel', async () => { - const permit = createSchedulePermit(); - await expect( - erc20RecurringPaymentProxy.connect(relayer).cancelSchedule(permit), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotSubscriber'); await expect( erc20RecurringPaymentProxy.connect(user).cancelScheduleBatch(await simpleBatch()), ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotSubscriber'); }); - - it("reverts when another subscriber tries to cancel someone else's schedule", async () => { - const permit = createSchedulePermit(); - const hijack = { ...permit, subscriber: userAddress }; - await expect(erc20RecurringPaymentProxy.connect(user).cancelSchedule(hijack)).to.not.be - .reverted; - expect( - await erc20RecurringPaymentProxy.cancelledSchedules( - await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit), - ), - ).to.equal(false); - }); }); describe('admitCycles', () => { - const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 32); + 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); @@ -1600,20 +1097,6 @@ describe('ERC20RecurringPaymentProxy', () => { ).to.be.revertedWith('Pausable: paused'); }); - it('keeps the single-fee entry point relayer-only', async () => { - const now = await latestTs(); - const permit = createSchedulePermit({ firstPayment: now, deadline: now + 86400 }); - const signature = await createSignature(permit, subscriber); - const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromPermit(permit); - await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); - - await expect( - erc20RecurringPaymentProxy - .connect(subscriber) - .triggerRecurringPayment(permit, signature, 1, '0x1234567890abcdef'), - ).to.be.revertedWith('AccessControl: account'); - }); - it('reverts when a non-relayer tries to admit cycles', async () => { const permit = await batchPermit(); const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); @@ -1624,7 +1107,7 @@ describe('ERC20RecurringPaymentProxy', () => { }); describe('revokeCycles', () => { - const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 32); + 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); @@ -1724,13 +1207,6 @@ describe('ERC20RecurringPaymentProxy', () => { }; }; - it('matches ethers _TypedDataEncoder for SchedulePermit', async () => { - const permit = createSchedulePermit(); - expect(await erc20RecurringPaymentProxy.hashSchedule(permit)).to.equal( - await hashPermitOffchain(permit), - ); - }); - it('matches ethers _TypedDataEncoder for the worked-example SchedulePermitBatch', async () => { const permit = workedExample(); expect(await erc20RecurringPaymentProxy.hashScheduleBatch(permit)).to.equal( From f8f84dacd426fca76c227288f4615cf70ecb8a5b Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 18:07:29 +0200 Subject: [PATCH 10/17] feat(recurring): emit schedule and admin events --- .../contracts/ERC20RecurringPaymentProxy.sol | 21 ++++++++++++++++++- .../ERC20RecurringPaymentProxy.test.ts | 21 ++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index a4ddfffd51..6f94e143d9 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -58,6 +58,18 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran mapping(bytes32 => bool) public cancelledSchedules; mapping(bytes32 => uint256) public admittedCycles; + 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 Leg { @@ -199,6 +211,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran function admitCycles(bytes32 scheduleKey, uint256 mask) external onlyRole(RELAYER_ROLE) { admittedCycles[scheduleKey] |= mask; + emit CyclesAdmitted(scheduleKey, mask); } /** @@ -207,6 +220,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran */ function revokeCycles(bytes32 scheduleKey, uint256 mask) external onlyRole(RELAYER_ROLE) { admittedCycles[scheduleKey] &= ~mask; + emit CyclesRevoked(scheduleKey, mask); } function _assertUnpaid(bytes32 scheduleKey, uint8 index) private view { @@ -383,6 +397,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran if (token.balanceOf(address(this)) != baseline) { revert ERC20RecurringPaymentProxy__UnexpectedBalance(); } + emit PaymentTriggered(scheduleKey, p.subscriber, p.token, index, payerTotal); } /** @@ -393,7 +408,9 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran */ function cancelScheduleBatch(SchedulePermitBatch calldata p) external { _assertSubscriber(p.subscriber); - _cancel(_scheduleKeyFromBatch(p)); + bytes32 scheduleKey = _scheduleKeyFromBatch(p); + _cancel(scheduleKey); + emit ScheduleCancelled(scheduleKey, p.subscriber); } function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { @@ -404,7 +421,9 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran function setFeeProxy(address newProxy) external onlyOwner { if (newProxy == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); + address oldProxy = address(erc20FeeProxy); erc20FeeProxy = IERC20FeeProxy(newProxy); + emit FeeProxyUpdated(oldProxy, newProxy); } function pause() external onlyOwner { diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index a5c2f54665..0c3cf76bc5 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -207,7 +207,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); }); @@ -488,7 +490,9 @@ describe('ERC20RecurringPaymentProxy', () => { 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); @@ -964,7 +968,10 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = await simpleBatch(); const signature = await createBatchSignature(permit, subscriber); - await erc20RecurringPaymentProxy.connect(subscriber).cancelScheduleBatch(permit); + const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); + await expect(erc20RecurringPaymentProxy.connect(subscriber).cancelScheduleBatch(permit)) + .to.emit(erc20RecurringPaymentProxy, 'ScheduleCancelled') + .withArgs(scheduleKey, subscriberAddress); await expect( erc20RecurringPaymentProxy @@ -1026,7 +1033,9 @@ describe('ERC20RecurringPaymentProxy', () => { 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).admitCycles(scheduleKey, bit(3))) + .to.emit(erc20RecurringPaymentProxy, 'CyclesAdmitted') + .withArgs(scheduleKey, bit(3)); await expect( erc20RecurringPaymentProxy @@ -1141,7 +1150,9 @@ describe('ERC20RecurringPaymentProxy', () => { 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).revokeCycles(scheduleKey, bit(3))) + .to.emit(erc20RecurringPaymentProxy, 'CyclesRevoked') + .withArgs(scheduleKey, bit(3)); await expect( erc20RecurringPaymentProxy From c1a5f989492f551d7025cd35d605d2a851eb74ae Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 18:10:02 +0200 Subject: [PATCH 11/17] fix(recurring): grant and revoke relayer as separate calls --- .../contracts/ERC20RecurringPaymentProxy.sol | 35 ++++-- .../ERC20RecurringPaymentProxy.test.ts | 108 ++++++++++-------- 2 files changed, 86 insertions(+), 57 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 6f94e143d9..f90c62aaa6 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -6,7 +6,6 @@ import '@openzeppelin/contracts/security/Pausable.sol'; import '@openzeppelin/contracts/security/ReentrancyGuard.sol'; import '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; import '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; -import '@openzeppelin/contracts/access/Ownable.sol'; import './interfaces/ERC20FeeProxy.sol'; import './lib/SafeERC20.sol'; @@ -14,7 +13,7 @@ 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; error ERC20RecurringPaymentProxy__BadSignature(); @@ -35,9 +34,12 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran 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'); bytes32 private constant _LEG_TYPEHASH = @@ -102,7 +104,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } _grantRole(DEFAULT_ADMIN_ROLE, adminSafe); _grantRole(RELAYER_ROLE, relayerEOA); - transferOwnership(adminSafe); erc20FeeProxy = IERC20FeeProxy(erc20FeeProxyAddress); } @@ -413,24 +414,36 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran emit ScheduleCancelled(scheduleKey, p.subscriber); } - function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { - if (newRelayer == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); - _revokeRole(RELAYER_ROLE, oldRelayer); - _grantRole(RELAYER_ROLE, newRelayer); + /** + * @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); + } + + /** + * @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(); } @@ -438,7 +451,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran address token, address to, uint256 amount - ) external onlyOwner nonReentrant { + ) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant { if (token == address(0) || to == address(0)) { revert ERC20RecurringPaymentProxy__ZeroAddress(); } diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index 0c3cf76bc5..e88a520648 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -119,7 +119,6 @@ describe('ERC20RecurringPaymentProxy', () => { 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(), @@ -169,16 +168,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(), @@ -187,18 +186,51 @@ 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).grantRelayer(newRelayerAddress), + ).to.be.revertedWith('AccessControl: account'); await expect( - erc20RecurringPaymentProxy.connect(user).setRelayer(relayerAddress, newRelayerAddress), - ).to.be.revertedWith('Ownable: caller is not the owner'); + 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 expect( + erc20RecurringPaymentProxy.grantRelayer(ethers.constants.AddressZero), + ).to.be.revertedWith('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 expect(erc20RecurringPaymentProxy.revokeRelayer(userAddress)).to.be.revertedWith( + 'ERC20RecurringPaymentProxy__NotRelayer', + ); + + expect(await erc20RecurringPaymentProxy.hasRole(relayerRole, relayerAddress)).to.be.true; + expect(await erc20RecurringPaymentProxy.hasRole(relayerRole, userAddress)).to.be.false; }); }); @@ -219,7 +251,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 () => { @@ -244,7 +276,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', ); }); @@ -252,7 +284,7 @@ describe('ERC20RecurringPaymentProxy', () => { await erc20RecurringPaymentProxy.pause(); await expect(erc20RecurringPaymentProxy.connect(user).unpause()).to.be.revertedWith( - 'Ownable: caller is not the owner', + 'AccessControl: account', ); }); @@ -271,38 +303,22 @@ describe('ERC20RecurringPaymentProxy', () => { }); }); - describe('Ownership', () => { - it('should allow owner to transfer ownership', async () => { - await erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress); - expect(await erc20RecurringPaymentProxy.owner()).to.equal(newOwnerAddress); - }); - - it('should revert when non-owner tries to transfer ownership', async () => { - await expect( - erc20RecurringPaymentProxy.connect(user).transferOwnership(newOwnerAddress), - ).to.be.revertedWith('Ownable: caller is not the owner'); - }); - - it('should emit OwnershipTransferred event', async () => { - await expect(erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress)) - .to.emit(erc20RecurringPaymentProxy, 'OwnershipTransferred') - .withArgs(ownerAddress, newOwnerAddress); - }); - - it('should allow new owner to renounce ownership', async () => { - await erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress); - - await expect(erc20RecurringPaymentProxy.connect(newOwner).renounceOwnership()) - .to.emit(erc20RecurringPaymentProxy, 'OwnershipTransferred') - .withArgs(newOwnerAddress, ethers.constants.AddressZero); + 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; - expect(await erc20RecurringPaymentProxy.owner()).to.equal(ethers.constants.AddressZero); + await erc20RecurringPaymentProxy.connect(newOwner).revokeRole(adminRole, ownerAddress); + expect(await erc20RecurringPaymentProxy.hasRole(adminRole, ownerAddress)).to.be.false; }); - 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 when a non-admin tries to grant admin', async () => { + await expect( + erc20RecurringPaymentProxy + .connect(user) + .grantRole(await erc20RecurringPaymentProxy.DEFAULT_ADMIN_ROLE(), userAddress), + ).to.be.revertedWith('AccessControl: account'); }); }); @@ -322,7 +338,7 @@ describe('ERC20RecurringPaymentProxy', () => { await expect( erc20RecurringPaymentProxy.connect(user).rescueTokens(testERC20.address, userAddress, 10), - ).to.be.revertedWith('Ownable: caller is not the owner'); + ).to.be.revertedWith('AccessControl: account'); }); it('reverts rescue to the zero address', async () => { From c0340547269bb7910a9d1dc37f2a116022362b40 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 18:19:07 +0200 Subject: [PATCH 12/17] refactor(recurring): pack schedule state into one mapping --- .../contracts/ERC20RecurringPaymentProxy.sol | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index f90c62aaa6..c94aef361e 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -54,11 +54,14 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran 'Leg(address recipient,uint128 amount,bytes8 paymentReference)' ); - /* replay defence */ - mapping(bytes32 => uint256) public triggeredPaymentsBitmap; - mapping(bytes32 => uint8) public lastPaymentIndex; - mapping(bytes32 => bool) public cancelledSchedules; - mapping(bytes32 => uint256) public admittedCycles; + struct ScheduleState { + uint256 bitmap; + uint256 admitted; + uint8 lastIndex; + bool cancelled; + } + + mapping(bytes32 => ScheduleState) public schedules; event PaymentTriggered( bytes32 indexed scheduleKey, @@ -184,34 +187,50 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran return _scheduleKeyFromBatch(p); } + 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(bytes32 scheduleKey) private view { - if (cancelledSchedules[scheduleKey]) revert ERC20RecurringPaymentProxy__Cancelled(); + function _assertNotCancelled(ScheduleState storage state) private view { + if (state.cancelled) revert ERC20RecurringPaymentProxy__Cancelled(); } - function _cancel(bytes32 scheduleKey) private { - cancelledSchedules[scheduleKey] = true; + function _cancel(ScheduleState storage state) private { + state.cancelled = true; } function _assertRelayerOrAdmitted( address subscriber, - bytes32 scheduleKey, + ScheduleState storage state, uint8 index ) private view { if (hasRole(RELAYER_ROLE, msg.sender)) { return; } if (msg.sender != subscriber) revert ERC20RecurringPaymentProxy__NotSubscriber(); - if (admittedCycles[scheduleKey] & (1 << index) == 0) { + if (state.admitted & (1 << index) == 0) { revert ERC20RecurringPaymentProxy__NotAdmitted(); } } function admitCycles(bytes32 scheduleKey, uint256 mask) external onlyRole(RELAYER_ROLE) { - admittedCycles[scheduleKey] |= mask; + schedules[scheduleKey].admitted |= mask; emit CyclesAdmitted(scheduleKey, mask); } @@ -220,34 +239,34 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran * Relayer-initiated triggers are unaffected. */ function revokeCycles(bytes32 scheduleKey, uint256 mask) external onlyRole(RELAYER_ROLE) { - admittedCycles[scheduleKey] &= ~mask; + schedules[scheduleKey].admitted &= ~mask; emit CyclesRevoked(scheduleKey, mask); } - function _assertUnpaid(bytes32 scheduleKey, uint8 index) private view { - if (triggeredPaymentsBitmap[scheduleKey] & (1 << index) != 0) { + function _assertUnpaid(ScheduleState storage state, uint8 index) private view { + if (state.bitmap & (1 << index) != 0) { revert ERC20RecurringPaymentProxy__AlreadyPaid(); } } function _assertOrder( - bytes32 scheduleKey, + ScheduleState storage state, uint8 index, bool strictOrder ) private view { - if (strictOrder && index != lastPaymentIndex[scheduleKey] + 1) { + if (strictOrder && index != state.lastIndex + 1) { revert ERC20RecurringPaymentProxy__PaymentOutOfOrder(); } } function _markPaid( - bytes32 scheduleKey, + ScheduleState storage state, uint8 index, bool strictOrder ) private { - triggeredPaymentsBitmap[scheduleKey] |= (1 << index); + state.bitmap |= (1 << index); if (strictOrder) { - lastPaymentIndex[scheduleKey] = index; + state.lastIndex = index; } } @@ -351,7 +370,8 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); bytes32 scheduleKey = _scheduleKeyFromBatch(p); - _assertRelayerOrAdmitted(p.subscriber, scheduleKey, index); + ScheduleState storage state = schedules[scheduleKey]; + _assertRelayerOrAdmitted(p.subscriber, state, index); bytes32 digest = _hashScheduleBatch(p); @@ -375,15 +395,15 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _assertScheduleLegs(p); - _assertNotCancelled(scheduleKey); - _assertOrder(scheduleKey, index, p.strictOrder); - _assertUnpaid(scheduleKey, index); + _assertNotCancelled(state); + _assertOrder(state, index, p.strictOrder); + _assertUnpaid(state, index); bool useInitial = p.initialLegs.length != 0 && index == 1; uint256 legsSum = _sumLegs(useInitial ? p.initialLegs : p.recurringLegs); uint256 payerTotal = legsSum + p.relayerFee; - _markPaid(scheduleKey, index, p.strictOrder); + _markPaid(state, index, p.strictOrder); IERC20 token = IERC20(p.token); IERC20FeeProxy proxy = erc20FeeProxy; @@ -410,7 +430,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran function cancelScheduleBatch(SchedulePermitBatch calldata p) external { _assertSubscriber(p.subscriber); bytes32 scheduleKey = _scheduleKeyFromBatch(p); - _cancel(scheduleKey); + _cancel(schedules[scheduleKey]); emit ScheduleCancelled(scheduleKey, p.subscriber); } From 6d375729c013e610cfd3b329da5207064a65d421 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Mon, 24 Aug 2026 23:52:21 +0200 Subject: [PATCH 13/17] feat(recurring): add 0.2.0 ABI --- .../ERC20RecurringPaymentProxy/0.2.0.json | 526 ++++++++++++++++++ 1 file changed, 526 insertions(+) create mode 100644 packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.2.0.json 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..28e926cf49 --- /dev/null +++ b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.2.0.json @@ -0,0 +1,526 @@ +{ + "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__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" + } + ] +} From 0f6682a22151484631c0a992d3b316ab292c0274 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 00:02:24 +0200 Subject: [PATCH 14/17] fix(recurring): register versioned contract ABIs --- .../ERC20RecurringPaymentProxy/index.ts | 10 ++++++--- .../smart-contracts/test/lib/artifact.test.ts | 22 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) 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 { @@ -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), From 177aa918458c765949b32332612bcb0179a72e5d Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 10:57:23 +0200 Subject: [PATCH 15/17] test(recurring): cover remaining batch proxy paths --- .../ERC20RecurringPaymentProxy.test.ts | 591 ++++++++++++++++-- 1 file changed, 546 insertions(+), 45 deletions(-) diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index e88a520648..786016a9c7 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -3,6 +3,37 @@ import { Contract, Signer } from 'ethers'; import { ethers } from 'hardhat'; import { ERC20FeeProxy, TestERC20 } from '../../types'; +const containsRevertSelector = ( + value: unknown, + selector: string, + seen = new Set(), +): 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 erc20RecurringPaymentProxy: Contract; let erc20FeeProxy: ERC20FeeProxy; @@ -115,6 +146,35 @@ describe('ERC20RecurringPaymentProxy', () => { } }; + 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); @@ -136,6 +196,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', () => { @@ -217,21 +301,53 @@ describe('ERC20RecurringPaymentProxy', () => { }); it('reverts grant of the zero address', async () => { - await expect( + await expectCustomError( erc20RecurringPaymentProxy.grantRelayer(ethers.constants.AddressZero), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); + '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 expect(erc20RecurringPaymentProxy.revokeRelayer(userAddress)).to.be.revertedWith( + 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', + ); + }); }); describe('setFeeProxy', () => { @@ -301,6 +417,19 @@ 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('Admin role', () => { @@ -344,13 +473,21 @@ describe('ERC20RecurringPaymentProxy', () => { it('reverts rescue to the zero address', async () => { await testERC20.transfer(erc20RecurringPaymentProxy.address, 10); - await expect( + await expectCustomError( erc20RecurringPaymentProxy.rescueTokens( testERC20.address, ethers.constants.AddressZero, 10, ), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); + }); + + it('reverts rescue when the token is the zero address', async () => { + await expectCustomError( + erc20RecurringPaymentProxy.rescueTokens(ethers.constants.AddressZero, ownerAddress, 10), + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); }); }); @@ -427,7 +564,8 @@ describe('ERC20RecurringPaymentProxy', () => { initialLegs: [], recurringLegs: [], }; - await expect(erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit)).to.be.revertedWith( + await expectCustomError( + erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit), 'ERC20RecurringPaymentProxy__ZeroScheduleId', ); }); @@ -466,7 +604,12 @@ describe('ERC20RecurringPaymentProxy', () => { }); const warpTo = async (timestamp: number) => { - await ethers.provider.send('evm_setNextBlockTimestamp', [timestamp]); + 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', []); }; @@ -547,11 +690,12 @@ describe('ERC20RecurringPaymentProxy', () => { const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); const subscriberBefore = await testERC20.balanceOf(subscriberAddress); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); }); @@ -560,11 +704,12 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = workedExample(testERC20.address); const signature = await createBatchSignature(permit, subscriber); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, permit.totalPayments + 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__IndexOutOfBounds'); + 'ERC20RecurringPaymentProxy__IndexOutOfBounds', + ); }); it('reverts when dueTimes are not strictly increasing', async () => { @@ -578,7 +723,7 @@ describe('ERC20RecurringPaymentProxy', () => { dueTimes: [permit.dueTimes[0], permit.dueTimes[0], permit.dueTimes[2], permit.dueTimes[3]], }; - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch( @@ -586,12 +731,14 @@ describe('ERC20RecurringPaymentProxy', () => { await createBatchSignature(decreasing, subscriber), 1, ), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__InvalidDueTimes'); - await expect( + 'ERC20RecurringPaymentProxy__InvalidDueTimes', + ); + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(equal, await createBatchSignature(equal, subscriber), 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__InvalidDueTimes'); + 'ERC20RecurringPaymentProxy__InvalidDueTimes', + ); }); it('rejects index 256 before the call is encoded', async () => { @@ -617,11 +764,12 @@ describe('ERC20RecurringPaymentProxy', () => { const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); const subscriberBefore = await testERC20.balanceOf(subscriberAddress); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAddress'); + 'ERC20RecurringPaymentProxy__ZeroAddress', + ); expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); }); @@ -718,11 +866,12 @@ describe('ERC20RecurringPaymentProxy', () => { const recipientBefore = await testERC20.balanceOf(recipientAddress); const feeBefore = await testERC20.balanceOf(feeAddressString); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAmount'); + 'ERC20RecurringPaymentProxy__ZeroAmount', + ); expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); expect(await testERC20.balanceOf(subscriberAddress)).to.equal(subscriberBefore); @@ -747,16 +896,308 @@ describe('ERC20RecurringPaymentProxy', () => { const subscriberBefore = await testERC20.balanceOf(subscriberAddress); const recipientBefore = await testERC20.balanceOf(recipientAddress); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ZeroAmount'); + '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); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .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('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', + ); + }); + + 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) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__InvalidDueTimes', + ); + }); + + 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 createBatchSignature(permit, subscriber); + await expectCustomError( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__EmptyLegs', + ); + }); + + 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) + .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('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); + + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1); + + expect(await testERC20.balanceOf(recipientAddress)).to.equal(10 * maxLegs); + }); + + 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) + .triggerRecurringPaymentBatch(permit, signature, 1); + + 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); + + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPaymentBatch(permit, signature, 1); + + 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); + + await erc20RecurringPaymentProxy + .connect(relayer) + .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); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .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', () => { @@ -814,11 +1255,12 @@ describe('ERC20RecurringPaymentProxy', () => { const signature = await createBatchSignature(permit, subscriber); const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + '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); @@ -836,11 +1278,12 @@ describe('ERC20RecurringPaymentProxy', () => { const signature = await createBatchSignature(permit, subscriber); const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__ShortPull'); + 'ERC20RecurringPaymentProxy__ShortPull', + ); expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); }); @@ -856,11 +1299,12 @@ describe('ERC20RecurringPaymentProxy', () => { const signature = await createBatchSignature(permit, subscriber); const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + 'ERC20RecurringPaymentProxy__TransferFailed', + ); expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); }); @@ -876,11 +1320,12 @@ describe('ERC20RecurringPaymentProxy', () => { const signature = await createBatchSignature(permit, subscriber); const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__TransferFailed'); + 'ERC20RecurringPaymentProxy__TransferFailed', + ); expect(await erc20RecurringPaymentProxy.triggeredPaymentsBitmap(scheduleKey)).to.equal(0); expect(await failTransfer.balanceOf(recipientAddress)).to.equal(0); }); @@ -948,11 +1393,12 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = await walletPermit(mockWallet.address); const signature = '0x' + '11'.repeat(65); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__BadSignature'); + 'ERC20RecurringPaymentProxy__BadSignature', + ); }); }); @@ -989,17 +1435,26 @@ describe('ERC20RecurringPaymentProxy', () => { .to.emit(erc20RecurringPaymentProxy, 'ScheduleCancelled') .withArgs(scheduleKey, subscriberAddress); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(relayer) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__Cancelled'); + 'ERC20RecurringPaymentProxy__Cancelled', + ); }); it('reverts when a non-subscriber tries to cancel', async () => { - await expect( + await expectCustomError( erc20RecurringPaymentProxy.connect(user).cancelScheduleBatch(await simpleBatch()), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotSubscriber'); + '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; }); }); @@ -1036,11 +1491,12 @@ describe('ERC20RecurringPaymentProxy', () => { const permit = await batchPermit(); const signature = await createBatchSignature(permit, subscriber); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(subscriber) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotAdmitted'); + 'ERC20RecurringPaymentProxy__NotAdmitted', + ); }); it('does not let admitting index 3 admit index 4', async () => { @@ -1053,11 +1509,12 @@ describe('ERC20RecurringPaymentProxy', () => { .to.emit(erc20RecurringPaymentProxy, 'CyclesAdmitted') .withArgs(scheduleKey, bit(3)); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(subscriber) .triggerRecurringPaymentBatch(permit, signature, 4), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotAdmitted'); + 'ERC20RecurringPaymentProxy__NotAdmitted', + ); await expect( erc20RecurringPaymentProxy @@ -1085,9 +1542,10 @@ describe('ERC20RecurringPaymentProxy', () => { const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); - await expect( + await expectCustomError( erc20RecurringPaymentProxy.connect(user).triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotSubscriber'); + 'ERC20RecurringPaymentProxy__NotSubscriber', + ); }); it('still enforces NotDueYet on the self-trigger path', async () => { @@ -1100,11 +1558,12 @@ describe('ERC20RecurringPaymentProxy', () => { const scheduleKey = await erc20RecurringPaymentProxy.scheduleKeyFromBatch(permit); await erc20RecurringPaymentProxy.connect(relayer).admitCycles(scheduleKey, bit(1)); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(subscriber) .triggerRecurringPaymentBatch(permit, signature, 1), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotDueYet'); + 'ERC20RecurringPaymentProxy__NotDueYet', + ); }); it('still enforces pause on the self-trigger path', async () => { @@ -1129,6 +1588,16 @@ describe('ERC20RecurringPaymentProxy', () => { 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', () => { @@ -1170,11 +1639,12 @@ describe('ERC20RecurringPaymentProxy', () => { .to.emit(erc20RecurringPaymentProxy, 'CyclesRevoked') .withArgs(scheduleKey, bit(3)); - await expect( + await expectCustomError( erc20RecurringPaymentProxy .connect(subscriber) .triggerRecurringPaymentBatch(permit, signature, 3), - ).to.be.revertedWith('ERC20RecurringPaymentProxy__NotAdmitted'); + 'ERC20RecurringPaymentProxy__NotAdmitted', + ); }); it('lets the relayer trigger a cycle after its admitted bit is revoked', async () => { @@ -1201,6 +1671,37 @@ describe('ERC20RecurringPaymentProxy', () => { 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', () => { From 58145a72cf28dbb413bea4eebf1cefee8807ce97 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 11:26:25 +0200 Subject: [PATCH 16/17] fix(recurring): restore chain time after batch warp --- .../test/contracts/ERC20RecurringPaymentProxy.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index 786016a9c7..0a214a8b9e 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -35,6 +35,7 @@ const expectCustomError = async (call: Promise, errorName: string): Pro }; describe('ERC20RecurringPaymentProxy', () => { + let chainSnapshot: unknown; let erc20RecurringPaymentProxy: Contract; let erc20FeeProxy: ERC20FeeProxy; let testERC20: TestERC20; @@ -57,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(); From 7bc657a21b81b3bf92ad58bbcbbac76659628636 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 14:47:59 +0200 Subject: [PATCH 17/17] fix(recurring): unique refs, hash-once, and lastIndex overflow --- .../contracts/ERC20RecurringPaymentProxy.sol | 85 ++++++++++++++----- .../ERC20RecurringPaymentProxy/0.2.0.json | 5 ++ .../ERC20RecurringPaymentProxy.test.ts | 33 +++++++ 3 files changed, 102 insertions(+), 21 deletions(-) diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index c94aef361e..1c447dd116 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -30,6 +30,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran error ERC20RecurringPaymentProxy__InvalidDueTimes(); error ERC20RecurringPaymentProxy__TooManyLegs(); error ERC20RecurringPaymentProxy__EmptyLegs(); + error ERC20RecurringPaymentProxy__DuplicatePaymentReference(); error ERC20RecurringPaymentProxy__ZeroAmount(); error ERC20RecurringPaymentProxy__NotSubscriber(); error ERC20RecurringPaymentProxy__Cancelled(); @@ -61,6 +62,9 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran 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( @@ -130,7 +134,26 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran return keccak256(abi.encodePacked(words)); } - function _hashScheduleBatch(SchedulePermitBatch calldata p) private view returns (bytes32) { + 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, @@ -142,9 +165,9 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran p.deadline, p.strictOrder, p.scheduleId, - _hashUint32Array(p.dueTimes), - _hashLegs(p.initialLegs), - _hashLegs(p.recurringLegs) + dueTimesHash, + initialLegsHash, + recurringLegsHash ) ); @@ -152,7 +175,10 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } function hashScheduleBatch(SchedulePermitBatch calldata p) public view returns (bytes32) { - return _hashScheduleBatch(p); + (bytes32 dueTimesHash, bytes32 initialLegsHash, bytes32 recurringLegsHash) = _hashPermitParts( + p + ); + return _hashScheduleBatch(p, dueTimesHash, initialLegsHash, recurringLegsHash); } function _assertSigner( @@ -165,7 +191,12 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } - function _scheduleKeyFromBatch(SchedulePermitBatch calldata p) private pure returns (bytes32) { + 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( @@ -176,15 +207,30 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran p.relayerFee, p.totalPayments, p.strictOrder, - _hashUint32Array(p.dueTimes), - _hashLegs(p.initialLegs), - _hashLegs(p.recurringLegs) + dueTimesHash, + initialLegsHash, + recurringLegsHash ) ); } function scheduleKeyFromBatch(SchedulePermitBatch calldata p) public pure returns (bytes32) { - return _scheduleKeyFromBatch(p); + (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) { @@ -254,7 +300,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran uint8 index, bool strictOrder ) private view { - if (strictOrder && index != state.lastIndex + 1) { + if (strictOrder && uint256(index) != uint256(state.lastIndex) + 1) { revert ERC20RecurringPaymentProxy__PaymentOutOfOrder(); } } @@ -306,12 +352,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } } - function _assertNonZeroRecipient(address account, uint256 amount) private pure { - if (amount > 0 && account == address(0)) { - revert ERC20RecurringPaymentProxy__ZeroAddress(); - } - } - function _assertLegs(Leg[] calldata legs) private pure { if (legs.length == 0) revert ERC20RecurringPaymentProxy__EmptyLegs(); for (uint256 i = 0; i < legs.length; ++i) { @@ -321,6 +361,11 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran 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(); + } + } } } @@ -369,12 +414,10 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran } if (index == 0) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); - bytes32 scheduleKey = _scheduleKeyFromBatch(p); + (bytes32 scheduleKey, bytes32 digest) = _keyAndDigest(p); ScheduleState storage state = schedules[scheduleKey]; _assertRelayerOrAdmitted(p.subscriber, state, index); - bytes32 digest = _hashScheduleBatch(p); - _assertSigner(p.subscriber, digest, signature); if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); @@ -429,7 +472,7 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran */ function cancelScheduleBatch(SchedulePermitBatch calldata p) external { _assertSubscriber(p.subscriber); - bytes32 scheduleKey = _scheduleKeyFromBatch(p); + bytes32 scheduleKey = scheduleKeyFromBatch(p); _cancel(schedules[scheduleKey]); emit ScheduleCancelled(scheduleKey, p.subscriber); } 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 index 28e926cf49..b267f7a73d 100644 --- a/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.2.0.json +++ b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.2.0.json @@ -12,6 +12,11 @@ { "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" }, diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index 0a214a8b9e..8179f00cb0 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -1060,6 +1060,39 @@ describe('ERC20RecurringPaymentProxy', () => { 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) + .triggerRecurringPaymentBatch(permit, signature, 1), + 'ERC20RecurringPaymentProxy__DuplicatePaymentReference', + ); + }); + + 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', + ); + }); + it('reverts a zero-address leg recipient', async () => { const permit = await dueBatchPermit({ recurringLegs: [