Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => {
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<string, unknown> = {}) => {
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);

Expand Down