diff --git a/packages/payment-processor/src/index.ts b/packages/payment-processor/src/index.ts index f9aba001e..099f3277e 100644 --- a/packages/payment-processor/src/index.ts +++ b/packages/payment-processor/src/index.ts @@ -29,6 +29,7 @@ export * as Escrow from './payment/erc20-escrow-payment'; export * from './payment/prepared-transaction'; export * from './payment/utils-near'; export * from './payment/single-request-forwarder'; +export * from './payment/erc20-recurring-payment-proxy'; import * as utils from './payment/utils'; diff --git a/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts b/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts new file mode 100644 index 000000000..4b9969656 --- /dev/null +++ b/packages/payment-processor/src/payment/erc20-recurring-payment-proxy.ts @@ -0,0 +1,228 @@ +import { CurrencyTypes, PaymentTypes } from '@requestnetwork/types'; +import { providers, Signer, BigNumberish } from 'ethers'; +import { erc20RecurringPaymentProxyArtifact } from '@requestnetwork/smart-contracts'; +import { ERC20__factory } from '@requestnetwork/smart-contracts/types'; +import { getErc20Allowance } from './erc20'; + +/** + * Retrieves the current ERC-20 allowance that a subscriber (`payerAddress`) has + * granted to the `ERC20RecurringPaymentProxy` on a specific network. + * + * @param payerAddress - Address of the token owner (subscriber) whose allowance is queried. + * @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'`). + * + * @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`.. + */ +export async function getPayerRecurringPaymentAllowance({ + payerAddress, + tokenAddress, + provider, + network, +}: { + payerAddress: string; + tokenAddress: string; + provider: Signer | providers.Provider; + network: CurrencyTypes.EvmChainName; +}): Promise { + const erc20RecurringPaymentProxy = erc20RecurringPaymentProxyArtifact.connect(network, provider); + + if (!erc20RecurringPaymentProxy.address) { + throw new Error(`ERC20RecurringPaymentProxy not found on ${network}`); + } + + const allowance = await getErc20Allowance( + payerAddress, + erc20RecurringPaymentProxy.address, + provider, + tokenAddress, + ); + + return allowance.toString(); +} + +/** + * Encodes the transaction data to set the allowance for the ERC20RecurringPaymentProxy. + * + * @param tokenAddress - The ERC20 token contract address + * @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 isUSDT - Flag to indicate if the token is USDT, which requires special handling + * + * @returns Array of transaction objects ready to be sent to the blockchain + * + * @throws {Error} If the ERC20RecurringPaymentProxy is not deployed on the specified network + * + * @remarks + * • For USDT, it returns two transactions: approve(0) and then approve(amount) + * • For other ERC20 tokens, it returns a single approve(amount) transaction + */ +export function encodeSetRecurringAllowance({ + tokenAddress, + amount, + provider, + network, + isUSDT = false, +}: { + tokenAddress: string; + amount: BigNumberish; + provider: providers.Provider | Signer; + network: CurrencyTypes.EvmChainName; + isUSDT?: boolean; +}): Array<{ to: string; data: string; value: number }> { + const erc20RecurringPaymentProxy = erc20RecurringPaymentProxyArtifact.connect(network, provider); + + if (!erc20RecurringPaymentProxy.address) { + throw new Error(`ERC20RecurringPaymentProxy not found on ${network}`); + } + + const paymentTokenContract = ERC20__factory.connect(tokenAddress, provider); + + const transactions: Array<{ to: string; data: string; value: number }> = []; + + if (isUSDT) { + const resetData = paymentTokenContract.interface.encodeFunctionData('approve', [ + erc20RecurringPaymentProxy.address, + 0, + ]); + transactions.push({ to: tokenAddress, data: resetData, value: 0 }); + } + + const setData = paymentTokenContract.interface.encodeFunctionData('approve', [ + erc20RecurringPaymentProxy.address, + amount, + ]); + transactions.push({ to: tokenAddress, data: setData, value: 0 }); + + return transactions; +} + +/** + * Encodes the transaction data to trigger a recurring payment through the ERC20RecurringPaymentProxy. + * + * @param permitTuple - The SchedulePermit struct data + * @param permitSignature - The signature authorizing the recurring payment schedule + * @param paymentIndex - The index of the payment to trigger (1-based) + * @param paymentReference - Reference data for the payment + * @param network - The EVM chain name where the proxy is deployed + * + * @returns The encoded function data as a hex string, ready to be used in a transaction + * + * @throws {Error} If the ERC20RecurringPaymentProxy is not deployed on the specified network + * + * @remarks + * • The function only encodes the transaction data without sending it + * • The encoded data can be used with any web3 library or multisig wallet + * • Make sure the paymentIndex matches the expected payment sequence + */ +export function encodeRecurringPaymentTrigger({ + permitTuple, + permitSignature, + paymentIndex, + paymentReference, + network, + provider, +}: { + permitTuple: PaymentTypes.SchedulePermit; + permitSignature: string; + paymentIndex: number; + paymentReference: string; + network: CurrencyTypes.EvmChainName; + provider: providers.Provider | Signer; +}): string { + const proxyContract = erc20RecurringPaymentProxyArtifact.connect(network, provider); + + return proxyContract.interface.encodeFunctionData('triggerRecurringPayment', [ + permitTuple, + permitSignature, + paymentIndex, + paymentReference, + ]); +} + +/** + * Triggers a recurring payment through the ERC20RecurringPaymentProxy. + * + * @param permitTuple - The SchedulePermit struct data + * @param permitSignature - The signature authorizing the recurring payment schedule + * @param paymentIndex - The index of the payment to trigger (1-based) + * @param paymentReference - Reference data for the payment + * @param signer - The signer that will trigger the transaction (must have RELAYER_ROLE) + * @param network - The EVM chain name where the proxy is deployed + * + * @returns A Promise resolving to the transaction response (TransactionResponse) + * + * @throws {Error} If the ERC20RecurringPaymentProxy is not deployed on the specified network + * @throws {Error} If the transaction fails (e.g. wrong index, expired permit, insufficient allowance) + * + * @remarks + * • The function returns the transaction response immediately after sending + * • The signer must have been granted RELAYER_ROLE by the proxy admin + * • Make sure all preconditions are met (allowance, balance, timing) before calling + * • To wait for confirmation, call tx.wait() on the returned TransactionResponse + */ +export async function triggerRecurringPayment({ + permitTuple, + permitSignature, + paymentIndex, + paymentReference, + signer, + network, +}: { + permitTuple: PaymentTypes.SchedulePermit; + permitSignature: string; + paymentIndex: number; + paymentReference: string; + signer: Signer; + network: CurrencyTypes.EvmChainName; +}): Promise { + const proxyAddress = getRecurringPaymentProxyAddress(network); + + const data = encodeRecurringPaymentTrigger({ + permitTuple, + permitSignature, + paymentIndex, + paymentReference, + network, + provider: signer, + }); + + 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') + * + * @returns The deployed proxy contract address for the specified network + * + * @throws {Error} If the ERC20RecurringPaymentProxy has no known deployment + * on the provided network + * + * @remarks + * • This is a pure helper that doesn't require a provider or make any network calls + * • 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); + + if (!address) { + throw new Error(`ERC20RecurringPaymentProxy not found on ${network}`); + } + + return address; +} 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 new file mode 100644 index 000000000..872090126 --- /dev/null +++ b/packages/payment-processor/test/payment/erc-20-recurring-payment.test.ts @@ -0,0 +1,381 @@ +import { erc20RecurringPaymentProxyArtifact } from '@requestnetwork/smart-contracts'; +import { CurrencyTypes, PaymentTypes } from '@requestnetwork/types'; +import { Wallet, providers } from 'ethers'; +import { + encodeRecurringPaymentTrigger, + encodeSetRecurringAllowance, + triggerRecurringPayment, +} from '../../src/payment/erc20-recurring-payment-proxy'; + +const mnemonic = 'candy maple cake sugar pudding cream honey rich smooth crumble sweet treat'; +const provider = new providers.JsonRpcProvider('http://localhost:8545'); +const wallet = Wallet.fromMnemonic(mnemonic).connect(provider); +const network: CurrencyTypes.EvmChainName = 'private'; +const erc20ContractAddress = '0x9FBDa871d559710256a2502A2517b794B482Db40'; + +const schedulePermit: PaymentTypes.SchedulePermit = { + subscriber: wallet.address, + token: erc20ContractAddress, + recipient: '0x3234567890123456789012345678901234567890', + feeAddress: '0x4234567890123456789012345678901234567890', + amount: '1000000000000000000', // 1 token + feeAmount: '10000000000000000', // 0.01 token + relayerFee: '5000000000000000', // 0.005 token + periodSeconds: 86400, // 1 day + firstPayment: Math.floor(Date.now() / 1000), + totalPayments: 12, + nonce: '1', + deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + strictOrder: true, +}; + +const paymentReference = '0x0000000000000000000000000000000000000000000000000000000000000001'; + +// Helper function to create EIP-712 signature for SchedulePermit +async function createSchedulePermitSignature( + permit: PaymentTypes.SchedulePermit, + signer: Wallet, + proxyAddress: string, +): Promise { + const domain = { + name: 'ERC20RecurringPaymentProxy', + version: '1', + chainId: await signer.getChainId(), + verifyingContract: proxyAddress, + }; + + const types = { + SchedulePermit: [ + { name: 'subscriber', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'feeAddress', type: 'address' }, + { name: 'amount', type: 'uint128' }, + { name: 'feeAmount', type: 'uint128' }, + { name: 'relayerFee', type: 'uint128' }, + { name: 'periodSeconds', type: 'uint32' }, + { name: 'firstPayment', type: 'uint32' }, + { name: 'totalPayments', type: 'uint8' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'strictOrder', type: 'bool' }, + ], + }; + + // Convert string values to numbers where needed + const message = { + subscriber: permit.subscriber, + token: permit.token, + recipient: permit.recipient, + feeAddress: permit.feeAddress, + amount: permit.amount, + feeAmount: permit.feeAmount, + relayerFee: permit.relayerFee, + periodSeconds: permit.periodSeconds, + firstPayment: permit.firstPayment, + totalPayments: permit.totalPayments, + nonce: typeof permit.nonce === 'string' ? permit.nonce : permit.nonce.toString(), + deadline: permit.deadline, + strictOrder: permit.strictOrder, + }; + + try { + return await (signer.provider as any).send('eth_signTypedData', [ + await signer.getAddress(), + { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + ...types, + }, + primaryType: 'SchedulePermit', + domain, + message, + }, + ]); + } catch (_) { + // Fallback to ethers helper (works in most in-process Hardhat environments) + return await (signer as any)._signTypedData(domain, types, message); + } +} + +describe('erc20-recurring-payment-proxy', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('encodeSetRecurringAllowance', () => { + it('should return a single transaction for a non-USDT token', () => { + const amount = '1000000000000000000'; + const transactions = encodeSetRecurringAllowance({ + tokenAddress: erc20ContractAddress, + amount, + provider, + network, + isUSDT: false, + }); + + expect(transactions).toHaveLength(1); + const [tx] = transactions; + expect(tx.to).toBe(erc20ContractAddress); + expect(tx.data).toContain('095ea7b3'); // approve + expect(tx.value).toBe(0); + }); + + it('should return two transactions for a USDT token', () => { + const amount = '1000000000000000000'; + const transactions = encodeSetRecurringAllowance({ + tokenAddress: erc20ContractAddress, + amount, + provider, + network, + isUSDT: true, + }); + + expect(transactions).toHaveLength(2); + + const [tx1, tx2] = transactions; + // tx1 is approve(0) + expect(tx1.to).toBe(erc20ContractAddress); + expect(tx1.data).toContain('095ea7b3'); // approve + // check that amount is 0 + expect(tx1.data).toContain( + '0000000000000000000000000000000000000000000000000000000000000000', + ); + expect(tx1.value).toBe(0); + + // tx2 is approve(amount) + expect(tx2.to).toBe(erc20ContractAddress); + expect(tx2.data).toContain('095ea7b3'); // approve + expect(tx2.data).not.toContain( + '0000000000000000000000000000000000000000000000000000000000000000', + ); + expect(tx2.value).toBe(0); + }); + + it('should default to non-USDT behavior if isUSDT is not provided', () => { + const amount = '1000000000000000000'; + const transactions = encodeSetRecurringAllowance({ + tokenAddress: erc20ContractAddress, + amount, + provider, + network, + }); + + expect(transactions).toHaveLength(1); + const [tx] = transactions; + expect(tx.to).toBe(erc20ContractAddress); + expect(tx.data).toContain('095ea7b3'); // approve + expect(tx.value).toBe(0); + }); + + it('should throw when proxy not found', () => { + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'connect').mockReturnValue({ + address: '', + } as any); + + expect(() => { + encodeSetRecurringAllowance({ + tokenAddress: erc20ContractAddress, + amount: '1000000000000000000', + provider, + network, + }); + }).toThrow('ERC20RecurringPaymentProxy not found on private'); + }); + }); + + describe('encodeRecurringPaymentTrigger', () => { + it('should encode trigger data correctly', async () => { + const proxyAddress = erc20RecurringPaymentProxyArtifact.getAddress(network); + const permitSignature = await createSchedulePermitSignature( + schedulePermit, + wallet, + proxyAddress!, + ); + + const encodedData = encodeRecurringPaymentTrigger({ + permitTuple: schedulePermit, + permitSignature, + paymentIndex: 1, + paymentReference, + network, + provider, + }); + + // Verify it's a valid hex string + expect(encodedData.startsWith('0x')).toBe(true); + }); + }); + + describe('triggerRecurringPayment', () => { + it('should throw if proxy not deployed on network', async () => { + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(''); + + await expect( + triggerRecurringPayment({ + permitTuple: schedulePermit, + permitSignature: '0x1234567890abcdef', // This won't be used due to the mock + paymentIndex: 1, + paymentReference, + signer: wallet, + network, + }), + ).rejects.toThrow('ERC20RecurringPaymentProxy not found on private'); + }); + }); +}); + +describe('ERC20 Recurring Payment', () => { + const permit: PaymentTypes.SchedulePermit = { + subscriber: '0x1234567890123456789012345678901234567890', + token: '0x1234567890123456789012345678901234567890', + recipient: '0x1234567890123456789012345678901234567890', + feeAddress: '0x1234567890123456789012345678901234567890', + amount: '1000000000000000000', // 1 token + feeAmount: '10000000000000000', // 0.01 token + relayerFee: '5000000000000000', // 0.005 token + periodSeconds: 86400, // 1 day + firstPayment: Math.floor(Date.now() / 1000), + totalPayments: 12, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + strictOrder: false, + }; + + it('should encode recurring payment trigger', async () => { + const proxyAddress = erc20RecurringPaymentProxyArtifact.getAddress(network); + const permitSignature = await createSchedulePermitSignature(permit, wallet, proxyAddress!); + + const encoded = encodeRecurringPaymentTrigger({ + permitTuple: permit, + permitSignature, + paymentIndex: 1, + paymentReference, + network, + provider, + }); + expect(encoded).toBeDefined(); + }); + + it('should trigger recurring payment with proper setup', async () => { + // Mock the proxy address to ensure it exists + const mockProxyAddress = '0xd8672a4A1bf37D36beF74E36edb4f17845E76F4e'; + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(mockProxyAddress); + + // Create a valid permit with the wallet as subscriber + const validPermit: PaymentTypes.SchedulePermit = { + subscriber: wallet.address, + token: erc20ContractAddress, + recipient: '0x3234567890123456789012345678901234567890', + feeAddress: '0x4234567890123456789012345678901234567890', + amount: '1000000000000000000', // 1 token + feeAmount: '10000000000000000', // 0.01 token + relayerFee: '5000000000000000', // 0.005 token + periodSeconds: 86400, // 1 day + firstPayment: Math.floor(Date.now() / 1000), + totalPayments: 12, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + strictOrder: false, + }; + + // Create a valid signature for the permit + const permitSignature = await createSchedulePermitSignature( + validPermit, + wallet, + mockProxyAddress, + ); + + // Mock the provider to simulate a successful transaction + const mockProvider = { + sendTransaction: jest.fn().mockResolvedValue({ + hash: '0x1234567890abcdef', + wait: jest.fn().mockResolvedValue({ + status: 1, + transactionHash: '0x1234567890abcdef', + }), + }), + }; + + // Mock the wallet to use our mock provider + const mockWallet = { + ...wallet, + provider: mockProvider, + sendTransaction: mockProvider.sendTransaction, + }; + + // Test the trigger function + const result = await triggerRecurringPayment({ + permitTuple: validPermit, + permitSignature, + paymentIndex: 1, + paymentReference, + signer: mockWallet as any, + network, + }); + + expect(result).toBeDefined(); + expect(mockProvider.sendTransaction).toHaveBeenCalledWith({ + to: mockProxyAddress, + data: expect.any(String), + value: 0, + }); + }); + + it('should handle triggerRecurringPayment errors properly', async () => { + // Mock the proxy address to ensure it exists + const mockProxyAddress = '0xd8672a4A1bf37D36beF74E36edb4f17845E76F4e'; + jest.spyOn(erc20RecurringPaymentProxyArtifact, 'getAddress').mockReturnValue(mockProxyAddress); + + // Create a valid permit + const validPermit: PaymentTypes.SchedulePermit = { + subscriber: wallet.address, + token: erc20ContractAddress, + recipient: '0x3234567890123456789012345678901234567890', + feeAddress: '0x4234567890123456789012345678901234567890', + amount: '1000000000000000000', + feeAmount: '10000000000000000', + relayerFee: '5000000000000000', + periodSeconds: 86400, + firstPayment: Math.floor(Date.now() / 1000), + totalPayments: 12, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 3600, + strictOrder: false, + }; + + const permitSignature = await createSchedulePermitSignature( + validPermit, + wallet, + mockProxyAddress, + ); + + // Mock provider that throws an error + const mockProvider = { + sendTransaction: jest.fn().mockRejectedValue(new Error('Transaction failed')), + }; + + const mockWallet = { + ...wallet, + provider: mockProvider, + sendTransaction: mockProvider.sendTransaction, + }; + + // Test that the function properly handles errors + await expect( + triggerRecurringPayment({ + permitTuple: validPermit, + permitSignature, + paymentIndex: 1, + paymentReference, + signer: mockWallet as any, + network, + }), + ).rejects.toThrow('Transaction failed'); + }); +}); diff --git a/packages/smart-contracts/deploy/utils-zk.ts b/packages/smart-contracts/deploy/utils-zk.ts index 403e57b47..89d258de1 100644 --- a/packages/smart-contracts/deploy/utils-zk.ts +++ b/packages/smart-contracts/deploy/utils-zk.ts @@ -158,7 +158,7 @@ export const deployContract = async ( log(` - Contract source: ${fullContractSource}`); log(` - Encoded constructor arguments: ${constructorArgs}\n`); - if (!options?.noVerify && hre.network.config.verifyURL) { + if (!options?.noVerify && (hre.network.config as any).verifyURL) { log(`Requesting contract verification...`); await verifyContract({ address: contract.address, diff --git a/packages/smart-contracts/hardhat.config.ts b/packages/smart-contracts/hardhat.config.ts index 77ec4e98f..78b144be9 100644 --- a/packages/smart-contracts/hardhat.config.ts +++ b/packages/smart-contracts/hardhat.config.ts @@ -5,7 +5,7 @@ import '@nomiclabs/hardhat-ethers'; import '@matterlabs/hardhat-zksync-node'; import '@matterlabs/hardhat-zksync-deploy'; import '@matterlabs/hardhat-zksync-solc'; -import '@matterlabs/hardhat-zksync-verify'; +import '@nomicfoundation/hardhat-verify'; import { subtask, task } from 'hardhat/config'; import { config } from 'dotenv'; @@ -214,42 +214,7 @@ export default { version: '1.3.16', }, etherscan: { - apiKey: { - base: process.env.ETHERSCAN_API_KEY, - mainnet: process.env.ETHERSCAN_API_KEY, - rinkeby: process.env.ETHERSCAN_API_KEY, - goerli: process.env.ETHERSCAN_API_KEY, - sepolia: process.env.ETHERSCAN_API_KEY, - // binance smart chain - bsc: process.env.BSCSCAN_API_KEY, - bscTestnet: process.env.BSCSCAN_API_KEY, - // fantom mainnet - opera: process.env.FTMSCAN_API_KEY, - // polygon - polygon: process.env.POLYGONSCAN_API_KEY, - polygonMumbai: process.env.POLYGONSCAN_API_KEY, - // arbitrum - arbitrumOne: process.env.ARBISCAN_API_KEY, - // avalanche - avalanche: process.env.SNOWTRACE_API_KEY, - // xdai - xdai: process.env.GNOSISSCAN_API_KEY, - // optimism - optimism: process.env.OPTIMISM_API_KEY, - // moonbeam - moonbeam: process.env.MOONBEAM_API_KEY, - // core - core: process.env.CORE_API_KEY, - // other networks don't need an API key, but you still need - // to specify one; any string placeholder will work - sokol: 'api-key', - aurora: 'api-key', - auroraTestnet: 'api-key', - mantle: 'api-key', - 'mantle-testnet': 'api-key', - celo: process.env.CELOSCAN_API_KEY, - sonic: process.env.SONIC_API_KEY, - }, + apiKey: process.env.ETHERSCAN_API_KEY, customChains: [ { network: 'optimism', diff --git a/packages/smart-contracts/package.json b/packages/smart-contracts/package.json index e99882b6f..b6aeead06 100644 --- a/packages/smart-contracts/package.json +++ b/packages/smart-contracts/package.json @@ -60,7 +60,7 @@ "@matterlabs/hardhat-zksync-solc": "0.4.2", "@matterlabs/hardhat-zksync-verify": "0.2.1", "@matterlabs/zksync-contracts": "0.6.1", - "@nomicfoundation/hardhat-verify": "2.0.0", + "@nomicfoundation/hardhat-verify": "2.0.14", "@nomiclabs/hardhat-ethers": "2.2.3", "@nomiclabs/hardhat-waffle": "2.0.6", "@nomiclabs/hardhat-web3": "2.0.0", @@ -85,6 +85,7 @@ "hardhat": "2.22.15", "solhint": "3.3.6", "typechain": "8.3.2", + "typescript": "4.8.4", "web3": "1.7.3", "zksync-web3": "0.14.3" } diff --git a/packages/smart-contracts/scripts-create2/compute-one-address.ts b/packages/smart-contracts/scripts-create2/compute-one-address.ts index 84eca479b..a8592f3a2 100644 --- a/packages/smart-contracts/scripts-create2/compute-one-address.ts +++ b/packages/smart-contracts/scripts-create2/compute-one-address.ts @@ -65,7 +65,8 @@ export const computeCreate2DeploymentAddressesFromList = async ( case 'ERC20SwapToPay': case 'ERC20SwapToConversion': case 'ERC20TransferableReceivable': - case 'SingleRequestProxyFactory': { + case 'SingleRequestProxyFactory': + case 'ERC20RecurringPaymentProxy': { try { const constructorArgs = getConstructorArgs(contract, chain); address = await computeCreate2DeploymentAddress({ contract, constructorArgs }, hre); diff --git a/packages/smart-contracts/scripts-create2/constructor-args.ts b/packages/smart-contracts/scripts-create2/constructor-args.ts index c1ad4e414..c56ca213a 100644 --- a/packages/smart-contracts/scripts-create2/constructor-args.ts +++ b/packages/smart-contracts/scripts-create2/constructor-args.ts @@ -1,11 +1,20 @@ import * as artifacts from '../src/lib'; import { CurrencyTypes } from '@requestnetwork/types'; -const getAdminWalletAddress = (contract: string): string => { - if (!process.env.ADMIN_WALLET_ADDRESS) { - throw new Error(`ADMIN_WALLET_ADDRESS missing to get constructor args for: ${contract}`); +const getEnvVariable = (name: string, contract: string): string => { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} missing to get constructor args for: ${contract}`); } - return process.env.ADMIN_WALLET_ADDRESS; + return value; +}; + +const getAdminWalletAddress = (contract: string): string => { + return getEnvVariable('ADMIN_WALLET_ADDRESS', contract); +}; + +const getRecurringPaymentExecutorWalletAddress = (contract: string): string => { + return getEnvVariable('RECURRING_PAYMENT_EXECUTOR_WALLET_ADDRESS', contract); }; export const getConstructorArgs = ( @@ -78,6 +87,18 @@ export const getConstructorArgs = ( return [ethereumFeeProxyAddress, erc20FeeProxyAddress, getAdminWalletAddress(contract)]; } + case 'ERC20RecurringPaymentProxy': { + if (!network) { + throw new Error('ERC20RecurringPaymentProxy requires network parameter'); + } + const erc20FeeProxy = artifacts.erc20FeeProxyArtifact; + const erc20FeeProxyAddress = erc20FeeProxy.getAddress(network); + + const adminSafe = getAdminWalletAddress(contract); + const executorEOA = getRecurringPaymentExecutorWalletAddress(contract); + + return [adminSafe, executorEOA, erc20FeeProxyAddress]; + } default: return []; } diff --git a/packages/smart-contracts/scripts-create2/utils.ts b/packages/smart-contracts/scripts-create2/utils.ts index 6301f58e2..40037a482 100644 --- a/packages/smart-contracts/scripts-create2/utils.ts +++ b/packages/smart-contracts/scripts-create2/utils.ts @@ -21,6 +21,7 @@ export const create2ContractDeploymentList = [ 'ERC20EscrowToPay', 'ERC20TransferableReceivable', 'SingleRequestProxyFactory', + 'ERC20RecurringPaymentProxy', ]; /** @@ -59,6 +60,8 @@ export const getArtifact = (contract: string): artifacts.ContractArtifact { @@ -20,4 +21,5 @@ export default async function deploy(_args: any, hre: HardhatRuntimeEnvironment) await deployBatchConversionPayment(_args, hre); await deployERC20TransferableReceivable(_args, hre, mainPaymentAddresses); await deploySingleRequestProxyFactory(_args, hre, mainPaymentAddresses); + await deployERC20RecurringPaymentProxy(_args, hre, mainPaymentAddresses.ERC20FeeProxyAddress); } diff --git a/packages/smart-contracts/scripts/test-deploy-erc20-recurring-payment-proxy.ts b/packages/smart-contracts/scripts/test-deploy-erc20-recurring-payment-proxy.ts new file mode 100644 index 000000000..761e22a7a --- /dev/null +++ b/packages/smart-contracts/scripts/test-deploy-erc20-recurring-payment-proxy.ts @@ -0,0 +1,26 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; +import { deployOne } from './deploy-one'; + +export async function deployERC20RecurringPaymentProxy( + args: any, + hre: HardhatRuntimeEnvironment, + erc20FeeProxyAddress: string, +) { + try { + const [deployer] = await hre.ethers.getSigners(); + const { address: ERC20RecurringPaymentProxyAddress } = await deployOne( + args, + hre, + 'ERC20RecurringPaymentProxy', + { + constructorArguments: [deployer.address, deployer.address, erc20FeeProxyAddress], + }, + ); + + console.log( + `ERC20RecurringPaymentProxy Contract deployed: ${ERC20RecurringPaymentProxyAddress}`, + ); + } catch (e) { + console.error(e); + } +} diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol new file mode 100644 index 000000000..394140ad3 --- /dev/null +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import '@openzeppelin/contracts/access/AccessControl.sol'; +import '@openzeppelin/contracts/security/Pausable.sol'; +import '@openzeppelin/contracts/security/ReentrancyGuard.sol'; +import '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; +import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; +import '@openzeppelin/contracts/access/Ownable.sol'; +import './interfaces/ERC20FeeProxy.sol'; +import './lib/SafeERC20.sol'; + +/** + * @title ERC20RecurringPaymentProxy + * @notice Triggers recurring ERC20 payments based on predefined schedules. + */ +contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, ReentrancyGuard, Ownable { + using SafeERC20 for IERC20; + using ECDSA for bytes32; + + error ERC20RecurringPaymentProxy__BadSignature(); + error ERC20RecurringPaymentProxy__SignatureExpired(); + error ERC20RecurringPaymentProxy__IndexTooLarge(); + error ERC20RecurringPaymentProxy__PaymentOutOfOrder(); + error ERC20RecurringPaymentProxy__IndexOutOfBounds(); + error ERC20RecurringPaymentProxy__NotDueYet(); + error ERC20RecurringPaymentProxy__AlreadyPaid(); + error ERC20RecurringPaymentProxy__ZeroAddress(); + + bytes32 public constant RELAYER_ROLE = keccak256('RELAYER_ROLE'); + + /* keccak256 of the typed-data struct with relayerFee field */ + bytes32 private constant _PERMIT_TYPEHASH = + keccak256( + 'SchedulePermit(address subscriber,address token,address recipient,' + 'address feeAddress,uint128 amount,uint128 feeAmount,uint128 relayerFee,' + 'uint32 periodSeconds,uint32 firstPayment,uint8 totalPayments,' + 'uint256 nonce,uint256 deadline,bool strictOrder)' + ); + + /* replay defence */ + mapping(bytes32 => uint256) public triggeredPaymentsBitmap; + mapping(bytes32 => uint8) public lastPaymentIndex; + + IERC20FeeProxy public erc20FeeProxy; + + struct SchedulePermit { + address subscriber; + address token; + address recipient; + address feeAddress; + uint128 amount; + uint128 feeAmount; + uint128 relayerFee; + uint32 periodSeconds; + uint32 firstPayment; + uint8 totalPayments; + uint256 nonce; + uint256 deadline; + bool strictOrder; + } + + constructor( + address adminSafe, + address relayerEOA, + address erc20FeeProxyAddress + ) EIP712('ERC20RecurringPaymentProxy', '1') { + if (adminSafe == address(0) || relayerEOA == address(0) || erc20FeeProxyAddress == address(0)) { + revert ERC20RecurringPaymentProxy__ZeroAddress(); + } + _grantRole(DEFAULT_ADMIN_ROLE, adminSafe); + _grantRole(RELAYER_ROLE, relayerEOA); + transferOwnership(adminSafe); + erc20FeeProxy = IERC20FeeProxy(erc20FeeProxyAddress); + } + + function _hashSchedule(SchedulePermit calldata p) private view returns (bytes32) { + bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, p)); + + return _hashTypedDataV4(structHash); + } + + function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { + erc20FeeProxy.transferFromWithReferenceAndFee( + p.token, + p.recipient, + p.amount, + paymentReference, + p.feeAmount, + p.feeAddress + ); + } + + function triggerRecurringPayment( + SchedulePermit calldata p, + bytes calldata signature, + uint8 index, + bytes calldata paymentReference + ) external whenNotPaused onlyRole(RELAYER_ROLE) nonReentrant { + bytes32 digest = _hashSchedule(p); + + if (digest.recover(signature) != p.subscriber) + revert ERC20RecurringPaymentProxy__BadSignature(); + if (block.timestamp > p.deadline) revert ERC20RecurringPaymentProxy__SignatureExpired(); + + if (index >= 256) revert ERC20RecurringPaymentProxy__IndexTooLarge(); + + if (p.strictOrder) { + if (index != lastPaymentIndex[digest] + 1) + revert ERC20RecurringPaymentProxy__PaymentOutOfOrder(); + lastPaymentIndex[digest] = index; + } + + if (index > p.totalPayments) revert ERC20RecurringPaymentProxy__IndexOutOfBounds(); + + uint256 execTime = uint256(p.firstPayment) + uint256(index - 1) * p.periodSeconds; + if (block.timestamp < execTime) revert ERC20RecurringPaymentProxy__NotDueYet(); + + uint256 mask = 1 << index; + uint256 word = triggeredPaymentsBitmap[digest]; + if (word & mask != 0) revert ERC20RecurringPaymentProxy__AlreadyPaid(); + triggeredPaymentsBitmap[digest] = word | mask; + + uint256 total = p.amount + p.feeAmount + p.relayerFee; + + IERC20 token = IERC20(p.token); + token.safeTransferFrom(p.subscriber, address(this), total); + + /* USDT-safe zero-approve then set allowance */ + token.safeApprove(address(erc20FeeProxy), 0); + token.safeApprove(address(erc20FeeProxy), p.amount + p.feeAmount); + + _proxyTransfer(p, paymentReference); + + if (p.relayerFee != 0) { + token.safeTransfer(msg.sender, p.relayerFee); + } + } + + function setRelayer(address oldRelayer, address newRelayer) external onlyOwner { + if (newRelayer == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); + _revokeRole(RELAYER_ROLE, oldRelayer); + _grantRole(RELAYER_ROLE, newRelayer); + } + + function setFeeProxy(address newProxy) external onlyOwner { + if (newProxy == address(0)) revert ERC20RecurringPaymentProxy__ZeroAddress(); + erc20FeeProxy = IERC20FeeProxy(newProxy); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } +} diff --git a/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.1.0.json b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.1.0.json new file mode 100644 index 000000000..a81f4474f --- /dev/null +++ b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/0.1.0.json @@ -0,0 +1,282 @@ +{ + "abi": [ + { + "inputs": [ + { "internalType": "address", "name": "adminSafe", "type": "address" }, + { "internalType": "address", "name": "relayerEOA", "type": "address" }, + { "internalType": "address", "name": "erc20FeeProxyAddress", "type": "address" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__AlreadyPaid", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__BadSignature", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__IndexOutOfBounds", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__IndexTooLarge", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__NotDueYet", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__PaymentOutOfOrder", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__SignatureExpired", "type": "error" }, + { "inputs": [], "name": "ERC20RecurringPaymentProxy__ZeroAddress", "type": "error" }, + { "inputs": [], "name": "InvalidShortString", "type": "error" }, + { + "inputs": [{ "internalType": "string", "name": "str", "type": "string" }], + "name": "StringTooLong", + "type": "error" + }, + { "anonymous": false, "inputs": [], "name": "EIP712DomainChanged", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "previousOwner", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "newOwner", "type": "address" } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "internalType": "address", "name": "account", "type": "address" } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { "indexed": true, "internalType": "bytes32", "name": "newAdminRole", "type": "bytes32" } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "sender", "type": "address" } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "sender", "type": "address" } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "internalType": "address", "name": "account", "type": "address" } + ], + "name": "Unpaused", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAYER_ROLE", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "eip712Domain", + "outputs": [ + { "internalType": "bytes1", "name": "fields", "type": "bytes1" }, + { "internalType": "string", "name": "name", "type": "string" }, + { "internalType": "string", "name": "version", "type": "string" }, + { "internalType": "uint256", "name": "chainId", "type": "uint256" }, + { "internalType": "address", "name": "verifyingContract", "type": "address" }, + { "internalType": "bytes32", "name": "salt", "type": "bytes32" }, + { "internalType": "uint256[]", "name": "extensions", "type": "uint256[]" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "erc20FeeProxy", + "outputs": [{ "internalType": "contract IERC20FeeProxy", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "role", "type": "bytes32" }], + "name": "getRoleAdmin", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "hasRole", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "name": "lastPaymentIndex", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes32", "name": "role", "type": "bytes32" }, + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "newProxy", "type": "address" }], + "name": "setFeeProxy", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "oldRelayer", "type": "address" }, + { "internalType": "address", "name": "newRelayer", "type": "address" } + ], + "name": "setRelayer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes4", "name": "interfaceId", "type": "bytes4" }], + "name": "supportsInterface", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "newOwner", "type": "address" }], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { "internalType": "address", "name": "subscriber", "type": "address" }, + { "internalType": "address", "name": "token", "type": "address" }, + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "address", "name": "feeAddress", "type": "address" }, + { "internalType": "uint128", "name": "amount", "type": "uint128" }, + { "internalType": "uint128", "name": "feeAmount", "type": "uint128" }, + { "internalType": "uint128", "name": "relayerFee", "type": "uint128" }, + { "internalType": "uint32", "name": "periodSeconds", "type": "uint32" }, + { "internalType": "uint32", "name": "firstPayment", "type": "uint32" }, + { "internalType": "uint8", "name": "totalPayments", "type": "uint8" }, + { "internalType": "uint256", "name": "nonce", "type": "uint256" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" }, + { "internalType": "bool", "name": "strictOrder", "type": "bool" } + ], + "internalType": "struct ERC20RecurringPaymentProxy.SchedulePermit", + "name": "p", + "type": "tuple" + }, + { "internalType": "bytes", "name": "signature", "type": "bytes" }, + { "internalType": "uint8", "name": "index", "type": "uint8" }, + { "internalType": "bytes", "name": "paymentReference", "type": "bytes" } + ], + "name": "triggerRecurringPayment", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "name": "triggeredPaymentsBitmap", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} diff --git a/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/index.ts b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/index.ts new file mode 100644 index 000000000..e2fd59afd --- /dev/null +++ b/packages/smart-contracts/src/lib/artifacts/ERC20RecurringPaymentProxy/index.ts @@ -0,0 +1,48 @@ +import { ContractArtifact } from '../../ContractArtifact'; + +import { abi as ABI_0_1_0 } from './0.1.0.json'; + +import type { ERC20RecurringPaymentProxy } from '../../../types'; + +export const erc20RecurringPaymentProxyArtifact = new ContractArtifact( + { + '0.1.0': { + abi: ABI_0_1_0, + deployment: { + private: { + address: '0xd8672a4A1bf37D36beF74E36edb4f17845E76F4e', + creationBlockNumber: 0, + }, + sepolia: { + address: '0xb6C7448458d8616B00eb8f93883d0081Fa2aDec9', + creationBlockNumber: 8689800, + }, + matic: { + address: '0xD3BD678f219439c2bcf602Beb07e601a91b8Cd3d', + creationBlockNumber: 73564289, + }, + 'arbitrum-one': { + address: '0xC335f956e91faa1DC103f9B54f7009c470dc6EEf', + creationBlockNumber: 354140859, + }, + bsc: { + address: '0xbb0f20890E9e405f9D4c2e707590525ccAA3De16', + creationBlockNumber: 52840120, + }, + xdai: { + address: '0xC82f76682E16fD2f030A010785a870e6fC6Ad1D1', + creationBlockNumber: 40915683, + }, + mainnet: { + address: '0xF0Bebbc99E26Ba7F651E7Ff14e0D3029B0228880', + creationBlockNumber: 22845710, + }, + base: { + address: '0xd2c3392e3e70F4CE033Cb39CC80B414aDdD64fFa', + creationBlockNumber: 32422496, + }, + }, + }, + }, + '0.1.0', +); diff --git a/packages/smart-contracts/src/lib/artifacts/index.ts b/packages/smart-contracts/src/lib/artifacts/index.ts index 20597971a..61ed113ee 100644 --- a/packages/smart-contracts/src/lib/artifacts/index.ts +++ b/packages/smart-contracts/src/lib/artifacts/index.ts @@ -15,6 +15,7 @@ export * from './BatchPayments'; export * from './BatchNoConversionPayments'; export * from './BatchConversionPayments'; export * from './SingleRequestProxyFactory'; +export * from './ERC20RecurringPaymentProxy'; /** * Request Storage */ diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts new file mode 100644 index 000000000..ed8ee0d85 --- /dev/null +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -0,0 +1,655 @@ +import { expect } from 'chai'; +import { Contract, Signer } from 'ethers'; +import { ethers } from 'hardhat'; +import { ERC20FeeProxy, TestERC20 } from '../../types'; + +describe('ERC20RecurringPaymentProxy', () => { + let erc20RecurringPaymentProxy: Contract; + let erc20FeeProxy: ERC20FeeProxy; + let testERC20: TestERC20; + + let owner: Signer; + let relayer: Signer; + let user: Signer; + let newRelayer: Signer; + let newOwner: Signer; + let subscriber: Signer; + let recipient: Signer; + let feeAddress: Signer; + + let ownerAddress: string; + let relayerAddress: string; + let userAddress: string; + let newRelayerAddress: string; + let newOwnerAddress: string; + let subscriberAddress: string; + let recipientAddress: string; + let feeAddressString: string; + + beforeEach(async () => { + [owner, relayer, user, newRelayer, newOwner, subscriber, recipient, feeAddress] = + await ethers.getSigners(); + ownerAddress = await owner.getAddress(); + relayerAddress = await relayer.getAddress(); + userAddress = await user.getAddress(); + newRelayerAddress = await newRelayer.getAddress(); + newOwnerAddress = await newOwner.getAddress(); + subscriberAddress = await subscriber.getAddress(); + recipientAddress = await recipient.getAddress(); + feeAddressString = await feeAddress.getAddress(); + + // Deploy ERC20FeeProxy + const ERC20FeeProxyFactory = await ethers.getContractFactory('ERC20FeeProxy'); + erc20FeeProxy = await ERC20FeeProxyFactory.deploy(); + await erc20FeeProxy.deployed(); + + // Deploy ERC20RecurringPaymentProxy + const ERC20RecurringPaymentProxyFactory = await ethers.getContractFactory( + 'ERC20RecurringPaymentProxy', + ); + erc20RecurringPaymentProxy = await ERC20RecurringPaymentProxyFactory.deploy( + ownerAddress, + relayerAddress, + erc20FeeProxy.address, + ); + await erc20RecurringPaymentProxy.deployed(); + + // Deploy test ERC20 token + const TestERC20Factory = await ethers.getContractFactory('TestERC20'); + testERC20 = await TestERC20Factory.deploy(1000); + await testERC20.deployed(); + }); + + // Helper function to create a valid SchedulePermit + const createSchedulePermit = (overrides: any = {}) => { + const now = Math.floor(Date.now() / 1000); + return { + subscriber: subscriberAddress, + token: testERC20.address, + recipient: recipientAddress, + feeAddress: feeAddressString, + amount: 100, + feeAmount: 10, + relayerFee: 5, + periodSeconds: 3600, + firstPayment: now, + totalPayments: 3, + nonce: 0, + deadline: now + 86400, // 24 hours from now + strictOrder: false, + ...overrides, + }; + }; + + // Helper function to create EIP712 signature + const createSignature = async (permit: any, signer: Signer) => { + const domain = { + name: 'ERC20RecurringPaymentProxy', + version: '1', + chainId: await signer.getChainId(), + verifyingContract: erc20RecurringPaymentProxy.address, + }; + + const types = { + SchedulePermit: [ + { name: 'subscriber', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'feeAddress', type: 'address' }, + { name: 'amount', type: 'uint128' }, + { name: 'feeAmount', type: 'uint128' }, + { name: 'relayerFee', type: 'uint128' }, + { name: 'periodSeconds', type: 'uint32' }, + { name: 'firstPayment', type: 'uint32' }, + { name: 'totalPayments', type: 'uint8' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'strictOrder', type: 'bool' }, + ], + }; + + // Some providers (Hardhat in-process) happily accept the string-encoded data (what + // ethers' _signTypedData sends). Others (Hardhat JSON-RPC, Ganache) expect the object + // version. To work everywhere we try the object version first and fall back to + // the built-in helper if the call is rejected. + + const typedDataObject = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + ...types, + }, + primaryType: 'SchedulePermit', + domain, + message: permit, + }; + + const address = await signer.getAddress(); + try { + // This matches the spec used by Hardhat JSON-RPC & Ganache + return await (signer.provider as any).send('eth_signTypedData', [address, typedDataObject]); + } catch (_) { + // Fallback to ethers helper (works in most in-process Hardhat environments) + return await (signer as any)._signTypedData(domain, types, permit); + } + }; + + describe('Deployment', () => { + it('should be deployed with correct initial values', async () => { + expect(erc20RecurringPaymentProxy.address).to.not.equal(ethers.constants.AddressZero); + expect(await erc20RecurringPaymentProxy.erc20FeeProxy()).to.equal(erc20FeeProxy.address); + expect(await erc20RecurringPaymentProxy.owner()).to.equal(ownerAddress); + expect( + await erc20RecurringPaymentProxy.hasRole( + await erc20RecurringPaymentProxy.RELAYER_ROLE(), + relayerAddress, + ), + ).to.be.true; + expect( + await erc20RecurringPaymentProxy.hasRole( + await erc20RecurringPaymentProxy.DEFAULT_ADMIN_ROLE(), + ownerAddress, + ), + ).to.be.true; + }); + + it('should be unpaused by default', async () => { + expect(await erc20RecurringPaymentProxy.paused()).to.be.false; + }); + }); + + describe('Access Control', () => { + it('should have correct role constants', async () => { + const RELAYER_ROLE = await erc20RecurringPaymentProxy.RELAYER_ROLE(); + const DEFAULT_ADMIN_ROLE = await erc20RecurringPaymentProxy.DEFAULT_ADMIN_ROLE(); + + expect(RELAYER_ROLE).to.equal( + ethers.utils.keccak256(ethers.utils.toUtf8Bytes('RELAYER_ROLE')), + ); + expect(DEFAULT_ADMIN_ROLE).to.equal(ethers.constants.HashZero); + }); + + it('should grant relayer role to the specified address', async () => { + expect( + await erc20RecurringPaymentProxy.hasRole( + await erc20RecurringPaymentProxy.RELAYER_ROLE(), + relayerAddress, + ), + ).to.be.true; + }); + + it('should grant admin role to the specified address', async () => { + expect( + await erc20RecurringPaymentProxy.hasRole( + await erc20RecurringPaymentProxy.DEFAULT_ADMIN_ROLE(), + ownerAddress, + ), + ).to.be.true; + }); + }); + + describe('setRelayer', () => { + it('should allow owner to set new relayer', async () => { + await erc20RecurringPaymentProxy.setRelayer(relayerAddress, newRelayerAddress); + + expect( + await erc20RecurringPaymentProxy.hasRole( + await erc20RecurringPaymentProxy.RELAYER_ROLE(), + relayerAddress, + ), + ).to.be.false; + expect( + await erc20RecurringPaymentProxy.hasRole( + await erc20RecurringPaymentProxy.RELAYER_ROLE(), + newRelayerAddress, + ), + ).to.be.true; + }); + + it('should revert when non-owner tries to set relayer', async () => { + await expect( + erc20RecurringPaymentProxy.connect(user).setRelayer(relayerAddress, newRelayerAddress), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should emit RoleRevoked and RoleGranted events', async () => { + await expect(erc20RecurringPaymentProxy.setRelayer(relayerAddress, newRelayerAddress)) + .to.emit(erc20RecurringPaymentProxy, 'RoleRevoked') + .withArgs(await erc20RecurringPaymentProxy.RELAYER_ROLE(), relayerAddress, ownerAddress) + .and.to.emit(erc20RecurringPaymentProxy, 'RoleGranted') + .withArgs(await erc20RecurringPaymentProxy.RELAYER_ROLE(), newRelayerAddress, ownerAddress); + }); + }); + + describe('setFeeProxy', () => { + it('should allow owner to set new fee proxy', async () => { + const newERC20FeeProxy = await (await ethers.getContractFactory('ERC20FeeProxy')).deploy(); + await newERC20FeeProxy.deployed(); + + await erc20RecurringPaymentProxy.setFeeProxy(newERC20FeeProxy.address); + expect(await erc20RecurringPaymentProxy.erc20FeeProxy()).to.equal(newERC20FeeProxy.address); + }); + + it('should revert when non-owner tries to set fee proxy', async () => { + const newERC20FeeProxy = await (await ethers.getContractFactory('ERC20FeeProxy')).deploy(); + await newERC20FeeProxy.deployed(); + + await expect( + erc20RecurringPaymentProxy.connect(user).setFeeProxy(newERC20FeeProxy.address), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should revert when trying to set zero address as fee proxy', async () => { + await expect(erc20RecurringPaymentProxy.setFeeProxy(ethers.constants.AddressZero)).to.be + .reverted; + }); + }); + + describe('Pausable functionality', () => { + it('should allow owner to pause the contract', async () => { + await erc20RecurringPaymentProxy.pause(); + expect(await erc20RecurringPaymentProxy.paused()).to.be.true; + }); + + it('should allow owner to unpause the contract', async () => { + await erc20RecurringPaymentProxy.pause(); + expect(await erc20RecurringPaymentProxy.paused()).to.be.true; + + await erc20RecurringPaymentProxy.unpause(); + expect(await erc20RecurringPaymentProxy.paused()).to.be.false; + }); + + it('should revert when non-owner tries to pause', async () => { + await expect(erc20RecurringPaymentProxy.connect(user).pause()).to.be.revertedWith( + 'Ownable: caller is not the owner', + ); + }); + + it('should revert when non-owner tries to unpause', async () => { + await erc20RecurringPaymentProxy.pause(); + + await expect(erc20RecurringPaymentProxy.connect(user).unpause()).to.be.revertedWith( + 'Ownable: caller is not the owner', + ); + }); + + it('should emit Paused event when paused', async () => { + await expect(erc20RecurringPaymentProxy.pause()) + .to.emit(erc20RecurringPaymentProxy, 'Paused') + .withArgs(ownerAddress); + }); + + it('should emit Unpaused event when unpaused', async () => { + await erc20RecurringPaymentProxy.pause(); + + await expect(erc20RecurringPaymentProxy.unpause()) + .to.emit(erc20RecurringPaymentProxy, 'Unpaused') + .withArgs(ownerAddress); + }); + }); + + describe('Ownership', () => { + it('should allow owner to transfer ownership', async () => { + await erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress); + expect(await erc20RecurringPaymentProxy.owner()).to.equal(newOwnerAddress); + }); + + it('should revert when non-owner tries to transfer ownership', async () => { + await expect( + erc20RecurringPaymentProxy.connect(user).transferOwnership(newOwnerAddress), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('should emit OwnershipTransferred event', async () => { + await expect(erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress)) + .to.emit(erc20RecurringPaymentProxy, 'OwnershipTransferred') + .withArgs(ownerAddress, newOwnerAddress); + }); + + it('should allow new owner to renounce ownership', async () => { + await erc20RecurringPaymentProxy.transferOwnership(newOwnerAddress); + + await expect(erc20RecurringPaymentProxy.connect(newOwner).renounceOwnership()) + .to.emit(erc20RecurringPaymentProxy, 'OwnershipTransferred') + .withArgs(newOwnerAddress, ethers.constants.AddressZero); + + expect(await erc20RecurringPaymentProxy.owner()).to.equal(ethers.constants.AddressZero); + }); + + it('should revert when non-owner tries to renounce ownership', async () => { + await expect(erc20RecurringPaymentProxy.connect(user).renounceOwnership()).to.be.revertedWith( + 'Ownable: caller is not the owner', + ); + }); + }); + + describe('Trigger Recurring Payment', () => { + beforeEach(async () => { + // Transfer tokens to subscriber and approve the recurring payment proxy + await testERC20.transfer(subscriberAddress, 500); + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 500); + }); + + it('should trigger a valid recurring payment', async () => { + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + const subscriberBalanceBefore = await testERC20.balanceOf(subscriberAddress); + const recipientBalanceBefore = await testERC20.balanceOf(recipientAddress); + const feeAddressBalanceBefore = await testERC20.balanceOf(feeAddressString); + const relayerBalanceBefore = await testERC20.balanceOf(relayerAddress); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ) + .to.emit(erc20FeeProxy, 'TransferWithReferenceAndFee') + .withArgs( + testERC20.address, + recipientAddress, + permit.amount, + ethers.utils.keccak256(paymentReference), + permit.feeAmount, + feeAddressString, + ); + + // Check balance changes + const subscriberBalanceAfter = await testERC20.balanceOf(subscriberAddress); + const recipientBalanceAfter = await testERC20.balanceOf(recipientAddress); + const feeAddressBalanceAfter = await testERC20.balanceOf(feeAddressString); + const relayerBalanceAfter = await testERC20.balanceOf(relayerAddress); + + expect(subscriberBalanceAfter).to.equal(subscriberBalanceBefore.sub(115)); // amount + fee + gas + expect(recipientBalanceAfter).to.equal(recipientBalanceBefore.add(100)); // amount + expect(feeAddressBalanceAfter).to.equal(feeAddressBalanceBefore.add(10)); // fee + expect(relayerBalanceAfter).to.equal(relayerBalanceBefore.add(5)); // gas fee + }); + + it('should revert when called by non-relayer', async () => { + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(user) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('AccessControl: account'); + }); + + it('should revert when contract is paused', async () => { + await erc20RecurringPaymentProxy.pause(); + + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.revertedWith('Pausable: paused'); + }); + + it('should revert with bad signature', async () => { + const permit = createSchedulePermit(); + const signature = await createSignature(permit, user); // Wrong signer + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + }); + + it('should revert when signature is expired', async () => { + const permit = createSchedulePermit({ + deadline: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago + }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + }); + + it('should revert when index is too large (>= 256)', async () => { + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 256, paymentReference), + ).to.be.reverted; + }); + + it('should revert when execution is out of order', async () => { + const permit = createSchedulePermit({ strictOrder: true, periodSeconds: 1 }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + // Advance time so payment #2 is due, ensuring the only failure reason is order. + await ethers.provider.send('evm_increaseTime', [1]); + await ethers.provider.send('evm_mine', []); + + // Try to execute index 2 before index 1 + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 2, paymentReference), + ).to.be.reverted; + }); + + it('should allow out of order trigger if strictOrder is false', async () => { + const permit = createSchedulePermit({ strictOrder: false, periodSeconds: 1 }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + // Fast forward time to make multiple payments due + await ethers.provider.send('evm_increaseTime', [5]); + await ethers.provider.send('evm_mine', []); + + // Execute index 2 before index 1, which should be allowed + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 2, paymentReference), + ).to.not.be.reverted; + }); + + it('should revert when index is out of bounds', async () => { + const permit = createSchedulePermit({ totalPayments: 1 }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 2, paymentReference), + ).to.be.reverted; + }); + + it('should revert when payment is not due yet', async () => { + const permit = createSchedulePermit({ + firstPayment: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + }); + + it('should revert when payment is already triggered', async () => { + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + // Trigger first time + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference); + + // Try to trigger the same index again + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + }); + + it('should allow sequential trigger of multiple payments', async () => { + const permit = createSchedulePermit({ totalPayments: 3, periodSeconds: 1 }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + // Trigger first payment + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference); + + // Advance time by periodSeconds to allow second payment + await ethers.provider.send('evm_increaseTime', [permit.periodSeconds]); + await ethers.provider.send('evm_mine', []); + + // Trigger second payment + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 2, paymentReference); + + // Advance time by periodSeconds to allow third payment + await ethers.provider.send('evm_increaseTime', [permit.periodSeconds]); + await ethers.provider.send('evm_mine', []); + + // Trigger third payment + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 3, paymentReference); + + // Verify all payments were triggered + // Note: We can't directly call _hashSchedule as it's private, but we can verify through the bitmap + // The bitmap should have bits 1, 2, and 3 set (2^1 + 2^2 + 2^3 = 14) + // We'll check this by trying to trigger the same indices again, which should fail + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; // Should fail because already triggered + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 2, paymentReference), + ).to.be.reverted; // Should fail because already triggered + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 3, paymentReference), + ).to.be.reverted; // Should fail because already triggered + }); + + it('should handle zero relayer fee correctly', async () => { + const permit = createSchedulePermit({ relayerFee: 0 }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + const relayerBalanceBefore = await testERC20.balanceOf(relayerAddress); + + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference); + + const relayerBalanceAfter = await testERC20.balanceOf(relayerAddress); + expect(relayerBalanceAfter).to.equal(relayerBalanceBefore); // No relayer fee transferred + }); + + it('should handle zero fee amount correctly', async () => { + const permit = createSchedulePermit({ feeAmount: 0 }); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + const feeAddressBalanceBefore = await testERC20.balanceOf(feeAddressString); + + await erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference); + + const feeAddressBalanceAfter = await testERC20.balanceOf(feeAddressString); + expect(feeAddressBalanceAfter).to.equal(feeAddressBalanceBefore); // No fee transferred + }); + + it('should revert when subscriber has insufficient balance', async () => { + const permit = createSchedulePermit({ amount: 1000 }); // More than subscriber has + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + }); + + it('should revert when subscriber has insufficient allowance', async () => { + const permit = createSchedulePermit(); + const signature = await createSignature(permit, subscriber); + const paymentReference = '0x1234567890abcdef'; + + // Revoke approval + await testERC20.connect(subscriber).approve(erc20RecurringPaymentProxy.address, 0); + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(permit, signature, 1, paymentReference), + ).to.be.reverted; + }); + }); + + describe('Integration: Paused state affects execution', () => { + it('should revert trigger when contract is paused', async () => { + await erc20RecurringPaymentProxy.pause(); + + // Create a minimal SchedulePermit for testing + const schedulePermit = { + subscriber: userAddress, + token: testERC20.address, + recipient: userAddress, + feeAddress: userAddress, + amount: 100, + feeAmount: 10, + relayerFee: 5, + periodSeconds: 3600, + firstPayment: Math.floor(Date.now() / 1000), + totalPayments: 1, + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 3600, + }; + + const signature = '0x' + '0'.repeat(130); // Dummy signature + const paymentReference = '0x1234'; + + await expect( + erc20RecurringPaymentProxy + .connect(relayer) + .triggerRecurringPayment(schedulePermit, signature, 1, paymentReference), + ).to.be.revertedWith('Pausable: paused'); + }); + }); +}); diff --git a/packages/types/src/payment-types.ts b/packages/types/src/payment-types.ts index 807dfb80c..251155fe9 100644 --- a/packages/types/src/payment-types.ts +++ b/packages/types/src/payment-types.ts @@ -2,6 +2,7 @@ import { IIdentity } from './identity-types'; import * as RequestLogic from './request-logic-types'; import * as ExtensionTypes from './extension-types'; import { ICreationParameters } from './extensions/pn-any-declarative-types'; +import { BigNumberish } from 'ethers'; /** Interface for payment network extensions state and interpretation */ export interface IPaymentNetwork< @@ -392,3 +393,22 @@ export interface MetaDetail { paymentNetworkId: BATCH_PAYMENT_NETWORK_ID; requestDetails: RequestDetail[]; } + +/** + * Parameters for a recurring payment schedule permit + */ +export interface SchedulePermit { + subscriber: string; + token: string; + recipient: string; + feeAddress: string; + amount: BigNumberish; + feeAmount: BigNumberish; + relayerFee: BigNumberish; + periodSeconds: number; + firstPayment: number; + totalPayments: number; + nonce: BigNumberish; + deadline: BigNumberish; + strictOrder: boolean; +} diff --git a/yarn.lock b/yarn.lock index 2ea661a21..e7be0a5d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4284,17 +4284,17 @@ "@nomicfoundation/ethereumjs-rlp" "5.0.4" ethereum-cryptography "0.1.3" -"@nomicfoundation/hardhat-verify@2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@nomicfoundation/hardhat-verify/-/hardhat-verify-2.0.0.tgz" - integrity sha512-DlzeYWcPtcD82AD1wSsmjPjrbuESSKlY5XMvUYnr5YMQc81yGUc++U3M7ghHo0JSnZEUhXq+hDc8/HkpOZ6h8A== +"@nomicfoundation/hardhat-verify@2.0.14": + version "2.0.14" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-verify/-/hardhat-verify-2.0.14.tgz#ba80918fac840f1165825f2a422a694486f82f6f" + integrity sha512-z3iVF1WYZHzcdMMUuureFpSAfcnlfJbJx3faOnGrOYg6PRTki1Ut9JAuRccnFzMHf1AmTEoSUpWcyvBCoxL5Rg== dependencies: "@ethersproject/abi" "^5.1.2" "@ethersproject/address" "^5.0.2" cbor "^8.1.0" - chalk "^2.4.2" debug "^4.1.1" lodash.clonedeep "^4.5.0" + picocolors "^1.1.0" semver "^6.3.0" table "^6.8.0" undici "^5.14.0" @@ -20222,7 +20222,7 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -picocolors@^1.0.0: +picocolors@^1.0.0, picocolors@^1.1.0: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -22768,7 +22768,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -22786,15 +22786,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz" @@ -22929,7 +22920,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22964,13 +22955,6 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" @@ -24210,6 +24194,11 @@ typescript@2.9.1: resolved "https://registry.npmjs.org/typescript/-/typescript-2.9.1.tgz" integrity sha512-h6pM2f/GDchCFlldnriOhs1QHuwbnmj6/v7499eMHqPeW4V2G0elua2eIc2nu8v2NdHV0Gm+tzX83Hr6nUFjQA== +typescript@4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" + integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== + typescript@5.8.3: version "5.8.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" @@ -26171,7 +26160,7 @@ workerpool@6.2.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -26206,15 +26195,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"