All pages
Powered by GitBook
1 of 30

SDK Guides

Request Client

Configure the Request Client

Configure the client

The following command creates a new Request Client instance and configures it to :

  • Connect to the Gnosis Request Node Gateway maintained by the Request Network Foundation.

  • Use the web3-signature package to create requests using a web3 wallet like Metamask.

const web3SignatureProvider = new Web3SignatureProvider(provider);
const requestClient = new RequestNetwork({
  nodeConnectionConfig: { 
    baseURL: 'https://xdai.gateway.request.network/' 
  },
  signatureProvider: web3SignatureProvider,
});

Mock Storage

To create mock storage requests, where the request is stored in memory on the local machine and cleared as soon as the script is finished running, set the useMockStorage argument to true when instantiating the RequestNetworkobject.

const requestClient = new RequestNetwork({
  useMockStorage: true,
});

Updating a Request

After a request is created, it can be updated:

Name
Description
Role Authorized

accept

accept a request, indicating that it will be paid

payer

cancel

cancel a request

payee, payer

reduceExpectedAmount

reduce the expected amount

payee

increaseExpectedAmount

increase the expected amount

payer

addStakeholders

grant 1 or more third parties access to view an encrypted request

payee, payer, third party

Feature exists. More docs coming soon...

Payment Reference

In the Reference-based Payment Networks, Payments are linked to Requests via a paymentReference which is derived from the requestId and payment recipient address.

This paymentReference consists of the last 8 bytes of a salted hash of the requestId and payment recipient address, concatenated :

last8Bytes(hash(lowercase(requestId + salt + address)))
  • requestId is the id of the request

  • salt is a random number with at least 8 bytes of randomness. It must be unique to each request

  • address is the payment address for payments, the refund address for refunds

  • lowercase() transforms all characters to lowercase

  • hash() is a keccak256 hash function

  • last8Bytes() take the last 8 bytes

Use the PaymentReferenceCalculator to calculate the payment reference.

Compute a Request ID without creating the request

/** Create request parameters */
export interface ICreateRequestParameters {
  requestInfo: RequestLogic.ICreateParameters | IRequestInfo;
  signer: Identity.IIdentity;
  paymentNetwork?: Payment.PaymentNetworkCreateParameters;
  topics?: any[];
  contentData?: any;
  disablePaymentDetection?: boolean;
  disableEvents?: boolean;
}
  
  /**
   * Gets the ID of a request without creating it.
   *
   * @param requestParameters Parameters to create a request
   * @returns The requestId
   */
  public async computeRequestId(
    parameters: Types.ICreateRequestParameters,
  ): Promise<RequestLogicTypes.RequestId>

Use your own signature mechanism

In a previous chapter, we used the signature providers @requestnetwork/web3-signature and @requestnetwork/epk-signature (this one is made for test purpose). But, if you are not using web3, you need to inject your own signature mechanism to the request client. This is fairly simple, you need to implement a class following this interface: (see on github)

export interface ISignatureProvider {
  supportedMethods: Signature.METHOD[];
  supportedIdentityTypes: Identity.TYPE[];

  sign: (data: any, signer: Identity.IIdentity) => Promise<Signature.ISignedData>;
}

Example 1

For example, your own package to sign needs an ethereum address and return the signature as a hexadecimal string:

class mySignaturePackage {
  /**
   * Sign data
   *
   * @param data the data to sign
   * @param address the address to sign with
   * @returns a promise resolving the signature
   */
  public async sign(data: any, address: string): Promise<string>;
}

Your signature provider would look like:

import { IdentityTypes, SignatureProviderTypes, SignatureTypes } from '@requestnetwork/types';
import Utils from '@requestnetwork/utils';

// Your package
import mySignaturePackage from 'mySignaturePackage';

/**
 * Implementation of the signature provider for my wallet
 */
export default class MySignatureProvider implements SignatureProviderTypes.ISignatureProvider {
  /** list of supported signing methods */
  public supportedMethods: SignatureTypes.METHOD[] = [SignatureTypes.METHOD.ECDSA];
  /** list of supported identity types */
  public supportedIdentityTypes: IdentityTypes.TYPE[] = [IdentityTypes.TYPE.ETHEREUM_ADDRESS];

  /**
   * Signs data
   *
   * @param string data the data to sign
   * @returns IIdentity the identity to sign with. If not given, the default signer will be used
   *
   * @returns string the signature
   */
  public async sign(
    data: any,
    signer: IdentityTypes.IIdentity,
  ): Promise<SignatureTypes.ISignedData> {
    if (!this.supportedIdentityTypes.includes(signer.type)) {
      throw Error(`Identity type not supported ${signer.type}`);
    }

    // Hash the normalized data (e.g. avoid case sensitivity)
    const hashData = Utils.crypto.normalizeKeccak256Hash(data).value;

    // use your signature package
    const signatureValue = mySignaturePackage.sign(hashData, signer.value);

    return {
      data,
      signature: {
        method: SignatureTypes.METHOD.ECDSA,
        value: signatureValue,
      },
    };
  }
}

Now you can inject it into the request client:

import MySignatureProvider from 'mySignatureProvider';

const mySignatureProvider = new MySignatureProvider();

// We can initialize the RequestNetwork class with the signature provider
const requestNetwork = new RequestNetwork.RequestNetwork({
  signatureProvider: mySignatureProvider,
});

## Example 2

For example, your own package to sign needs an internal identifier and return the signature as a Buffer:

class mySignaturePackage {
  /**
   * Sign a Buffer
   *
   * @param data the data to sign
   * @param walletId a way to get the right wallet to sign with
   * @returns a promise resolving the signature
   */
  public async sign(data: Buffer, walletId: number): Promise<Buffer>;
}

Your signature provider would look like:

import { IdentityTypes, SignatureProviderTypes, SignatureTypes } from '@requestnetwork/types';
import Utils from '@requestnetwork/utils';

// Your package
import mySignaturePackage from 'mySignaturePackage';

/** Type of the dictionary of wallet id indexed by address */
type IWalletIdDictionary = Map<string, number>;

/**
 * Implementation of the signature provider for my wallet
 */
export default class MySignatureProvider implements SignatureProviderTypes.ISignatureProvider {
  /** list of supported signing method */
  public supportedMethods: SignatureTypes.METHOD[] = [SignatureTypes.METHOD.ECDSA];
  /** list of supported identity types */
  public supportedIdentityTypes: IdentityTypes.TYPE[] = [IdentityTypes.TYPE.ETHEREUM_ADDRESS];

  /** Dictionary containing all the private keys indexed by address */
  private walletIdDictionary: IWalletIdDictionary;

  constructor(identity?: ?IdentityTypes.IIdentity, walletId?: number) {
    this.walletIdDictionary = new Map<string, number>();

    if (identity && walletId) {
      this.addSignatureParameters(identity, walletId);
    }
  }

  /**
   * Signs data
   *
   * @param string data the data to sign
   * @returns IIdentity the identity to sign with. If not given, the default signer will be used
   *
   * @returns string the signature
   */
  public async sign(
    data: any,
    signer: IdentityTypes.IIdentity,
  ): Promise<SignatureTypes.ISignedData> {
    if (!this.supportedIdentityTypes.includes(signer.type)) {
      throw Error(`Identity type not supported ${signer.type}`);
    }

    // toLowerCase to avoid mismatch because of case
    const walletId: number | undefined = this.walletIdDictionary.get(signer.value.toLowerCase());

    if (!walletId) {
      throw Error(`Identity unknown: ${signer.type}, ${signer.value}`);
    }

    // Hash the normalized data (e.g. avoid case sensitivity)
    const hashData = Utils.crypto.normalizeKeccak256Hash(data).value;

    // convert the hash from a string '0x...' to a Buffer
    const hashDataBuffer = Buffer.from(hashData.slice(2), 'hex');

    // use your signature package
    const signatureValueBuffer = mySignaturePackage.sign(hashDataBuffer, walletId);

    // convert the signature to a string '0x...'
    const signatureValue = `0x${signatureValueBuffer.toString('hex')}`;

    return {
      data,
      signature: {
        method: SignatureTypes.METHOD.ECDSA,
        value: signatureValue,
      },
    };
  }

  /**
   * Function to add a new identity in the provider
   *
   * @param identity the new identity
   * @param walletId the wallet id matching the identity
   */
  public addIdentity(identity: IdentityTypes.IIdentity, walletId: number): void {
    if (!this.supportedIdentityTypes.includes(identity.type)) {
      throw Error(`Identity type not supported ${identity.type}`);
    }

    this.walletIdDictionary.set(identity.value.toLowerCase(), walletId);
  }
}

Now you can inject it into the request client:

import MySignatureProvider from 'mySignatureProvider';

const mySignatureProvider = new MySignatureProvider(anIdentity, aWalletId);

// We can initialize the RequestNetwork class with the signature provider
const requestNetwork = new RequestNetwork.RequestNetwork({
  signatureProvider: mySignatureProvider,
});

// later on, you can even add more supported identities
mySignatureProvider.addIdentity(anotherIdentity, anotherWalletId);

Support a new currency

Request Network allows you to support any currency. Head out to the currency package to see how we identify and manage currencies in the CurrencyManager. You don't need to add new currencies in this repository.

Instead, when instanciating the CurrencyManager, you can feed it with a list of supported currencies for your dapp:

const list: CurrencyInput[] = [
    { type: RequestLogicTypes.CURRENCY.ETH, decimals: 18, network: 'anything', symbol: 'ANY' },
];
const currencyManager = new CurrencyManager(list);

To implement new types of currencies (aside fiat, BTC, ETH, ERC20), head towards payment networks.

In-Memory Requests

Overview

In-memory requests allow for creating and managing requests without immediately persisting them to storage. This enables faster payment workflows and deferred persistence.

Key benefits:

  • Faster payment flow: In-memory requests are helpful when payment is the priority, such as in e-commerce cases. In this scenario, the request is a receipt rather than an invoice.

  • Deferred Persistence: With in-memory requests, a request can be created on the front end with a user's signature and passed on to the backend for persistence.

How it works:

The flow of creating and paying an in-memory request is similar to a regular request with the following key differences:

  • Create an in-memory request by passing the argument skipPeristence: true when instantiating the RequestNetwork instance.

  • An in-memory request is not persisted immediately like normal requests. Instead, it is stored in memory on the device where it was created. It can be persisted at a later time using the persistTransaction()function.

  • An in-memory request has the inMemoryInfo property.

  • Avoid calling getData() on an in-memory request because it will fail silently by returning an empty EventEmitter object.

  • Retrieving an in-memory request with requestClient.fromRequestId() will fail because the request has not been persisted yet so it is not possible to read it from the Request Node.

1

Install necessary dependencies

To create in-memory requests, it is necessary to install the following package:

npm install @requestnetwork/request-client.js

Along with the following package for payments:

npm install @requestnetwork/payment-processor
2

Create an in-memory request

Create an in-memory request by passing the argument skipPeristence: true when instantiating the RequestNetwork instance.

// Request parameters 
const requestParameters = {...}


const web3SignatureProvider = new Web3SignatureProvider(
    ethersProvider!.provider
  );

 const inMemoryRequestNetwork = new RequestNetwork({
    nodeConnectionConfig: {
      baseURL: "https://gnosis.gateway.request.network",
    },
    signatureProvider: web3SignatureProvider,
   
  });

 let inMemoryRequest =
    await inMemoryRequestNetwork.createRequest(requestParameters);
3

Pay an in-memory request

To pay an in-memory request, pass the inMemoryInfo.requestData property to the payment function.

import {
  payRequest
} from "@requestnetwork/payment-processor";

const paymentTx = await payRequest(
    inMemoryRequest.inMemoryInfo.requestData,
    signer
  );
  
await paymentTx.wait(confirmationBlocks);
4

Persist in-memory request

In-memory requests need to be persisted using a new RequestNetwork client that does not use the skipPersistence property.

const persistingRequestNetwork = new RequestNetwork({
    nodeConnectionConfig: {
      baseURL: "https://gnosis.gateway.request.network",
    },
  });

await persistingRequestNetwork.persistRequest(inMemoryRequest);

Encryption and Decryption

Encrypt with a wallet signature using Lit Protocol

This document outlines how to encrypt and decrypt requests using Lit Protocol. Encryption and decryption are performed using the end-user's wallet signatures, ensuring only they can access the data. Neither Request Network nor Lit Protocol can access the data without consent from the user.

This allows the end-user to own their data without requiring them to know about or manage their public key, as is the case when they Encrypt with an Ethereum private key.

Encryption with Lit Protocol supports the Add Stakeholder feature for adding view access to a 3rd party other than the payee or payer.

The LitCipherProvider is suitable for both frontend and backend use.

Introduction

This implementation utilizes a two-step encryption process to secure sensitive data within requests:

  1. Symmetric Encryption: The data is first encrypted using a randomly generated symmetric key (e.g., AES-256). This provides efficient encryption for larger data payloads.

  2. Asymmetric Encryption with Lit Protocol: The symmetric key is then encrypted using Lit Protocol's decentralized key management network. Only authorized parties (payer and payee) can access the symmetric key and decrypt the data.

For a deeper introduction to Encryption and Decryption in Request Network, see Private Requests using Encryption

Benefits

  • Ease-of-use: Encrypt using a signature instead of a public key.

  • Efficiency: Symmetric encryption is efficient for large data, while Lit Protocol secures the key.

  • Decentralized Access Control: Lit Protocol ensures that only authorized parties can decrypt the data.

Architecture

The system consists of three main components:

  • Request Network: Handles the creation, storage, and lifecycle of payment requests on the blockchain.

  • Lit Protocol: Provides a decentralized key management network and encryption capabilities.

  • Wallet Addresses: Used as the primary identifiers for access control in Lit Protocol.

Workflow

Encryption Process

  1. Request Creation: The payer creates a request object using the Request Network SDK.

  2. Symmetric Key Generation: A unique symmetric key is randomly generated.

  3. Data Encryption: The payee and payer encrypt the sensitive data within the request using the generated symmetric key.

  4. Encrypt Symmetric Key with Lit:

    • Define Access Control Conditions: The payee and payer define access control conditions using Lit Actions, specifying that only the Ethereum addresses of the payer and payee can decrypt the symmetric key.

    • Encrypt with Lit: The payee and payer encrypt the symmetric key using Lit's encryptString function, leveraging their wallet to sign the encryption.

  5. Store Encrypted Data: The payee and payer store the following on the Request Network:

    • Encrypted request data

    • Lit access control conditions

    • Encrypted symmetric key

Decryption Process

  1. Retrieve Request: The payer and payee retrieve the following request data from the Request Network:

    1. Encrypted request data

    2. Lit access control conditions

    3. Encrypted symmetric key

  2. Decrypt Symmetric Key with Lit: The payer and payee use Lit's decryptString function with their wallet to decrypt the encrypted symmetric key. Lit Protocol verifies the payer's and payee's addresses against access control conditions. If authorized, the symmetric key is decrypted.

  3. Decrypt Data: The payer and payee use the decrypted symmetric key to decrypt the sensitive data.

Installation

npm install @requestnetwork/lit-protocol-cipher @requestnetwork/request-client.js ethers@5.7.2

Usage

import { LitProtocolCipherProvider } from '@requestnetwork/lit-protocol-cipher';
import { RequestNetwork, Types } from '@requestnetwork/request-client.js';
import { LitNodeClient } from '@lit-protocol/lit-node-client';

// Node connection configuration
const nodeConnectionConfig = {
  baseURL: 'https://req-node.request.network',
  connectionTimeout: 10000,
  retry: {
    retries: 3
  }
};

// Initialize Lit Node Client
const litClient = new LitNodeClient({
  litNetwork: 'datil',
  debug: false
});

// Initialize the Lit Provider
const litProvider = new LitProtocolCipherProvider(
  litClient,
  nodeConnectionConfig,
  'ethereum' // optional chain parameter
);

// Connect to Lit Network
await litProvider.initializeClient();

// Initialize wallet and get session signatures
const wallet = new Wallet('your-private-key');
const address = await wallet.getAddress();

// Get session signatures
await litProvider.getSessionSignatures(wallet, address);

// Enable decryption
litProvider.enableDecryption(true);

// Initialize Request Network
const requestNetwork = new RequestNetwork({
  cipherProvider: litProvider,
  signatureProvider: new Web3SignatureProvider(wallet),
  nodeConnectionConfig
});

Creating Encrypted Requests

const payeeIdentity = {
  type: Types.Identity.TYPE.ETHEREUM_ADDRESS,
  value: 'payee-ethereum-address'
};

const payerIdentity = {
  type: Types.Identity.TYPE.ETHEREUM_ADDRESS,
  value: 'payer-ethereum-address'
};

// Define encryption parameters
const encryptionParams = [
  {
    key: payeeIdentity.value,
    method: Types.Encryption.METHOD.KMS
  },
  {
    key: payerIdentity.value,
    method: Types.Encryption.METHOD.KMS
  }
];

// Create request parameters
const requestCreateParameters = {
  requestInfo: {
    currency: {
      type: Types.RequestLogic.CURRENCY.ERC20,
      value: '0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C',
      network: 'sepolia',
    },
    expectedAmount: '1000000000000000000',
    payee: payeeIdentity,
    payer: payerIdentity,
    timestamp: Utils.getCurrentTimestampInSecond(),
  },
  paymentNetwork: {
    id: Types.Extension.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
    parameters: {
      paymentNetworkName: 'sepolia',
      paymentAddress: payeeIdentity.value,
      feeAddress: '0x0000000000000000000000000000000000000000',
      feeAmount: '0',
    },
  },
  contentData: {
    reason: '🍕',
    dueDate: '2023.06.16',
  },
  signer: payeeIdentity,
};

// Create the encrypted request
const request = await requestNetwork._createEncryptedRequest({
  requestParameters: requestCreateParameters,
  encryptionParams
});

Decrypting Requests

// Fetch an existing request
const requestId = "request_id_here";
const request = await requestNetwork.fromRequestId(requestId);

// If you have the correct permissions (wallet address in encryption params),
// and decryption is enabled, the data will be automatically decrypted
const requestData = await request.getData();

// The decrypted data will include:
console.log({
  requestInfo: requestData.requestInfo,
  paymentNetwork: requestData.paymentNetwork,
  contentData: requestData.contentData,
  state: requestData.state
});

Disable Decryption

// Disable decryption
litProvider.enableDecryption(false)

Decryption Requirements

  1. The wallet address must be included in the original encryption parameters

  2. Session signatures must be valid

  3. Decryption must be enabled

  4. The Lit Protocol client must be connected

Cleanup

// Proper cleanup sequence
try {
  // First disconnect the wallet
  await litProvider.disconnectWallet();
  
  // Then disconnect the client
  await litProvider.disconnectClient();
} catch (error) {
  console.error('Cleanup error:', error);
}

ICipherProvider Interface

