From 53b925df00423f2a3a23e603015b7d0ee79d4142 Mon Sep 17 00:00:00 2001 From: LeoSlrRf Date: Tue, 25 Aug 2026 13:37:41 +0200 Subject: [PATCH] feat(recurring): add 0.2.0 types and batch trigger helpers --- .../payment/erc20-recurring-payment-proxy.ts | 94 +++++++++- .../payment/erc-20-recurring-payment.test.ts | 173 +++++++++++++++++- packages/types/src/payment-types.ts | 53 ++++++ 3 files changed, 313 insertions(+), 7 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 656c9effe1..d9ee9d5495 100644 --- a/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts +++ b/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts @@ -1,9 +1,25 @@ import { CurrencyTypes, PaymentTypes } from '@requestnetwork/types'; -import { providers, Signer, BigNumberish } from 'ethers'; +import { providers, Signer, BigNumberish, utils } from 'ethers'; import { erc20RecurringPaymentProxyArtifact } from '@requestnetwork/smart-contracts'; import { ERC20__factory } from '@requestnetwork/smart-contracts/types'; import { getErc20Allowance } from './erc20'; +const RECURRING_PROXY_V2 = '0.2.0'; + +function getRecurringPaymentProxyInterface(version: string): utils.Interface { + return new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi(version)); +} + +function connectRecurringPaymentProxy( + network: CurrencyTypes.EvmChainName, + provider: Signer | providers.Provider, + version?: string, +) { + return version + ? erc20RecurringPaymentProxyArtifact.connect(network, provider, version) + : erc20RecurringPaymentProxyArtifact.connect(network, provider); +} + /** * Retrieves the current ERC-20 allowance that a subscriber (`payerAddress`) has * granted to the `ERC20RecurringPaymentProxy` on a specific network. @@ -12,25 +28,28 @@ import { getErc20Allowance } from './erc20'; * @param tokenAddress - Address of the ERC-20 token involved in the recurring payment schedule. * @param provider - A Web3 provider or signer used to perform the on-chain call. * @param network - The EVM chain name (e.g. `'mainnet'`, `'goerli'`, `'matic'`). + * @param version - Artifact version. Defaults to the artifact last version (`0.1.0`). * * @returns A Promise that resolves to the allowance **as a decimal string** (same * units as `token.decimals`). An empty allowance is returned as `"0"`. * * @throws {Error} If the `ERC20RecurringPaymentProxy` has no known deployment - * on the provided `network`.. + * on the provided `network`. */ export async function getPayerRecurringPaymentAllowance({ payerAddress, tokenAddress, provider, network, + version, }: { payerAddress: string; tokenAddress: string; provider: Signer | providers.Provider; network: CurrencyTypes.EvmChainName; + version?: string; }): Promise { - const erc20RecurringPaymentProxy = erc20RecurringPaymentProxyArtifact.connect(network, provider); + const erc20RecurringPaymentProxy = connectRecurringPaymentProxy(network, provider, version); if (!erc20RecurringPaymentProxy.address) { throw new Error(`ERC20RecurringPaymentProxy not found on ${network}`); @@ -53,6 +72,7 @@ export async function getPayerRecurringPaymentAllowance({ * @param amount - The amount to approve, as a BigNumberish value * @param provider - Web3 provider or signer to interact with the blockchain * @param network - The EVM chain name where the proxy is deployed + * @param version - Artifact version. Defaults to the artifact last version (`0.1.0`). * * @returns Array of transaction objects ready to be sent to the blockchain * @@ -63,13 +83,15 @@ export function encodeSetRecurringAllowance({ amount, provider, network, + version, }: { tokenAddress: string; amount: BigNumberish; provider: providers.Provider | Signer; network: CurrencyTypes.EvmChainName; + version?: string; }): Array<{ to: string; data: string; value: number }> { - const erc20RecurringPaymentProxy = erc20RecurringPaymentProxyArtifact.connect(network, provider); + const erc20RecurringPaymentProxy = connectRecurringPaymentProxy(network, provider, version); if (!erc20RecurringPaymentProxy.address) { throw new Error(`ERC20RecurringPaymentProxy not found on ${network}`); @@ -184,10 +206,65 @@ export async function triggerRecurringPayment({ return tx; } +/** + * Encodes the 0.2.0 `triggerRecurringPaymentBatch` calldata. + * Does not require a deployed proxy address. + */ +export function encodeRecurringPaymentTriggerBatch({ + permitTuple, + permitSignature, + paymentIndex, +}: { + permitTuple: PaymentTypes.SchedulePermitBatch; + permitSignature: string; + paymentIndex: number; +}): string { + return getRecurringPaymentProxyInterface(RECURRING_PROXY_V2).encodeFunctionData( + 'triggerRecurringPaymentBatch', + [permitTuple, permitSignature, paymentIndex], + ); +} + +/** + * Triggers a 0.2.0 recurring payment through `triggerRecurringPaymentBatch`. + * + * @throws {Error} If the 0.2.0 proxy has no known deployment on the provided network + */ +export async function triggerRecurringPaymentBatch({ + permitTuple, + permitSignature, + paymentIndex, + signer, + network, +}: { + permitTuple: PaymentTypes.SchedulePermitBatch; + permitSignature: string; + paymentIndex: number; + signer: Signer; + network: CurrencyTypes.EvmChainName; +}): Promise { + const proxyAddress = getRecurringPaymentProxyAddress(network, RECURRING_PROXY_V2); + + const data = encodeRecurringPaymentTriggerBatch({ + permitTuple, + permitSignature, + paymentIndex, + }); + + const tx = await signer.sendTransaction({ + to: proxyAddress, + data, + value: 0, + }); + + return tx; +} + /** * Returns the deployed address of the ERC20RecurringPaymentProxy contract for a given network. * * @param network - The EVM chain name (e.g. 'mainnet', 'sepolia', 'matic') + * @param version - Artifact version. Defaults to the artifact last version (`0.1.0`). * * @returns The deployed proxy contract address for the specified network * @@ -199,8 +276,13 @@ export async function triggerRecurringPayment({ * • The address is looked up from the deployment artifacts maintained by the smart-contracts package * • Use this when you only need the address and don't need to interact with the contract */ -export function getRecurringPaymentProxyAddress(network: CurrencyTypes.EvmChainName): string { - const address = erc20RecurringPaymentProxyArtifact.getAddress(network); +export function getRecurringPaymentProxyAddress( + network: CurrencyTypes.EvmChainName, + version?: string, +): string { + const address = version + ? erc20RecurringPaymentProxyArtifact.getAddress(network, version) + : erc20RecurringPaymentProxyArtifact.getAddress(network); if (!address) { throw new Error(`ERC20RecurringPaymentProxy not found on ${network}`); 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 f14349e8e3..dff3808a4c 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 @@ -1,10 +1,13 @@ import { erc20RecurringPaymentProxyArtifact } from '@requestnetwork/smart-contracts'; import { CurrencyTypes, PaymentTypes } from '@requestnetwork/types'; -import { Wallet, providers } from 'ethers'; +import { Wallet, providers, utils } from 'ethers'; import { encodeRecurringPaymentTrigger, + encodeRecurringPaymentTriggerBatch, encodeSetRecurringAllowance, + getRecurringPaymentProxyAddress, triggerRecurringPayment, + triggerRecurringPaymentBatch, } from '../../src/payment/erc20-recurring-payment-proxy'; const mnemonic = 'candy maple cake sugar pudding cream honey rich smooth crumble sweet treat'; @@ -331,3 +334,171 @@ describe('ERC20 Recurring Payment', () => { ).rejects.toThrow('Transaction failed'); }); }); + +describe('erc20-recurring-payment-proxy 0.2.0', () => { + const paymentRef = (n: number) => utils.hexZeroPad(utils.hexlify(n), 8); + const now = Math.floor(Date.now() / 1000); + + const schedulePermitBatch: PaymentTypes.SchedulePermitBatch = { + subscriber: wallet.address, + token: erc20ContractAddress, + relayerFee: '5000000000000000', + totalPayments: 2, + nonce: 0, + deadline: now + 3600, + strictOrder: false, + scheduleId: '0x0808080808080808080808080808080808080808080808080808080808080808', + dueTimes: [now - 1, now + 86400], + initialLegs: [], + recurringLegs: [ + { + recipient: '0x3234567890123456789012345678901234567890', + amount: '1000000000000000000', + paymentReference: paymentRef(0x61), + }, + ], + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getRecurringPaymentProxyAddress', () => { + it('passes an explicit version to the artifact', () => { + const mockProxyAddress = '0xd8672a4A1bf37D36beF74E36edb4f17845E76F4e'; + const getAddress = jest + .spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress') + .mockReturnValue(mockProxyAddress); + + expect(getRecurringPaymentProxyAddress(network, '0.2.0')).toBe(mockProxyAddress); + expect(getAddress).toHaveBeenCalledWith(network, '0.2.0'); + }); + + it('throws when the 0.2.0 proxy is not deployed', () => { + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(''); + + expect(() => getRecurringPaymentProxyAddress(network, '0.2.0')).toThrow( + 'ERC20RecurringPaymentProxy not found on private', + ); + }); + }); + + describe('encodeSetRecurringAllowance', () => { + it('connects with the requested version', () => { + const mockProxyAddress = '0xd8672a4A1bf37D36beF74E36edb4f17845E76F4e'; + const connect = jest.spyOn(erc20RecurringPaymentProxyArtifact, 'connect').mockReturnValue({ + address: mockProxyAddress, + } as any); + + const transactions = encodeSetRecurringAllowance({ + tokenAddress: erc20ContractAddress, + amount: '1000000000000000000', + provider, + network, + version: '0.2.0', + }); + + expect(connect).toHaveBeenCalledWith(network, provider, '0.2.0'); + expect(transactions).toHaveLength(1); + expect(transactions[0].data).toContain('095ea7b3'); + }); + }); + + describe('encodeRecurringPaymentTriggerBatch', () => { + it('encodes triggerRecurringPaymentBatch without a deployment', () => { + const encodedData = encodeRecurringPaymentTriggerBatch({ + permitTuple: schedulePermitBatch, + permitSignature: '0x1234', + paymentIndex: 1, + }); + + expect(encodedData.startsWith('0x')).toBe(true); + + const iface = new utils.Interface(erc20RecurringPaymentProxyArtifact.getContractAbi('0.2.0')); + const decoded = iface.decodeFunctionData('triggerRecurringPaymentBatch', encodedData); + expect(decoded.index).toBe(1); + expect(decoded.p.subscriber).toBe(schedulePermitBatch.subscriber); + expect(decoded.p.scheduleId).toBe(schedulePermitBatch.scheduleId); + }); + }); + + describe('triggerRecurringPaymentBatch', () => { + it('should throw if the 0.2.0 proxy is not deployed', async () => { + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(''); + + await expect( + triggerRecurringPaymentBatch({ + permitTuple: schedulePermitBatch, + permitSignature: '0x1234567890abcdef', + paymentIndex: 1, + signer: wallet, + network, + }), + ).rejects.toThrow('ERC20RecurringPaymentProxy not found on private'); + }); + + it('sends triggerRecurringPaymentBatch to the 0.2.0 address', async () => { + const mockProxyAddress = '0x1111111111111111111111111111111111111111'; + const getAddress = 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, + }; + + const result = await triggerRecurringPaymentBatch({ + permitTuple: schedulePermitBatch, + permitSignature: '0x1234', + paymentIndex: 1, + signer: mockWallet as any, + network, + }); + + expect(result).toBeDefined(); + expect(getAddress).toHaveBeenCalledWith(network, '0.2.0'); + 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('triggerRecurringPaymentBatch'); + }); + + it('should handle triggerRecurringPaymentBatch errors properly', async () => { + jest + .spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress') + .mockReturnValue('0x1111111111111111111111111111111111111111'); + + const mockProvider = { + sendTransaction: jest.fn().mockRejectedValue(new Error('Transaction failed')), + }; + const mockWallet = { + ...wallet, + provider: mockProvider, + sendTransaction: mockProvider.sendTransaction, + }; + + await expect( + triggerRecurringPaymentBatch({ + permitTuple: schedulePermitBatch, + permitSignature: '0x1234', + paymentIndex: 1, + signer: mockWallet as any, + network, + }), + ).rejects.toThrow('Transaction failed'); + }); + }); +}); diff --git a/packages/types/src/payment-types.ts b/packages/types/src/payment-types.ts index 504d313407..b1854836a5 100644 --- a/packages/types/src/payment-types.ts +++ b/packages/types/src/payment-types.ts @@ -413,6 +413,59 @@ export interface SchedulePermit { strictOrder: boolean; } +/** + * One transfer in a 0.2.0 recurring schedule. `paymentReference` is 8-byte hex. + */ +export interface SchedulePermitLeg { + recipient: string; + amount: BigNumberish; + paymentReference: string; +} + +/** + * Parameters for a 0.2.0 recurring payment schedule permit (SchedulePermitBatch). + */ +export interface SchedulePermitBatch { + subscriber: string; + token: string; + relayerFee: BigNumberish; + totalPayments: number; + nonce: BigNumberish; + deadline: BigNumberish; + strictOrder: boolean; + scheduleId: string; + dueTimes: number[]; + initialLegs: SchedulePermitLeg[]; + recurringLegs: SchedulePermitLeg[]; +} + +/** + * EIP-712 types for `SchedulePermitBatch`. Field order matches the 0.2.0 contract. + */ +export const SCHEDULE_PERMIT_BATCH_EIP712_TYPES: { + SchedulePermitBatch: Array<{ name: string; type: string }>; + Leg: Array<{ name: string; type: string }>; +} = { + SchedulePermitBatch: [ + { name: 'subscriber', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'relayerFee', type: 'uint128' }, + { name: 'totalPayments', type: 'uint8' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'strictOrder', type: 'bool' }, + { name: 'scheduleId', type: 'bytes32' }, + { name: 'dueTimes', type: 'uint32[]' }, + { name: 'initialLegs', type: 'Leg[]' }, + { name: 'recurringLegs', type: 'Leg[]' }, + ], + Leg: [ + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint128' }, + { name: 'paymentReference', type: 'bytes8' }, + ], +}; + /** * Parameters for Commerce Escrow payment data */