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..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 @@ -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,144 @@ describe('erc20-recurring-payment-proxy 0.2.0', () => { ); }); }); + + const derivedScheduleKey = `0x${'11'.repeat(32)}`; + + describe('encodeAdmitCycles', () => { + it('encodes admitCycles without a deployment', () => { + const scheduleKey = derivedScheduleKey; + 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 = derivedScheduleKey; + 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 = derivedScheduleKey; + 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 = derivedScheduleKey; + 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'); + }); + }); });