From f650b5580f214d6f5862d87b56d3c982d5b0b802 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 14:02:34 +0200 Subject: [PATCH 1/2] feat(recurring): add 0.2.0 admit and revoke cycle helpers --- .../payment/erc20-recurring-payment-proxy.ts | 74 +++++++++ .../payment/erc-20-recurring-payment.test.ts | 142 ++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts b/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts index f31064aea..9fb6a0468 100644 --- a/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts +++ b/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts @@ -388,6 +388,80 @@ export async function signSchedulePermitBatch({ } } +/** + * Encodes the 0.2.0 `admitCycles` calldata. + * Does not require a deployed proxy address. + */ +export function encodeAdmitCycles({ + scheduleKey, + mask, +}: { + scheduleKey: string; + mask: BigNumberish; +}): string { + return getRecurringPaymentProxyInterface(RECURRING_PROXY_V2).encodeFunctionData('admitCycles', [ + scheduleKey, + mask, + ]); +} + +/** + * Admits cycles so a subscriber can self-trigger them. + * The signer must hold `RELAYER_ROLE`. + * + * @throws {Error} If the 0.2.0 proxy has no known deployment on the provided network + */ +export async function admitCycles({ + scheduleKey, + mask, + signer, + network, +}: { + scheduleKey: string; + mask: BigNumberish; + signer: Signer; + network: CurrencyTypes.EvmChainName; +}): Promise { + return sendToRecurringProxyV2(signer, network, encodeAdmitCycles({ scheduleKey, mask })); +} + +/** + * Encodes the 0.2.0 `revokeCycles` calldata. + * Does not require a deployed proxy address. + */ +export function encodeRevokeCycles({ + scheduleKey, + mask, +}: { + scheduleKey: string; + mask: BigNumberish; +}): string { + return getRecurringPaymentProxyInterface(RECURRING_PROXY_V2).encodeFunctionData('revokeCycles', [ + scheduleKey, + mask, + ]); +} + +/** + * Revokes previously admitted cycles. Relayer-initiated triggers are unaffected. + * The signer must hold `RELAYER_ROLE`. + * + * @throws {Error} If the 0.2.0 proxy has no known deployment on the provided network + */ +export async function revokeCycles({ + scheduleKey, + mask, + signer, + network, +}: { + scheduleKey: string; + mask: BigNumberish; + signer: Signer; + network: CurrencyTypes.EvmChainName; +}): Promise { + return sendToRecurringProxyV2(signer, network, encodeRevokeCycles({ scheduleKey, mask })); +} + async function sendToRecurringProxyV2( signer: Signer, network: CurrencyTypes.EvmChainName, diff --git a/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts b/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts index f712525c1..3812adad8 100644 --- a/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts +++ b/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts @@ -2,13 +2,17 @@ import { erc20RecurringPaymentProxyArtifact } from '@requestnetwork/smart-contra import { CurrencyTypes, PaymentTypes } from '@requestnetwork/types'; import { Wallet, providers, utils } from 'ethers'; import { + admitCycles, cancelScheduleBatch, + encodeAdmitCycles, encodeCancelScheduleBatch, encodeRecurringPaymentTrigger, encodeRecurringPaymentTriggerBatch, + encodeRevokeCycles, encodeSetRecurringAllowance, getRecurringPaymentProxyAddress, hashScheduleBatch, + revokeCycles, scheduleKeyFromBatch, signSchedulePermitBatch, triggerRecurringPayment, @@ -654,4 +658,142 @@ describe('erc20-recurring-payment-proxy 0.2.0', () => { ); }); }); + + describe('encodeAdmitCycles', () => { + it('encodes admitCycles without a deployment', () => { + const scheduleKey = schedulePermitBatch.scheduleId; + const mask = 2; + const encodedData = encodeAdmitCycles({ scheduleKey, mask }); + + expect(encodedData.startsWith('0x')).toBe(true); + + const iface = new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi('0.2.0')); + const decoded = iface.decodeFunctionData('admitCycles', encodedData); + expect(decoded.scheduleKey).toBe(scheduleKey); + expect(decoded.mask.toNumber()).toBe(mask); + }); + }); + + describe('admitCycles', () => { + const scheduleKey = schedulePermitBatch.scheduleId; + const mask = 2; + + it('should throw if the 0.2.0 proxy is not deployed', async () => { + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(''); + + await expect( + admitCycles({ + scheduleKey, + mask, + signer: wallet, + network, + }), + ).rejects.toThrow('ERC20RecurringPaymentProxy not found on private'); + }); + + it('sends admitCycles to the 0.2.0 address', async () => { + const mockProxyAddress = '0x1111111111111111111111111111111111111111'; + jest + .spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress') + .mockReturnValue(mockProxyAddress); + + const mockProvider = { + sendTransaction: jest.fn().mockResolvedValue({ + hash: '0xabcdef', + wait: jest.fn().mockResolvedValue({ status: 1, transactionHash: '0xabcdef' }), + }), + }; + const mockWallet = { + ...wallet, + provider: mockProvider, + sendTransaction: mockProvider.sendTransaction, + }; + + await admitCycles({ + scheduleKey, + mask, + signer: mockWallet as any, + network, + }); + + expect(mockProvider.sendTransaction).toHaveBeenCalledWith({ + to: mockProxyAddress, + data: expect.any(String), + value: 0, + }); + + const sentData = mockProvider.sendTransaction.mock.calls[0][0].data; + const iface = new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi('0.2.0')); + expect(iface.parseTransaction({ data: sentData }).name).toBe('admitCycles'); + }); + }); + + describe('encodeRevokeCycles', () => { + it('encodes revokeCycles without a deployment', () => { + const scheduleKey = schedulePermitBatch.scheduleId; + const mask = 2; + const encodedData = encodeRevokeCycles({ scheduleKey, mask }); + + expect(encodedData.startsWith('0x')).toBe(true); + + const iface = new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi('0.2.0')); + const decoded = iface.decodeFunctionData('revokeCycles', encodedData); + expect(decoded.scheduleKey).toBe(scheduleKey); + expect(decoded.mask.toNumber()).toBe(mask); + }); + }); + + describe('revokeCycles', () => { + const scheduleKey = schedulePermitBatch.scheduleId; + const mask = 2; + + it('should throw if the 0.2.0 proxy is not deployed', async () => { + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(''); + + await expect( + revokeCycles({ + scheduleKey, + mask, + signer: wallet, + network, + }), + ).rejects.toThrow('ERC20RecurringPaymentProxy not found on private'); + }); + + it('sends revokeCycles to the 0.2.0 address', async () => { + const mockProxyAddress = '0x1111111111111111111111111111111111111111'; + jest + .spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress') + .mockReturnValue(mockProxyAddress); + + const mockProvider = { + sendTransaction: jest.fn().mockResolvedValue({ + hash: '0xabcdef', + wait: jest.fn().mockResolvedValue({ status: 1, transactionHash: '0xabcdef' }), + }), + }; + const mockWallet = { + ...wallet, + provider: mockProvider, + sendTransaction: mockProvider.sendTransaction, + }; + + await revokeCycles({ + scheduleKey, + mask, + signer: mockWallet as any, + network, + }); + + expect(mockProvider.sendTransaction).toHaveBeenCalledWith({ + to: mockProxyAddress, + data: expect.any(String), + value: 0, + }); + + const sentData = mockProvider.sendTransaction.mock.calls[0][0].data; + const iface = new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi('0.2.0')); + expect(iface.parseTransaction({ data: sentData }).name).toBe('revokeCycles'); + }); + }); }); From 127aba591891f098e4264d4ea97bf6249cde933b Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 15:33:37 +0200 Subject: [PATCH 2/2] test(recurring): use derived schedule key in admit/revoke tests --- .../test/payment/erc-20-recurring-payment.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts b/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts index 3812adad8..7ec35502d 100644 --- a/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts +++ b/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts @@ -659,9 +659,11 @@ describe('erc20-recurring-payment-proxy 0.2.0', () => { }); }); + const derivedScheduleKey = `0x${'11'.repeat(32)}`; + describe('encodeAdmitCycles', () => { it('encodes admitCycles without a deployment', () => { - const scheduleKey = schedulePermitBatch.scheduleId; + const scheduleKey = derivedScheduleKey; const mask = 2; const encodedData = encodeAdmitCycles({ scheduleKey, mask }); @@ -675,7 +677,7 @@ describe('erc20-recurring-payment-proxy 0.2.0', () => { }); describe('admitCycles', () => { - const scheduleKey = schedulePermitBatch.scheduleId; + const scheduleKey = derivedScheduleKey; const mask = 2; it('should throw if the 0.2.0 proxy is not deployed', async () => { @@ -730,7 +732,7 @@ describe('erc20-recurring-payment-proxy 0.2.0', () => { describe('encodeRevokeCycles', () => { it('encodes revokeCycles without a deployment', () => { - const scheduleKey = schedulePermitBatch.scheduleId; + const scheduleKey = derivedScheduleKey; const mask = 2; const encodedData = encodeRevokeCycles({ scheduleKey, mask }); @@ -744,7 +746,7 @@ describe('erc20-recurring-payment-proxy 0.2.0', () => { }); describe('revokeCycles', () => { - const scheduleKey = schedulePermitBatch.scheduleId; + const scheduleKey = derivedScheduleKey; const mask = 2; it('should throw if the 0.2.0 proxy is not deployed', async () => {