interface ICipherProvider {
  encrypt(data: any, options: any): Promise<any>;
  decrypt(encryptedData: any, options: any): Promise<any>;
  isEncryptionAvailable(): boolean;
  isDecryptionAvailable(): boolean;
  enableDecryption(option: boolean): void;
  isDecryptionEnabled(): boolean;
}

Encrypt with an Ethereum private key

Manipulating private keys must be done with care. Losing them can lead to a loss of data, privacy or non-repudiation safety!

For an introduction to Encryption and Decryption in Request Network, see Private Requests using Encryption

A request can be encrypted to make its details private to selected stakeholders. In this guide, we won't explain how encryption is managed under the hood. We will mention encryption or decryption of requests with payers and payees keys. Although in practice, we will use an intermediate symmetric key. See more details on github.

The transaction layer manages the encryption, see more details on the Request Protocol section.

To manipulate encrypted requests you need a CipherProvider (recommended) or DecryptionProvider (deprecated). Both of them require direct access to the private key. They're best suited for backends.

  • EthereumPrivateKeyCipherProvider: Provides both encryption and decryption utilities.

  • EthereumPrivateKeyDecryptionProvider (deprecated) provides only decryption utilities.

Create an encrypted request

EthereumPrivateKeyCipherProvider

See on Github.

import { EthereumPrivateKeyCipherProvider } from '@requestnetwork/epk-cipher';

const cipherProvider = new EthereumPrivateKeyCipherProvider({
  # Warning: private keys should never be stored in clear, this is a basic tutorial
  key: '0x4025da5692759add08f98f4b056c41c71916a671cedc7584a80d73adc7fb43c0',
  method: RequestNetwork.Types.Encryption.METHOD.ECIES,
});

const requestNetwork = new RequestNetwork({
  cipherProvider,
  signatureProvider,
  useMockStorage: true,
});

Then you can create an encrypted request:

const payeeEncryptionPublicKey = {
  key: 'cf4a1d0bbef8bf0e3fa479a9def565af1b22ea6266294061bfb430701b54a83699e3d47bf52e9f0224dcc29a02721810f1f624f1f70ea3cc5f1fb752cfed379d',
  method: RequestNetwork.Types.Encryption.METHOD.ECIES,
};
const payerEncryptionPublicKey = {
  key: '299708c07399c9b28e9870c4e643742f65c94683f35d1b3fc05d0478344ee0cc5a6a5e23f78b5ff8c93a04254232b32350c8672d2873677060d5095184dad422',
  method: RequestNetwork.Types.Encryption.METHOD.ECIES,
};

const invoice = await requestNetwork._createEncryptedRequest(
  {
    requestParameters,
    signer: requestParameters.payee,
    paymentNetwork,
  },
  [payeeEncryptionPublicKey, payerEncryptionPublicKey],
);

Note: You must give at least one encryption key you can decrypt with the decryption provider. Otherwise, an error will be triggered after the creation.

EthereumPrivateKeyDecryptionProvider

EthereumPrivateKeyDecryptionProvider is deprecated in favor of EthereumPrivateKeyCipherProvider

Get invoice information from its request ID

Let's step back for a second: the requester sent a request that he encrypted with the payer's public key, as well as with his own, to retrieve it later. This is an essential and typical example, but a request can be encrypted with many keys to give access to its status and details.

If the decryption provider knows a private key matching one of the keys used at the creation, it can decrypt it. Like a clear request you can get it from its request id.

const invoiceFromRequestID = await requestNetwork.fromRequestId(requestId);

const requestData = invoiceFromRequestID.getData();

console.log(requestData);

/* { 
 requestId,
 currency,
 expectedAmount,
 payee,
 payer,
 timestamp,
 extensions,
 version,
 events,
 state,
 creator,
 meta,
 balance,
 contentData,
} */

Accepting/canceling an invoice information

Like a clear request, you can update it if the decryption provider is instantiated with a matching private key.

//Accept
await request.accept(payerIdentity);

//Cancel
await request.cancel(payeeIdentity);

//Increase the expected amount
await request.decreaseExpectedAmountRequest(amount, payeeIdentity);

//Decrease the expected amount
await request.increaseExpectedAmountRequest(amount, payerIdentity);

Enabling/Disabling Decryption

// Disable decryption
cipherProvider.enableDecryption(false);
// Check if decryption is enabled
const isEnabled = cipherProvider.isDecryptionEnabled();
// Re-enable decryption
cipherProvider.enableDecryption(true);

Checking Capabilities

// Check if encryption is available
const canEncrypt = cipherProvider.isEncryptionAvailable();
// Check if decryption is available
const canDecrypt = cipherProvider.isDecryptionAvailable();
// Check if an identity is registered
const isRegistered = await cipherProvider.isIdentityRegistered({
type: 'ethereum_address',
value: '0x123...'
});// Some code

Share an encrypted request

The content of an encrypted request can be shared to additional third parties using the addStakeholder feature.

Calling request.addStakeholder() allows any stakeholder of a request to add the public key of a third party as a stakeholder on a request. The third party can now read the content of the request.

Feature exists. Docs coming soon...

Payment

Detect a payment

Request payments can be detected easily, thanks to the integration of The Graph.

Our payment-subgraphs indexes Request's proxy smart contracts and allow you to query payment data easily.

Other methods are available to detect a payment by simply watching the Proxy Smart Contract used for payment. Payment transactions include a paymentReference that links them to the request.

Native Payment

Conversion Payment

A "conversion" request is one that is denominated in one currency but paid in another currency. This is facilitated by on-chain price feeds provided by oracles. The typical use case is to denominate a request in fiat like USD and pay the request in stablecoins like USDC or DAI.

Declarative Payment

Example:

https://github.com/RequestNetwork/quickstart-node-js/blob/main/src/declarePaymentSentAndReceived.js
const waitForConfirmation = async (dataOrPromise) => {
  const data = await dataOrPromise;
  return new Promise((resolve, reject) => {
    data.on("confirmed", resolve);
    data.on("error", reject);
  });
};

(async () => {
  const {
    RequestNetwork,
    Types,
    Utils,
  } = require("@requestnetwork/request-client.js");
  const {
    EthereumPrivateKeySignatureProvider,
  } = require("@requestnetwork/epk-signature");
  const { config } = require("dotenv");
  const { Wallet } = require("ethers");

  // Load environment variables from .env file
  config();

  const payeeEpkSignatureProvider = new EthereumPrivateKeySignatureProvider({
    method: Types.Signature.METHOD.ECDSA,
    privateKey: process.env.PAYEE_PRIVATE_KEY, // Must include 0x prefix
  });

  const payerEpkSignatureProvider = new EthereumPrivateKeySignatureProvider({
    method: Types.Signature.METHOD.ECDSA,
    privateKey: process.env.PAYER_PRIVATE_KEY, // Must include 0x prefix
  });

  const payeeRequestClient = new RequestNetwork({
    nodeConnectionConfig: {
      baseURL: "https://sepolia.gateway.request.network/",
    },
    signatureProvider: payeeEpkSignatureProvider,
  });

  const payerRequestClient = new RequestNetwork({
    nodeConnectionConfig: {
      baseURL: "https://sepolia.gateway.request.network/",
    },
    signatureProvider: payerEpkSignatureProvider,
  });

  const payeeIdentityAddress = new Wallet(process.env.PAYEE_PRIVATE_KEY)
    .address;
  const payerIdentityAddress = new Wallet(process.env.PAYER_PRIVATE_KEY)
    .address;

  const payeeIdentity = {
    type: Types.Identity.TYPE.ETHEREUM_ADDRESS,
    value: payeeIdentityAddress,
  };

  const payerIdentity = {
    type: Types.Identity.TYPE.ETHEREUM_ADDRESS,
    value: payerIdentityAddress,
  };

  // In this example, the payee is also the payment recipient.
  const paymentRecipient = payeeIdentityAddress;
  const feeRecipient = "0x0000000000000000000000000000000000000000";

  const requestCreateParameters = {
    requestInfo: {
      currency: {
        type: Types.RequestLogic.CURRENCY.ERC20,
        value: "0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C", // FAU token address
        network: "sepolia",
      },
      expectedAmount: "1000000000000000000", // 1.0
      payee: payeeIdentity,
      payer: payerIdentity,
      timestamp: Utils.getCurrentTimestampInSecond(),
    },
    paymentNetwork: {
      // We can declare payments because ERC20 fee proxy payment network inherits from declarative payment network
      id: Types.Extension.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
      parameters: {
        paymentNetworkName: "sepolia",
        paymentAddress: paymentRecipient,
        feeAddress: feeRecipient,
        feeAmount: "0",
      },
    },
    contentData: {
      reason: "🍕",
      dueDate: "2023.06.16",
      builderId: "request-network",
      createdWith: "quickstart",
    },
    signer: payeeIdentity,
  };

  const payeeRequest = await payeeRequestClient.createRequest(
    requestCreateParameters,
  );
  const payeeRequestData = await payeeRequest.waitForConfirmation();

  const payerRequest = await payerRequestClient.fromRequestId(
    payeeRequestData.requestId,
  );
  const payerRequestData = payerRequest.getData();

  const payerRequestDataAfterSent = await payerRequest.declareSentPayment(
    payerRequestData.expectedAmount,
    "payment initiated from the bank",
    payerIdentity,
  );
  console.log(
    "payerRequestDataAfterSent: " +
      JSON.stringify(payerRequestDataAfterSent, null, 2),
  );

  const payerRequestDataAfterSentConfirmed = await waitForConfirmation(
    payerRequestDataAfterSent,
  );
  console.log(
    "payerRequestDataAfterSentConfirmed: " +
      JSON.stringify(payerRequestDataAfterSentConfirmed, null, 2),
  );
  console.log(
    "Observe extensionsData contains 3 events: paymentNetwork 'create', contentData 'create', and paymentNetwork 'declareSentPayment'",
  );

  const payeeRequestDataRefreshed = await payeeRequest.refresh();

  const payeeRequestDataAfterReceived =
    await payeeRequest.declareReceivedPayment(
      payeeRequestDataRefreshed.expectedAmount,
      "payment received from the bank",
      payeeIdentity,
    );

  const payeeRequestDataAfterReceivedConfirmed = await waitForConfirmation(
    payeeRequestDataAfterReceived,
  );
  console.log(
    "payeeRequestDataAfterReceivedConfirmed: " +
      JSON.stringify(payeeRequestDataAfterReceivedConfirmed, null, 2),
  );
  console.log(
    "Observe extensionsData contains 4 events: paymentNetwork 'create', contentData 'create', paymentNetwork 'declareSentPayment', and paymentNetwork 'declareReceivedPayment'",
  );

  console.log(
    "Request balance: " +
      payeeRequestDataAfterReceivedConfirmed.balance.balance,
  );
  console.log(
    "Request balance events: " +
      JSON.stringify(
        payeeRequestDataAfterReceivedConfirmed.balance.events,
        null,
        2,
      ),
  );
})();

Configuring Payment Fees

Single Request Forwarder

Overview

The Single Request Forwarder is a smart contract solution that enables integration with Request Network's payment system without modifying existing smart contracts.

Single Request Forwarder Payment Flow

The Single Request Forwarder Factory contact addresses can be found : Smart Contract Addresses

The contract name is SingleRequestProxyFactory

Key Benefits:

  • Universal Compatibility: Works with any system that can make standard crypto transfers.

  • No Code Changes: Integrate with Request Network without modifying existing smart contracts.

  • Exchange Friendly: Enable payments from centralized exchanges.

How it works:

  1. Request: Create a request in the Request Network protocol

  2. Deploy: Deploy a unique Single Request Forwarder for your request

  3. Pay: The Payer sends funds to the Single Request Forwarder

  4. Complete: The Single Request Forwarder forwards the payment to the Payee and emits an event to enable payment detection.

Integration Guide

Create a request

For a complete guide on request creation, see Create a request

const request = await requestClient.createRequest(requestCreateParameters);

const requestData = request.getData() 

// In case of in-memory request
 const requestData = request.inMemoryInfo.requestData

Deploy Single Request Forwarder

To deploy a Single Request Forwarder, call deploySingleRequestForwarder() which takes in the following arguments:

  • requestData: the data of the created request

  • signer: An Ethers v5 Signer to sign the deployment transaction

The deploySingleRequestForwarder() function automatically deploys the correct type of Single Request Forwarder based on the Request data passed into the function; either an Ethereum Single Request Forwarder or ERC20 Single Request Forwarder

It returns

  • Single Request Forwarder Address

import { deploySingleRequestForwarder } from "@requestnetwork/payment-processor"

 const forwarderAddress = await deploySingleRequestForwarder(
      requestData,
      signer
    );

console.log(`Single Request Forwarder Deployed At: ${forwarderAddress}`)
// Single Request Forwarder Deployed At : 0x1234567890123456789012345678901234567890

Pay through a Single Request Forwarder

Pay through a Single Request Forwarder using the RN SDK

To pay a request through a Single Request Forwarder using the Request Network SDK, call payRequestWithSingleRequestForwarder() which takes in the following arguments:

  • singleRequestForwarderAddress: the address of the SRP deployed in the previous step.

  • signer: A wallet signer who is making the transfer of funds.

  • amount: Amount of funds that need to be transferred.

import { payRequestWithSingleRequestForwarder } from "@requestnetwork/payment-processor"
import { utils } from "ethers"
const paymentAmount = utils.parseUnits("1" , 18)
await payRequestWithSingleRequestForwarder(forwarderAddress , signer, paymentAmount)

Pay through a Single Request Forwarder using a Direct Transfer

Once we have the Single Request Forwarder address, we can pay by directly transferring the money to the address itself. The Single Request Forwarder will automatically process the payment. For ERC20 payments, the process of paying with a Single Request Forwarder happens in two steps:

  • Transferring the tokens to the Single Request Forwarder

  • Make a zero-value transaction to the Single Request Forwarder (i.e. Send 0 ETH to the contract)

Design Features:

  • Single Use: Each Single Request Forwarder deployment processes payments for a specific request.

  • Immutable Parameters: Payment details cannot be modified after deployment.

  • Fund Recovery: Built-in mechanisms to send stuck funds to the payment receiver.

Batch Payment

Functions:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/src/payment/batch-conversion-proxy.ts
import { ContractTransaction, Signer, providers, BigNumber, constants } from 'ethers';
import { batchConversionPaymentsArtifact } from '@requestnetwork/smart-contracts';
import { BatchConversionPayments__factory } from '@requestnetwork/smart-contracts/types';
import {
  ClientTypes,
  CurrencyTypes,
  ExtensionTypes,
  PaymentTypes,
  RequestLogicTypes,
} from '@requestnetwork/types';
import { ITransactionOverrides } from './transaction-overrides';
import {
  comparePnTypeAndVersion,
  getAmountToPay,
  getPnAndNetwork,
  getProvider,
  getProxyAddress,
  getRequestPaymentValues,
  getSigner,
  MAX_ALLOWANCE,
  validateConversionFeeProxyRequest,
  validateErc20FeeProxyRequest,
} from './utils';
import {
  padAmountForChainlink,
  getPaymentNetworkExtension,
} from '@requestnetwork/payment-detection';
import { IPreparedTransaction } from './prepared-transaction';
import { IConversionPaymentSettings } from './index';
import { getConversionPathForErc20Request } from './any-to-erc20-proxy';
import { checkErc20Allowance, encodeApproveAnyErc20 } from './erc20';
import { CurrencyManager } from '@requestnetwork/currency';
import {
  BatchPaymentNetworks,
  EnrichedRequest,
  IConversionSettings,
  IRequestPaymentOptions,
} from '../types';
import { validateEthFeeProxyRequest } from './eth-fee-proxy';
import { getConversionPathForEthRequest } from './any-to-eth-proxy';

const CURRENCY = RequestLogicTypes.CURRENCY;

/**
 * Processes a transaction to pay a batch of requests with an ERC20 currency
 * that can be 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 signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum.
 * @param options It contains 3 paramaters required to do a batch payments:
 *  - conversion: It must contains the currencyManager.
 *  - skipFeeUSDLimit: It checks the value of batchFeeAmountUSDLimit of the batch proxy deployed.
 * Setting the value to true skips the USD fee limit, and reduces gas consumption.
 *  - version: The version of the batch conversion proxy.
 * @param overrides Optionally, override default transaction values, like gas.
 * @dev We only implement batchPayments using two ERC20 functions:
 *      batchMultiERC20ConversionPayments, and batchMultiERC20Payments.
 */
