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,
});
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 RequestNetwork
object.
const requestClient = new RequestNetwork({
useMockStorage: true,
});
After a request is created, it can be updated:
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...
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.
/** 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>
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>;
}
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);
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 allow for creating and managing requests without immediately persisting them to storage. This enables faster payment workflows and deferred persistence.
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.
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.
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);
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);
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);
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.
This implementation utilizes a two-step encryption process to secure sensitive data within requests:
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.
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
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.
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.
Request Creation: The payer creates a request object using the Request Network SDK.
Symmetric Key Generation: A unique symmetric key is randomly generated.
Data Encryption: The payee and payer encrypt the sensitive data within the request using the generated symmetric key.
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.
Store Encrypted Data: The payee and payer store the following on the Request Network:
Encrypted request data
Lit access control conditions
Encrypted symmetric key
Retrieve Request: The payer and payee retrieve the following request data from the Request Network:
Encrypted request data
Lit access control conditions
Encrypted symmetric key
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.
Decrypt Data: The payer and payee use the decrypted symmetric key to decrypt the sensitive data.
npm install @requestnetwork/lit-protocol-cipher @requestnetwork/request-client.js ethers@5.7.2
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
});
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
});
// 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
litProvider.enableDecryption(false)
The wallet address must be included in the original encryption parameters
Session signatures must be valid
Decryption must be enabled
The Lit Protocol client must be connected
// 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);
}
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;
}
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.
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 is deprecated in favor of EthereumPrivateKeyCipherProvider
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,
} */
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);
// Disable decryption
cipherProvider.enableDecryption(false);
// Check if decryption is enabled
const isEnabled = cipherProvider.isDecryptionEnabled();
// Re-enable decryption
cipherProvider.enableDecryption(true);
// 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
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...
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.
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.
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,
),
);
})();
The Single Request Forwarder is a smart contract solution that enables integration with Request Network's payment system without modifying existing smart contracts.
The Single Request Forwarder Factory contact addresses can be found : Smart Contract Addresses
The contract name is SingleRequestProxyFactory
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.
Request: Create a request in the Request Network protocol
Deploy: Deploy a unique Single Request Forwarder for your request
Pay: The Payer sends funds to the Single Request Forwarder
Complete: The Single Request Forwarder forwards the payment to the Payee and emits an event to enable payment detection.
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
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
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)
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)
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.
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;
}
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'));
});
});
});
});
A "swap-to-pay" payment is where the payment sender sends one currency but the payment recipient receives a different currency.
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),
]);
}
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(),
);
});
});
});
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.
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,
};
}
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(),
);
});
});
});
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,
]);
}
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();
});
});
});
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`);
});
});
});
});
The Request Network Escrow isn't a separate payment network. Rather, it builds on top of the ERC20_FEE_PROXY_CONTRACT
payment network.
Using the request-client.js
package, the payer
creates a request with the ERC20_FEE_PROXY_CONTRACT
payment network.
Using the payment-processor
package, payer
:
Approves the escrow contract using approveErc20ForEscrow()
Pays the escrow contract using payEscrow()
Waits until the work is complete
Pays the payee from the Escrow contract using payRequestFromEscrow()
These steps are shown by our unit tests:
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
To create a streaming request, Create a request like normal, but set the paymentNetwork
parameter to the ERC777_STREAM
payment network.
// 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,
},
},
// 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
},
},
See Github for tests showing usage.
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)',
];
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);
};
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));
};
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),
);
};
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.
Privacy: Obfuscates payer address when paying a request.
Compliance: Ensures transactions adhere to regulatory requirements. See Hinkal Compliance for details
See Hinkal Supported Chains for a list of chains on which Hinkal Private Payments are supported.
To use Hinkal Private Payments, install the necessary package:
npm install @requestnetwork/payment-processor
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
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
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.
For more details about Hinkal Private Payments, refer to Pull Request #1482 on GitHub.
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.
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
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
// 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
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
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
{
"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",
...
}
}
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
?
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.
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.
Ensure you're using compatible versions of React Native and Expo.
The crypto polyfill may not cover all use cases. Test thoroughly and adjust as needed.
Be cautious when handling private keys. Never expose them in your code or version control.
The example code uses environment variables for private keys. Ensure you set these up correctly and securely.