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
Expand Up @@ -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));
Expand Down Expand Up @@ -243,21 +254,151 @@ export async function triggerRecurringPaymentBatch({
signer: Signer;
network: CurrencyTypes.EvmChainName;
}): Promise<providers.TransactionResponse> {
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<providers.TransactionResponse> {
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<string> {
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<string> {
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<typeof getSchedulePermitBatchDomain>,
typedTypes: typeof PaymentTypes.SCHEDULE_PERMIT_BATCH_EIP712_TYPES,
value: PaymentTypes.SchedulePermitBatch,
) => Promise<string>;
}
)._signTypedData(domain, types, permitTuple);
}
}

async function sendToRecurringProxyV2(
signer: Signer,
network: CurrencyTypes.EvmChainName,
data: string,
): Promise<providers.TransactionResponse> {
const proxyAddress = getRecurringPaymentProxyAddress(network, RECURRING_PROXY_V2);
return signer.sendTransaction({
to: proxyAddress,
data,
value: 0,
});

return tx;
}

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