export async function payBatchConversionProxyRequest(
  enrichedRequests: EnrichedRequest[],
  signerOrProvider: providers.Provider | Signer = getProvider(),
  options: IRequestPaymentOptions,
  overrides?: ITransactionOverrides,
): Promise<ContractTransaction> {
  const { data, to, value } = prepareBatchConversionPaymentTransaction(enrichedRequests, options);
  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 can be different from the request currency (eg. fiat).
 * It can be used with a Multisig contract.
 * @param enrichedRequests List of EnrichedRequests to pay.
 * @param options It contains 3 paramaters required to prepare a batch payments:
 *  - conversion: It must contains the currencyManager.
 *  - skipFeeUSDLimit: It checks the value of batchFeeAmountUSDLimit of the batch proxy deployed.
 * Setting the value to true skips the USD fee limit, and reduces gas consumption.
 *  - version: The version of the batch conversion proxy.
 */
export function prepareBatchConversionPaymentTransaction(
  enrichedRequests: EnrichedRequest[],
  options: IRequestPaymentOptions,
): IPreparedTransaction {
  const encodedTx = encodePayBatchConversionRequest(
    enrichedRequests,
    options.skipFeeUSDLimit,
    options.conversion,
  );
  const value = getBatchTxValue(enrichedRequests);
  const proxyAddress = getBatchConversionProxyAddress(enrichedRequests[0].request, options.version);
  return {
    data: encodedTx,
    to: proxyAddress,
    value,
  };
}

const mapPnToDetailsBuilder: Record<
  BatchPaymentNetworks,
  (req: EnrichedRequest, isNative: boolean) => PaymentTypes.RequestDetail
> = {
  'pn-any-to-erc20-proxy': getRequestDetailWithConversion,
  'pn-any-to-eth-proxy': getRequestDetailWithConversion,
  'pn-erc20-fee-proxy-contract': getRequestDetailWithoutConversion,
  'pn-eth-fee-proxy-contract': getRequestDetailWithoutConversion,
};

const mapPnToAllowedCurrencies: Record<BatchPaymentNetworks, RequestLogicTypes.CURRENCY[]> = {
  'pn-any-to-erc20-proxy': [CURRENCY.ERC20, CURRENCY.ISO4217, CURRENCY.ETH],
  'pn-any-to-eth-proxy': [CURRENCY.ERC20, CURRENCY.ISO4217],
  'pn-erc20-fee-proxy-contract': [CURRENCY.ERC20],
  'pn-eth-fee-proxy-contract': [CURRENCY.ETH],
};

const mapPnToBatchId: Record<BatchPaymentNetworks, PaymentTypes.BATCH_PAYMENT_NETWORK_ID> = {
  'pn-any-to-erc20-proxy':
    PaymentTypes.BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_CONVERSION_PAYMENTS,
  'pn-any-to-eth-proxy': PaymentTypes.BATCH_PAYMENT_NETWORK_ID.BATCH_ETH_CONVERSION_PAYMENTS,
  'pn-erc20-fee-proxy-contract': PaymentTypes.BATCH_PAYMENT_NETWORK_ID.BATCH_MULTI_ERC20_PAYMENTS,
  'pn-eth-fee-proxy-contract': PaymentTypes.BATCH_PAYMENT_NETWORK_ID.BATCH_ETH_PAYMENTS,
};

const computeRequestDetails = ({
  enrichedRequest,
  extension,
}: {
  enrichedRequest: EnrichedRequest;
  extension: ExtensionTypes.IState<any> | undefined;
}) => {
  const paymentNetworkId = enrichedRequest.paymentNetworkId;
  const allowedCurrencies = mapPnToAllowedCurrencies[paymentNetworkId];
  const detailsBuilder = mapPnToDetailsBuilder[paymentNetworkId];
  const isNative =
    paymentNetworkId === ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY ||
    paymentNetworkId === ExtensionTypes.PAYMENT_NETWORK_ID.ETH_FEE_PROXY_CONTRACT;

  extension = extension ?? getPaymentNetworkExtension(enrichedRequest.request);

  comparePnTypeAndVersion(extension, enrichedRequest.request);
  if (!allowedCurrencies.includes(enrichedRequest.request.currencyInfo.type)) {
    throw new Error(`wrong request currencyInfo type`);
  }

  return {
    input: detailsBuilder(enrichedRequest, isNative),
    extension,
  };
};

/**
 * Encodes a transaction to pay a batch of requests with an ERC20 currency
 * that can be different from the request currency (eg. fiat).
 * It can be used with a Multisig contract.
 * @param enrichedRequests List of EnrichedRequests to pay.
 * @param skipFeeUSDLimit It checks the value of batchFeeAmountUSDLimit of the batch proxy deployed.
 * Setting the value to true skips the USD fee limit, and reduces gas consumption.
 */
function encodePayBatchConversionRequest(
  enrichedRequests: EnrichedRequest[],
  skipFeeUSDLimit = false,
  conversion: IConversionSettings | undefined,
): string {
  if (!(conversion && conversion.currencyManager)) {
    throw 'the conversion object or the currencyManager is undefined';
  }
  const { feeAddress } = getRequestPaymentValues(enrichedRequests[0].request);

  const { network } = getPnAndNetwork(enrichedRequests[0].request);

  const requestDetails: Record<BatchPaymentNetworks, PaymentTypes.RequestDetail[]> = {
    'pn-any-to-erc20-proxy': [],
    'pn-any-to-eth-proxy': [],
    'pn-erc20-fee-proxy-contract': [],
    'pn-eth-fee-proxy-contract': [],
  };

  const requestExtensions: Record<BatchPaymentNetworks, ExtensionTypes.IState<any> | undefined> = {
    'pn-any-to-erc20-proxy': undefined,
    'pn-any-to-eth-proxy': undefined,
    'pn-erc20-fee-proxy-contract': undefined,
    'pn-eth-fee-proxy-contract': undefined,
  };

  for (const enrichedRequest of enrichedRequests) {
    const request = enrichedRequest.request;
    const { input, extension } = computeRequestDetails({
      enrichedRequest,
      extension: requestExtensions[enrichedRequest.paymentNetworkId],
    });
    requestDetails[enrichedRequest.paymentNetworkId].push(input);
    requestExtensions[enrichedRequest.paymentNetworkId] = extension;

    if (network !== getPnAndNetwork(request).network)
      throw new Error('All the requests must have the same network');
  }

  /**
   * The native with conversion payment inputs must be the last element.
   * See BatchConversionPayment batchPayments method in @requestnetwork/smart-contracts
   */
  const metaDetails = Object.entries(requestDetails)
    .map(([pn, details]) => ({
      paymentNetworkId: mapPnToBatchId[pn as BatchPaymentNetworks],
      requestDetails: details,
    }))
    .filter((details) => details.requestDetails.length > 0)
    .sort((a, b) => a.paymentNetworkId - b.paymentNetworkId);

  const hasNativePayment =
    requestDetails['pn-any-to-eth-proxy'].length > 0 ||
    requestDetails['pn-eth-fee-proxy-contract'].length > 0;

  const pathsToUSD = getUSDPathsForFeeLimit(
    [...metaDetails.map((details) => details.requestDetails).flat()],
    network,
    skipFeeUSDLimit,
    conversion.currencyManager,
    hasNativePayment,
  );

  const proxyContract = BatchConversionPayments__factory.createInterface();
  return proxyContract.encodeFunctionData('batchPayments', [
    metaDetails,
    pathsToUSD,
    feeAddress || constants.AddressZero,
  ]);
}

/**
 * Get the batch input associated to a request without conversion.
 * @param enrichedRequest The enrichedRequest to pay.
 */
function getRequestDetailWithoutConversion(
  enrichedRequest: EnrichedRequest,
  isNative: boolean,
): PaymentTypes.RequestDetail {
  const request = enrichedRequest.request;
  isNative ? validateEthFeeProxyRequest(request) : validateErc20FeeProxyRequest(request);

  const currencyManager =
    enrichedRequest.paymentSettings?.currencyManager || CurrencyManager.getDefault();
  const tokenAddress = isNative
    ? currencyManager.getNativeCurrency(
        RequestLogicTypes.CURRENCY.ETH,
        request.currencyInfo.network as string,
      )?.hash
    : request.currencyInfo.value;
  if (!tokenAddress) {
    throw new Error('Could not find the request currency');
  }
  const { paymentReference, paymentAddress, feeAmount } = getRequestPaymentValues(request);

  return {
    recipient: paymentAddress,
    requestAmount: getAmountToPay(request).toString(),
    path: [tokenAddress],
    paymentReference: `0x${paymentReference}`,
    feeAmount: feeAmount?.toString() || '0',
    maxToSpend: '0',
    maxRateTimespan: '0',
  };
}

/**
 * Get the batch input associated to a request with conversion.
 * @param enrichedRequest The enrichedRequest to pay.
 */
function getRequestDetailWithConversion(
  enrichedRequest: EnrichedRequest,
  isNative: boolean,
): PaymentTypes.RequestDetail {
  const { request, paymentSettings } = enrichedRequest;
  const { path, requestCurrency } = (
    isNative ? getConversionPathForEthRequest : getConversionPathForErc20Request
  )(request, paymentSettings);

  isNative
    ? validateEthFeeProxyRequest(
        request,
        undefined,
        undefined,
        ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY,
      )
    : validateConversionFeeProxyRequest(request, path);

  const { paymentReference, paymentAddress, feeAmount, maxRateTimespan } =
    getRequestPaymentValues(request);

  const requestAmount = BigNumber.from(request.expectedAmount).sub(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',
  };
}

const getBatchTxValue = (enrichedRequests: EnrichedRequest[]) => {
  return enrichedRequests.reduce((prev, curr) => {
    if (
      curr.paymentNetworkId !== ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY &&
      curr.paymentNetworkId !== ExtensionTypes.PAYMENT_NETWORK_ID.ETH_FEE_PROXY_CONTRACT
    )
      return prev;
    return prev.add(
      curr.paymentNetworkId === ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY
        ? curr.paymentSettings.maxToSpend
        : getAmountToPay(curr.request),
    );
  }, BigNumber.from(0));
};

/**
 * Get the list of conversion paths from tokens to the USD address through currencyManager.
 * If there is no path to USD for a token, it goes to the next token.
 * @param requestDetails List of ERC20 requests to pay.
 * @param network The network targeted.
 * @param skipFeeUSDLimit Setting the value to true skips the USD fee limit, it skips the path calculation.
 * @param currencyManager The currencyManager used to get token conversion paths to USD.
 */
function getUSDPathsForFeeLimit(
  requestDetails: PaymentTypes.RequestDetail[],
  network: string,
  skipFeeUSDLimit: boolean,
  currencyManager: CurrencyTypes.ICurrencyManager<unknown>,
  hasNativePayment: boolean,
): string[][] {
  if (skipFeeUSDLimit) return [];

  const USDCurrency = currencyManager.fromSymbol('USD');
  if (!USDCurrency) throw 'Cannot find the USD currency information';

  // Native to USD conversion path
  let nativeConversionPath: string[] = [];
  if (hasNativePayment) {
    const nativeCurrencyHash = currencyManager.getNativeCurrency(
      RequestLogicTypes.CURRENCY.ETH,
      network,
    )?.hash;
    if (!nativeCurrencyHash) throw 'Cannot find the Native currency information';
    nativeConversionPath =
      currencyManager.getConversionPath({ hash: nativeCurrencyHash }, USDCurrency, network) || [];
  }

  // get a list of unique token addresses
  const tokenAddresses = requestDetails
    .map((rd) => rd.path[rd.path.length - 1])
    .filter((value, index, self) => self.indexOf(value) === index);

  // get the token currencies and keep the one that are defined
  const tokenCurrencies: Array<CurrencyTypes.CurrencyDefinition<unknown>> = tokenAddresses
    .map((token) => currencyManager.fromAddress(token, network))
    .filter((value): value is CurrencyTypes.CurrencyDefinition => !!value);

  // get all the conversion paths to USD when it exists and return it
  const path = tokenCurrencies
    .map((t) => currencyManager.getConversionPath(t, USDCurrency, network))
    .filter((value): value is string[] => !!value);
  return hasNativePayment ? path.concat([nativeConversionPath]) : path;
}

/**
 * @param network The network targeted.
 * @param version The version of the batch conversion proxy, the last one by default.
 * @returns
 */
function getBatchDeploymentInformation(
  network: CurrencyTypes.EvmChainName,
  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 signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum.
 * @param paymentSettings The payment settings are necessary for conversion payment approval.
 * @param version The version of the batch conversion proxy, which can be different from request pn version.
 * @param overrides Optionally, override default transaction values, like gas.
 */
export async function approveErc20BatchConversionIfNeeded(
  request: ClientTypes.IRequestData,
  account: string,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  amount: BigNumber = MAX_ALLOWANCE,
  paymentSettings?: IConversionPaymentSettings,
  version?: string,
  overrides?: ITransactionOverrides,
): Promise<ContractTransaction | void> {
  if (
    !(await hasErc20BatchConversionApproval(
      request,
      account,
      signerOrProvider,
      paymentSettings,
      version,
    ))
  ) {
    return approveErc20BatchConversion(
      request,
      getSigner(signerOrProvider),
      amount,
      paymentSettings,
      version,
      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 signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum.
 * @param paymentSettings The payment settings are necessary for conversion payment approval.
 * @param version The version of the batch conversion proxy.
 */
export async function hasErc20BatchConversionApproval(
  request: ClientTypes.IRequestData,
  account: string,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  paymentSettings?: IConversionPaymentSettings,
  version?: string,
): Promise<boolean> {
  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 signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum.
 * @param paymentSettings The payment settings are necessary for conversion payment approval.
 * @param version The version of the batch conversion proxy, which can be different from request pn version.
 * @param overrides Optionally, override default transaction values, like gas.
 */
export async function approveErc20BatchConversion(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  amount: BigNumber = MAX_ALLOWANCE,
  paymentSettings?: IConversionPaymentSettings,
  version?: string,
  overrides?: ITransactionOverrides,
): Promise<ContractTransaction> {
  const preparedTx = prepareApproveErc20BatchConversion(
    request,
    signerOrProvider,
    amount,
    paymentSettings,
    version,
    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 signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum.
 * @param paymentSettings The payment settings are necessary for conversion payment approval.
 * @param version The version of the batch conversion proxy.
 * @param overrides Optionally, override default transaction values, like gas.
 */
export function prepareApproveErc20BatchConversion(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  amount: BigNumber = MAX_ALLOWANCE,
  paymentSettings?: IConversionPaymentSettings,
  version?: string,
  overrides?: ITransactionOverrides,
): IPreparedTransaction {
  const encodedTx = encodeApproveErc20BatchConversion(
    request,
    signerOrProvider,
    amount,
    paymentSettings,
    version,
  );
  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 signerOrProvider The Web3 provider, or signer. Defaults to window.ethereum.
 * @param paymentSettings The payment settings are necessary for conversion payment approval.
 * @param version The version of the batch conversion proxy.
 */
export function encodeApproveErc20BatchConversion(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  amount: BigNumber = MAX_ALLOWANCE,
  paymentSettings?: IConversionPaymentSettings,
  version?: string,
): string {
  const proxyAddress = getBatchConversionProxyAddress(request, version);
  return encodeApproveAnyErc20(
    getTokenAddress(request, paymentSettings),
    proxyAddress,
    getSigner(signerOrProvider),
    amount,
  );
}

/**
 * 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 {
  if (paymentSettings) {
    if (!paymentSettings.currency) throw 'paymentSetting must have a currency';
    return paymentSettings.currency.value;
  }

  return request.currencyInfo.value;
}

Tests:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/test/payment/batch-proxy.test.ts
import { BigNumber, providers, Wallet } from 'ethers';

import {
  ClientTypes,
  ExtensionTypes,
  IdentityTypes,
  RequestLogicTypes,
} from '@requestnetwork/types';
import {
  approveErc20BatchConversionIfNeeded,
  getBatchConversionProxyAddress,
  getErc20Balance,
  IConversionPaymentSettings,
  payBatchConversionProxyRequest,
  prepareBatchConversionPaymentTransaction,
} from '../../src';
import { deepCopy } from '@requestnetwork/utils';
import { revokeErc20Approval } from '@requestnetwork/payment-processor/src/payment/utils';
import { batchConversionPaymentsArtifact } from '@requestnetwork/smart-contracts';
import { CurrencyManager, UnsupportedCurrencyError } from '@requestnetwork/currency';
import { CurrencyTypes } from '@requestnetwork/types/src';
import { EnrichedRequest, IRequestPaymentOptions } from 'payment-processor/src/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 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);

const currencyManager = new CurrencyManager([
  ...CurrencyManager.getDefaultList(),
  {
    address: DAITokenAddress,
    decimals: 18,
    network: 'private',
    symbol: 'DAI',
    type: RequestLogicTypes.CURRENCY.ERC20,
  },
]);

// Cf. ERC20Alpha in TestERC20.sol
const currency: RequestLogicTypes.ICurrency = {
  type: RequestLogicTypes.CURRENCY.ERC20,
  value: DAITokenAddress,
  network: 'private',
};

const nativeCurrency: RequestLogicTypes.ICurrency = {
  type: RequestLogicTypes.CURRENCY.ETH,
  value: 'ETH-private',
  network: 'private',
};

const conversionPaymentSettings: IConversionPaymentSettings = {
  currency: currency,
  maxToSpend: '10000000000000000000000000000',
  currencyManager: currencyManager,
};

const ethConversionPaymentSettings: IConversionPaymentSettings = {
  currency: nativeCurrency,
  maxToSpend: '200000000000000000000',
  currencyManager: currencyManager,
};

const options: IRequestPaymentOptions = {
  conversion: {
    currency: currency,
    currencyManager: currencyManager,
  },
  skipFeeUSDLimit: true,
};

// requests setting

const EURExpectedAmount = 55000_00; // 55 000 €
const EURFeeAmount = 2_00; // 2 €
// amounts used for DAI and FAU requests
const expectedAmount = 100000;
const feeAmount = 100;

const EURToERC20ValidRequest: 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: {
    [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: {
      events: [],
      id: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
      type: ExtensionTypes.TYPE.PAYMENT_NETWORK,
      values: {
        feeAddress,
        feeAmount: EURFeeAmount,
        paymentAddress,
        salt: 'salt',
        network: 'private',
        acceptedTokens: [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: currency,
  events: [],
  expectedAmount: expectedAmount,
  extensions: {
    [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: {
      events: [],
      id: ExtensionTypes.PAYMENT_NETWORK_ID.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 EURToETHValidRequest: ClientTypes.IRequestData = {
  ...EURToERC20ValidRequest,
  extensions: {
    [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY]: {
      events: [],
      id: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY,
      type: ExtensionTypes.TYPE.PAYMENT_NETWORK,
      values: {
        feeAddress,
        feeAmount: EURFeeAmount,
        paymentAddress,
        salt: 'salt',
        network: 'private',
      },
      version: '0.1.0',
    },
  },
};

const ETHValidRequest: ClientTypes.IRequestData = {
  ...DAIValidRequest,
  currency: 'ETH-private',
  currencyInfo: nativeCurrency,
  extensions: {
    [ExtensionTypes.PAYMENT_NETWORK_ID.ETH_FEE_PROXY_CONTRACT]: {
      events: [],
      id: ExtensionTypes.PAYMENT_NETWORK_ID.ETH_FEE_PROXY_CONTRACT,
      type: ExtensionTypes.TYPE.PAYMENT_NETWORK,
      values: {
        feeAddress,
        feeAmount: feeAmount,
        paymentAddress,
        salt: 'salt',
        network: 'private',
      },
      version: '0.1.0',
    },
  },
};

const FAUValidRequest = 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, isNative?: boolean): BigNumber => {
  //   token decimals       10**18
  //   amount               amount / 100
  //   AggEurUsd.sol     x  1.20
  //   AggDaiUsd.sol     /  1.01 OR AggEthUsd.sol / 500
  return BigNumber.from(10)
    .pow(18)
    .mul(amount)
    .div(100)
    .mul(120)
    .div(100)
    .mul(100)
    .div(isNative ? 50000 : 101);
};

describe('batch-proxy', () => {
  beforeAll(async () => {
    // Revoke DAI and FAU approvals
    await revokeErc20Approval(
      getBatchConversionProxyAddress(DAIValidRequest),
      DAITokenAddress,
      wallet,
    );
    await revokeErc20Approval(
      getBatchConversionProxyAddress(FAUValidRequest),
      FAUTokenAddress,
      wallet,
    );

    // Approve the contract to spent DAI with a conversion request
    const approvalTx = await approveErc20BatchConversionIfNeeded(
      EURToERC20ValidRequest,
      wallet.address,
      wallet.provider,
      undefined,
      conversionPaymentSettings,
    );
    expect(approvalTx).toBeDefined();
    if (approvalTx) {
      await approvalTx.wait(1);
    }
  });

  describe(`Conversion:`, () => {
    beforeEach(() => {
      jest.restoreAllMocks();
      EURRequest = deepCopy(EURToERC20ValidRequest);
      enrichedRequests = [
        {
          paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
          request: EURToERC20ValidRequest,
          paymentSettings: conversionPaymentSettings,
        },
        {
          paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
          request: EURRequest,
          paymentSettings: conversionPaymentSettings,
        },
      ];
    });

    describe('Throw an error', () => {
      it('should throw an error if the token is not accepted', async () => {
        await expect(
          payBatchConversionProxyRequest(
            [
              {
                paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
                request: EURToERC20ValidRequest,
                paymentSettings: {
                  ...conversionPaymentSettings,
                  currency: {
                    ...conversionPaymentSettings.currency,
                    value: '0x775eb53d00dd0acd3ec1696472105d579b9b386b',
                  },
                } as IConversionPaymentSettings,
              },
            ],
            wallet,
            options,
          ),
        ).rejects.toThrowError(
          new UnsupportedCurrencyError({
            value: '0x775eb53d00dd0acd3ec1696472105d579b9b386b',
            network: 'private',
          }),
        );
      });
      it('should throw an error if request has no currency within paymentSettings', async () => {
        const wrongPaymentSettings = deepCopy(conversionPaymentSettings);
        wrongPaymentSettings.currency = undefined;
        await expect(
          payBatchConversionProxyRequest(
            [
              {
                paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
                request: EURRequest,
                paymentSettings: wrongPaymentSettings,
              },
            ],
            wallet,
            options,
          ),
        ).rejects.toThrowError('currency must be provided in the paymentSettings');
      });
      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
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: {
            events: [],
            id: ExtensionTypes.PAYMENT_NETWORK_ID.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, wallet, options),
        ).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
          [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: {
            events: [],
            id: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
            type: ExtensionTypes.TYPE.PAYMENT_NETWORK,
            values: {
              feeAddress,
              feeAmount: feeAmount,
              paymentAddress: paymentAddress,
              network: 'private',
              salt: 'salt',
            },
            version: '0.1.0',
          },
        };

        await expect(
          payBatchConversionProxyRequest(enrichedRequests, wallet, options),
        ).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, wallet, options),
        ).rejects.toThrowError("The currency '' is unknown or not supported");
      });
      it('should throw an error if a request has no extension', async () => {
        EURRequest.extensions = [] as any;
        await expect(
          payBatchConversionProxyRequest(enrichedRequests, wallet, options),
        ).rejects.toThrowError('no payment network found');
      });
      it('should throw an error if there is a wrong version mapping', async () => {
        EURRequest.extensions = {
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: {
            ...EURRequest.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY],
            version: '0.3.0',
          },
        };
        await expect(
          payBatchConversionProxyRequest(enrichedRequests, wallet, options),
        ).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: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
              request: EURToERC20ValidRequest,
              paymentSettings: conversionPaymentSettings,
            },
          ],
          wallet,
          options,
          { gasPrice: '20000000000' },
        );
        expect(spy).toHaveBeenCalledWith({
          data: '0x92cddb91000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000c5fdf4076b8f3a5357c5e395ab970b5b54098fef000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000f17f52151ebef6c7334fad080c5704d77216b73200000000000000000000000000000000000000000000000000000500918bd80000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000bebc2000000000000000000000000000000000000000000204fce5e3e250261100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000017b4158805772ced11225e77339f90beb5aae968000000000000000000000000775eb53d00dd0acd3ec1696472105d579b9b386b00000000000000000000000038cf23c52bb4b13f051aec09580a2de845a7fa35000000000000000000000000000000000000000000000000000000000000000886dfbccad783599a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
          gasPrice: '20000000000',
          to: getBatchConversionProxyAddress(EURToERC20ValidRequest, '0.1.0'),
          value: BigNumber.from(0),
        });
        wallet.sendTransaction = originalSendTransaction;
      });

      for (const skipFeeUSDLimit of ['true', 'false']) {
        it(`should convert and pay a request in EUR with ERC20, ${
          skipFeeUSDLimit === 'true' ? 'skipFeeUSDLimit' : 'no skipFeeUSDLimit'
        } `, async () => {
          // Get the balances to compare after payment
          const initialETHFromBalance = await wallet.getBalance();
          const initialDAIFromBalance = await getErc20Balance(
            DAIValidRequest,
            wallet.address,
            provider,
          );

          const tx = await payBatchConversionProxyRequest(
            [
              {
                paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
                request: EURToERC20ValidRequest,
                paymentSettings: conversionPaymentSettings,
              },
            ],
            wallet,
            {
              ...options,
              skipFeeUSDLimit: skipFeeUSDLimit === 'true',
            },
          );
          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 totalFeeToPay =
            skipFeeUSDLimit === 'true'
              ? amountToPay.add(feeToPay).mul(BATCH_CONV_FEE).div(BATCH_DENOMINATOR).add(feeToPay)
              : BigNumber.from('150891089116411368418'); // eq to $150 batch fee (USD limit) + 2€
          const expectedAmountToPay = amountToPay.add(totalFeeToPay);
          expect(
            BigNumber.from(initialETHFromBalance).sub(ETHFromBalance).toNumber(),
          ).toBeGreaterThan(0);
          expect(
            BigNumber.from(initialDAIFromBalance).sub(BigNumber.from(DAIFromBalance)),
            // Calculation of expectedAmountToPay when there there is no fee USD limit
            //   expectedAmount:    55 000
            //   feeAmount:        +     2
            //   AggEurUsd.sol     x  1.20
            //   AggDaiUsd.sol     /  1.01
            //   BATCH_CONV_FEE      x  1.003
          ).toEqual(expectedAmountToPay);
        });
        it(`should convert and pay a request in EUR with ETH, ${
          skipFeeUSDLimit === 'true' ? 'skipFeeUSDLimit' : 'no skipFeeUSDLimit'
        } `, async () => {
          const fromOldBalance = await provider.getBalance(wallet.address);
          const toOldBalance = await provider.getBalance(paymentAddress);
          const feeOldBalance = await provider.getBalance(feeAddress);

          const tx = await payBatchConversionProxyRequest(
            [
              {
                paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY,
                request: EURToETHValidRequest,
                paymentSettings: ethConversionPaymentSettings,
              },
            ],
            wallet,
            {
              ...options,
              skipFeeUSDLimit: skipFeeUSDLimit === 'true',
            },
          );
          const confirmedTx = await tx.wait(1);
          expect(confirmedTx.status).toEqual(1);
          expect(tx.hash).toBeDefined();

          // Get the new balances
          const fromNewBalance = await provider.getBalance(wallet.address);
          const toNewBalance = await provider.getBalance(paymentAddress);
          const feeNewBalance = await provider.getBalance(feeAddress);
          const gasPrice = confirmedTx.effectiveGasPrice;

          const amountToPay = expectedConversionAmount(EURExpectedAmount, true);
          const feeToPay = expectedConversionAmount(EURFeeAmount, true);
          const totalFeeToPay =
            skipFeeUSDLimit === 'true'
              ? amountToPay.add(feeToPay).mul(BATCH_CONV_FEE).div(BATCH_DENOMINATOR).add(feeToPay)
              : // Capped fee total:
                //                2 (Fees in €)
                //           x 1.20
                //           +  150 (Batch Fees capped to 150$)
                //           /  500
                BigNumber.from('304800000000000000');
          const expectedAmountToPay = amountToPay.add(totalFeeToPay);

          expect(
            fromOldBalance
              .sub(fromNewBalance)
              .sub(confirmedTx.cumulativeGasUsed.mul(gasPrice))
              .toString(),
          ).toEqual(expectedAmountToPay.toString());

          expect(feeNewBalance.sub(feeOldBalance).toString()).toEqual(totalFeeToPay.toString());

          expect(toNewBalance.sub(toOldBalance).toString()).toEqual(amountToPay.toString());
        });
      }

      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: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
            request: EURToERC20ValidRequest,
            paymentSettings: conversionPaymentSettings,
          }),
          wallet,
          options,
        );
        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 convert and pay two requests in EUR with ETH', async () => {
        const fromOldBalance = await provider.getBalance(wallet.address);
        const toOldBalance = await provider.getBalance(paymentAddress);
        const feeOldBalance = await provider.getBalance(feeAddress);

        // Convert and pay
        const tx = await payBatchConversionProxyRequest(
          Array(2).fill({
            paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY,
            request: EURToETHValidRequest,
            paymentSettings: ethConversionPaymentSettings,
          }),
          wallet,
          options,
        );
        const confirmedTx = await tx.wait(1);
        expect(confirmedTx.status).toEqual(1);
        expect(tx.hash).toBeDefined();

        // Get the new balances
        const fromNewBalance = await provider.getBalance(wallet.address);
        const toNewBalance = await provider.getBalance(paymentAddress);
        const feeNewBalance = await provider.getBalance(feeAddress);
        const gasPrice = confirmedTx.effectiveGasPrice;

        const amountToPay = expectedConversionAmount(EURExpectedAmount, true).mul(2);
        const feeToPay = expectedConversionAmount(EURFeeAmount, true).mul(2);
        const totalFeeToPay = amountToPay
          .add(feeToPay)
          .mul(BATCH_CONV_FEE)
          .div(BATCH_DENOMINATOR)
          .add(feeToPay);
        const expectedAmountToPay = amountToPay.add(totalFeeToPay);

        expect(
          fromOldBalance
            .sub(fromNewBalance)
            .sub(confirmedTx.cumulativeGasUsed.mul(gasPrice))
            .toString(),
        ).toEqual(expectedAmountToPay.toString());

        expect(feeNewBalance.sub(feeOldBalance).toString()).toEqual(totalFeeToPay.toString());

        expect(toNewBalance.sub(toOldBalance).toString()).toEqual(amountToPay.toString());
      });

      it('should pay heterogeneous payments (ETH/ERC20 with/without conversion)', async () => {
        const fromOldBalanceETH = await provider.getBalance(wallet.address);
        const toOldBalanceETH = await provider.getBalance(paymentAddress);
        const feeOldBalanceETH = await provider.getBalance(feeAddress);
        const fromOldBalanceDAI = await getErc20Balance(DAIValidRequest, wallet.address, provider);
        const toOldBalanceDAI = await getErc20Balance(DAIValidRequest, paymentAddress, provider);
        const feeOldBalanceDAI = await getErc20Balance(DAIValidRequest, feeAddress, provider);

        // Convert the two first requests and pay the three requests
        const tx = await payBatchConversionProxyRequest(
          [
            {
              paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
              request: EURToERC20ValidRequest,
              paymentSettings: conversionPaymentSettings,
            },
            {
              paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
              request: EURToERC20ValidRequest,
              paymentSettings: conversionPaymentSettings,
            },
            {
              paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
              request: DAIValidRequest,
              paymentSettings: { maxToSpend: '0' },
            },
            {
              paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ETH_FEE_PROXY_CONTRACT,
              request: ETHValidRequest,
              paymentSettings: {
                ...ethConversionPaymentSettings,
                maxToSpend: '0',
              },
            },
            {
              paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ETH_PROXY,
              request: EURToETHValidRequest,
              paymentSettings: ethConversionPaymentSettings,
            },
          ],
          wallet,
          options,
        );
        const confirmedTx = await tx.wait(1);
        expect(confirmedTx.status).toEqual(1);
        expect(tx.hash).toBeDefined();

        const gasPrice = confirmedTx.effectiveGasPrice;

        const fromNewBalanceETH = await provider.getBalance(wallet.address);
        const toNewBalanceETH = await provider.getBalance(paymentAddress);
        const feeNewBalanceETH = await provider.getBalance(feeAddress);
        const fromNewBalanceDAI = await getErc20Balance(DAIValidRequest, wallet.address, provider);
        const toNewBalanceDAI = await getErc20Balance(DAIValidRequest, paymentAddress, provider);
        const feeNewBalanceDAI = await getErc20Balance(DAIValidRequest, feeAddress, provider);

        // Computes amount related to DAI with conversion payments
        const DAIConvAmount = expectedConversionAmount(EURExpectedAmount).mul(2);
        const DAIConvFeeAmount = expectedConversionAmount(EURFeeAmount).mul(2);
        const DAIConvTotalFees = DAIConvAmount.add(DAIConvFeeAmount)
          .mul(BATCH_CONV_FEE)
          .div(BATCH_DENOMINATOR)
          .add(DAIConvFeeAmount);
        const DAIConvTotal = DAIConvAmount.add(DAIConvTotalFees);

        // Computes amount related to payments without conversion (same for ETH or ERC20)
        const NoConvAmount = BigNumber.from(expectedAmount);
        const NoConvFeeAmount = BigNumber.from(feeAmount);
        const NoConvTotalFees = NoConvAmount.add(NoConvFeeAmount)
          .mul(BATCH_CONV_FEE)
          .div(BATCH_DENOMINATOR)
          .add(NoConvFeeAmount);
        const NoConvTotal = NoConvAmount.add(NoConvTotalFees);

        // Computes amount related to ETH with conversion payments
        const ETHConvAmount = expectedConversionAmount(EURExpectedAmount, true);
        const ETHConvFeeAmount = expectedConversionAmount(EURFeeAmount, true);
        const ETHConvTotalFees = ETHConvAmount.add(ETHConvFeeAmount)
          .mul(BATCH_CONV_FEE)
          .div(BATCH_DENOMINATOR)
          .add(ETHConvFeeAmount);
        const ETHConvTotal = ETHConvAmount.add(ETHConvTotalFees);

        // Totals
        const DAIAmount = DAIConvAmount.add(NoConvAmount);
        const DAIFeesTotal = DAIConvTotalFees.add(NoConvTotalFees);
        const DAITotal = DAIConvTotal.add(NoConvTotal);
        const ETHAmount = ETHConvAmount.add(NoConvAmount);
        const ETHFeesTotal = ETHConvTotalFees.add(NoConvTotalFees);
        const ETHTotal = ETHConvTotal.add(NoConvTotal);

        // DAI Checks
        expect(BigNumber.from(fromOldBalanceDAI).sub(fromNewBalanceDAI)).toEqual(DAITotal);
        expect(BigNumber.from(toNewBalanceDAI).sub(toOldBalanceDAI)).toEqual(DAIAmount);
        expect(BigNumber.from(feeNewBalanceDAI).sub(feeOldBalanceDAI)).toEqual(DAIFeesTotal);

        // ETH Checks
        expect(
          fromOldBalanceETH
            .sub(fromNewBalanceETH)
            .sub(confirmedTx.cumulativeGasUsed.mul(gasPrice))
            .toString(),
        ).toEqual(ETHTotal.toString());
        expect(toNewBalanceETH.sub(toOldBalanceETH)).toEqual(ETHAmount);
        expect(feeNewBalanceETH.sub(feeOldBalanceETH).toString()).toEqual(ETHFeesTotal.toString());
      }, 20000);
    });
  });

  describe('No conversion:', () => {
    beforeEach(() => {
      FAURequest = deepCopy(FAUValidRequest);
      enrichedRequests = [
        {
          paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
          request: DAIValidRequest,
          paymentSettings: { maxToSpend: '0' },
        },
        {
          paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
          request: FAURequest,
          paymentSettings: { maxToSpend: '0' },
        },
      ];
    });

    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, wallet, options),
        ).rejects.toThrowError('wrong request currencyInfo type');
      });

      it("should throw an error if one request's currencyInfo has no value", async () => {
        FAURequest.currencyInfo.value = '';
        await expect(
          payBatchConversionProxyRequest(enrichedRequests, wallet, options),
        ).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 = '' as CurrencyTypes.ChainName;
        await expect(
          payBatchConversionProxyRequest(enrichedRequests, wallet, options),
        ).rejects.toThrowError(
          'request cannot be processed, or is not an pn-erc20-fee-proxy-contract request',
        );
      });

      it('should throw an error if request has no extension', async () => {
        FAURequest.extensions = [] as any;
        await expect(
          payBatchConversionProxyRequest(enrichedRequests, wallet, options),
        ).rejects.toThrowError('no payment network found');
      });

      it('should throw an error if there is a wrong version mapping', async () => {
        FAURequest.extensions = {
          [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: {
            ...DAIValidRequest.extensions[
              ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT
            ],
            version: '0.3.0',
          },
        };
        await expect(
          payBatchConversionProxyRequest(enrichedRequests, wallet, options),
        ).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, wallet, options, {
          gasPrice: '20000000000',
        });
        expect(spy).toHaveBeenCalledWith({
          data: '0x92cddb9100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000c5fdf4076b8f3a5357c5e395ab970b5b54098fef00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000f17f52151ebef6c7334fad080c5704d77216b73200000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000038cf23c52bb4b13f051aec09580a2de845a7fa35000000000000000000000000000000000000000000000000000000000000000886dfbccad783599a000000000000000000000000000000000000000000000000000000000000000000000000f17f52151ebef6c7334fad080c5704d77216b73200000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000009fbda871d559710256a2502a2517b794b482db40000000000000000000000000000000000000000000000000000000000000000886dfbccad783599a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
          gasPrice: '20000000000',
          to: getBatchConversionProxyAddress(DAIValidRequest, '0.1.0'),
          value: BigNumber.from(0),
        });
        wallet.sendTransaction = originalSendTransaction;
      });
      it(`should pay 2 different ERC20 requests with fees`, async () => {
        // Approve the contract for DAI and FAU tokens
        const FAUApprovalTx = await approveErc20BatchConversionIfNeeded(
          FAUValidRequest,
          wallet.address,
          wallet,
        );
        if (FAUApprovalTx) await FAUApprovalTx.wait(1);

        const DAIApprovalTx = await approveErc20BatchConversionIfNeeded(
          DAIValidRequest,
          wallet.address,
          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, wallet, options);
        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: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
                request: {
                  ...DAIValidRequest,
                  extensions: {
                    [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: {
                      ...DAIValidRequest.extensions[
                        ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT
                      ],
                      version: '0.1.0',
                    },
                  },
                } as any,
              } as EnrichedRequest,
              {
                paymentNetworkId: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
                request: {
                  ...FAUValidRequest,
                  extensions: {
                    [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: {
                      ...FAUValidRequest.extensions[
                        ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT
                      ],
                      version: '0.1.0',
                    },
                  },
                } as any,
              } as unknown as EnrichedRequest,
            ],
            options,
          ).to,
        ).toBe(batchConversionPaymentsArtifact.getAddress('private', '0.1.0'));
      });
    });
  });
});

Swap-to-Pay Payment

A "swap-to-pay" payment is where the payment sender sends one currency but the payment recipient receives a different currency.

Functions:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/src/payment/swap-erc20-fee-proxy.ts
import { constants, ContractTransaction, Signer, BigNumber, BigNumberish, providers } from 'ethers';

import { erc20FeeProxyArtifact, erc20SwapToPayArtifact } from '@requestnetwork/smart-contracts';
import { ERC20SwapToPay__factory } from '@requestnetwork/smart-contracts/types';
import { ClientTypes } from '@requestnetwork/types';

import { ITransactionOverrides } from './transaction-overrides';
import {
  getAmountToPay,
  getProvider,
  getProxyAddress,
  getRequestPaymentValues,
  getSigner,
  validateErc20FeeProxyRequest,
} from './utils';
import { IPreparedTransaction } from './prepared-transaction';
import { Erc20PaymentNetwork } from '@requestnetwork/payment-detection';
import { EvmChains } from '@requestnetwork/currency';

/**
 * Details required for a token swap:
 *
 *  - maxInputAmount: maximum number of ERC20 allowed for the swap before payment, considering both amount and fees
 *  - path: array of token addresses to be used for the "swap path".
 *    ['0xPaymentCurrency', '0xIntermediate1', ..., '0xRequestCurrency']
 *  - deadline: time in milliseconds since UNIX epoch, after which the swap should not be executed.
 */
export interface ISwapSettings {
  deadline: number;
  maxInputAmount: BigNumberish;
  path: string[];
}

/**
 * Details required for a request payment transaction
 * @member overrides custom swap transaction parameters
 */
export interface ISwapTransactionOptions extends IRequestPaymentOptions {
  overrides?: ITransactionOverrides;
}

/**
 * Details required for a proxy payment:
 * @member {BigNumberish} amount custom request amount to pay
 * @member {BigNumberish} feeAmount custom fee amount to pay for the proxy
 */
export interface IRequestPaymentOptions {
  amount?: BigNumberish;
  feeAmount?: BigNumberish;
}

/**
 * Processes a transaction to swap tokens and pay an ERC20 Request through a proxy with fees.
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 * @param swapSettings settings for the swap: swap path, max amount to swap, deadline
 * @param options to override amount, feeAmount and transaction parameters
 */
export async function swapErc20FeeProxyRequest(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  swapSettings: ISwapSettings,
  options?: ISwapTransactionOptions,
): Promise<ContractTransaction> {
  const preparedTx = prepareSwapToPayErc20FeeRequest(
    request,
    signerOrProvider,
    swapSettings,
    options,
  );
  const signer = getSigner(signerOrProvider);
  const tx = await signer.sendTransaction(preparedTx);
  return tx;
}

/**
 * Prepare a transaction to swap tokens and pay an ERC20 Request through a proxy with fees.
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 * @param swapSettings settings for the swap: swap path, max amount to swap, deadline
 * @param options to override amount, feeAmount and transaction parameters
 */
export function prepareSwapToPayErc20FeeRequest(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  swapSettings: ISwapSettings,
  options?: ISwapTransactionOptions,
): IPreparedTransaction {
  const { network } = request.currencyInfo;
  EvmChains.assertChainSupported(network!);
  const encodedTx = encodeSwapToPayErc20FeeRequest(
    request,
    signerOrProvider,
    swapSettings,
    options,
  );
  const proxyAddress = erc20SwapToPayArtifact.getAddress(network);
  return {
    data: encodedTx,
    to: proxyAddress,
    value: 0,
    ...options?.overrides,
  };
}

/**
 * Encodes the call to pay a request through the ERC20 fee proxy contract, can be used with a Multisig contract.
 * @param request request to pay
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum
 * @param swapSettings settings for the swap
 * @param options to override amount, feeAmount and transaction parameters
 */
export function encodeSwapToPayErc20FeeRequest(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  swapSettings: ISwapSettings,
  options?: IRequestPaymentOptions,
): string {
  const { paymentReference, paymentAddress, feeAddress, feeAmount, network } =
    getRequestPaymentValues(request);
  EvmChains.assertChainSupported(network!);

  validateErc20FeeProxyRequest(request, options?.amount, options?.feeAmount);

  const signer = getSigner(signerOrProvider);
  const tokenAddress = request.currencyInfo.value;
  const amountToPay = getAmountToPay(request, options?.amount);
  const feeToPay = BigNumber.from(options?.feeAmount || feeAmount || 0);

  if (
    swapSettings.path[swapSettings.path.length - 1].toLowerCase() !== tokenAddress.toLowerCase()
  ) {
    throw new Error('Last item of the path should be the request currency');
  }
  // eslint-disable-next-line no-magic-numbers
  if (Date.now() > swapSettings.deadline * 1000) {
    throw new Error('A swap with a past deadline will fail, the transaction will not be pushed');
  }
  if (!request.currencyInfo.network) {
    throw new Error('Request currency network is missing');
  }

  const feeProxyAddress = getProxyAddress(
    request,
    Erc20PaymentNetwork.ERC20FeeProxyPaymentDetector.getDeploymentInformation,
  );

  const swapToPayAddress = erc20FeeProxyArtifact.getAddress(network);
  const swapToPayContract = ERC20SwapToPay__factory.connect(swapToPayAddress, signer);

  return swapToPayContract.interface.encodeFunctionData('swapTransferWithReference', [
    feeProxyAddress,
    paymentAddress,
    amountToPay,
    swapSettings.maxInputAmount,
    swapSettings.path,
    `0x${paymentReference}`,
    feeToPay,
    feeAddress || constants.AddressZero,
    // eslint-disable-next-line no-magic-numbers
    Math.round(swapSettings.deadline / 1000),
  ]);
}

Tests:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/test/payment/swap-erc20-fee-proxy.test.ts
import { BigNumber, providers, Wallet } from 'ethers';

import {
  ClientTypes,
  CurrencyTypes,
  ExtensionTypes,
  IdentityTypes,
  RequestLogicTypes,
} from '@requestnetwork/types';
import { deepCopy } from '@requestnetwork/utils';

import {
  approveErc20ForSwapToPayIfNeeded,
  getErc20Balance,
  ISwapSettings,
  swapErc20FeeProxyRequest,
} from '../../src';
import { ERC20__factory } from '@requestnetwork/smart-contracts/types';
import { erc20SwapToPayArtifact } from '@requestnetwork/smart-contracts';
import { revokeErc20Approval } from '../../src/payment/utils';

/* eslint-disable no-magic-numbers */
/* eslint-disable @typescript-eslint/no-unused-expressions */

const erc20ContractAddress = '0x9FBDa871d559710256a2502A2517b794B482Db40';
const alphaErc20Address = '0x38cF23C52Bb4B13F051Aec09580a2dE845a7FA35';

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);

const validRequest: 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,
    value: erc20ContractAddress,
  },

  events: [],
  expectedAmount: '100',
  extensions: {
    [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: {
      events: [],
      id: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT,
      type: ExtensionTypes.TYPE.PAYMENT_NETWORK,
      values: {
        feeAddress,
        feeAmount: '2',
        paymentAddress,
        salt: 'salt',
      },
      version: '0.1.0',
    },
  },
  extensionsData: [],
  meta: {
    transactionManagerMeta: {},
  },
  pending: null,
  requestId: 'abcd',
  state: RequestLogicTypes.STATE.CREATED,
  timestamp: 0,
  version: '1.0',
};

const validSwapSettings: ISwapSettings = {
  deadline: 2599732187000, // This test will fail in 2052
  maxInputAmount: 204,
  path: [alphaErc20Address, erc20ContractAddress],
};

describe('swap-erc20-fee-proxy', () => {
  beforeAll(async () => {
    // revoke erc20SwapToPay approval
    await revokeErc20Approval(
      erc20SwapToPayArtifact.getAddress(
        validRequest.currencyInfo.network! as CurrencyTypes.EvmChainName,
      ),
      alphaErc20Address,
      wallet.provider,
    );
  });
  describe('encodeSwapErc20FeeRequest', () => {
    beforeAll(async () => {
      // revoke erc20SwapToPay approval
      await revokeErc20Approval(
        erc20SwapToPayArtifact.getAddress(
          validRequest.currencyInfo.network! as CurrencyTypes.EvmChainName,
        ),
        alphaErc20Address,
        wallet.provider,
      );
    });
    it('should throw an error if the request is not erc20', async () => {
      const request = deepCopy(validRequest) as ClientTypes.IRequestData;
      request.currencyInfo.type = RequestLogicTypes.CURRENCY.ETH;

      await expect(
        swapErc20FeeProxyRequest(request, wallet, validSwapSettings),
      ).rejects.toThrowError(
        'request cannot be processed, or is not an pn-erc20-fee-proxy-contract request',
      );
    });

    it('should throw an error if the currencyInfo has no value', async () => {
      const request = deepCopy(validRequest);
      request.currencyInfo.value = '';
      await expect(
        swapErc20FeeProxyRequest(request, wallet, validSwapSettings),
      ).rejects.toThrowError(
        'request cannot be processed, or is not an pn-erc20-fee-proxy-contract request',
      );
    });

    it('should throw an error if currencyInfo has no network', async () => {
      const request = deepCopy(validRequest);
      request.currencyInfo.network = '' as CurrencyTypes.EvmChainName;
      await expect(
        swapErc20FeeProxyRequest(request, wallet, validSwapSettings),
      ).rejects.toThrowError('Unsupported chain ');
    });

    it('should throw an error if request has no extension', async () => {
      const request = deepCopy(validRequest);
      request.extensions = [] as any;

      await expect(
        swapErc20FeeProxyRequest(request, wallet, validSwapSettings),
      ).rejects.toThrowError('no payment network found');
    });
  });

  describe('swapErc20FeeProxyRequest', () => {
    it('should consider override parameters', async () => {
      const spy = jest.fn();
      const originalSendTransaction = wallet.sendTransaction.bind(wallet);
      wallet.sendTransaction = spy;
      await swapErc20FeeProxyRequest(
        validRequest,
        wallet,
        {
          deadline: 2599732187000, // This test will fail in 2052
          maxInputAmount: 206,
          path: [alphaErc20Address, erc20ContractAddress],
        },
        {
          overrides: { gasPrice: '20000000000' },
        },
      );
      expect(spy).toHaveBeenCalledWith({
        data: '0x5f2993bf00000000000000000000000075c35c980c0d37ef46df04d31a140b65503c0eed000000000000000000000000f17f52151ebef6c7334fad080c5704d77216b732000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000ce000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c5fdf4076b8f3a5357c5e395ab970b5b54098fef000000000000000000000000000000000000000000000000000000009af4c3db000000000000000000000000000000000000000000000000000000000000000200000000000000000000000038cf23c52bb4b13f051aec09580a2de845a7fa350000000000000000000000009fbda871d559710256a2502a2517b794b482db40000000000000000000000000000000000000000000000000000000000000000886dfbccad783599a000000000000000000000000000000000000000000000000',
        gasPrice: '20000000000',
        to: '0xA4392264a2d8c998901D10C154C91725b1BF0158',
        value: 0,
      });
      wallet.sendTransaction = originalSendTransaction;
    });

    it('should swap and pay with an ERC20 request with fees', async () => {
      // first approve the SwapToPay contract to spend ALPHA tokens
      const approvalTx = await approveErc20ForSwapToPayIfNeeded(
        validRequest,
        wallet.address,
        alphaErc20Address,
        wallet.provider,
        BigNumber.from(204).mul(BigNumber.from(10).pow(18)),
      );
      expect(approvalTx).toBeDefined();
      if (approvalTx) {
        await approvalTx.wait(1);
      }

      // get the balances to compare after payment
      const balanceEthBefore = await wallet.getBalance();
      const balanceAlphaBefore = await ERC20__factory.connect(
        alphaErc20Address,
        provider,
      ).balanceOf(wallet.address);
      const issuerBalanceErc20Before = await getErc20Balance(
        validRequest,
        paymentAddress,
        provider,
      );
      const feeBalanceErc20Before = await getErc20Balance(validRequest, feeAddress, provider);

      // Swap and pay
      const tx = await swapErc20FeeProxyRequest(validRequest, wallet, {
        deadline: Date.now() + 1000000,
        maxInputAmount: 206,
        path: [alphaErc20Address, erc20ContractAddress],
      });
      const confirmedTx = await tx.wait(1);

      expect(confirmedTx.status).toEqual(1);
      expect(tx.hash).toBeDefined();

      // Get the new balances
      const balanceEthAfter = await wallet.getBalance();
      const balanceAlphaAfter = await ERC20__factory.connect(alphaErc20Address, provider).balanceOf(
        wallet.address,
      );
      const issuerBalanceErc20After = await getErc20Balance(validRequest, paymentAddress, provider);
      const feeBalanceErc20After = await getErc20Balance(validRequest, feeAddress, provider);

      // Check each balance
      expect(BigNumber.from(balanceEthBefore).sub(balanceEthAfter).toNumber()).toBeGreaterThan(0);
      expect(BigNumber.from(balanceAlphaAfter).toString()).toEqual(
        BigNumber.from(balanceAlphaBefore).sub(204).toString(),
      );
      expect(BigNumber.from(issuerBalanceErc20After).toString()).toEqual(
        BigNumber.from(issuerBalanceErc20Before).add(100).toString(),
      );
      expect(BigNumber.from(feeBalanceErc20After).toString()).toEqual(
        BigNumber.from(feeBalanceErc20Before).add(2).toString(),
      );
    });
  });
});

Swap-to-Conversion Payment

A "swap-to-conversion" payment is where the request is denominated in currency A, the payer sends currency B and the payee receives currency C.

Payment Processor Functions:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/src/payment/swap-conversion-erc20.ts
import { ContractTransaction, providers, Signer, BigNumberish, BigNumber } from 'ethers';

import { erc20SwapConversionArtifact } from '@requestnetwork/smart-contracts';
import { ClientTypes, ExtensionTypes } from '@requestnetwork/types';

import { ITransactionOverrides } from './transaction-overrides';
import { getProvider, getSigner } from './utils';
import { checkErc20Allowance, encodeApproveAnyErc20 } from './erc20';
import { IPreparedTransaction } from './prepared-transaction';

/**
 * Processes the approval transaction of a given payment ERC20 to be spent by the swap router,
 * if the current approval is missing or not sufficient.
 * @param request request to pay, used to know the network
 * @param ownerAddress address of the payer
 * @param paymentTokenAddress ERC20 currency used for the swap
 * @param signerOrProvider the web3 provider. Defaults to Etherscan.
 * @param minAmount ensures the approved amount is sufficient to pay this amount
 * @param overrides optionally, override default transaction values, like gas.
 */
export async function approveErc20ForSwapWithConversionIfNeeded(
  request: ClientTypes.IRequestData,
  ownerAddress: string,
  paymentTokenAddress: string,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  minAmount: BigNumberish,
  overrides?: ITransactionOverrides,
): Promise<ContractTransaction | void> {
  if (
    !(await hasErc20ApprovalForSwapWithConversion(
      request,
      ownerAddress,
      paymentTokenAddress,
      signerOrProvider,
      minAmount,
    ))
  ) {
    return approveErc20ForSwapWithConversionToPay(
      request,
      paymentTokenAddress,
      signerOrProvider,
      overrides,
    );
  }
}

/**
 * Verify if a given payment ERC20 to be spent by the swap router
 * @param request request to pay, used to know the network
 * @param ownerAddress address of the payer
 * @param paymentTokenAddress ERC20 currency used for the swap
 * @param signerOrProvider the web3 provider. Defaults to Etherscan.
 * @param minAmount ensures the approved amount is sufficient to pay this amount
 * @param overrides optionally, override default transaction values, like gas.
 */
export async function hasErc20ApprovalForSwapWithConversion(
  request: ClientTypes.IRequestData,
  ownerAddress: string,
  paymentTokenAddress: string,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  minAmount: BigNumberish,
): Promise<boolean> {
  if (!request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]) {
    throw new Error(`The request must have the payment network any-to-erc20-proxy`);
  }
  const network =
    request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY].values.network;
  return await checkErc20Allowance(
    ownerAddress,
    erc20SwapConversionArtifact.getAddress(network),
    signerOrProvider,
    paymentTokenAddress,
    minAmount,
  );
}

/**
 * Processes the approval transaction of the payment ERC20 to be spent by the swap router.
 * @param request request to pay, used to know the network
 * @param paymentTokenAddress picked currency for the swap to pay
 * @param signerOrProvider the web3 provider. Defaults to Etherscan.
 * @param overrides optionally, override default transaction values, like gas.
 */
export async function approveErc20ForSwapWithConversionToPay(
  request: ClientTypes.IRequestData,
  paymentTokenAddress: string,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  overrides?: ITransactionOverrides,
): Promise<ContractTransaction> {
  const network =
    request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY].values.network;
  if (!network) {
    throw new Error(`Payment network currency must have a network`);
  }

  const preparedTx = prepareApprovalErc20ForSwapWithConversionToPay(
    request,
    paymentTokenAddress,
    signerOrProvider,
    overrides,
  );
  const signer = getSigner(signerOrProvider);
  const tx = await signer.sendTransaction(preparedTx);
  return tx;
}

/**
 * Prepare the approval transaction of the payment ERC20 to be spent by the swap router.
 * @param request request to pay, used to know the network
 * @param paymentTokenAddress picked currency for the swap to pay
 * @param signerOrProvider the web3 provider. Defaults to Etherscan.
 * @param overrides optionally, override default transaction values, like gas.
 */
export function prepareApprovalErc20ForSwapWithConversionToPay(
  request: ClientTypes.IRequestData,
  paymentTokenAddress: string,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  overrides?: ITransactionOverrides,
  amount?: BigNumber,
): IPreparedTransaction {
  const network =
    request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY].values.network;
  if (!network) {
    throw new Error(`Payment network currency must have a network`);
  }

  const encodedTx = encodeApproveAnyErc20(
    paymentTokenAddress,
    erc20SwapConversionArtifact.getAddress(network),
    signerOrProvider,
    amount,
  );
  return {
    data: encodedTx,
    to: paymentTokenAddress,
    value: 0,
    ...overrides,
  };
}

Payment Processor Test:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/test/payment/swap-any-to-erc20.test.ts
import { Wallet, providers, BigNumber } from 'ethers';

import {
  ClientTypes,
  ExtensionTypes,
  IdentityTypes,
  RequestLogicTypes,
} from '@requestnetwork/types';
import { deepCopy } from '@requestnetwork/utils';

import { approveErc20ForSwapWithConversionIfNeeded } from '../../src/payment/swap-conversion-erc20';
import { ERC20, ERC20__factory } from '@requestnetwork/smart-contracts/types';
import { swapToPayAnyToErc20Request } from '../../src/payment/swap-any-to-erc20';
import { IConversionSettings } from '../../src/types';

import { currencyManager } from './shared';
import { UnsupportedCurrencyError } from '@requestnetwork/currency';

/* eslint-disable no-magic-numbers */
/* eslint-disable @typescript-eslint/no-unused-expressions */

const paymentTokenAddress = '0x9FBDa871d559710256a2502A2517b794B482Db40';
const acceptedTokenAddress = '0x38cF23C52Bb4B13F051Aec09580a2dE845a7FA35';
let paymentToken: ERC20;
let acceptedToken: ERC20;

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);

const validRequest: ClientTypes.IRequestData = {
  balance: {
    balance: '0',
    events: [],
  },
  contentData: {},
  creator: {
    type: IdentityTypes.TYPE.ETHEREUM_ADDRESS,
    value: wallet.address,
  },
  currency: 'USD',
  currencyInfo: {
    type: RequestLogicTypes.CURRENCY.ISO4217,
    value: 'USD',
  },

  events: [],
  expectedAmount: '100',
  extensions: {
    [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: {
      events: [],
      id: ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY,
      type: ExtensionTypes.TYPE.PAYMENT_NETWORK,
      values: {
        feeAddress,
        feeAmount: '2',
        paymentAddress,
        salt: 'salt',
        acceptedTokens: [acceptedTokenAddress],
        network: 'private',
      },
      version: '0.1.0',
    },
  },
  extensionsData: [],
  meta: {
    transactionManagerMeta: {},
  },
  pending: null,
  requestId: 'abcd',
  state: RequestLogicTypes.STATE.CREATED,
  timestamp: 0,
  version: '1.0',
};

const validSwapSettings = {
  deadline: 2599732187000, // This test will fail in 2052
  maxInputAmount: '3000000000000000000',
  path: [paymentTokenAddress, acceptedTokenAddress],
};
const validConversionSettings: IConversionSettings = {
  currency: {
    type: 'ERC20' as any,
    value: acceptedTokenAddress,
    network: 'private',
  },
  currencyManager,
};

beforeAll(async () => {
  paymentToken = await ERC20__factory.connect(paymentTokenAddress, provider);
  acceptedToken = await ERC20__factory.connect(acceptedTokenAddress, provider);
});

describe('swap-any-to-erc20', () => {
  describe('swapErc20FeeProxyRequest', () => {
    it('should throw an error if the settings are missing', async () => {
      await expect(
        swapToPayAnyToErc20Request(validRequest, wallet, {
          conversion: validConversionSettings,
        }),
      ).rejects.toThrowError('Swap Settings are required');

      await expect(
        swapToPayAnyToErc20Request(validRequest, wallet, {
          swap: validSwapSettings,
        }),
      ).rejects.toThrowError('Conversion Settings are required');
    });

    it('should throw an error if the payment network is wrong', async () => {
      const request = deepCopy(validRequest);
      delete request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY];

      await expect(
        swapToPayAnyToErc20Request(request, wallet, {
          conversion: validConversionSettings,
          swap: validSwapSettings,
        }),
      ).rejects.toThrowError('The request must have the payment network any-to-erc20-proxy');
    });

    it('should throw an error if the conversion path is impossible', async () => {
      const request = deepCopy(validRequest);
      (request.currencyInfo = {
        type: RequestLogicTypes.CURRENCY.ISO4217,
        value: 'XXX',
      }),
        await expect(
          swapToPayAnyToErc20Request(request, wallet, {
            conversion: validConversionSettings,
            swap: validSwapSettings,
          }),
        ).rejects.toThrowError(
          /Impossible to find a conversion path between from XXX \(0x.*\) to ERC20_1 \(0x.*\)/,
        );
    });

    it('should throw an error if the conversion currency is not an acceptedTokens', async () => {
      const wrongCurrency: RequestLogicTypes.ICurrency = {
        type: 'ERC20' as any,
        value: '0x17b4158805772ced11225e77339f90beb5aae968',
        network: 'private',
      };
      await expect(
        swapToPayAnyToErc20Request(validRequest, wallet, {
          conversion: {
            currency: wrongCurrency,
            currencyManager,
          },
          swap: {
            deadline: 2599732187000, // This test will fail in 2052
            maxInputAmount: '3000000000000000000',
            path: [paymentTokenAddress, wrongCurrency.value],
          },
        }),
      ).rejects.toThrowError(new UnsupportedCurrencyError(wrongCurrency));
    });

    it('should swap and pay with an ERC20 request with fees', async () => {
      // first approve the SwapToPay contract to spend tokens
      const approvalTx = await approveErc20ForSwapWithConversionIfNeeded(
        validRequest,
        wallet.address,
        paymentTokenAddress,
        wallet.provider,
        BigNumber.from(204).mul(BigNumber.from(10).pow(18)),
      );
      if (approvalTx) {
        await approvalTx.wait(1);
      }

      // get the balances to compare after payment
      const initialPayerBalance = await paymentToken.balanceOf(wallet.address);
      const initialPayeeBalance = await acceptedToken.balanceOf(paymentAddress);
      const initialBuilderBalance = await acceptedToken.balanceOf(feeAddress);

      // Swap and pay
      const tx = await swapToPayAnyToErc20Request(validRequest, wallet, {
        swap: validSwapSettings,
        conversion: validConversionSettings,
      });

      const confirmedTx = await tx.wait(1);

      expect(confirmedTx.status).toEqual(1);
      expect(tx.hash).toBeDefined();

      // Get the new balances
      const finalPayerBalance = await paymentToken.balanceOf(wallet.address);
      const finalPayeeBalance = await acceptedToken.balanceOf(paymentAddress);
      const finalBuilderBalance = await acceptedToken.balanceOf(feeAddress);

      // Check each balance

      //   expectedAmount:      100000000
      //   feeAmount:        +    2000000
      //                     =  102000000 (8 decimals)
      //   AggDaiUsd.sol     /  101000000
      //                     =  1009900990099009900
      //   Swap fees         *                1.005
      //                     =  1014950495049504949 (18 decimals)
      //   Swapper           *  2
      //                     =  2029900990099009898 (18 decimals) paid by payer in erc20BeforeSwap
      expect(finalPayerBalance.toString()).toEqual(
        initialPayerBalance.sub('2029900990099009898').toString(),
      );

      //   expectedAmount:      100000000 (8 decimals)
      //   AggDaiUsd.sol     /  101000000
      //                     =  990099009900990099 (18 decimals) received by payee in erc20AfterConversion
      expect(finalPayeeBalance.toString()).toEqual(
        initialPayeeBalance.add('990099009900990099').toString(),
      );

      //   feeAmount:           2000000 (8 decimals)
      //   AggDaiUsd.sol     /  101000000
      //                     =  19801980198019801 (18 decimals) received by fee address in erc20AfterConversion
      //      +
      //
      //   Swap fees            100000000
      //   feeAmount         +    2000000
      //                     =  102000000 (8 decimals)
      //   AggDaiUsd.sol     /  101000000
      //                     =  1009900990099009900
      //   Swap fees         *                0.005
      //                     =     5049504950495049
      //   Total fees        =    24851485148514850
      expect(finalBuilderBalance.toString()).toEqual(
        initialBuilderBalance.add('24851485148514850').toString(),
      );
    });
  });
});

Transferable Receivable Payment

Functions:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/src/payment/erc20-transferable-receivable.ts
import {
  ContractTransaction,
  Signer,
  BigNumberish,
  providers,
  BigNumber,
  constants,
  ethers,
} from 'ethers';

import {
  Erc20PaymentNetwork,
  ERC20TransferableReceivablePaymentDetector,
} from '@requestnetwork/payment-detection';
import { ERC20TransferableReceivable__factory } from '@requestnetwork/smart-contracts/types';
import { ClientTypes } from '@requestnetwork/types';

import { ITransactionOverrides } from './transaction-overrides';
import {
  getAmountToPay,
  getProxyAddress,
  getProvider,
  getSigner,
  getRequestPaymentValues,
  validateERC20TransferableReceivable,
  validatePayERC20TransferableReceivable,
} from './utils';
import { IPreparedTransaction } from './prepared-transaction';

// The ERC20 receivable smart contract ABI fragment
const erc20TransferableReceivableContractAbiFragment = [
  'function receivableTokenIdMapping(bytes32) public view returns (uint256)',
];

/**
 * Gets the receivableTokenId from a ERC20TransferableReceivable contract given
 * a paymentReference and paymentAddress of the request
 * @param request
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 */
export async function getReceivableTokenIdForRequest(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer,
): Promise<BigNumber> {
  // Setup the ERC20 proxy contract interface
  const contract = new ethers.Contract(
    getProxyAddress(request, ERC20TransferableReceivablePaymentDetector.getDeploymentInformation),
    erc20TransferableReceivableContractAbiFragment,
    signerOrProvider,
  );

  const { paymentReference, paymentAddress } = getRequestPaymentValues(request);

  return await contract.receivableTokenIdMapping(
    ethers.utils.solidityKeccak256(['address', 'bytes'], [paymentAddress, `0x${paymentReference}`]),
  );
}

/**
 * Helper method to determine whether a request has a receivable minted yet
 *
 * @param request
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 */
export async function hasReceivableForRequest(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer,
): Promise<boolean> {
  const receivableTokenId = await getReceivableTokenIdForRequest(request, signerOrProvider);
  return !receivableTokenId.isZero();
}

/**
 * Processes a transaction to mint an ERC20TransferableReceivable.
 * @param request
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 * @param overrides optionally, override default transaction values, like gas.
 */
export async function mintErc20TransferableReceivable(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  overrides?: ITransactionOverrides,
): Promise<ContractTransaction> {
  const { data, to, value } = prepareMintErc20TransferableReceivableTransaction(request);
  const signer = getSigner(signerOrProvider);
  return signer.sendTransaction({ data, to, value, ...overrides });
}

/**
 * Encodes the call to mint a request through an ERC20TransferableReceivable contract, can be used with a Multisig contract.
 * @param request request to pay
 */
export function prepareMintErc20TransferableReceivableTransaction(
  request: ClientTypes.IRequestData,
): IPreparedTransaction {
  validateERC20TransferableReceivable(request);

  return {
    data: encodeMintErc20TransferableReceivableRequest(request),
    to: getProxyAddress(
      request,
      Erc20PaymentNetwork.ERC20TransferableReceivablePaymentDetector.getDeploymentInformation,
    ),
    value: 0,
  };
}

/**
 * Encodes call to mint a request through an ERC20TransferableReceivable contract, can be used with a Multisig contract.
 * @param request request to pay
 */
export function encodeMintErc20TransferableReceivableRequest(
  request: ClientTypes.IRequestData,
): string {
  validateERC20TransferableReceivable(request);

  const tokenAddress = request.currencyInfo.value;

  const { paymentReference, paymentAddress } = getRequestPaymentValues(request);
  const amount = getAmountToPay(request);

  const receivableContract = ERC20TransferableReceivable__factory.createInterface();
  return receivableContract.encodeFunctionData('mint', [
    paymentAddress,
    `0x${paymentReference}`,
    amount,
    tokenAddress,
  ]);
}

/**
 * Processes a transaction to pay an ERC20 receivable Request.
 * @param request
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 * @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 of the request.
 * @param overrides optionally, override default transaction values, like gas.
 */
export async function payErc20TransferableReceivableRequest(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer = getProvider(),
  amount?: BigNumberish,
  feeAmount?: BigNumberish,
  overrides?: ITransactionOverrides,
): Promise<ContractTransaction> {
  await validatePayERC20TransferableReceivable(request, signerOrProvider, amount, feeAmount);

  const { data, to, value } = await prepareErc20TransferableReceivablePaymentTransaction(
    request,
    signerOrProvider,
    amount,
    feeAmount,
  );
  const signer = getSigner(signerOrProvider);
  return signer.sendTransaction({ data, to, value, ...overrides });
}

/**
 * Encodes the call to pay a request through the ERC20 receivable contract, can be used with a Multisig contract.
 * @param request request to pay
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 * @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 async function prepareErc20TransferableReceivablePaymentTransaction(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer,
  amount?: BigNumberish,
  feeAmountOverride?: BigNumberish,
): Promise<IPreparedTransaction> {
  return {
    data: await encodePayErc20TransferableReceivableRequest(
      request,
      signerOrProvider,
      amount,
      feeAmountOverride,
    ),
    to: getProxyAddress(
      request,
      Erc20PaymentNetwork.ERC20TransferableReceivablePaymentDetector.getDeploymentInformation,
    ),
    value: 0,
  };
}

/**
 * Encodes the call to pay a request through the ERC20 receivable contract, can be used with a Multisig contract.
 * @param request request to pay
 * @param signerOrProvider the Web3 provider, or signer. Defaults to window.ethereum.
 * @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 async function encodePayErc20TransferableReceivableRequest(
  request: ClientTypes.IRequestData,
  signerOrProvider: providers.Provider | Signer,
  amount?: BigNumberish,
  feeAmountOverride?: BigNumberish,
): Promise<string> {
  const amountToPay = getAmountToPay(request, amount);
  const { paymentReference, feeAddress, feeAmount } = getRequestPaymentValues(request);
  const feeToPay = BigNumber.from(feeAmountOverride || feeAmount || 0);

  const receivableContract = ERC20TransferableReceivable__factory.createInterface();

  // get tokenId from request
  const receivableTokenId = await getReceivableTokenIdForRequest(request, signerOrProvider);

  return receivableContract.encodeFunctionData('payOwner', [
    receivableTokenId,
    amountToPay,
    `0x${paymentReference}`,
    feeToPay,
    feeAddress || constants.AddressZero,
  ]);
}

Tests:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/test/payment/erc20-transferable-receivable.test.ts
import { Wallet, BigNumber, providers, utils } from 'ethers';

import {
  ClientTypes,
  ExtensionTypes,
  IdentityTypes,
  RequestLogicTypes,
} from '@requestnetwork/types';
import { deepCopy } from '@requestnetwork/utils';

import { Erc20PaymentNetwork, PaymentReferenceCalculator } from '@requestnetwork/payment-detection';
import { ERC20TransferableReceivable__factory } from '@requestnetwork/smart-contracts/types';

import { approveErc20, getErc20Balance } from '../../src/payment/erc20';
import {
  getReceivableTokenIdForRequest,
  mintErc20TransferableReceivable,
  payErc20TransferableReceivableRequest,
} from '../../src/payment/erc20-transferable-receivable';
import { getProxyAddress } from '../../src/payment/utils';

/* eslint-disable no-magic-numbers */
/* eslint-disable @typescript-eslint/no-unused-expressions */

const erc20ContractAddress = '0x9FBDa871d559710256a2502A2517b794B482Db40';

const mnemonic = 'candy maple cake sugar pudding cream honey rich smooth crumble sweet treat';
const feeAddress = '0x75c35C980C0d37ef46DF04d31A140b65503c0eEd';
const provider = new providers.JsonRpcProvider('http://localhost:8545');
const payeeWallet = Wallet.createRandom().connect(provider);
const thirdPartyWallet = Wallet.createRandom().connect(provider);
const wallet = Wallet.fromMnemonic(mnemonic, "m/44'/60'/0'/0/1").connect(provider);
const paymentAddress = payeeWallet.address;

const validRequest: 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,
    value: erc20ContractAddress,
  },
  events: [],
  expectedAmount: '100',
  extensions: {
    [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_TRANSFERABLE_RECEIVABLE]: {
      events: [],
      id: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_TRANSFERABLE_RECEIVABLE,
      type: ExtensionTypes.TYPE.PAYMENT_NETWORK,
      values: {
        feeAddress,
        feeAmount: '0',
        paymentAddress,
        salt: '0ee84db293a752c6',
      },
      version: '0.2.0',
    },
  },
  payee: {
    type: IdentityTypes.TYPE.ETHEREUM_ADDRESS,
    value: paymentAddress,
  },
  extensionsData: [],
  meta: {
    transactionManagerMeta: {},
  },
  pending: null,
  requestId: '0188791633ff0ec72a7dbdefb886d2db6cccfa98287320839c2f173c7a4e3ce7e1',
  state: RequestLogicTypes.STATE.CREATED,
  timestamp: 0,
  version: '1.0',
};

