From 0c91c081d90af8a23b185365f32b7c5779c1014a Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 13:53:52 +0200 Subject: [PATCH] feat(recurring): add 0.2.0 cancel, hash, and sign helpers --- .../payment/erc20-recurring-payment-proxy.ts | 157 +++++++++++++++++- .../payment/erc-20-recurring-payment.test.ts | 153 +++++++++++++++++ 2 files changed, 302 insertions(+), 8 deletions(-) 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 d9ee9d549..f31064aea 100644 --- a/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts +++ b/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts @@ -5,6 +5,17 @@ import { ERC20__factory } from '@requestnetwork/smart-contracts/types'; import { getErc20Allowance } from './erc20'; const RECURRING_PROXY_V2 = '0.2.0'; +const EIP712_DOMAIN_NAME = 'ERC20RecurringPaymentProxy'; +const EIP712_DOMAIN_VERSION = '1'; + +function getSchedulePermitBatchDomain(chainId: number, verifyingContract: string) { + return { + name: EIP712_DOMAIN_NAME, + version: EIP712_DOMAIN_VERSION, + chainId, + verifyingContract, + }; +} function getRecurringPaymentProxyInterface(version: string): utils.Interface { return new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi(version)); @@ -243,21 +254,151 @@ export async function triggerRecurringPaymentBatch({ signer: Signer; network: CurrencyTypes.EvmChainName; }): Promise { - const proxyAddress = getRecurringPaymentProxyAddress(network, RECURRING_PROXY_V2); + return sendToRecurringProxyV2( + signer, + network, + encodeRecurringPaymentTriggerBatch({ + permitTuple, + permitSignature, + paymentIndex, + }), + ); +} + +/** + * Encodes the 0.2.0 `cancelScheduleBatch` calldata. + * Does not require a deployed proxy address. + */ +export function encodeCancelScheduleBatch({ + permitTuple, +}: { + permitTuple: PaymentTypes.SchedulePermitBatch; +}): string { + return getRecurringPaymentProxyInterface(RECURRING_PROXY_V2).encodeFunctionData( + 'cancelScheduleBatch', + [permitTuple], + ); +} - const data = encodeRecurringPaymentTriggerBatch({ +/** + * Cancels a 0.2.0 schedule. The signer must be the permit subscriber. + * + * @throws {Error} If the 0.2.0 proxy has no known deployment on the provided network + */ +export async function cancelScheduleBatch({ + permitTuple, + signer, + network, +}: { + permitTuple: PaymentTypes.SchedulePermitBatch; + signer: Signer; + network: CurrencyTypes.EvmChainName; +}): Promise { + return sendToRecurringProxyV2(signer, network, encodeCancelScheduleBatch({ permitTuple })); +} + +/** + * Off-chain EIP-712 digest of a 0.2.0 `SchedulePermitBatch`. + * Uses the same domain as the contract (`ERC20RecurringPaymentProxy` / `1`). + */ +export function hashScheduleBatch({ + permitTuple, + network, + chainId, +}: { + permitTuple: PaymentTypes.SchedulePermitBatch; + network: CurrencyTypes.EvmChainName; + chainId: number; +}): string { + const verifyingContract = getRecurringPaymentProxyAddress(network, RECURRING_PROXY_V2); + return utils._TypedDataEncoder.hash( + getSchedulePermitBatchDomain(chainId, verifyingContract), + PaymentTypes.SCHEDULE_PERMIT_BATCH_EIP712_TYPES, permitTuple, - permitSignature, - paymentIndex, - }); + ); +} - const tx = await signer.sendTransaction({ +/** + * On-chain `scheduleKeyFromBatch` for a 0.2.0 permit. + * + * @throws {Error} If the 0.2.0 proxy has no known deployment on the provided network + */ +export async function scheduleKeyFromBatch({ + permitTuple, + provider, + network, +}: { + permitTuple: PaymentTypes.SchedulePermitBatch; + provider: providers.Provider | Signer; + network: CurrencyTypes.EvmChainName; +}): Promise { + const proxyContract = connectRecurringPaymentProxy(network, provider, RECURRING_PROXY_V2); + return proxyContract.scheduleKeyFromBatch(permitTuple); +} + +/** + * Signs a 0.2.0 `SchedulePermitBatch` with EIP-712 typed data. + */ +export async function signSchedulePermitBatch({ + permitTuple, + signer, + network, +}: { + permitTuple: PaymentTypes.SchedulePermitBatch; + signer: Signer; + network: CurrencyTypes.EvmChainName; +}): Promise { + const verifyingContract = getRecurringPaymentProxyAddress(network, RECURRING_PROXY_V2); + const chainId = await signer.getChainId(); + const domain = getSchedulePermitBatchDomain(chainId, verifyingContract); + const types = PaymentTypes.SCHEDULE_PERMIT_BATCH_EIP712_TYPES; + const address = await signer.getAddress(); + + try { + if (!signer.provider) { + throw new Error('No provider'); + } + return await (signer.provider as providers.JsonRpcProvider).send('eth_signTypedData', [ + address, + { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + ...types, + }, + primaryType: 'SchedulePermitBatch', + domain, + message: permitTuple, + }, + ]); + } catch (_) { + return await ( + signer as Signer & { + _signTypedData: ( + typedDomain: ReturnType, + typedTypes: typeof PaymentTypes.SCHEDULE_PERMIT_BATCH_EIP712_TYPES, + value: PaymentTypes.SchedulePermitBatch, + ) => Promise; + } + )._signTypedData(domain, types, permitTuple); + } +} + +async function sendToRecurringProxyV2( + signer: Signer, + network: CurrencyTypes.EvmChainName, + data: string, +): Promise { + const proxyAddress = getRecurringPaymentProxyAddress(network, RECURRING_PROXY_V2); + return signer.sendTransaction({ to: proxyAddress, data, value: 0, }); - - return tx; } /** 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 dff3808a4..f712525c1 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,10 +2,15 @@ import { erc20RecurringPaymentProxyArtifact } from '@requestnetwork/smart-contra import { CurrencyTypes, PaymentTypes } from '@requestnetwork/types'; import { Wallet, providers, utils } from 'ethers'; import { + cancelScheduleBatch, + encodeCancelScheduleBatch, encodeRecurringPaymentTrigger, encodeRecurringPaymentTriggerBatch, encodeSetRecurringAllowance, getRecurringPaymentProxyAddress, + hashScheduleBatch, + scheduleKeyFromBatch, + signSchedulePermitBatch, triggerRecurringPayment, triggerRecurringPaymentBatch, } from '../../src/payment/erc20-recurring-payment-proxy'; @@ -501,4 +506,152 @@ describe('erc20-recurring-payment-proxy 0.2.0', () => { ).rejects.toThrow('Transaction failed'); }); }); + + describe('encodeCancelScheduleBatch', () => { + it('encodes cancelScheduleBatch without a deployment', () => { + const encodedData = encodeCancelScheduleBatch({ permitTuple: schedulePermitBatch }); + + expect(encodedData.startsWith('0x')).toBe(true); + + const iface = new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi('0.2.0')); + const decoded = iface.decodeFunctionData('cancelScheduleBatch', encodedData); + expect(decoded.p.subscriber).toBe(schedulePermitBatch.subscriber); + expect(decoded.p.scheduleId).toBe(schedulePermitBatch.scheduleId); + }); + }); + + describe('cancelScheduleBatch', () => { + it('should throw if the 0.2.0 proxy is not deployed', async () => { + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(''); + + await expect( + cancelScheduleBatch({ + permitTuple: schedulePermitBatch, + signer: wallet, + network, + }), + ).rejects.toThrow('ERC20RecurringPaymentProxy not found on private'); + }); + + it('sends cancelScheduleBatch 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 cancelScheduleBatch({ + permitTuple: schedulePermitBatch, + 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('cancelScheduleBatch'); + }); + }); + + describe('hashScheduleBatch', () => { + it('returns a 32-byte EIP-712 digest', () => { + const mockProxyAddress = '0x1111111111111111111111111111111111111111'; + jest + .spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress') + .mockReturnValue(mockProxyAddress); + + const digest = hashScheduleBatch({ + permitTuple: schedulePermitBatch, + network, + chainId: 1, + }); + + expect(digest).toMatch(/^0x[0-9a-fA-F]{64}$/); + expect(digest).toBe( + utils._TypedDataEncoder.hash( + { + name: 'ERC20RecurringPaymentProxy', + version: '1', + chainId: 1, + verifyingContract: mockProxyAddress, + }, + PaymentTypes.SCHEDULE_PERMIT_BATCH_EIP712_TYPES, + schedulePermitBatch, + ), + ); + }); + }); + + describe('scheduleKeyFromBatch', () => { + it('calls the 0.2.0 contract view', async () => { + const mockKey = `0x${'11'.repeat(32)}`; + const scheduleKeyFromBatchFn = jest.fn().mockResolvedValue(mockKey); + const connect = jest.spyOn(erc20RecurringPaymentProxyArtifact, 'connect').mockReturnValue({ + scheduleKeyFromBatch: scheduleKeyFromBatchFn, + } as any); + + const key = await scheduleKeyFromBatch({ + permitTuple: schedulePermitBatch, + provider, + network, + }); + + expect(key).toBe(mockKey); + expect(connect).toHaveBeenCalledWith(network, provider, '0.2.0'); + expect(scheduleKeyFromBatchFn).toHaveBeenCalledWith(schedulePermitBatch); + }); + }); + + describe('signSchedulePermitBatch', () => { + it('returns a 65-byte signature from the typed-data fallback', async () => { + const mockProxyAddress = '0x1111111111111111111111111111111111111111'; + jest + .spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress') + .mockReturnValue(mockProxyAddress); + + const signature = `0x${'ab'.repeat(65)}`; + const mockSigner = { + getAddress: jest.fn().mockResolvedValue(wallet.address), + getChainId: jest.fn().mockResolvedValue(1), + provider: { + send: jest.fn().mockRejectedValue(new Error('no rpc')), + }, + _signTypedData: jest.fn().mockResolvedValue(signature), + }; + + const result = await signSchedulePermitBatch({ + permitTuple: schedulePermitBatch, + signer: mockSigner as any, + network, + }); + + expect(result).toMatch(/^0x[0-9a-fA-F]{130}$/); + expect(mockSigner._signTypedData).toHaveBeenCalledWith( + { + name: 'ERC20RecurringPaymentProxy', + version: '1', + chainId: 1, + verifyingContract: mockProxyAddress, + }, + PaymentTypes.SCHEDULE_PERMIT_BATCH_EIP712_TYPES, + schedulePermitBatch, + ); + }); + }); });