diff --git a/packages/request-client.js/package.json b/packages/request-client.js/package.json index 5248e7be65..4d829ebbee 100644 --- a/packages/request-client.js/package.json +++ b/packages/request-client.js/package.json @@ -49,6 +49,7 @@ "@requestnetwork/epk-signature": "0.10.0", "@requestnetwork/multi-format": "0.28.0", "@requestnetwork/payment-detection": "0.54.0", + "@requestnetwork/payment-processor": "0.57.0", "@requestnetwork/request-logic": "0.44.0", "@requestnetwork/smart-contracts": "0.48.0", "@requestnetwork/transaction-manager": "0.45.0", diff --git a/packages/request-client.js/src/api/request.ts b/packages/request-client.js/src/api/request.ts index 4de7171fbc..12a1145ba7 100644 --- a/packages/request-client.js/src/api/request.ts +++ b/packages/request-client.js/src/api/request.ts @@ -11,6 +11,9 @@ import { PaymentTypes, RequestLogicTypes, } from '@requestnetwork/types'; +import { getRecurringPaymentProxyAddress } from '@requestnetwork/payment-processor'; +import { ERC20__factory } from '@requestnetwork/smart-contracts/types'; +import { BigNumber } from 'ethers'; import * as Types from '../types'; import ContentDataExtension from './content-data-extension'; import localUtils from './utils'; @@ -203,12 +206,24 @@ export default class Request { * * @param signerIdentity Identity of the signer. The identity type must be supported by the signature provider. * @param refundInformation refund information to add (any because it is specific to the payment network used by the request) - * @returns The updated request + * @param options Optional cancellation options for recurring payments + * @param options.isRecurringPayment Whether this is a recurring payment cancellation + * @param options.isPayerCancel Whether the payer is canceling (true) or payee (false). Only relevant for recurring payments. + * @param options.recurringPaymentInfo Information needed to generate allowance revocation calldata for recurring payments + * @returns The updated request and optional calldata for allowance revocation (if payer cancels recurring payment) */ public async cancel( signerIdentity: IdentityTypes.IIdentity, refundInformation?: any, - ): Promise { + options?: { + isRecurringPayment?: boolean; + isPayerCancel?: boolean; + recurringPaymentInfo?: { + tokenAddress: string; + network: CurrencyTypes.EvmChainName; + }; + }, + ): Promise { const extensionsData: any[] = []; if (refundInformation) { if (!this.paymentNetwork) { @@ -225,8 +240,28 @@ export default class Request { }; const cancelResult = await this.requestLogic.cancelRequest(parameters, signerIdentity, true); + const result = await this.handleRequestDataEvents(cancelResult); + + // Generate allowance revocation calldata if payer is canceling a recurring payment + let allowanceRevocationCalldata: string | undefined; + if (options?.isRecurringPayment && options?.isPayerCancel) { + if (!options.recurringPaymentInfo) { + throw new Error( + 'recurringPaymentInfo is required when the payer cancels a recurring payment', + ); + } + const proxyAddress = getRecurringPaymentProxyAddress(options.recurringPaymentInfo.network); + const erc20Interface = ERC20__factory.createInterface(); + allowanceRevocationCalldata = erc20Interface.encodeFunctionData('approve', [ + proxyAddress, + BigNumber.from(0), + ]); + } - return this.handleRequestDataEvents(cancelResult); + return { + ...result, + ...(allowanceRevocationCalldata && { allowanceRevocationCalldata }), + }; } /** diff --git a/packages/request-client.js/test/api/request.test.ts b/packages/request-client.js/test/api/request.test.ts index 75b1609efd..cee8c406dc 100644 --- a/packages/request-client.js/test/api/request.test.ts +++ b/packages/request-client.js/test/api/request.test.ts @@ -219,6 +219,57 @@ describe('api/request', () => { request.cancel(signatureIdentity, { refundAddress: bitcoinAddress }), ).rejects.toThrowError('Cannot add refund information without payment network'); }); + + it('returns allowance revocation calldata when payer cancels recurring payment', async () => { + const request = new Request('1', mockRequestLogic, currencyManager); + const result = await request.cancel(signatureIdentity, undefined, { + isRecurringPayment: true, + isPayerCancel: true, + recurringPaymentInfo: { + tokenAddress: '0x9FBDa871d559710256a2502A2517b794B482Db40', + network: 'private', + }, + }); + + expect(result.allowanceRevocationCalldata).toBeDefined(); + expect(typeof result.allowanceRevocationCalldata).toBe('string'); + expect(result.allowanceRevocationCalldata?.startsWith('0x')).toBe(true); + }); + + it('does not return allowance revocation calldata when payee cancels recurring payment', async () => { + const request = new Request('1', mockRequestLogic, currencyManager); + const result = await request.cancel(signatureIdentity, undefined, { + isRecurringPayment: true, + isPayerCancel: false, + recurringPaymentInfo: { + tokenAddress: '0x9FBDa871d559710256a2502A2517b794B482Db40', + network: 'private', + }, + }); + + expect(result.allowanceRevocationCalldata).toBeUndefined(); + }); + + it('does not return allowance revocation calldata for non-recurring payment cancellation', async () => { + const request = new Request('1', mockRequestLogic, currencyManager); + const result = await request.cancel(signatureIdentity); + + expect(result.allowanceRevocationCalldata).toBeUndefined(); + }); + + it('does not return allowance revocation calldata when isRecurringPayment is false', async () => { + const request = new Request('1', mockRequestLogic, currencyManager); + const result = await request.cancel(signatureIdentity, undefined, { + isRecurringPayment: false, + isPayerCancel: true, + recurringPaymentInfo: { + tokenAddress: '0x9FBDa871d559710256a2502A2517b794B482Db40', + network: 'private', + }, + }); + + expect(result.allowanceRevocationCalldata).toBeUndefined(); + }); }); describe('increaseExpectedAmountRequest', () => {