describe('erc20-transferable-receivable', () => {
  beforeAll(async () => {
    // Send funds to payeeWallet
    let tx = {
      to: paymentAddress,
      // Convert currency unit from ether to wei
      value: utils.parseEther('1'),
    };

    let txResponse = await wallet.sendTransaction(tx);
    await txResponse.wait(1);

    // Send funds to thirdPartyWallet
    tx = {
      to: thirdPartyWallet.address,
      // Convert currency unit from ether to wei
      value: utils.parseEther('1'),
    };

    txResponse = await wallet.sendTransaction(tx);
    await txResponse.wait(1);

    const mintTx = await mintErc20TransferableReceivable(validRequest, payeeWallet, {
      gasLimit: BigNumber.from('20000000'),
    });
    const confirmedTx = await mintTx.wait(1);

    expect(confirmedTx.status).toBe(1);
    expect(mintTx.hash).not.toBeUndefined();
  });

  beforeEach(() => {
    jest.restoreAllMocks();
  });

  describe('mintErc20TransferableReceivable works', () => {
    it('rejects paying without minting', async () => {
      // Different request without a minted receivable
      const request = deepCopy(validRequest) as ClientTypes.IRequestData;
      // Change the request id
      request.requestId = '0188791633ff0ec72a7dbdefb886d2db6cccfa98287320839c2f173c7a4e3ce7e2';

      await expect(payErc20TransferableReceivableRequest(request, wallet)).rejects.toThrowError(
        'The receivable for this request has not been minted yet. Please check with the payee.',
      );
    });
  });

  describe('payErc20TransferableReceivableRequest', () => {
    it('should throw an error if the request is not erc20', async () => {
      const request = deepCopy(validRequest) as ClientTypes.IRequestData;
      request.currencyInfo.type = RequestLogicTypes.CURRENCY.ETH;

      await expect(payErc20TransferableReceivableRequest(request, wallet)).rejects.toThrowError(
        'request cannot be processed, or is not an pn-erc20-transferable-receivable request',
      );
    });

    it('should throw an error if the currencyInfo has no value', async () => {
      const request = deepCopy(validRequest);
      request.currencyInfo.value = '';
      await expect(payErc20TransferableReceivableRequest(request, wallet)).rejects.toThrowError(
        'request cannot be processed, or is not an pn-erc20-transferable-receivable request',
      );
    });

    it('should throw an error if the payee is undefined', async () => {
      const request = deepCopy(validRequest);
      request.payee = undefined;
      await expect(payErc20TransferableReceivableRequest(request, wallet)).rejects.toThrowError(
        'Expected a payee for this request',
      );
    });

    it('should throw an error if currencyInfo has no network', async () => {
      const request = deepCopy(validRequest);
      // @ts-expect-error Type '""' is not assignable to type 'ChainName | undefined'
      request.currencyInfo.network = '';
      await expect(payErc20TransferableReceivableRequest(request, wallet)).rejects.toThrowError(
        'Payment currency must have a network',
      );
    });

    it('should throw an error if request has no extension', async () => {
      const request = deepCopy(validRequest);
      request.extensions = [] as any;

      await expect(payErc20TransferableReceivableRequest(request, wallet)).rejects.toThrowError(
        'PaymentNetwork not found',
      );
    });

    it('should consider override parameters', async () => {
      const spy = jest.fn();
      const originalSendTransaction = wallet.sendTransaction.bind(wallet);
      wallet.sendTransaction = spy;
      await payErc20TransferableReceivableRequest(validRequest, wallet, undefined, undefined, {
        gasPrice: '20000000000',
      });
      const shortReference = PaymentReferenceCalculator.calculate(
        validRequest.requestId,
        validRequest.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_TRANSFERABLE_RECEIVABLE]
          .values.salt,
        paymentAddress,
      );

      const tokenId = await getReceivableTokenIdForRequest(validRequest, wallet);
      expect(tokenId.isZero()).toBe(false);

      expect(spy).toHaveBeenCalledWith({
        data: `0x314ee2d900000000000000000000000000000000${utils
          .hexZeroPad(tokenId.toHexString(), 16)
          .substring(
            2,
          )}000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000075c35c980c0d37ef46df04d31a140b65503c0eed0000000000000000000000000000000000000000000000000000000000000008${shortReference}000000000000000000000000000000000000000000000000`,
        gasPrice: '20000000000',
        to: '0xF426505ac145abE033fE77C666840063757Be9cd',
        value: 0,
      });
      wallet.sendTransaction = originalSendTransaction;
    });

    it('should pay an ERC20 transferable receivable request with fees', async () => {
      // first approve the contract
      const approvalTx = await approveErc20(validRequest, wallet);
      const approvalTxReceipt = await approvalTx.wait(1);

      expect(approvalTxReceipt.status).toBe(1);
      expect(approvalTx.hash).not.toBeUndefined();

      // get the balance to compare after payment
      const balanceEthBefore = await wallet.getBalance();
      const balanceErc20Before = await getErc20Balance(validRequest, payeeWallet.address, provider);

      const tx = await payErc20TransferableReceivableRequest(validRequest, wallet, 1, 0, {
        gasLimit: BigNumber.from('20000000'),
      });

      const confirmedTx = await tx.wait(1);

      const balanceEthAfter = await wallet.getBalance();
      const balanceErc20After = await getErc20Balance(validRequest, payeeWallet.address, provider);

      expect(confirmedTx.status).toBe(1);
      expect(tx.hash).not.toBeUndefined();

      expect(balanceEthAfter.lte(balanceEthBefore)).toBeTruthy(); // 'ETH balance should be lower'

      // ERC20 balance should be lower
      expect(
        BigNumber.from(balanceErc20After).eq(BigNumber.from(balanceErc20Before).add(1)),
      ).toBeTruthy();
    });

    it('other wallets can mint receivable for owner', async () => {
      // Request without a receivable minted yet
      const request = deepCopy(validRequest) as ClientTypes.IRequestData;
      // Change the request id
      request.requestId = '0188791633ff0ec72a7dbdefb886d2db6cccfa98287320839c2f173c7a4e3ce7e3';

      const mintTx = await mintErc20TransferableReceivable(request, thirdPartyWallet, {
        gasLimit: BigNumber.from('20000000'),
      });
      let confirmedTx = await mintTx.wait(1);

      expect(confirmedTx.status).toBe(1);
      expect(mintTx.hash).not.toBeUndefined();

      // get the balance to compare after payment
      const balanceErc20Before = await getErc20Balance(request, payeeWallet.address, provider);

      const tx = await payErc20TransferableReceivableRequest(request, wallet, 1, 0, {
        gasLimit: BigNumber.from('20000000'),
      });

      confirmedTx = await tx.wait(1);

      const balanceErc20After = await getErc20Balance(request, payeeWallet.address, provider);

      expect(confirmedTx.status).toBe(1);
      expect(tx.hash).not.toBeUndefined();

      // ERC20 balance should be lower
      expect(
        BigNumber.from(balanceErc20After).eq(BigNumber.from(balanceErc20Before).add(1)),
      ).toBeTruthy();
    });

    it('rejects paying unless minted to correct owner', async () => {
      // Request without a receivable minted yet
      const request = deepCopy(validRequest) as ClientTypes.IRequestData;
      // Change the request id
      request.requestId = '0188791633ff0ec72a7dbdefb886d2db6cccfa98287320839c2f173c7a4e3ce7e4';

      let shortReference = PaymentReferenceCalculator.calculate(
        request.requestId,
        request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_TRANSFERABLE_RECEIVABLE].values
          .salt,
        paymentAddress,
      );
      let receivableContract = ERC20TransferableReceivable__factory.createInterface();
      let data = receivableContract.encodeFunctionData('mint', [
        thirdPartyWallet.address,
        `0x${shortReference}`,
        '100',
        erc20ContractAddress,
      ]);
      let tx = await thirdPartyWallet.sendTransaction({
        data,
        to: getProxyAddress(
          request,
          Erc20PaymentNetwork.ERC20TransferableReceivablePaymentDetector.getDeploymentInformation,
        ),
        value: 0,
      });
      let confirmedTx = await tx.wait(1);

      expect(confirmedTx.status).toBe(1);
      expect(tx.hash).not.toBeUndefined();

      await expect(payErc20TransferableReceivableRequest(request, wallet)).rejects.toThrowError(
        'The receivable for this request has not been minted yet. Please check with the payee.',
      );

      // Mint the receivable for the correct paymentAddress
      shortReference = PaymentReferenceCalculator.calculate(
        request.requestId,
        request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_TRANSFERABLE_RECEIVABLE].values
          .salt,
        paymentAddress,
      );
      receivableContract = ERC20TransferableReceivable__factory.createInterface();
      data = receivableContract.encodeFunctionData('mint', [
        paymentAddress,
        `0x${shortReference}`,
        '100',
        erc20ContractAddress,
      ]);
      tx = await thirdPartyWallet.sendTransaction({
        data,
        to: getProxyAddress(
          request,
          Erc20PaymentNetwork.ERC20TransferableReceivablePaymentDetector.getDeploymentInformation,
        ),
        value: 0,
      });
      confirmedTx = await tx.wait(1);

      expect(confirmedTx.status).toBe(1);
      expect(tx.hash).not.toBeUndefined();

      // get the balance to compare after payment
      const balanceErc20Before = await getErc20Balance(request, payeeWallet.address, provider);

      tx = await payErc20TransferableReceivableRequest(request, wallet, 1, 0, {
        gasLimit: BigNumber.from('20000000'),
      });

      confirmedTx = await tx.wait(1);

      const balanceErc20After = await getErc20Balance(request, payeeWallet.address, provider);

      expect(confirmedTx.status).toBe(1);
      expect(tx.hash).not.toBeUndefined();

      // ERC20 balance should be lower
      expect(
        BigNumber.from(balanceErc20After).eq(BigNumber.from(balanceErc20Before).add(1)),
      ).toBeTruthy();
    });
  });
});

