Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string> {
const erc20RecurringPaymentProxy = erc20RecurringPaymentProxyArtifact.connect(network, provider);
const erc20RecurringPaymentProxy = connectRecurringPaymentProxy(network, provider, version);

if (!erc20RecurringPaymentProxy.address) {
throw new Error(`ERC20RecurringPaymentProxy not found on ${network}`);
Expand All @@ -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
*
Expand All @@ -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}`);
Expand Down Expand Up @@ -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<providers.TransactionResponse> {
const proxyAddress = getRecurringPaymentProxyAddress(network, RECURRING_PROXY_V2);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Batch proxy has no deployment

When triggerRecurringPaymentBatch is called with any supported network, it unconditionally resolves version 0.2.0, whose artifact has an empty deployment map, so address lookup throws No deployment for network: <network> before sendTransaction and the new submission helper cannot trigger a payment.

Knowledge Base Used:


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
*
Expand All @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
});
Loading