Swap-to-Pay Payment
Last updated
Last updated
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(),
);
});
});
});
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),
]);
}