Meta Payments

Tests:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/advanced-logic/test/extensions/payment-network/meta.test.ts
import { ExtensionTypes, RequestLogicTypes } from '@requestnetwork/types';
import { deepCopy } from '@requestnetwork/utils';
import { CurrencyManager, UnsupportedCurrencyError } from '@requestnetwork/currency';

import * as DataConversionERC20FeeAddData from '../../utils/payment-network/erc20/any-to-erc20-proxy-add-data-generator';
import * as MetaCreate from '../../utils/payment-network/meta-pn-data-generator';
import * as TestData from '../../utils/test-data-generator';
import MetaPaymentNetwork from '../../../src/extensions/payment-network/meta';

const metaPn = new MetaPaymentNetwork(CurrencyManager.getDefault());
const baseParams = {
  feeAddress: '0x0000000000000000000000000000000000000001',
  feeAmount: '0',
  paymentAddress: '0x0000000000000000000000000000000000000002',
  refundAddress: '0x0000000000000000000000000000000000000003',
  salt: 'ea3bc7caf64110ca',
  network: 'rinkeby',
  acceptedTokens: ['0xFab46E002BbF0b4509813474841E0716E6730136'],
  maxRateTimespan: 1000000,
} as ExtensionTypes.PnAnyToErc20.ICreationParameters;
const otherBaseParams = {
  ...baseParams,
  salt: 'ea3bc7caf64110cb',
} as ExtensionTypes.PnAnyToErc20.ICreationParameters;

