diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 0c19b101a..a24f10463 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(); @@ -392,17 +420,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(); } @@ -420,7 +451,6 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran _assertLegArrays(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 b736a1e23..067d81dc9 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -1455,6 +1455,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);