diff --git a/packages/payment-processor/src/index.ts b/packages/payment-processor/src/index.ts index fc48075b2c..0bbf676fbc 100644 --- a/packages/payment-processor/src/index.ts +++ b/packages/payment-processor/src/index.ts @@ -9,6 +9,7 @@ export * from './payment/near-input-data'; export * from './payment/eth-proxy'; export * from './payment/eth-fee-proxy'; export * from './payment/batch-proxy'; +export * from './payment/batch-conversion-proxy'; export * from './payment/swap-conversion-erc20'; export * from './payment/swap-any-to-erc20'; export * from './payment/swap-erc20'; diff --git a/packages/payment-processor/src/payment/any-to-erc20-proxy.ts b/packages/payment-processor/src/payment/any-to-erc20-proxy.ts index a557f826d7..8f81445381 100644 --- a/packages/payment-processor/src/payment/any-to-erc20-proxy.ts +++ b/packages/payment-processor/src/payment/any-to-erc20-proxy.ts @@ -1,6 +1,10 @@ import { constants, ContractTransaction, Signer, providers, BigNumberish, BigNumber } from 'ethers'; -import { CurrencyManager, UnsupportedCurrencyError } from '@requestnetwork/currency'; +import { + CurrencyDefinition, + CurrencyManager, + UnsupportedCurrencyError, +} from '@requestnetwork/currency'; import { AnyToERC20PaymentDetector } from '@requestnetwork/payment-detection'; import { Erc20ConversionProxy__factory } from '@requestnetwork/smart-contracts/types'; import { ClientTypes, RequestLogicTypes } from '@requestnetwork/types'; @@ -20,13 +24,13 @@ import { IConversionPaymentSettings } from './index'; /** * Processes a transaction to pay a request with an ERC20 currency that is different from the request currency (eg. fiat). - * The payment is made by the ERC20 fee proxy contract. - * @param request the request to pay - * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. - * @param paymentSettings payment settings - * @param amount optionally, the amount to pay. Defaults to remaining amount of the request. - * @param feeAmount optionally, the fee amount to pay. Defaults to the fee amount. - * @param overrides optionally, override default transaction values, like gas. + * The payment is made by the ERC20 Conversion fee proxy contract. + * @param request The request to pay + * @param signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum. + * @param paymentSettings The payment settings + * @param amount Optionally, the amount to pay. Defaults to remaining amount of the request. + * @param feeAmount Optionally, the fee amount to pay. Defaults to the fee amount. + * @param overrides Optionally, override default transaction values, like gas. */ export async function payAnyToErc20ProxyRequest( request: ClientTypes.IRequestData, @@ -47,12 +51,12 @@ export async function payAnyToErc20ProxyRequest( } /** - * Encodes the call to pay a request with an ERC20 currency that is different from the request currency (eg. fiat). The payment is made by the ERC20 fee proxy contract. - * @param request request to pay - * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. - * @param paymentSettings payment settings - * @param amount optionally, the amount to pay. Defaults to remaining amount of the request. - * @param feeAmountOverride optionally, the fee amount to pay. Defaults to the fee amount of the request. + * Encodes the call to pay a request with an ERC20 currency that is different from the request currency (eg. fiat). + * The payment is made by the ERC20 Conversion fee proxy contract. + * @param request The request to pay + * @param paymentSettings The payment settings + * @param amount Optionally, the amount to pay. Defaults to remaining amount of the request. + * @param feeAmountOverride Optionally, the fee amount to pay. Defaults to the fee amount of the request. */ export function encodePayAnyToErc20ProxyRequest( request: ClientTypes.IRequestData, @@ -60,6 +64,41 @@ export function encodePayAnyToErc20ProxyRequest( amount?: BigNumberish, feeAmountOverride?: BigNumberish, ): string { + const { + path, + paymentReference, + paymentAddress, + feeAddress, + maxRateTimespan, + amountToPay, + feeToPay, + } = prepareAnyToErc20Arguments(request, paymentSettings, amount, feeAmountOverride); + const proxyContract = Erc20ConversionProxy__factory.createInterface(); + return proxyContract.encodeFunctionData('transferFromWithReferenceAndFee', [ + paymentAddress, + amountToPay, + path, + `0x${paymentReference}`, + feeToPay, + feeAddress || constants.AddressZero, + BigNumber.from(paymentSettings.maxToSpend), + maxRateTimespan || 0, + ]); +} + +/** + * It checks paymentSettings values, it get request's path and requestCurrency + * @param request The request to pay + * @param paymentSettings The payment settings + * @param amount Optionally, the amount to pay. Defaults to remaining amount of the request. + * @param feeAmountOverride Optionally, the fee amount to pay. Defaults to the fee amount of the request. + */ +export function checkRequestAndGetPathAndCurrency( + request: ClientTypes.IRequestData, + paymentSettings: IConversionPaymentSettings, + amount?: BigNumberish, + feeAmountOverride?: BigNumberish, +): { path: string[]; requestCurrency: CurrencyDefinition } { if (!paymentSettings.currency) { throw new Error('currency must be provided in the paymentSettings'); } @@ -94,24 +133,53 @@ export function encodePayAnyToErc20ProxyRequest( // Check request validateConversionFeeProxyRequest(request, path, amount, feeAmountOverride); + return { path, requestCurrency }; +} + +/** + * Prepares all necessaries arguments required to encode an any-to-erc20 request + * @param request The request to pay + * @param paymentSettings The payment settings + * @param amount Optionally, the amount to pay. Defaults to remaining amount of the request. + * @param feeAmountOverride Optionally, the fee amount to pay. Defaults to the fee amount of the request. + */ +function prepareAnyToErc20Arguments( + request: ClientTypes.IRequestData, + paymentSettings: IConversionPaymentSettings, + amount?: BigNumberish, + feeAmountOverride?: BigNumberish, +): { + path: string[]; + paymentReference: string; + paymentAddress: string; + feeAddress: string | undefined; + maxRateTimespan: string | undefined; + amountToPay: BigNumber; + feeToPay: BigNumber; +} { + const { path, requestCurrency } = checkRequestAndGetPathAndCurrency( + request, + paymentSettings, + amount, + feeAmountOverride, + ); const { paymentReference, paymentAddress, feeAddress, feeAmount, maxRateTimespan } = getRequestPaymentValues(request); - + if (!paymentReference) { + throw new Error('paymentReference is missing'); + } const amountToPay = padAmountForChainlink(getAmountToPay(request, amount), requestCurrency); const feeToPay = padAmountForChainlink(feeAmountOverride || feeAmount || 0, requestCurrency); - - const proxyContract = Erc20ConversionProxy__factory.createInterface(); - return proxyContract.encodeFunctionData('transferFromWithReferenceAndFee', [ + return { + path, + paymentReference, paymentAddress, + feeAddress, + maxRateTimespan, amountToPay, - path, - `0x${paymentReference}`, feeToPay, - feeAddress || constants.AddressZero, - BigNumber.from(paymentSettings.maxToSpend), - maxRateTimespan || 0, - ]); + }; } export function prepareAnyToErc20ProxyPaymentTransaction( diff --git a/packages/payment-processor/src/payment/batch-conversion-proxy.ts b/packages/payment-processor/src/payment/batch-conversion-proxy.ts new file mode 100644 index 0000000000..60715f6357 --- /dev/null +++ b/packages/payment-processor/src/payment/batch-conversion-proxy.ts @@ -0,0 +1,375 @@ +import { ContractTransaction, Signer, providers, BigNumber, constants } from 'ethers'; +import { batchConversionPaymentsArtifact } from '@requestnetwork/smart-contracts'; +import { BatchConversionPayments__factory } from '@requestnetwork/smart-contracts/types'; +import { ClientTypes, PaymentTypes } from '@requestnetwork/types'; +import { ITransactionOverrides } from './transaction-overrides'; +import { + comparePnTypeAndVersion, + getPnAndNetwork, + getProvider, + getProxyAddress, + getRequestPaymentValues, + getSigner, +} from './utils'; +import { + padAmountForChainlink, + getPaymentNetworkExtension, +} from '@requestnetwork/payment-detection'; +import { IPreparedTransaction } from './prepared-transaction'; +import { EnrichedRequest, IConversionPaymentSettings } from './index'; +import { checkRequestAndGetPathAndCurrency } from './any-to-erc20-proxy'; +import { getBatchArgs } from './batch-proxy'; +import { checkErc20Allowance, encodeApproveAnyErc20 } from './erc20'; +import { BATCH_PAYMENT_NETWORK_ID } from '@requestnetwork/types/dist/payment-types'; +import { IState } from 'types/dist/extension-types'; +import { CurrencyInput, isERC20Currency, isISO4217Currency } from '@requestnetwork/currency/dist'; + +/** + * Processes a transaction to pay a batch of requests with an ERC20 currency + * that is different from the request currency (eg. fiat) + * The payment is made through ERC20 or ERC20Conversion proxies + * It can be used with a Multisig contract + * @param enrichedRequests List of EnrichedRequests to pay + * @param version The version of the batch conversion proxy + * @param signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum. + * @param overrides Optionally, override default transaction values, like gas. + * @dev We only implement batchRouter using two ERC20 functions: + * batchMultiERC20ConversionPayments, and batchMultiERC20Payments. + */ +export async function payBatchConversionProxyRequest( + enrichedRequests: EnrichedRequest[], + version: string, + signerOrProvider: providers.Provider | Signer = getProvider(), + overrides?: ITransactionOverrides, +): Promise { + const { data, to, value } = prepareBatchConversionPaymentTransaction(enrichedRequests, version); + const signer = getSigner(signerOrProvider); + return signer.sendTransaction({ data, to, value, ...overrides }); +} + +/** + * Prepares a transaction to pay a batch of requests with an ERC20 currency + * that is different from the request currency (eg. fiat) + * it can be used with a Multisig contract. + * @param enrichedRequests List of EnrichedRequests to pay + * @param version The version of the batch conversion proxy + */ +export function prepareBatchConversionPaymentTransaction( + enrichedRequests: EnrichedRequest[], + version: string, +): IPreparedTransaction { + const encodedTx = encodePayBatchConversionRequest(enrichedRequests); + const proxyAddress = getBatchConversionProxyAddress(enrichedRequests[0].request, version); + return { + data: encodedTx, + to: proxyAddress, + value: 0, + }; +} + +/** + * Encodes a transaction to pay a batch of requests with an ERC20 currency + * that is different from the request currency (eg. fiat) + * It can be used with a Multisig contract. + * @param enrichedRequests List of EnrichedRequests to pay + */ +export function encodePayBatchConversionRequest(enrichedRequests: EnrichedRequest[]): string { + const { feeAddress } = getRequestPaymentValues(enrichedRequests[0].request); + + const firstNetwork = getPnAndNetwork(enrichedRequests[0].request)[1]; + let firstConversionRequestExtension: IState | undefined; + let firstNoConversionRequestExtension: IState | undefined; + const requestsWithoutConversion: ClientTypes.IRequestData[] = []; + const conversionDetails: PaymentTypes.ConversionDetail[] = []; + + // fill conversionDetails and requestsWithoutConversion lists + for (const enrichedRequest of enrichedRequests) { + if ( + enrichedRequest.paymentNetworkId === + BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS + ) { + firstConversionRequestExtension = + firstConversionRequestExtension ?? getPaymentNetworkExtension(enrichedRequest.request); + + comparePnTypeAndVersion(firstConversionRequestExtension, enrichedRequest.request); + if ( + !( + isERC20Currency(enrichedRequest.request.currencyInfo as unknown as CurrencyInput) || + isISO4217Currency(enrichedRequest.request.currencyInfo as unknown as CurrencyInput) + ) + ) { + throw new Error(`wrong request currencyInfo type`); + } + conversionDetails.push(getInputConversionDetail(enrichedRequest)); + } else if ( + enrichedRequest.paymentNetworkId === BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS + ) { + firstNoConversionRequestExtension = + firstNoConversionRequestExtension ?? getPaymentNetworkExtension(enrichedRequest.request); + + // isERC20Currency is checked within getBatchArgs function + comparePnTypeAndVersion(firstNoConversionRequestExtension, enrichedRequest.request); + requestsWithoutConversion.push(enrichedRequest.request); + } + if (firstNetwork !== getPnAndNetwork(enrichedRequest.request)[1]) + throw new Error('All the requests must have the same network'); + } + + const metaDetails: PaymentTypes.MetaDetail[] = []; + // Add conversionDetails to metaDetails + if (conversionDetails.length > 0) { + metaDetails.push({ + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + conversionDetails: conversionDetails, + cryptoDetails: { + tokenAddresses: [], + recipients: [], + amounts: [], + paymentReferences: [], + feeAmounts: [], + }, // cryptoDetails is not used with paymentNetworkId 0 + }); + } + + // Get values and add cryptoDetails to metaDetails + if (requestsWithoutConversion.length > 0) { + const { tokenAddresses, paymentAddresses, amountsToPay, paymentReferences, feesToPay } = + getBatchArgs(requestsWithoutConversion, 'ERC20'); + + // add ERC20 no-conversion payments + metaDetails.push({ + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS, + conversionDetails: [], + cryptoDetails: { + tokenAddresses: tokenAddresses, + recipients: paymentAddresses, + amounts: amountsToPay.map((x) => x.toString()), + paymentReferences: paymentReferences, + feeAmounts: feesToPay.map((x) => x.toString()), + }, + }); + } + + const proxyContract = BatchConversionPayments__factory.createInterface(); + return proxyContract.encodeFunctionData('batchRouter', [ + metaDetails, + feeAddress || constants.AddressZero, + ]); +} + +/** + * Get the conversion detail values from one enriched request + * @param enrichedRequest The enrichedRequest to pay + */ +function getInputConversionDetail(enrichedRequest: EnrichedRequest): PaymentTypes.ConversionDetail { + const paymentSettings = enrichedRequest.paymentSettings; + if (!paymentSettings) throw Error('the enrichedRequest has no paymentSettings'); + + const { path, requestCurrency } = checkRequestAndGetPathAndCurrency( + enrichedRequest.request, + paymentSettings, + ); + + const { paymentReference, paymentAddress, feeAmount, maxRateTimespan } = getRequestPaymentValues( + enrichedRequest.request, + ); + + const requestAmount = BigNumber.from(enrichedRequest.request.expectedAmount).sub( + enrichedRequest.request.balance?.balance || 0, + ); + + const padRequestAmount = padAmountForChainlink(requestAmount, requestCurrency); + const padFeeAmount = padAmountForChainlink(feeAmount || 0, requestCurrency); + return { + recipient: paymentAddress, + requestAmount: padRequestAmount.toString(), + path: path, + paymentReference: `0x${paymentReference}`, + feeAmount: padFeeAmount.toString(), + maxToSpend: paymentSettings.maxToSpend.toString(), + maxRateTimespan: maxRateTimespan || '0', + }; +} + +/** + * + * @param network The network targeted + * @param version The version of the batch conversion proxy + * @returns + */ +function getBatchDeploymentInformation( + network: string, + version: string, +): { address: string } | null { + return { address: batchConversionPaymentsArtifact.getAddress(network, version) }; +} + +/** + * Gets batch conversion contract Address + * @param request The request for an ERC20 payment with/out conversion + * @param version The version of the batch conversion proxy + */ +export function getBatchConversionProxyAddress( + request: ClientTypes.IRequestData, + version: string, +): string { + return getProxyAddress(request, getBatchDeploymentInformation, version); +} + +/** + * ERC20 Batch conversion proxy approvals methods + */ + +/** + * Processes the approval transaction of the targeted ERC20 with batch conversion proxy. + * @param request The request for an ERC20 payment with/out conversion + * @param account The account that will be used to pay the request + * @param version The version of the batch conversion proxy, which can be different from request pn version + * @param signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum. + * @param paymentSettings The payment settings are necessary for conversion payment approval + * @param overrides Optionally, override default transaction values, like gas. + */ +export async function approveErc20BatchConversionIfNeeded( + request: ClientTypes.IRequestData, + account: string, + version: string, + signerOrProvider: providers.Provider | Signer = getProvider(), + paymentSettings?: IConversionPaymentSettings, + overrides?: ITransactionOverrides, +): Promise { + if ( + !(await hasErc20BatchConversionApproval( + request, + account, + version, + signerOrProvider, + paymentSettings, + )) + ) { + return approveErc20BatchConversion( + request, + version, + getSigner(signerOrProvider), + paymentSettings, + overrides, + ); + } +} + +/** + * Checks if the batch conversion proxy has the necessary allowance from a given account + * to pay a given request with ERC20 batch conversion proxy + * @param request The request for an ERC20 payment with/out conversion + * @param account The account that will be used to pay the request + * @param version The version of the batch conversion proxy + * @param signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum. + * @param paymentSettings The payment settings are necessary for conversion payment approval + */ +export async function hasErc20BatchConversionApproval( + request: ClientTypes.IRequestData, + account: string, + version: string, + signerOrProvider: providers.Provider | Signer = getProvider(), + paymentSettings?: IConversionPaymentSettings, +): Promise { + return checkErc20Allowance( + account, + getBatchConversionProxyAddress(request, version), + signerOrProvider, + getTokenAddress(request, paymentSettings), + request.expectedAmount, + ); +} + +/** + * Processes the transaction to approve the batch conversion proxy to spend signer's tokens to pay + * the request in its payment currency. Can be used with a Multisig contract. + * @param request The request for an ERC20 payment with/out conversion + * @param version The version of the batch conversion proxy, which can be different from request pn version + * @param signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum. + * @param paymentSettings The payment settings are necessary for conversion payment approval + * @param overrides Optionally, override default transaction values, like gas. + */ +export async function approveErc20BatchConversion( + request: ClientTypes.IRequestData, + version: string, + signerOrProvider: providers.Provider | Signer = getProvider(), + paymentSettings?: IConversionPaymentSettings, + overrides?: ITransactionOverrides, +): Promise { + const preparedTx = prepareApproveErc20BatchConversion( + request, + version, + signerOrProvider, + paymentSettings, + overrides, + ); + const signer = getSigner(signerOrProvider); + const tx = await signer.sendTransaction(preparedTx); + return tx; +} + +/** + * Prepare the transaction to approve the proxy to spend signer's tokens to pay + * the request in its payment currency. Can be used with a Multisig contract. + * @param request The request for an ERC20 payment with/out conversion + * @param version The version of the batch conversion proxy + * @param signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum. + * @param paymentSettings The payment settings are necessary for conversion payment approval + * @param overrides Optionally, override default transaction values, like gas. + */ +export function prepareApproveErc20BatchConversion( + request: ClientTypes.IRequestData, + version: string, + signerOrProvider: providers.Provider | Signer = getProvider(), + paymentSettings?: IConversionPaymentSettings, + overrides?: ITransactionOverrides, +): IPreparedTransaction { + const encodedTx = encodeApproveErc20BatchConversion( + request, + version, + signerOrProvider, + paymentSettings, + ); + return { + data: encodedTx, + to: getTokenAddress(request, paymentSettings), + value: 0, + ...overrides, + }; +} + +/** + * Encodes the transaction to approve the batch conversion proxy to spend signer's tokens to pay + * the request in its payment currency. Can be used with a Multisig contract. + * @param request The request for an ERC20 payment with/out conversion + * @param version The version of the batch conversion proxy + * @param signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum. + * @param paymentSettings The payment settings are necessary for conversion payment approval + */ +export function encodeApproveErc20BatchConversion( + request: ClientTypes.IRequestData, + version: string, + signerOrProvider: providers.Provider | Signer = getProvider(), + paymentSettings?: IConversionPaymentSettings, +): string { + const proxyAddress = getBatchConversionProxyAddress(request, version); + return encodeApproveAnyErc20( + getTokenAddress(request, paymentSettings), + proxyAddress, + getSigner(signerOrProvider), + ); +} + +/** + * Get the address of the token to interact with, + * if it is a conversion payment, the info is inside paymentSettings + * @param request The request for an ERC20 payment with/out conversion + * @param paymentSettings The payment settings are necessary for conversion payment + * */ +function getTokenAddress( + request: ClientTypes.IRequestData, + paymentSettings?: IConversionPaymentSettings, +): string { + return paymentSettings ? paymentSettings.currency!.value : request.currencyInfo.value; +} diff --git a/packages/payment-processor/src/payment/batch-proxy.ts b/packages/payment-processor/src/payment/batch-proxy.ts index 8872c8eea1..531daac2be 100644 --- a/packages/payment-processor/src/payment/batch-proxy.ts +++ b/packages/payment-processor/src/payment/batch-proxy.ts @@ -34,7 +34,7 @@ import { checkErc20Allowance, encodeApproveAnyErc20 } from './erc20'; * Processes a transaction to pay a batch of ETH Requests with fees. * Requests paymentType must be "ETH" or "ERC20" * @param requests List of requests - * @param version version of the batch proxy, which can be different from request pn version + * @param version The version version of the batch proxy, which can be different from request pn version * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. * @param batchFee Only for batch ETH: additional fee applied to a batch, between 0 and 1000, default value = 10 * @param overrides optionally, override default transaction values, like gas. @@ -55,7 +55,7 @@ export async function payBatchProxyRequest( * Prepate the transaction to pay a batch of requests through the batch proxy contract, can be used with a Multisig contract. * Requests paymentType must be "ETH" or "ERC20" * @param requests list of ETH requests to pay - * @param version version of the batch proxy, which can be different from request pn version + * @param version The version version of the batch proxy, which can be different from request pn version * @param batchFee additional fee applied to a batch */ export function prepareBatchPaymentTransaction( @@ -116,9 +116,7 @@ export function encodePayBatchRequest(requests: ClientTypes.IRequestData[]): str const pn = getPaymentNetworkExtension(requests[0]); for (let i = 0; i < requests.length; i++) { validateErc20FeeProxyRequest(requests[i]); - if (!comparePnTypeAndVersion(pn, requests[i])) { - throw new Error(`Every payment network type and version must be identical`); - } + comparePnTypeAndVersion(pn, requests[i]); } if (isMultiTokens) { @@ -155,10 +153,14 @@ export function encodePayBatchRequest(requests: ClientTypes.IRequestData[]): str /** * Get batch arguments * @param requests List of requests + * @param forcedPaymentType It force to considere the request as an ETH or an ERC20 payment * @returns List with the args required by batch Eth and Erc20 functions, * @dev tokenAddresses returned is for batch Erc20 functions */ -function getBatchArgs(requests: ClientTypes.IRequestData[]): { +export function getBatchArgs( + requests: ClientTypes.IRequestData[], + forcedPaymentType?: 'ETH' | 'ERC20', +): { tokenAddresses: Array; paymentAddresses: Array; amountsToPay: Array; @@ -173,7 +175,7 @@ function getBatchArgs(requests: ClientTypes.IRequestData[]): { const feesToPay: Array = []; let feeAddressUsed = constants.AddressZero; - const paymentType = requests[0].currencyInfo.type; + const paymentType = forcedPaymentType ?? requests[0].currencyInfo.type; for (let i = 0; i < requests.length; i++) { if (paymentType === 'ETH') { validateEthFeeProxyRequest(requests[i]); @@ -208,8 +210,8 @@ function getBatchArgs(requests: ClientTypes.IRequestData[]): { /** * Get Batch contract Address - * @param request - * @param version version of the batch proxy, which can be different from request pn version + * @param request The request to pay + * @param version The version version of the batch proxy, which can be different from request pn version */ export function getBatchProxyAddress(request: ClientTypes.IRequestData, version: string): string { const pn = getPaymentNetworkExtension(request); @@ -232,9 +234,9 @@ export function getBatchProxyAddress(request: ClientTypes.IRequestData, version: /** * Processes the approval transaction of the targeted ERC20 with batch proxy. - * @param request request to pay - * @param account account that will be used to pay the request - * @param version version of the batch proxy, which can be different from request pn version + * @param request The request to pay + * @param account The account that will be used to pay the request + * @param version The version version of the batch proxy, which can be different from request pn version * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. * @param overrides optionally, override default transaction values, like gas. */ @@ -253,9 +255,9 @@ export async function approveErc20BatchIfNeeded( /** * Checks if the batch proxy has the necessary allowance from a given account * to pay a given request with ERC20 batch - * @param request request to pay - * @param account account that will be used to pay the request - * @param version version of the batch proxy, which can be different from request pn version + * @param request The request to pay + * @param account The account that will be used to pay the request + * @param version The version version of the batch proxy, which can be different from request pn version * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. */ export async function hasErc20BatchApproval( @@ -276,8 +278,8 @@ export async function hasErc20BatchApproval( /** * Processes the transaction to approve the batch proxy to spend signer's tokens to pay * the request in its payment currency. Can be used with a Multisig contract. - * @param request request to pay - * @param version version of the batch proxy, which can be different from request pn version + * @param request The request to pay + * @param version The version version of the batch proxy, which can be different from request pn version * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. * @param overrides optionally, override default transaction values, like gas. */ @@ -296,8 +298,8 @@ export async function approveErc20Batch( /** * Prepare the transaction to approve the proxy to spend signer's tokens to pay * the request in its payment currency. Can be used with a Multisig contract. - * @param request request to pay - * @param version version of the batch proxy, which can be different from request pn version + * @param request The request to pay + * @param version The version version of the batch proxy, which can be different from request pn version * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. * @param overrides optionally, override default transaction values, like gas. */ @@ -320,8 +322,8 @@ export function prepareApproveErc20Batch( /** * Encodes the transaction to approve the batch proxy to spend signer's tokens to pay * the request in its payment currency. Can be used with a Multisig contract. - * @param request request to pay - * @param version version of the batch proxy, which can be different from request pn version + * @param request The request to pay + * @param version The version version of the batch proxy, which can be different from request pn version * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum. */ export function encodeApproveErc20Batch( diff --git a/packages/payment-processor/src/payment/index.ts b/packages/payment-processor/src/payment/index.ts index d5bc316573..ee1f039db6 100644 --- a/packages/payment-processor/src/payment/index.ts +++ b/packages/payment-processor/src/payment/index.ts @@ -1,6 +1,6 @@ import { ContractTransaction, Signer, BigNumber, BigNumberish, providers } from 'ethers'; -import { ClientTypes, ExtensionTypes } from '@requestnetwork/types'; +import { ClientTypes, ExtensionTypes, PaymentTypes } from '@requestnetwork/types'; import { getBtcPaymentUrl } from './btc-address-based'; import { _getErc20PaymentUrl, getAnyErc20Balance } from './erc20'; @@ -326,3 +326,21 @@ const throwIfNotWeb3 = (request: ClientTypes.IRequestData) => { throw new UnsupportedPaymentChain(request.currencyInfo.network); } }; + +/** + * Input of batch conversion payment processor + * It contains requests, paymentSettings, amount and feeAmount. + * Currently, these requests must have the same PN, version, and batchFee + * Also used in Invoicing repository. + * @dev next step: paymentNetworkId could get more values options, see the "ref" + * in batchConversionPayment.sol + */ +export interface EnrichedRequest { + paymentNetworkId: + | PaymentTypes.BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS + | PaymentTypes.BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS; + request: ClientTypes.IRequestData; + paymentSettings?: IConversionPaymentSettings; + amount?: BigNumberish; + feeAmount?: BigNumberish; +} diff --git a/packages/payment-processor/src/payment/utils.ts b/packages/payment-processor/src/payment/utils.ts index cd570bbafc..f107162cf1 100644 --- a/packages/payment-processor/src/payment/utils.ts +++ b/packages/payment-processor/src/payment/utils.ts @@ -119,7 +119,11 @@ export function getPaymentExtensionVersion(request: ClientTypes.IRequestData): s return extension.version; } -const getProxyNetwork = ( +/** + * @param pn It contains the payment network extension + * @param currency It contains the currency information + */ +export const getProxyNetwork = ( pn: ExtensionTypes.IState, currency: RequestLogicTypes.ICurrency, ): string => { @@ -132,18 +136,34 @@ const getProxyNetwork = ( throw new Error('Payment currency must have a network'); }; -export const getProxyAddress = ( +/** + * @param request The request to pay + * @return A list that contains the payment network extension and the currency information + */ +export function getPnAndNetwork( request: ClientTypes.IRequestData, - getDeploymentInformation: (network: string, version: string) => { address: string } | null, -): string => { +): [ExtensionTypes.IState, string] { const pn = getPaymentNetworkExtension(request); if (!pn) { throw new Error('PaymentNetwork not found'); } - const network = getProxyNetwork(pn, request.currencyInfo); - const deploymentInfo = getDeploymentInformation(network, pn.version); + return [pn, getProxyNetwork(pn, request.currencyInfo)]; +} + +/** + * @param request The request to pay + * @param getDeploymentInformation The function to get the proxy address + * @param version The version has to be set to get batch conversion proxy + */ +export const getProxyAddress = ( + request: ClientTypes.IRequestData, + getDeploymentInformation: (network: string, version: string) => { address: string } | null, + version?: string, +): string => { + const [pn, network] = getPnAndNetwork(request); + const deploymentInfo = getDeploymentInformation(network, version || pn.version); if (!deploymentInfo) { - throw new Error(`No deployment found for network ${network}, version ${pn.version}`); + throw new Error(`No deployment found for network ${network}, version ${version || pn.version}`); } return deploymentInfo.address; }; @@ -273,7 +293,6 @@ export function validateConversionFeeProxyRequest( PaymentTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY, ); const { tokensAccepted } = getRequestPaymentValues(request); - const requestCurrencyHash = path[0]; if (requestCurrencyHash.toLowerCase() !== getCurrencyHash(request.currencyInfo).toLowerCase()) { throw new Error(`The first entry of the path does not match the request currency`); @@ -316,18 +335,21 @@ export function getAmountToPay( /** * Compare 2 payment networks type and version in request's extension - * @param pn payment network - * @param request - * @returns true if type and version are identique else false + * and throw an exception if they are different + * @param pn The payment network extension + * @param request The request to pay */ export function comparePnTypeAndVersion( pn: ExtensionTypes.IState | undefined, request: ClientTypes.IRequestData, -): boolean { - return ( - pn?.type === getPaymentNetworkExtension(request)?.type && - pn?.version === getPaymentNetworkExtension(request)?.version - ); +): void { + const extension = getPaymentNetworkExtension(request); + if (!extension) { + throw new Error('no payment network found'); + } + if (!(pn?.type === extension.type && pn?.version === extension.version)) { + throw new Error(`Every payment network type and version must be identical`); + } } /** diff --git a/packages/payment-processor/test/payment/any-to-erc20-batch-proxy.test.ts b/packages/payment-processor/test/payment/any-to-erc20-batch-proxy.test.ts new file mode 100644 index 0000000000..4b94551b61 --- /dev/null +++ b/packages/payment-processor/test/payment/any-to-erc20-batch-proxy.test.ts @@ -0,0 +1,711 @@ +import { Wallet, providers, BigNumber } from 'ethers'; + +import { + ClientTypes, + ExtensionTypes, + IdentityTypes, + PaymentTypes, + RequestLogicTypes, +} from '@requestnetwork/types'; +import { getErc20Balance } from '../../src/payment/erc20'; +import Utils from '@requestnetwork/utils'; +import { revokeErc20Approval } from '@requestnetwork/payment-processor/src/payment/utils'; +import { EnrichedRequest, IConversionPaymentSettings } from '../../src/index'; +import { currencyManager } from './shared'; +import { + approveErc20BatchConversionIfNeeded, + getBatchConversionProxyAddress, + payBatchConversionProxyRequest, + prepareBatchConversionPaymentTransaction, +} from '../../src/payment/batch-conversion-proxy'; +import { batchConversionPaymentsArtifact } from '@requestnetwork/smart-contracts'; +import { UnsupportedCurrencyError } from '@requestnetwork/currency'; +import { BATCH_PAYMENT_NETWORK_ID } from '@requestnetwork/types/dist/payment-types'; + +/* eslint-disable no-magic-numbers */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +/** Used to to calculate batch fees */ +const BATCH_DENOMINATOR = 10000; +const BATCH_FEE = 30; +const BATCH_CONV_FEE = 30; + +const batchConvVersion = '0.1.0'; +const DAITokenAddress = '0x38cF23C52Bb4B13F051Aec09580a2dE845a7FA35'; +const FAUTokenAddress = '0x9FBDa871d559710256a2502A2517b794B482Db40'; +const mnemonic = 'candy maple cake sugar pudding cream honey rich smooth crumble sweet treat'; +const paymentAddress = '0xf17f52151EbEF6C7334FAD080c5704D77216b732'; +const feeAddress = '0xC5fdf4076b8F3A5357c5E395ab970B5B54098Fef'; +const provider = new providers.JsonRpcProvider('http://localhost:8545'); +const wallet = Wallet.fromMnemonic(mnemonic).connect(provider); + +// Cf. ERC20Alpha in TestERC20.sol +const alphaPaymentSettings: IConversionPaymentSettings = { + currency: { + type: RequestLogicTypes.CURRENCY.ERC20, + value: DAITokenAddress, + network: 'private', + }, + maxToSpend: '10000000000000000000000000000', + currencyManager, +}; + +// requests setting + +const EURExpectedAmount = 100; +const EURFeeAmount = 2; +// amounts used for DAI and FAU requests +const expectedAmount = 100000; +const feeAmount = 100; + +const EURValidRequest: ClientTypes.IRequestData = { + balance: { + balance: '0', + events: [], + }, + contentData: {}, + creator: { + type: IdentityTypes.TYPE.ETHEREUM_ADDRESS, + value: wallet.address, + }, + currency: 'EUR', + currencyInfo: { + type: RequestLogicTypes.CURRENCY.ISO4217, + value: 'EUR', + }, + events: [], + expectedAmount: EURExpectedAmount, + extensions: { + [PaymentTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: { + events: [], + id: ExtensionTypes.ID.PAYMENT_NETWORK_ANY_TO_ERC20_PROXY, + type: ExtensionTypes.TYPE.PAYMENT_NETWORK, + values: { + feeAddress, + feeAmount: EURFeeAmount, + paymentAddress, + salt: 'salt', + network: 'private', + tokensAccepted: [DAITokenAddress], + }, + version: '0.1.0', + }, + }, + extensionsData: [], + meta: { + transactionManagerMeta: {}, + }, + pending: null, + requestId: 'abcd', + state: RequestLogicTypes.STATE.CREATED, + timestamp: 0, + version: '1.0', +}; + +const DAIValidRequest: ClientTypes.IRequestData = { + balance: { + balance: '0', + events: [], + }, + contentData: {}, + creator: { + type: IdentityTypes.TYPE.ETHEREUM_ADDRESS, + value: wallet.address, + }, + currency: 'DAI', + currencyInfo: { + network: 'private', + type: RequestLogicTypes.CURRENCY.ERC20 as any, + value: DAITokenAddress, + }, + events: [], + expectedAmount: expectedAmount, + extensions: { + [PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: { + events: [], + id: ExtensionTypes.ID.PAYMENT_NETWORK_ERC20_FEE_PROXY_CONTRACT, + type: ExtensionTypes.TYPE.PAYMENT_NETWORK, + values: { + feeAddress, + feeAmount: feeAmount, + paymentAddress: paymentAddress, + salt: 'salt', + }, + version: '0.1.0', + }, + }, + extensionsData: [], + meta: { + transactionManagerMeta: {}, + }, + pending: null, + requestId: 'abcd', + state: RequestLogicTypes.STATE.CREATED, + timestamp: 0, + version: '1.0', +}; + +const FAUValidRequest = Utils.deepCopy(DAIValidRequest) as ClientTypes.IRequestData; +FAUValidRequest.currencyInfo = { + network: 'private', + type: RequestLogicTypes.CURRENCY.ERC20 as any, + value: FAUTokenAddress, +}; + +let enrichedRequests: EnrichedRequest[] = []; +// EUR and FAU requests modified within tests to throw errors +let EURRequest: ClientTypes.IRequestData; +let FAURequest: ClientTypes.IRequestData; + +/** + * Calcul the expected amount to pay for X euro into Y tokens + * @param amount in fiat: EUR + */ +const expectedConversionAmount = (amount: number): BigNumber => { + // token decimals 10**18 + // amount amount / 100 + // AggEurUsd.sol x 1.20 + // AggDaiUsd.sol / 1.01 + return BigNumber.from(10).pow(18).mul(amount).div(100).mul(120).div(100).mul(100).div(101); +}; + +describe('erc20-batch-conversion-proxy', () => { + beforeAll(async () => { + // Revoke DAI and FAU approvals + await revokeErc20Approval( + getBatchConversionProxyAddress(DAIValidRequest, batchConvVersion), + DAITokenAddress, + wallet, + ); + await revokeErc20Approval( + getBatchConversionProxyAddress(FAUValidRequest, batchConvVersion), + FAUTokenAddress, + wallet, + ); + }); + + describe(`Conversion:`, () => { + beforeEach(() => { + jest.restoreAllMocks(); + EURRequest = Utils.deepCopy(EURValidRequest); + enrichedRequests = [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURValidRequest, + paymentSettings: alphaPaymentSettings, + }, + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURRequest, + paymentSettings: alphaPaymentSettings, + }, + ]; + }); + + describe('Throw an error', () => { + it('should throw an error if the token is not accepted', async () => { + await expect( + payBatchConversionProxyRequest( + [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURValidRequest, + paymentSettings: { + ...alphaPaymentSettings, + currency: { + ...alphaPaymentSettings.currency, + value: '0x775eb53d00dd0acd3ec1696472105d579b9b386b', + }, + } as IConversionPaymentSettings, + }, + ], + batchConvVersion, + wallet, + ), + ).rejects.toThrowError( + new UnsupportedCurrencyError({ + value: '0x775eb53d00dd0acd3ec1696472105d579b9b386b', + network: 'private', + }), + ); + }); + it('should throw an error if request has no paymentSettings', async () => { + await expect( + payBatchConversionProxyRequest( + [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURRequest, + paymentSettings: undefined, + }, + ], + batchConvVersion, + wallet, + ), + ).rejects.toThrowError('the enrichedRequest has no paymentSettings'); + }); + it('should throw an error if the request is ETH', async () => { + EURRequest.currencyInfo.type = RequestLogicTypes.CURRENCY.ETH; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError(`wrong request currencyInfo type`); + }); + it('should throw an error if the request has a wrong network', async () => { + EURRequest.extensions = { + // ERC20_FEE_PROXY_CONTRACT instead of ANY_TO_ERC20_PROXY + [PaymentTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: { + events: [], + id: ExtensionTypes.ID.PAYMENT_NETWORK_ERC20_FEE_PROXY_CONTRACT, + type: ExtensionTypes.TYPE.PAYMENT_NETWORK, + values: { + feeAddress, + feeAmount: feeAmount, + paymentAddress: paymentAddress, + salt: 'salt', + network: 'fakePrivate', + }, + version: '0.1.0', + }, + }; + + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError('All the requests must have the same network'); + }); + it('should throw an error if the request has a wrong payment network id', async () => { + EURRequest.extensions = { + // ERC20_FEE_PROXY_CONTRACT instead of ANY_TO_ERC20_PROXY + [PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: { + events: [], + id: ExtensionTypes.ID.PAYMENT_NETWORK_ERC20_FEE_PROXY_CONTRACT, + type: ExtensionTypes.TYPE.PAYMENT_NETWORK, + values: { + feeAddress, + feeAmount: feeAmount, + paymentAddress: paymentAddress, + salt: 'salt', + }, + version: '0.1.0', + }, + }; + + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError( + 'request cannot be processed, or is not an pn-any-to-erc20-proxy request', + ); + }); + it("should throw an error if one request's currencyInfo has no value", async () => { + EURRequest.currencyInfo.value = ''; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError("The currency '' is unknown or not supported"); + }); + it('should throw an error if request has no extension', async () => { + EURRequest.extensions = [] as any; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError('no payment network found'); + }); + it('should throw an error if there is a wrong version mapping', async () => { + EURRequest.extensions = { + [PaymentTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: { + ...EURRequest.extensions[PaymentTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY], + version: '0.3.0', + }, + }; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError('Every payment network type and version must be identical'); + }); + }); + + describe('payment', () => { + it('should consider override parameters', async () => { + const spy = jest.fn(); + const originalSendTransaction = wallet.sendTransaction.bind(wallet); + wallet.sendTransaction = spy; + await payBatchConversionProxyRequest( + [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURValidRequest, + paymentSettings: alphaPaymentSettings, + }, + ], + batchConvVersion, + wallet, + { gasPrice: '20000000000' }, + ); + expect(spy).toHaveBeenCalledWith({ + data: '0xf0fa379f0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c5fdf4076b8f3a5357c5e395ab970b5b54098fef0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000f17f52151ebef6c7334fad080c5704d77216b7320000000000000000000000000000000000000000000000000000000005f5e10000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000001e84800000000000000000000000000000000000000000204fce5e3e250261100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000017b4158805772ced11225e77339f90beb5aae968000000000000000000000000775eb53d00dd0acd3ec1696472105d579b9b386b00000000000000000000000038cf23c52bb4b13f051aec09580a2de845a7fa35000000000000000000000000000000000000000000000000000000000000000886dfbccad783599a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + gasPrice: '20000000000', + to: getBatchConversionProxyAddress(EURValidRequest, '0.1.0'), + value: 0, + }); + wallet.sendTransaction = originalSendTransaction; + }); + it('should convert and pay a request in EUR with ERC20', async () => { + // Approve the contract + const approvalTx = await approveErc20BatchConversionIfNeeded( + EURValidRequest, + wallet.address, + batchConvVersion, + wallet.provider, + alphaPaymentSettings, + ); + expect(approvalTx).toBeDefined(); + if (approvalTx) { + await approvalTx.wait(1); + } + + // Get the balances to compare after payment + const initialETHFromBalance = await wallet.getBalance(); + const initialDAIFromBalance = await getErc20Balance( + DAIValidRequest, + wallet.address, + provider, + ); + + // Convert and pay + const tx = await payBatchConversionProxyRequest( + [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURValidRequest, + paymentSettings: alphaPaymentSettings, + }, + ], + batchConvVersion, + wallet, + ); + const confirmedTx = await tx.wait(1); + expect(confirmedTx.status).toEqual(1); + expect(tx.hash).toBeDefined(); + + // Get the new balances + const ETHFromBalance = await wallet.getBalance(); + const DAIFromBalance = await getErc20Balance(DAIValidRequest, wallet.address, provider); + + // Check each balance + const amountToPay = expectedConversionAmount(EURExpectedAmount); + const feeToPay = expectedConversionAmount(EURFeeAmount); + const expectedAmountToPay = amountToPay + .add(feeToPay) + .mul(BATCH_DENOMINATOR + BATCH_CONV_FEE) + .div(BATCH_DENOMINATOR); + expect( + BigNumber.from(initialETHFromBalance).sub(ETHFromBalance).toNumber(), + ).toBeGreaterThan(0); + expect( + BigNumber.from(initialDAIFromBalance).sub(BigNumber.from(DAIFromBalance)), + // Calculation of expectedAmountToPay + // expectedAmount: 1.00 + // feeAmount: + .02 + // = 1.02 + // AggEurUsd.sol x 1.20 + // AggDaiUsd.sol / 1.01 + // BATCH_CONV_FEE x 1.003 + // (exact result) = 1.215516831683168316 (over 18 decimals for this ERC20) + ).toEqual(expectedAmountToPay); + }); + it('should convert and pay two requests in EUR with ERC20', async () => { + // Get initial balances + const initialETHFromBalance = await wallet.getBalance(); + const initialDAIFromBalance = await getErc20Balance( + DAIValidRequest, + wallet.address, + provider, + ); + + // Convert and pay + const tx = await payBatchConversionProxyRequest( + Array(2).fill({ + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURValidRequest, + paymentSettings: alphaPaymentSettings, + }), + batchConvVersion, + wallet, + ); + const confirmedTx = await tx.wait(1); + expect(confirmedTx.status).toEqual(1); + expect(tx.hash).toBeDefined(); + + // Get balances + const ETHFromBalance = await wallet.getBalance(); + const DAIFromBalance = await getErc20Balance(DAIValidRequest, wallet.address, provider); + + // Checks ETH balances + expect( + BigNumber.from(initialETHFromBalance).sub(ETHFromBalance).toNumber(), + ).toBeGreaterThan(0); + + // Checks DAI balances + const amountToPay = expectedConversionAmount(EURExpectedAmount).mul(2); // multiply by the number of requests: 2 + const feeToPay = expectedConversionAmount(EURFeeAmount).mul(2); // multiply by the number of requests: 2 + const expectedAmoutToPay = amountToPay + .add(feeToPay) + .mul(BATCH_DENOMINATOR + BATCH_CONV_FEE) + .div(BATCH_DENOMINATOR); + expect(BigNumber.from(initialDAIFromBalance).sub(BigNumber.from(DAIFromBalance))).toEqual( + expectedAmoutToPay, + ); + }); + it('should pay 3 heterogeneous ERC20 payments with and without conversion', async () => { + // Get initial balances + const initialETHFromBalance = await wallet.getBalance(); + const initialDAIFromBalance = await getErc20Balance( + DAIValidRequest, + wallet.address, + provider, + ); + + // Convert the two first requests and pay the three requests + const tx = await payBatchConversionProxyRequest( + [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURValidRequest, + paymentSettings: alphaPaymentSettings, + }, + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS, + request: EURValidRequest, + paymentSettings: alphaPaymentSettings, + }, + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS, + request: DAIValidRequest, + }, + ], + batchConvVersion, + wallet, + ); + const confirmedTx = await tx.wait(1); + expect(confirmedTx.status).toEqual(1); + expect(tx.hash).toBeDefined(); + + // Get balances + const ETHFromBalance = await wallet.getBalance(); + const DAIFromBalance = await getErc20Balance(DAIValidRequest, wallet.address, provider); + + // Checks ETH balances + expect( + BigNumber.from(initialETHFromBalance).sub(ETHFromBalance).toNumber(), + ).toBeGreaterThan(0); + + // Checks DAI balances + let expectedConvAmountToPay = expectedConversionAmount(EURExpectedAmount).mul(2); // multiply by the number of conversion requests: 2 + const feeToPay = expectedConversionAmount(EURFeeAmount).mul(2); // multiply by the number of conversion requests: 2 + // expectedConvAmountToPay with fees and batch fees + expectedConvAmountToPay = expectedConvAmountToPay + .add(feeToPay) + .mul(BATCH_DENOMINATOR + BATCH_CONV_FEE) + .div(BATCH_DENOMINATOR); + const expectedNoConvAmountToPay = BigNumber.from(DAIValidRequest.expectedAmount) + .add(feeAmount) + .mul(BATCH_DENOMINATOR + BATCH_FEE) + .div(BATCH_DENOMINATOR); + + expect(BigNumber.from(initialDAIFromBalance).sub(BigNumber.from(DAIFromBalance))).toEqual( + expectedConvAmountToPay.add(expectedNoConvAmountToPay), + ); + }); + }); + }); + + describe('No conversion:', () => { + beforeEach(() => { + FAURequest = Utils.deepCopy(FAUValidRequest); + enrichedRequests = [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS, + request: DAIValidRequest, + }, + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS, + request: FAURequest, + }, + ]; + }); + + describe('Throw an error', () => { + it('should throw an error if the request is not erc20', async () => { + FAURequest.currencyInfo.type = RequestLogicTypes.CURRENCY.ETH; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError( + 'request cannot be processed, or is not an pn-erc20-fee-proxy-contract request', + ); + }); + + it("should throw an error if one request's currencyInfo has no value", async () => { + FAURequest.currencyInfo.value = ''; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError( + 'request cannot be processed, or is not an pn-erc20-fee-proxy-contract request', + ); + }); + + it("should throw an error if one request's currencyInfo has no network", async () => { + FAURequest.currencyInfo.network = ''; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError('Payment currency must have a network'); + }); + + it('should throw an error if request has no extension', async () => { + FAURequest.extensions = [] as any; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError('no payment network found'); + }); + + it('should throw an error if there is a wrong version mapping', async () => { + FAURequest.extensions = { + [PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: { + ...DAIValidRequest.extensions[PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT], + version: '0.3.0', + }, + }; + await expect( + payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet), + ).rejects.toThrowError('Every payment network type and version must be identical'); + }); + }); + + describe('payBatchConversionProxyRequest', () => { + it('should consider override parameters', async () => { + const spy = jest.fn(); + const originalSendTransaction = wallet.sendTransaction.bind(wallet); + wallet.sendTransaction = spy; + await payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet, { + gasPrice: '20000000000', + }); + expect(spy).toHaveBeenCalledWith({ + data: '0xf0fa379f0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c5fdf4076b8f3a5357c5e395ab970b5b54098fef00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000000200000000000000000000000038cf23c52bb4b13f051aec09580a2de845a7fa350000000000000000000000009fbda871d559710256a2502a2517b794b482db400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000f17f52151ebef6c7334fad080c5704d77216b732000000000000000000000000f17f52151ebef6c7334fad080c5704d77216b732000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000186a0000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000886dfbccad783599a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000886dfbccad783599a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000064', + gasPrice: '20000000000', + to: getBatchConversionProxyAddress(DAIValidRequest, '0.1.0'), + value: 0, + }); + wallet.sendTransaction = originalSendTransaction; + }); + it(`should pay 2 differents ERC20 requests with fees`, async () => { + // Approve the contract for DAI and FAU tokens + const FAUApprovalTx = await approveErc20BatchConversionIfNeeded( + FAUValidRequest, + wallet.address, + batchConvVersion, + wallet, + ); + if (FAUApprovalTx) await FAUApprovalTx.wait(1); + + const DAIApprovalTx = await approveErc20BatchConversionIfNeeded( + DAIValidRequest, + wallet.address, + batchConvVersion, + wallet, + ); + if (DAIApprovalTx) await DAIApprovalTx.wait(1); + + // Get initial balances + const initialETHFromBalance = await wallet.getBalance(); + const initialDAIFromBalance = await getErc20Balance( + DAIValidRequest, + wallet.address, + provider, + ); + const initialDAIFeeBalance = await getErc20Balance(DAIValidRequest, feeAddress, provider); + + const initialFAUFromBalance = await getErc20Balance( + FAUValidRequest, + wallet.address, + provider, + ); + const initialFAUFeeBalance = await getErc20Balance(FAUValidRequest, feeAddress, provider); + + // Batch payment + const tx = await payBatchConversionProxyRequest(enrichedRequests, batchConvVersion, wallet); + const confirmedTx = await tx.wait(1); + expect(confirmedTx.status).toBe(1); + expect(tx.hash).not.toBeUndefined(); + + // Get balances + const ETHFromBalance = await wallet.getBalance(); + const DAIFromBalance = await getErc20Balance(DAIValidRequest, wallet.address, provider); + const DAIFeeBalance = await getErc20Balance(DAIValidRequest, feeAddress, provider); + const FAUFromBalance = await getErc20Balance(FAUValidRequest, wallet.address, provider); + const FAUFeeBalance = await getErc20Balance(FAUValidRequest, feeAddress, provider); + + // Checks ETH balances + expect(ETHFromBalance.lte(initialETHFromBalance)).toBeTruthy(); // 'ETH balance should be lower' + + // Check FAU balances + const expectedFAUFeeAmountToPay = + feeAmount + ((FAUValidRequest.expectedAmount as number) * BATCH_FEE) / BATCH_DENOMINATOR; + + expect(BigNumber.from(FAUFromBalance)).toEqual( + BigNumber.from(initialFAUFromBalance).sub( + (FAUValidRequest.expectedAmount as number) + expectedFAUFeeAmountToPay, + ), + ); + expect(BigNumber.from(FAUFeeBalance)).toEqual( + BigNumber.from(initialFAUFeeBalance).add(expectedFAUFeeAmountToPay), + ); + // Check DAI balances + const expectedDAIFeeAmountToPay = + feeAmount + ((DAIValidRequest.expectedAmount as number) * BATCH_FEE) / BATCH_DENOMINATOR; + + expect(BigNumber.from(DAIFromBalance)).toEqual( + BigNumber.from(initialDAIFromBalance) + .sub(DAIValidRequest.expectedAmount as number) + .sub(expectedDAIFeeAmountToPay), + ); + expect(BigNumber.from(DAIFeeBalance)).toEqual( + BigNumber.from(initialDAIFeeBalance).add(expectedDAIFeeAmountToPay), + ); + }); + }); + + describe('prepareBatchPaymentTransaction', () => { + it('should consider the version mapping', () => { + expect( + prepareBatchConversionPaymentTransaction( + [ + { + paymentNetworkId: BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS, + request: { + ...DAIValidRequest, + extensions: { + [PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: { + ...DAIValidRequest.extensions[ + PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT + ], + version: '0.1.0', + }, + }, + } as any, + } as EnrichedRequest, + { + request: { + ...FAUValidRequest, + extensions: { + [PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: { + ...FAUValidRequest.extensions[ + PaymentTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT + ], + version: '0.1.0', + }, + }, + } as any, + } as EnrichedRequest, + ], + batchConvVersion, + ).to, + ).toBe(batchConversionPaymentsArtifact.getAddress('private', '0.1.0')); + }); + }); + }); +}); diff --git a/packages/payment-processor/test/payment/swap-erc20-fee-proxy.test.ts b/packages/payment-processor/test/payment/swap-erc20-fee-proxy.test.ts index 4d67d8d63c..d41dfcae9c 100644 --- a/packages/payment-processor/test/payment/swap-erc20-fee-proxy.test.ts +++ b/packages/payment-processor/test/payment/swap-erc20-fee-proxy.test.ts @@ -79,6 +79,14 @@ const validSwapSettings: ISwapSettings = { }; describe('swap-erc20-fee-proxy', () => { + beforeAll(async () => { + // revoke erc20SwapToPay approval + await revokeErc20Approval( + erc20SwapToPayArtifact.getAddress(validRequest.currencyInfo.network!), + alphaErc20Address, + wallet.provider, + ); + }); describe('encodeSwapErc20FeeRequest', () => { beforeAll(async () => { // revoke erc20SwapToPay approval diff --git a/packages/smart-contracts/src/lib/artifacts/BatchConversionPayments/index.ts b/packages/smart-contracts/src/lib/artifacts/BatchConversionPayments/index.ts index d83849d90a..1f09adc858 100644 --- a/packages/smart-contracts/src/lib/artifacts/BatchConversionPayments/index.ts +++ b/packages/smart-contracts/src/lib/artifacts/BatchConversionPayments/index.ts @@ -13,6 +13,42 @@ export const batchConversionPaymentsArtifact = new ContractArtifact