/* eslint-disable @typescript-eslint/no-unused-expressions */
describe('extensions/payment-network/meta', () => {
  describe('createCreationAction', () => {
    it('can create a create action with all parameters', () => {
      expect(
        metaPn.createCreationAction({
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [baseParams, otherBaseParams],
        }),
      ).toEqual({
        action: 'create',
        id: ExtensionTypes.PAYMENT_NETWORK_ID.META,
        parameters: {
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [baseParams, otherBaseParams],
        },
        version: '0.1.0',
      });
    });

    it('can create a create action without fee parameters', () => {
      expect(
        metaPn.createCreationAction({
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [
            { ...baseParams, feeAddress: undefined, feeAmount: undefined },
            otherBaseParams,
          ],
        }),
      ).toEqual({
        action: 'create',
        id: ExtensionTypes.PAYMENT_NETWORK_ID.META,
        parameters: {
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [
            { ...baseParams, feeAddress: undefined, feeAmount: undefined },
            otherBaseParams,
          ],
        },
        version: '0.1.0',
      });
    });

    it('cannot createCreationAction with duplicated salt', () => {
      expect(() => {
        metaPn.createCreationAction({
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [baseParams, baseParams],
        });
      }).toThrowError('Duplicate payment network identifier (salt)');
    });

    it('cannot createCreationAction with payment address not an ethereum address', () => {
      expect(() => {
        metaPn.createCreationAction({
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [
            { ...baseParams, paymentAddress: 'not an ethereum address' },
            otherBaseParams,
          ],
        });
      }).toThrowError("paymentAddress 'not an ethereum address' is not a valid address");
    });

    it('cannot createCreationAction with refund address not an ethereum address', () => {
      expect(() => {
        metaPn.createCreationAction({
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [
            { ...baseParams, refundAddress: 'not an ethereum address' },
            otherBaseParams,
          ],
        });
      }).toThrowError("refundAddress 'not an ethereum address' is not a valid address");
    });

    it('cannot createCreationAction with fee address not an ethereum address', () => {
      expect(() => {
        metaPn.createCreationAction({
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [
            { ...baseParams, feeAddress: 'not an ethereum address' },
            otherBaseParams,
          ],
        });
      }).toThrowError('feeAddress is not a valid address');
    });

    it('cannot createCreationAction with invalid fee amount', () => {
      expect(() => {
        metaPn.createCreationAction({
          [ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY]: [
            { ...baseParams, feeAmount: '-2000' },
            otherBaseParams,
          ],
        });
      }).toThrowError('feeAmount is not a valid amount');
    });
  });

  describe('applyActionToExtension', () => {
    describe('applyActionToExtension/create', () => {
      it('can applyActionToExtensions of creation', () => {
        // 'new extension state wrong'
        expect(
          metaPn.applyActionToExtension(
            MetaCreate.requestStateNoExtensions.extensions,
            MetaCreate.actionCreationMultipleAnyToErc20,
            MetaCreate.requestStateNoExtensions,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          ),
        ).toEqual(MetaCreate.extensionFullStateMultipleAnyToErc20);
      });

      it('cannot applyActionToExtensions of creation with a previous state', () => {
        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestFullStateCreated.extensions,
            MetaCreate.actionCreationMultipleAnyToErc20,
            MetaCreate.requestFullStateCreated,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError('This extension has already been created');
      });

      it('cannot applyActionToExtensions of creation on a non supported currency', () => {
        const requestCreatedNoExtension: RequestLogicTypes.IRequest = deepCopy(
          TestData.requestCreatedNoExtension,
        );
        requestCreatedNoExtension.currency = {
          type: RequestLogicTypes.CURRENCY.ERC20,
          value: '0x967da4048cD07aB37855c090aAF366e4ce1b9F48', // OCEAN token address
          network: 'mainnet',
        };

        expect(() => {
          metaPn.applyActionToExtension(
            TestData.requestCreatedNoExtension.extensions,
            MetaCreate.actionCreationMultipleAnyToErc20,
            requestCreatedNoExtension,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(
          'The currency (OCEAN-mainnet, 0x967da4048cD07aB37855c090aAF366e4ce1b9F48) of the request is not supported for this payment network.',
        );
      });

      it('cannot applyActionToExtensions of creation with payment address not valid', () => {
        const actionWithInvalidAddress = deepCopy(MetaCreate.actionCreationMultipleAnyToErc20);
        actionWithInvalidAddress.parameters[
          ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY
        ][0].paymentAddress = DataConversionERC20FeeAddData.invalidAddress;

        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateNoExtensions.extensions,
            actionWithInvalidAddress,
            MetaCreate.requestStateNoExtensions,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(
          `paymentAddress '${DataConversionERC20FeeAddData.invalidAddress}' is not a valid address`,
        );
      });

      it('cannot applyActionToExtensions of creation with no tokens accepted', () => {
        const actionWithInvalidToken = deepCopy(MetaCreate.actionCreationMultipleAnyToErc20);
        actionWithInvalidToken.parameters[
          ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY
        ][0].acceptedTokens = [];

        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateNoExtensions.extensions,
            actionWithInvalidToken,
            MetaCreate.requestStateNoExtensions,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError('acceptedTokens is required');
      });

      it('cannot applyActionToExtensions of creation with token address not valid', () => {
        const actionWithInvalidToken = deepCopy(MetaCreate.actionCreationMultipleAnyToErc20);
        actionWithInvalidToken.parameters[
          ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY
        ][0].acceptedTokens = ['invalid address'];

        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateNoExtensions.extensions,
            actionWithInvalidToken,
            MetaCreate.requestStateNoExtensions,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError('acceptedTokens must contains only valid ethereum addresses');
      });

      it('cannot applyActionToExtensions of creation with refund address not valid', () => {
        const testnetRefundAddress = deepCopy(MetaCreate.actionCreationMultipleAnyToErc20);
        testnetRefundAddress.parameters[
          ExtensionTypes.PAYMENT_NETWORK_ID.ANY_TO_ERC20_PROXY
        ][0].refundAddress = DataConversionERC20FeeAddData.invalidAddress;

        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateNoExtensions.extensions,
            testnetRefundAddress,
            MetaCreate.requestStateNoExtensions,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(
          `refundAddress '${DataConversionERC20FeeAddData.invalidAddress}' is not a valid address`,
        );
      });
      it('keeps the version used at creation', () => {
        const newState = metaPn.applyActionToExtension(
          {},
          { ...MetaCreate.actionCreationMultipleAnyToErc20, version: 'ABCD' },
          MetaCreate.requestStateNoExtensions,
          TestData.otherIdRaw.identity,
          TestData.arbitraryTimestamp,
        );
        expect(newState[metaPn.extensionId].version).toBe('ABCD');
      });

      it('requires a version at creation', () => {
        expect(() => {
          metaPn.applyActionToExtension(
            {},
            { ...MetaCreate.actionCreationMultipleAnyToErc20, version: '' },
            MetaCreate.requestStateNoExtensions,
            TestData.otherIdRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError('version is required at creation');
      });
    });

    describe('applyActionToExtension/applyApplyActionToExtension', () => {
      it('can applyActionToExtensions of applyApplyActionToExtension for addPaymentAddress', () => {
        expect(
          metaPn.applyActionToExtension(
            MetaCreate.requestStateCreatedMissingAddress.extensions,
            MetaCreate.actionApplyActionToPn,
            MetaCreate.requestStateCreatedMissingAddress,
            TestData.payeeRaw.identity,
            TestData.arbitraryTimestamp,
          ),
        ).toEqual(MetaCreate.extensionStateWithApplyAddPaymentAddressAfterCreation);
      });

      it('cannot applyActionToExtensions of applyApplyActionToExtension for addPaymentAddress without a previous state', () => {
        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateNoExtensions.extensions,
            MetaCreate.actionApplyActionToPn,
            MetaCreate.requestStateNoExtensions,
            TestData.payeeRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(`No payment network with identifier ${MetaCreate.salt2}`);
      });

      it('cannot applyActionToExtensions of applyApplyActionToExtension for addPaymentAddress without a payee', () => {
        const previousState = deepCopy(MetaCreate.requestStateCreatedMissingAddress);
        previousState.payee = undefined;

        expect(() => {
          metaPn.applyActionToExtension(
            previousState.extensions,
            MetaCreate.actionApplyActionToPn,
            previousState,
            TestData.payeeRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(`The request must have a payee`);
      });

      it('cannot applyActionToExtensions of applyApplyActionToExtension for addPaymentAddress signed by someone else than the payee', () => {
        const previousState = deepCopy(MetaCreate.requestStateCreatedMissingAddress);

        expect(() => {
          metaPn.applyActionToExtension(
            previousState.extensions,
            MetaCreate.actionApplyActionToPn,
            previousState,
            TestData.payerRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(`The signer must be the payee`);
      });

      it('cannot applyActionToExtensions of applyApplyActionToExtension for addPaymentAddress with payment address already given', () => {
        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestFullStateCreated.extensions,
            MetaCreate.actionApplyActionToPn,
            MetaCreate.requestFullStateCreated,
            TestData.payeeRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(`Payment address already given`);
      });

      it('cannot applyActionToExtensions of applyApplyActionToExtension for addPaymentAddress with payment address not valid', () => {
        const actionWithInvalidAddress = deepCopy(MetaCreate.actionApplyActionToPn);
        actionWithInvalidAddress.parameters.parameters.paymentAddress =
          DataConversionERC20FeeAddData.invalidAddress;

        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateCreatedMissingAddress.extensions,
            actionWithInvalidAddress,
            MetaCreate.requestStateCreatedMissingAddress,
            TestData.payeeRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(
          `paymentAddress '${DataConversionERC20FeeAddData.invalidAddress}' is not a valid address`,
        );
      });

      it('cannot applyActionToExtensions applyApplyActionToExtension when the pn identifier is wrong', () => {
        const actionWithInvalidPnIdentifier = deepCopy(MetaCreate.actionApplyActionToPn);
        actionWithInvalidPnIdentifier.parameters.pnIdentifier = 'wrongId';

        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateCreatedMissingAddress.extensions,
            actionWithInvalidPnIdentifier,
            MetaCreate.requestStateCreatedMissingAddress,
            TestData.payeeRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(`No payment network with identifier wrongId`);
      });

      it('cannot applyActionToExtensions applyApplyActionToExtension when the action does not exists on the sub pn', () => {
        const actionWithInvalidPnAction = deepCopy(MetaCreate.actionApplyActionToPn);
        actionWithInvalidPnAction.parameters.action = 'wrongAction' as ExtensionTypes.ACTION;

        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateCreatedMissingAddress.extensions,
            actionWithInvalidPnAction,
            MetaCreate.requestStateCreatedMissingAddress,
            TestData.payeeRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(`Unknown action: wrongAction`);
      });
    });
  });

  describe('declarative tests', () => {
    describe('applyActionToExtension/declareSentPayment', () => {
      it('can applyActionToExtensions of declareSentPayment', () => {
        expect(
          metaPn.applyActionToExtension(
            MetaCreate.requestFullStateCreated.extensions,
            MetaCreate.actionDeclareSentPayment,
            MetaCreate.requestFullStateCreated,
            TestData.payerRaw.identity,
            TestData.arbitraryTimestamp,
          ),
        ).toEqual(MetaCreate.extensionStateWithDeclaredSent);
      });

      it('cannot applyActionToExtensions of declareSentPayment without a previous state', () => {
        expect(() => {
          metaPn.applyActionToExtension(
            MetaCreate.requestStateNoExtensions.extensions,
            MetaCreate.actionDeclareSentPayment,
            MetaCreate.requestStateNoExtensions,
            TestData.payerRaw.identity,
            TestData.arbitraryTimestamp,
          );
        }).toThrowError(`The extension should be created before receiving any other action`);
      });
    });
  });
});

Escrow Payment

Escrow

The Request Network Escrow isn't a separate payment network. Rather, it builds on top of the ERC20_FEE_PROXY_CONTRACT payment network.

Typical Workflow

  1. Using the request-client.js package, the payer creates a request with the ERC20_FEE_PROXY_CONTRACT payment network.

  2. Using the payment-processor package, payer:

    1. Approves the escrow contract using approveErc20ForEscrow()

    2. Pays the escrow contract using payEscrow()

    3. Waits until the work is complete

    4. Pays the payee from the Escrow contract using payRequestFromEscrow()

These steps are shown by our unit tests:

https://github.com/RequestNetwork/requestNetwork/blob/master/packages/payment-processor/test/payment/erc20-escrow-payment.test.ts#L200-L339

Streaming Payment

Pay a series of requests with a stream of ERC777 Super Tokens from Superfluid.

The first request of a series is very similar to payment-network-erc20-fee-proxy, it defines the salt, paymentAddress and requestId to compute the paymentReference used for the whole series.

Other requests must define a previousRequestId and cannot redefine any of the payment properties (paymentAddress, feeAddress or salt).

Multiple requests can be paid with the same stream, typically recurring requests of fixed amounts paid continuously. A group of requests payable with the same stream are called a request series, they must all have the same currency.

For additional details, see the payment-network-erc777-stream-0.1.0 specification

Create a Streaming Request

To create a streaming request, Create a request like normal, but set the paymentNetwork parameter to the ERC777_STREAM payment network.

Create the first request in a series

  // The paymentNetwork is the method of payment and related details.
  paymentNetwork: {
    id: Types.Extension.PAYMENT_NETWORK_ID.ERC777_STREAM,
    parameters: {
      expectedFlowRate: expectedFlowRate // number, Expected amount of request currency per second
      expectedStartDate: expectedStartDate // timestamp, Expected start of stream	
      paymentAddress: payeeIdentity,
    },
  },

Create subsequent requests in a series

  // The paymentNetwork is the method of payment and related details.
  paymentNetwork: {
    id: Types.Extension.PAYMENT_NETWORK_ID.ERC777_STREAM,
    parameters: {
      originalRequestId: 'abcd', // first reqeust in the series
      previousRequestId: 'abcd', // previous request in the series
      recurrenceNumber: 1, // 1 if previous request is original, 2+ otherwise
    },
  },

Tests

See Github for tests showing usage.

  • https://github.com/RequestNetwork/requestNetwork/blob/master/packages/advanced-logic/test/extensions/payment-network/erc777/stream.test.ts

  • https://github.com/RequestNetwork/requestNetwork/tree/master/packages/payment-detection/test/erc777

Pay through a proxy-contract with a multisig

The imports you will need:

import { Contract, ContractTransaction, Signer } from 'ethers';
import {
  encodeApproveErc20,
  encodePayErc20Request,
} from '@requestnetwork/payment-processor/dist/payment/erc20-proxy';
import { getRequestPaymentValues } from '@requestnetwork/payment-processor/dist/payment/utils';
import { ClientTypes } from '@requestnetwork/types';

In this example, we will use the Gnosis multisig. Here is its partial abi:

const multisigAbi = [
  'function submitTransaction(address _destination, uint _value, bytes _data) returns (uint)',
];

Pay ETH request

export const payEthWithMultisig = async (
  request: ClientTypes.IRequestData,
  multisigAddress: string,
  signer: Signer,
): Promise<ContractTransaction> => {
  const multisigContract = new Contract(multisigAddress, multisigAbi, signer);
  const { paymentAddress, paymentReference } = getRequestPaymentValues(request);
  return multisigContract.submitTransaction(paymentAddress, 0, paymentReference);
};

Pay ERC20 request

Approve ERC20 spending

export const approveErc20WithMultisig = async (
  request: ClientTypes.IRequestData,
  multisigAddress: string,
  signer: Signer,
): Promise<ContractTransaction> => {
  const multisigContract = new Contract(multisigAddress, multisigAbi, signer);
  const tokenAddress = request.currencyInfo.value;
  return multisigContract.submitTransaction(tokenAddress, 0, encodeApproveErc20(request, signer));
};

Pay ERC20 request

import { erc20FeeProxyArtifact } from '@requestnetwork/smart-contracts';

export const payErc20WithMultisig = async (
  request: ClientTypes.IRequestData,
  multisigAddress: string,
  signer: Signer,
): Promise<ContractTransaction> => {
  const multisigContract = new Contract(multisigAddress, multisigAbi, signer);
  const proxyAddress = erc20FeeProxyArtifact.getAddress(request.currencyInfo.network);
  return multisigContract.submitTransaction(
    proxyAddress,
    0,
    encodePayErc20Request(request, signer),
  );
};

Hinkal Private Payments

The Request Network SDK supports Hinkal Private Payments using ERC-20 tokens. Hinkal is a middleware and suite of smart contracts on EVM-compatible chains that leverage zero-knowledge proofs and private addresses to facilitate compliant and private transactions.

Each public address has exactly one Hinkal private address.

The @requestnetwork/payment-processor package provides functions to:

  • Pay a request from a Hinkal private address to a public address: such that the payment sender's public address never appears on-chain.

  • Deposit to a Hinkal private address from a public address: such that the payment recipient's public address never appears on-chain. Callers can choose to deposit to their own private address or someone else's private address.

Paying a request where the payment recipient address is a Hinkal private address is not supported because the Request Network payment proxy smart contracts can only send funds to public addresses. Consider using Declarative Payment instead.

Benefits

  • Privacy: Obfuscates payer address when paying a request.

  • Compliance: Ensures transactions adhere to regulatory requirements. See Hinkal Compliance for details

Supported Chains

See Hinkal Supported Chains for a list of chains on which Hinkal Private Payments are supported.

Installation

To use Hinkal Private Payments, install the necessary package:

npm install @requestnetwork/payment-processor

Usage

Pay a request from a Hinkal private address

To pay a request from a Hinkal private address to a public address, where only the payment sender's address is obfuscated, use the `payErc20FeeProxyRequestFromHinkalShieldedAddress()` function. Ensure the payment sender's Hinkal private address has a positive balance using Deposit to a Hinkal private address

Strongly consider using Encryption and Decryption to keep the request contents private, including the payer and payee identity addresses, when paying requests from a Hinkal private address. Revealing the payer and payee identity addresses increases the likelihood of un-shielding the payment sender's address via on-chain analysis.

import { 
  payErc20FeeProxyRequestFromHinkalShieldedAddress,
} from '@requestnetwork/payment-processor';

// Instantiation of `RequestNetwork` and `Signer` omitted for brevity

const request = await requestClient.fromRequestId('insert request id');
const requestData = request.getData();

const relayerTx = await payErc20FeeProxyRequestFromHinkalShieldedAddress(
  requestData,
  signer,
);

See Quickstart - Browser for how to instantiate a RequestNetwork and Signer

Deposit to a Hinkal private address

To deposit funds to a Hinkal private address from a public address, where only the payment recipient's address is obfuscated, use the sendToHinkalShieldedAddressFromPublic() function.

  • Deposit to own Hinkal shielded address: omit the recipientInfo argument

  • Deposit to someone else's Hinkal shielded address: set recipientInfo to the shielded address of the payment recipient.

Hinkal private addresses must be shared out-of-band. This SDK doesn't offer functions for sharing Hinkal private addresses.

import { 
  sendToHinkalShieldedAddressFromPublic,
} from '@requestnetwork/payment-processor';

// Instantiation of `Signer` omitted for brevity

const recipientShieldedAddress = '142590100039484718476239190022599206250779986428210948946438848754146776167,0x096d6d5d8b2292aa52e57123a58fc4d5f3d66171acd895f22ce1a5b16ac51b9e,0xc025ccc6ef46399da52763a866a3a10d2eade509af27eb8411c5d251eb8cd34d'
const tx = await sendToHinkalShieldedAddressFromPublic({
    signerOrProvider: paymentSender,
    tokenAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC on Base
    amount: '1000000', // 1 USDC
    recipientInfo: recipientShieldedAddress, // omit to deposit to own Hinkal shielded address
})

See Quickstart - Browser for how to instantiate a Signer

Content Security Policy

The Hinkal SDK depends on snarkjs, a powerful library that enables local zero-knowledge proving in browser and Node.js environments. Snarkjs leverages WebAssembly to perform complex cryptographic computations efficiently.

As a result, any client-side application integrating the Hinkal SDK must adjust its Content-Security-Policy to allow the wasm-unsafe-eval directive under the script-src setting. This configuration ensures that the cryptographic processes can execute properly.

See Hinkal SDK Integration for more details.

Details

For more details about Hinkal Private Payments, refer to Pull Request #1482 on GitHub.

Mobile using Expo

Introduction

This guide demonstrates how to integrate Request Network into a React Native application using Expo. Due to the differences between Node.js and React Native environments, several polyfills and configurations are necessary to make Request Network work properly. Following this guide will set up your Expo project to use Request Network, handle polyfills for missing modules, and ensure smooth integration. A full Github repository with Request Network support can be found here.

Installation

After creating a new Expo project, install the necessary dependencies:

npm install @requestnetwork/request-client.js @requestnetwork/types @requestnetwork/payment-processor @requestnetwork/epk-signature buffer eventemitter3 stream-browserify http-browserify https-browserify react-native-get-random-values tweetnacl node-forge ethers@5.5.1

Setup

1- Create a new file named index.js in the root of your project

touch index.js

2- Add the following content to index.js

https://github.com/RequestNetwork/rn-expo-support/blob/5a771c31051ce89c4feee533692028d45e1415ab/index.js
// Buffer polyfill
import { Buffer } from "buffer";
global.Buffer = Buffer;

import "react-native-get-random-values";

// Crypto Polyfill
import cryptoPolyfill from "./cryptoPolyfill";
if (typeof global.crypto !== "object") {
  global.crypto = {};
}
Object.assign(global.crypto, cryptoPolyfill);

// Event Emitter polyfill
import EventEmitter from "eventemitter3";
global.EventEmitter = EventEmitter;

// Stream Polyfill
import { Readable, Writable } from "stream-browserify";
global.Readable = Readable;
global.Writable = Writable;

// HTTP Polyfill
import http from "http-browserify";
global.http = http;

// HTTPS Polyfill
import https from "https-browserify";
global.https = https;

// Starting expo router
import "expo-router/entry";

3- Create a file named cryptoPolyfill.js in the root of your project

touch cryptoPolyfill.js

4- Add the following content to cryptoPolyfill.js

https://github.com/RequestNetwork/rn-expo-support/blob/5a771c31051ce89c4feee533692028d45e1415ab/cryptoPolyfill.js
import nacl from "tweetnacl";
import forge from "node-forge";
import { Buffer } from "buffer";

const randomBytes = (size, callback) => {
  if (typeof size !== "number") {
    throw new TypeError("Expected number");
  }
  const bytes = Buffer.from(nacl.randomBytes(size));
  if (callback) {
    callback(null, bytes);
    return;
  }
  return bytes;
};

const createHash = (algorithm) => {
  const md = forge.md[algorithm.toLowerCase()].create();
  return {
    update: function (data) {
      md.update(
        typeof data === "string" ? data : forge.util.createBuffer(data)
      );
      return this;
    },
    digest: function (encoding) {
      const digest = md.digest().getBytes();
      return encoding === "hex"
        ? forge.util.bytesToHex(digest)
        : Buffer.from(digest, "binary");
    },
  };
};

const createCipheriv = (algorithm, key, iv) => {
  const cipher = forge.cipher.createCipher(
    algorithm,
    forge.util.createBuffer(key)
  );
  cipher.start({ iv: forge.util.createBuffer(iv) });
  let output = forge.util.createBuffer();

  return {
    update: (data) => {
      cipher.update(forge.util.createBuffer(data));
      output.putBuffer(cipher.output);
      return Buffer.from(output.getBytes(), "binary");
    },
    final: () => {
      cipher.finish();
      output.putBuffer(cipher.output);
      const result = Buffer.from(output.getBytes(), "binary");
      output.clear();
      return result;
    },
    getAuthTag: () => {
      if (algorithm.includes("gcm")) {
        return Buffer.from(cipher.mode.tag.getBytes(), "binary");
      }
      throw new Error("getAuthTag is only supported for GCM mode");
    },
  };
};

const createDecipheriv = (algorithm, key, iv) => {
  const decipher = forge.cipher.createDecipher(
    algorithm,
    forge.util.createBuffer(key)
  );
  decipher.start({ iv: forge.util.createBuffer(iv) });
  let output = forge.util.createBuffer();
  let authTag;

  return {
    update: (data) => {
      decipher.update(forge.util.createBuffer(data));
      output.putBuffer(decipher.output);
      return Buffer.from(output.getBytes(), "binary");
    },
    final: () => {
      decipher.finish();
      output.putBuffer(decipher.output);
      const result = Buffer.from(output.getBytes(), "binary");
      output.clear();
      return result;
    },
    setAuthTag: (tag) => {
      if (algorithm.includes("gcm")) {
        authTag = tag;
        decipher.mode.tag = forge.util.createBuffer(tag);
      } else {
        throw new Error("setAuthTag is only supported for GCM mode");
      }
    },
  };
};

const pbkdf2 = (password, salt, iterations, keylen, digest, callback) => {
  try {
    const derivedKey = forge.pkcs5.pbkdf2(
      password,
      salt,
      iterations,
      keylen,
      digest
    );
    const result = Buffer.from(derivedKey, "binary");
    if (callback) {
      callback(null, result);
    } else {
      return result;
    }
  } catch (error) {
    if (callback) {
      callback(error);
    } else {
      throw error;
    }
  }
};

const randomFillSync = (buffer, offset, size) => {
  const randomBytes = nacl.randomBytes(size);
  buffer.set(randomBytes, offset);
  return buffer;
};

const timingSafeEqual = (a, b) => {
  if (a.length !== b.length) {
    return false;
  }
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a[i] ^ b[i];
  }
  return result === 0;
};

const cryptoPolyfill = {
  randomBytes,
  createHash,
  createCipheriv,
  createDecipheriv,
  pbkdf2,
  randomFillSync,
  timingSafeEqual,
};

cryptoPolyfill.default = cryptoPolyfill;

module.exports = {
  randomBytes,
  createHash,
  createCipheriv,
  createDecipheriv,
  pbkdf2,
  randomFillSync,
  timingSafeEqual,
};

export default cryptoPolyfill;

5- Create / Update metro.config.js to use the custom polyfills

https://github.com/RequestNetwork/rn-expo-support/blob/5a771c31051ce89c4feee533692028d45e1415ab/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");

const defaultConfig = getDefaultConfig(__dirname);

defaultConfig.resolver.extraNodeModules = {
  ...defaultConfig.resolver.extraNodeModules,
  crypto: require.resolve("./cryptoPolyfill"),
  stream: require.resolve("stream-browserify"),
  buffer: require.resolve("buffer"),
  http: require.resolve("http-browserify"),
  https: require.resolve("https-browserify"),
};

module.exports = defaultConfig;

6- Update package.json to set the main entry point to index.js

https://github.com/RequestNetwork/rn-expo-support/blob/5a771c31051ce89c4feee533692028d45e1415ab/package.json
{
  "name": "rn-expo-support",
  "main": "./index.js",
  "version": "1.0.0",
  "scripts": {
    "start": "expo start",
    "reset-project": "node ./scripts/reset-project.js",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "test": "jest --watchAll",
    "lint": "expo lint"
  },
  "jest": {
    "preset": "jest-expo"
  },
  "dependencies": {
    "@expo/vector-icons": "^14.0.0",
    "@react-navigation/native": "^6.0.2",
    "@requestnetwork/epk-signature": "^0.6.0",
    "@requestnetwork/payment-processor": "^0.44.0",
    "@requestnetwork/request-client.js": "^0.45.0",
    "@requestnetwork/types": "^0.42.0",
    "@requestnetwork/web3-signature": "^0.5.0",
    "ethers": "^5.5.1",
    "eventemitter3": "^5.0.1",
    "expo": "~51.0.14",
    "expo-constants": "~16.0.2",
    "expo-crypto": "~13.0.2",
    "expo-font": "~12.0.7",
    "expo-linking": "~6.3.1",
    "expo-router": "~3.5.16",
    "expo-splash-screen": "~0.27.5",
    "expo-status-bar": "~1.12.1",
    "expo-system-ui": "~3.0.6",
    "expo-web-browser": "~13.0.3",
    "http-browserify": "^1.7.0",
    "https-browserify": "^1.0.0",
    "node-forge": "^1.3.1",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-native": "0.74.2",
    "react-native-crypto": "^2.2.0",
    "react-native-gesture-handler": "~2.16.1",
    "react-native-get-random-values": "^1.11.0",
    "react-native-quick-crypto": "^0.6.1",
    "react-native-randombytes": "^3.6.1",
    "react-native-reanimated": "~3.10.1",
    "react-native-safe-area-context": "4.10.1",
    "react-native-screens": "3.31.1",
    "react-native-web": "~0.19.10",
    "stream-browserify": "^3.0.0",
    "tweetnacl": "^1.0.3"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@types/jest": "^29.5.12",
    "@types/react": "~18.2.45",
    "@types/react-test-renderer": "^18.0.7",
    "jest": "^29.2.1",
    "jest-expo": "~51.0.1",
    "react-test-renderer": "18.2.0",
    "typescript": "~5.3.3"
  }
}

7- Ensure that app.json file includes the correct entry point

{
  "expo": {
    "entryPoint": "./index.js",
    ...
  }
}

Polyfills and Configurations

Crypto Polyfills

We've created a custom crypto polyfill (cryptoPolyfill.js) to provide the necessary cryptographic functions. This polyfill uses tweetnacl and node-forge libraries to implement various cryptographic operations.

Why tweetnacl and node-forge?

  1. tweetnacl: It's a fast, secure, and easy-to-use cryptography library. It provides a pure JavaScript implementation of the NaCl cryptography library, which is particularly useful for generating random bytes, essential for cryptographic operations.

  2. node-forge: It provides a comprehensive set of cryptographic tools and utilities. It implements various cryptographic algorithms and protocols that are not natively available in React Native. It's used in our polyfill for operations like hashing, cipher creation, and PBKDF2 key derivation.

Using these libraries allows us to implement a more complete set of cryptographic functions that closely mimic the Node.js crypto module, which is not available in React Native.

Important Notes

  1. Ensure you're using compatible versions of React Native and Expo.

  2. The crypto polyfill may not cover all use cases. Test thoroughly and adjust as needed.

  3. Be cautious when handling private keys. Never expose them in your code or version control.

  4. The example code uses environment variables for private keys. Ensure you set these up correctly and securely.