diff --git a/package.json b/package.json index 9034320d17..59e5642682 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,14 @@ "node": ">=22.0.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", - "workspaces": [ - "packages/*" - ], + "workspaces": { + "packages": [ + "packages/*" + ], + "nohoist": [ + "@requestnetwork/smart-contracts/@openzeppelin/**" + ] + }, "repository": { "type": "git", "url": "git+https://github.com/RequestNetwork/requestNetwork.git" diff --git a/packages/smart-contracts/deployments/tron/mainnet.json b/packages/smart-contracts/deployments/tron/mainnet.json index f1755f5186..ae0df3ed79 100644 --- a/packages/smart-contracts/deployments/tron/mainnet.json +++ b/packages/smart-contracts/deployments/tron/mainnet.json @@ -1,14 +1,20 @@ { "network": "mainnet", "chainId": "1", - "timestamp": "2024-01-01T00:00:00.000Z", - "deployer": "TO_BE_FILLED_ON_DEPLOYMENT", - "note": "Existing deployment from handover document", + "timestamp": "2026-05-27T09:23:48.624Z", + "deployer": "TR7EydtGnsxriSieLfEuspTAqhQRmoscWC", + "deploymentDuration": "3.227s", + "note": "Deployment of ERC20BatchPayments to Mainnet", "contracts": { "ERC20FeeProxy": { "address": "TCUDPYnS9dH3WvFEaE7wN7vnDa51J4R4fd", "hexAddress": "411b6ca35d39842cf8fbe49000653a1505412da659", "creationBlockNumber": 79216121 + }, + "ERC20BatchPayments": { + "address": "THm8vX6GNfRFZ15mRqdgvj56wjB6575S7C", + "hexAddress": "4155789c40d8ba55166296217cc244ca2dd3499f89", + "creationBlockNumber": 83068367 } } } diff --git a/packages/smart-contracts/deployments/tron/nile.json b/packages/smart-contracts/deployments/tron/nile.json index 77257b8bc6..818ebdd22a 100644 --- a/packages/smart-contracts/deployments/tron/nile.json +++ b/packages/smart-contracts/deployments/tron/nile.json @@ -9,6 +9,11 @@ "address": "THK5rNmrvCujhmrXa5DB1dASepwXTr9cJs", "hexAddress": "41508b3b4059c40bb3aac5da5ac006ccdd9c4dc957", "creationBlockNumber": 63208782 + }, + "ERC20BatchPayments": { + "address": "TC6nD547PRDVWuX8hBMREU7vVvSZNCAZot", + "hexAddress": "41175ee218bb15fc25224ab5937b0844f4f70a2b97", + "creationBlockNumber": 67794373 } } } diff --git a/packages/smart-contracts/scripts/tron/deploy-mainnet.js b/packages/smart-contracts/scripts/tron/deploy-mainnet.js index 873cca51c2..d3f9ec34dd 100644 --- a/packages/smart-contracts/scripts/tron/deploy-mainnet.js +++ b/packages/smart-contracts/scripts/tron/deploy-mainnet.js @@ -2,7 +2,7 @@ /** * Tron Mainnet Deployment Script * - * This script deploys the ERC20FeeProxy to Tron mainnet. + * This script deploys the ERC20FeeProxy and ERC20BatchPayments to Tron mainnet. * * ⚠️ WARNING: This deploys to MAINNET with real TRX! * @@ -28,6 +28,18 @@ const PRIVATE_KEY = process.env.TRON_PRIVATE_KEY; // Safety check const CONFIRM_MAINNET = process.env.CONFIRM_MAINNET_DEPLOY === 'true'; +const MAINNET_DEPLOYMENT_PATH = path.join(__dirname, '../../deployments/tron/mainnet.json'); + +/** + * Contracts to deploy + * + * Comment out the contracts you don't want to deploy. + */ +const CONTRACTS_TO_DEPLOY = [ + //'ERC20FeeProxy', + 'ERC20BatchPayments', +]; + if (!PRIVATE_KEY) { console.error('Error: TRON_PRIVATE_KEY environment variable is required'); process.exit(1); @@ -49,6 +61,13 @@ async function loadArtifact(contractName) { return JSON.parse(fs.readFileSync(artifactPath, 'utf8')); } +function loadExistingMainnetDeployment() { + if (!fs.existsSync(MAINNET_DEPLOYMENT_PATH)) { + return null; + } + return JSON.parse(fs.readFileSync(MAINNET_DEPLOYMENT_PATH, 'utf8')); +} + async function confirmDeployment() { if (CONFIRM_MAINNET) { return true; @@ -92,6 +111,24 @@ async function deployContract(contractName, constructorArgs = []) { }; } +async function deployContractWrapper({ + contractName, + deployments, + blockNumbers, + constructorArgs = [], +}) { + const contract = await deployContract(contractName, constructorArgs); + deployments[contractName] = { + address: contract.address, + hexAddress: contract.hexAddress, + }; + + // Get block number + const block = await tronWeb.trx.getCurrentBlock(); + const blockNumber = block.block_header.raw_data.number; + blockNumbers[contractName] = blockNumber; +} + async function main() { console.log('╔══════════════════════════════════════════════════════════╗'); console.log('║ TRON MAINNET DEPLOYMENT ║'); @@ -123,50 +160,80 @@ async function main() { console.log('\n🚀 Starting mainnet deployment...\n'); const deployments = {}; + const blockNumbers = {}; const startTime = Date.now(); try { - // Deploy ERC20FeeProxy only (no test tokens on mainnet) - const erc20FeeProxy = await deployContract('ERC20FeeProxy'); - deployments.ERC20FeeProxy = { - address: erc20FeeProxy.address, - hexAddress: erc20FeeProxy.hexAddress, - }; + const existingDeployment = loadExistingMainnetDeployment(); + + // Deploy ERC20FeeProxy + if (CONTRACTS_TO_DEPLOY.includes('ERC20FeeProxy')) { + await deployContractWrapper({ contractName: 'ERC20FeeProxy', deployments, blockNumbers }); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + + // Deploy ERC20BatchPayments + if (CONTRACTS_TO_DEPLOY.includes('ERC20BatchPayments')) { + const erc20FeeProxyAddress = deployments.ERC20FeeProxy + ? deployments.ERC20FeeProxy.address + : existingDeployment.contracts.ERC20FeeProxy.address; - // Get block number - const block = await tronWeb.trx.getCurrentBlock(); - const blockNumber = block.block_header.raw_data.number; + if (!erc20FeeProxyAddress) { + console.error( + 'ERC20FeeProxy address not found in deployments/tron/mainnet.json; cannot deploy ERC20BatchPayments', + ); + process.exit(1); + } + + console.log('Using ERC20FeeProxy at:', erc20FeeProxyAddress); + await deployContractWrapper({ + contractName: 'ERC20BatchPayments', + deployments, + blockNumbers, + constructorArgs: [erc20FeeProxyAddress], + }); + } // Print summary console.log('\n╔══════════════════════════════════════════════════════════╗'); console.log('║ MAINNET DEPLOYMENT SUMMARY ║'); console.log('╚══════════════════════════════════════════════════════════╝\n'); - console.log('ERC20FeeProxy:'); - console.log(` Address: ${deployments.ERC20FeeProxy.address}`); - console.log(` Block: ${blockNumber}`); - console.log( - ` Tronscan: https://tronscan.org/#/contract/${deployments.ERC20FeeProxy.address}`, - ); + for (const contractName of Object.keys(deployments)) { + console.log(`${contractName}:`); + console.log(` Address: ${deployments[contractName].address}`); + console.log(` Block: ${blockNumbers[contractName]}`); + console.log( + ` Tronscan: https://tronscan.org/#/contract/${deployments[contractName].address}`, + ); + } + + const newContracts = Object.entries(deployments).reduce((acc, [contractName, contract]) => { + acc[contractName] = { + ...contract, + creationBlockNumber: blockNumbers[contractName], + }; + return acc; + }, {}); + + const contracts = { + ...(existingDeployment.contracts || {}), + ...newContracts, + }; - // Save deployment info + // Save deployment info (merge with existing mainnet.json) const deploymentInfo = { network: 'mainnet', chainId: '1', timestamp: new Date().toISOString(), deployer: deployerAddress, deploymentDuration: `${(Date.now() - startTime) / 1000}s`, - contracts: { - ERC20FeeProxy: { - ...deployments.ERC20FeeProxy, - creationBlockNumber: blockNumber, - }, - }, + contracts, }; - const outputPath = path.join(__dirname, '../../deployments/tron/mainnet.json'); - fs.writeFileSync(outputPath, JSON.stringify(deploymentInfo, null, 2)); - console.log(`\nDeployment info saved to: ${outputPath}`); + fs.mkdirSync(path.dirname(MAINNET_DEPLOYMENT_PATH), { recursive: true }); + fs.writeFileSync(MAINNET_DEPLOYMENT_PATH, JSON.stringify(deploymentInfo, null, 2)); + console.log(`\nDeployment info saved to: ${MAINNET_DEPLOYMENT_PATH}`); // Next steps console.log('\n╔══════════════════════════════════════════════════════════╗'); @@ -174,8 +241,7 @@ async function main() { console.log('╚══════════════════════════════════════════════════════════╝\n'); console.log('1. Verify contract on Tronscan'); console.log('2. Run verification script: yarn tron:verify:mainnet'); - console.log('3. Update artifact registry in:'); - console.log(' packages/smart-contracts/src/lib/artifacts/ERC20FeeProxy/index.ts'); + console.log('3. Update artifact registry with new deployment addresses'); console.log('4. Test with a real TRC20 token payment'); } catch (error) { console.error('\n❌ Deployment failed:', error.message); diff --git a/packages/smart-contracts/scripts/tron/deploy-nile.js b/packages/smart-contracts/scripts/tron/deploy-nile.js index 9e5d21d8b8..5e83afeec2 100644 --- a/packages/smart-contracts/scripts/tron/deploy-nile.js +++ b/packages/smart-contracts/scripts/tron/deploy-nile.js @@ -91,6 +91,15 @@ async function main() { hexAddress: erc20FeeProxy.address, }; + // 2. Deploy ERC20BatchPayments + const erc20BatchPayments = await deployContract('ERC20BatchPayments', [ + deployments.ERC20FeeProxy.address, + ]); + deployments.ERC20BatchPayments = { + address: tronWeb.address.fromHex(erc20BatchPayments.address), + hexAddress: erc20BatchPayments.address, + }; + // 2. Deploy TestTRC20 for testing const testToken = await deployContract('TestTRC20', [ '1000000000000000000000000000', // 1 billion tokens @@ -141,6 +150,9 @@ async function main() { console.log('╚══════════════════════════════════════════════════════════╝\n'); console.log('1. Verify contracts on Nile Tronscan:'); console.log(' https://nile.tronscan.org/#/contract/' + deployments.ERC20FeeProxy.address); + console.log( + ' https://nile.tronscan.org/#/contract/' + deployments.ERC20BatchPayments.address, + ); console.log('\n2. Run tests against deployed contracts:'); console.log(' TRON_PRIVATE_KEY=... yarn tron:test:nile'); console.log('\n3. Update artifact registry with deployment addresses'); diff --git a/packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/0.1.0.json b/packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/0.1.0.json new file mode 100644 index 0000000000..e3bdc7a3cc --- /dev/null +++ b/packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/0.1.0.json @@ -0,0 +1,104 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_paymentErc20FeeProxy", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_tokenAddresses", + "type": "address[]" + }, + { + "internalType": "address[]", + "name": "_recipients", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_amounts", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "_paymentReferences", + "type": "bytes[]" + }, + { + "internalType": "uint256[]", + "name": "_feeAmounts", + "type": "uint256[]" + }, + { + "internalType": "address", + "name": "_feeAddress", + "type": "address" + } + ], + "name": "batchERC20PaymentsMultiTokensWithReference", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_tokenAddress", + "type": "address" + }, + { + "internalType": "address[]", + "name": "_recipients", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_amounts", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "_paymentReferences", + "type": "bytes[]" + }, + { + "internalType": "uint256[]", + "name": "_feeAmounts", + "type": "uint256[]" + }, + { + "internalType": "address", + "name": "_feeAddress", + "type": "address" + } + ], + "name": "batchERC20PaymentsWithReference", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paymentErc20FeeProxy", + "outputs": [ + { + "internalType": "contract IERC20FeeProxy", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} diff --git a/packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/index.ts b/packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/index.ts new file mode 100644 index 0000000000..534029b8c6 --- /dev/null +++ b/packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/index.ts @@ -0,0 +1,24 @@ +import { ContractArtifact } from '../../ContractArtifact'; + +import { abi as ABI_0_1_0 } from './0.1.0.json'; +// @ts-ignore Cannot find module +import type { ERC20BatchPayments } from '../../../types/tron'; + +export const erc20BatchPaymentsArtifact = new ContractArtifact( + { + tron: { + abi: ABI_0_1_0, + deployment: { + nile: { + address: 'TC6nD547PRDVWuX8hBMREU7vVvSZNCAZot', + creationBlockNumber: 63208782, + }, + tron: { + address: 'THm8vX6GNfRFZ15mRqdgvj56wjB6575S7C', + creationBlockNumber: 83068367, + }, + }, + }, + }, + '0.1.0', +); diff --git a/packages/smart-contracts/test/tron/ERC20BatchPayments.test.js b/packages/smart-contracts/test/tron/ERC20BatchPayments.test.js new file mode 100644 index 0000000000..9cad9b4eba --- /dev/null +++ b/packages/smart-contracts/test/tron/ERC20BatchPayments.test.js @@ -0,0 +1,774 @@ +const ERC20BatchPayments = artifacts.require('ERC20BatchPayments'); +const { + REF_A, + REF_B, + REF_C, + TRON_ZERO_ADDRESS, + waitForConfirmation, + balanceOf, + diff, + deployBaseSetup, + makeTokenApproval, + deployTokenWithSupply, + expectRevertOrNoBalanceChange, + assertBatchTokenBalancesZero, + deployBadTRC20, + sumStrings, + mulString, + getApprovalAmount, +} = require('./helpers'); + +contract('ERC20BatchPayments Tron Test Suite', (accounts) => { + const payer = accounts[0]; + const payee1 = accounts[1] || 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE'; + const payee2 = accounts[2] || 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs'; + const payee3 = accounts[3] || 'TFwt56qg984vEmk2UoDqUDeZhWEFSDaTmk'; + const feeAddress = accounts[4] || 'TNPGB28MjVCnEhTfpW51C2Ap3ZNnqGDXLB'; + + let batch; + let token1; + let token2; + let token3; + + before(async () => { + const setup = await deployBaseSetup({ + accounts, + batchDeployFn: (erc20FeeProxy) => ERC20BatchPayments.new(erc20FeeProxy.address), + }); + batch = setup.batch; + [token1, token2, token3] = setup.tokens; + + console.log('\n=== ERC20BatchPayments Test Setup ==='); + console.log('Batch:', batch.address); + await waitForConfirmation(3000); + }); + + beforeEach(async () => { + await waitForConfirmation(2000); + }); + + describe('Happy Path Payment Scenarios', () => { + describe('batchERC20PaymentsWithReference', () => { + it('should pay 3 ERC20 payments', async () => { + const amount1 = '2000'; + const amount2 = '300'; + const amount3 = '400'; + const fee1 = '200'; + const fee2 = '20'; + const fee3 = '30'; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1, amount2, amount3], [fee1, fee2, fee3]), + ); + + const payee1Before = await balanceOf(token1, payee1); + const payee2Before = await balanceOf(token1, payee2); + const feeBefore = await balanceOf(token1, feeAddress); + + await batch.batchERC20PaymentsWithReference( + token1.address, + [payee1, payee2, payee2], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ); + + assert.equal(diff(await balanceOf(token1, payee1), payee1Before).toString(), amount1); + assert.equal( + diff(await balanceOf(token1, payee2), payee2Before).toString(), + sumStrings([amount2, amount3]), + ); + assert.equal( + diff(await balanceOf(token1, feeAddress), feeBefore).toString(), + sumStrings([fee1, fee2, fee3]), + ); + }); + + it('should pay 10 ERC20 payments', async () => { + const amount = '200'; + const feeAmount = '100'; + const nbTxs = 10; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount(Array(nbTxs).fill(amount), Array(nbTxs).fill(feeAmount)), + ); + + const payee1Before = await balanceOf(token1, payee1); + const feeBefore = await balanceOf(token1, feeAddress); + + await batch.batchERC20PaymentsWithReference( + token1.address, + Array(nbTxs).fill(payee1), + Array(nbTxs).fill(amount), + Array(nbTxs).fill(REF_A), + Array(nbTxs).fill(feeAmount), + feeAddress, + { from: payer }, + ); + + assert.equal( + diff(await balanceOf(token1, payee1), payee1Before).toString(), + mulString(amount, nbTxs), + ); + assert.equal( + diff(await balanceOf(token1, feeAddress), feeBefore).toString(), + mulString(feeAmount, nbTxs), + ); + }); + + it('should leave no token balance on the batch contract after a successful payment', async () => { + const amount1 = '100'; + const fee1 = '10'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + await batch.batchERC20PaymentsWithReference( + token1.address, + [payee1], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ); + await waitForConfirmation(3000); + + await assertBatchTokenBalancesZero(batch, [token1]); + }); + + it('should pay a single ERC20 payment with BadTRC20', async () => { + const badToken = await deployBadTRC20(payer); + const paymentAmount = '100'; + const feeAmount = '10'; + + try { + await badToken.approve(batch.address, getApprovalAmount([paymentAmount], [feeAmount]), { + from: payer, + }); + await waitForConfirmation(3000); + + const payeeBefore = await balanceOf(badToken, payee1); + + await batch.batchERC20PaymentsWithReference( + badToken.address, + [payee1], + [paymentAmount], + [REF_A], + [feeAmount], + feeAddress, + { from: payer }, + ); + await waitForConfirmation(3000); + + const payeeAfter = await balanceOf(badToken, payee1); + assert( + payeeAfter > payeeBefore, + 'BadTRC20: payee balance should increase when batch payment succeeds', + ); + } catch (_error) { + console.log( + 'BadTRC20 batch payment rejected by Tron (acceptable for non-standard tokens)', + ); + } + }); + }); + + describe('batchERC20PaymentsMultiTokensWithReference', () => { + it('should pay 3 ERC20 payments in three different tokens', async () => { + const amount1 = '5000'; + const amount2 = '3000'; + const amount3 = '4000'; + const fee1 = '600'; + const fee2 = '200'; + const fee3 = '300'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + await makeTokenApproval(token2, payer, batch.address, getApprovalAmount([amount2], [fee2])); + await makeTokenApproval(token3, payer, batch.address, getApprovalAmount([amount3], [fee3])); + + const payee1Before = await balanceOf(token1, payee1); + const payee2Token2Before = await balanceOf(token2, payee2); + const payee2Token3Before = await balanceOf(token3, payee2); + const feeToken1Before = await balanceOf(token1, feeAddress); + const feeToken2Before = await balanceOf(token2, feeAddress); + const feeToken3Before = await balanceOf(token3, feeAddress); + + await batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token2.address, token3.address], + [payee1, payee2, payee2], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ); + + assert.equal(diff(await balanceOf(token1, payee1), payee1Before).toString(), amount1); + assert.equal(diff(await balanceOf(token2, payee2), payee2Token2Before).toString(), amount2); + assert.equal(diff(await balanceOf(token3, payee2), payee2Token3Before).toString(), amount3); + assert.equal(diff(await balanceOf(token1, feeAddress), feeToken1Before).toString(), fee1); + assert.equal(diff(await balanceOf(token2, feeAddress), feeToken2Before).toString(), fee2); + assert.equal(diff(await balanceOf(token3, feeAddress), feeToken3Before).toString(), fee3); + }); + + it('should pay 3 ERC20 payments in three different tokens with a zero amount payment', async () => { + const amount1 = '5000'; + const amount2 = '0'; + const amount3 = '4000'; + const fee1 = '600'; + const fee2 = '0'; + const fee3 = '300'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + await makeTokenApproval(token2, payer, batch.address, getApprovalAmount([amount2], [fee2])); + await makeTokenApproval(token3, payer, batch.address, getApprovalAmount([amount3], [fee3])); + + const payee1Before = await balanceOf(token1, payee1); + const payee2Token2Before = await balanceOf(token2, payee2); + const payee2Token3Before = await balanceOf(token3, payee2); + + await batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token2.address, token3.address], + [payee1, payee2, payee2], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ); + + assert.equal(diff(await balanceOf(token1, payee1), payee1Before).toString(), amount1); + assert.equal(diff(await balanceOf(token2, payee2), payee2Token2Before).toString(), amount2); + assert.equal(diff(await balanceOf(token3, payee2), payee2Token3Before).toString(), amount3); + }); + + it('should pay 4 ERC20 payments in two different tokens', async () => { + const amount1 = '200'; + const amount2 = '200'; + const amount3 = '200'; + const amount4 = '200'; + const fee1 = '10'; + const fee2 = '10'; + const fee3 = '10'; + const fee4 = '10'; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1, amount2], [fee1, fee2]), + ); + await makeTokenApproval( + token2, + payer, + batch.address, + getApprovalAmount([amount3, amount4], [fee3, fee4]), + ); + + const payee2Token1Before = await balanceOf(token1, payee2); + const payee2Token2Before = await balanceOf(token2, payee2); + + await batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token1.address, token2.address, token2.address], + [payee2, payee2, payee2, payee2], + [amount1, amount2, amount3, amount4], + [REF_A, REF_A, REF_A, REF_A], + [fee1, fee2, fee3, fee4], + feeAddress, + { from: payer }, + ); + + assert.equal( + diff(await balanceOf(token1, payee2), payee2Token1Before).toString(), + sumStrings([amount1, amount2]), + ); + assert.equal( + diff(await balanceOf(token2, payee2), payee2Token2Before).toString(), + sumStrings([amount3, amount4]), + ); + }); + + it('should pay 10 ERC20 payments in two different tokens', async () => { + const amount = '20'; + const feeAmount = '10'; + const nbPaymentsPerToken = 5; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount( + Array(nbPaymentsPerToken).fill(amount), + Array(nbPaymentsPerToken).fill(feeAmount), + ), + ); + await makeTokenApproval( + token2, + payer, + batch.address, + getApprovalAmount( + Array(nbPaymentsPerToken).fill(amount), + Array(nbPaymentsPerToken).fill(feeAmount), + ), + ); + + const payee1Token1Before = await balanceOf(token1, payee1); + const payee1Token2Before = await balanceOf(token2, payee1); + + await batch.batchERC20PaymentsMultiTokensWithReference( + [ + ...Array(nbPaymentsPerToken).fill(token1.address), + ...Array(nbPaymentsPerToken).fill(token2.address), + ], + Array(nbPaymentsPerToken * 2).fill(payee1), + Array(nbPaymentsPerToken * 2).fill(amount), + Array(nbPaymentsPerToken * 2).fill(REF_A), + Array(nbPaymentsPerToken * 2).fill(feeAmount), + feeAddress, + { from: payer }, + ); + + assert.equal( + diff(await balanceOf(token1, payee1), payee1Token1Before).toString(), + mulString(amount, nbPaymentsPerToken), + ); + assert.equal( + diff(await balanceOf(token2, payee1), payee1Token2Before).toString(), + mulString(amount, nbPaymentsPerToken), + ); + }); + + it('should pay 10 ERC20 payments in two different tokens without fees', async () => { + const amount = '20'; + const feeAmount = '0'; + const nbPaymentsPerToken = 5; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount( + Array(nbPaymentsPerToken).fill(amount), + Array(nbPaymentsPerToken).fill(feeAmount), + ), + ); + await makeTokenApproval( + token2, + payer, + batch.address, + getApprovalAmount( + Array(nbPaymentsPerToken).fill(amount), + Array(nbPaymentsPerToken).fill(feeAmount), + ), + ); + + const payee1Token1Before = await balanceOf(token1, payee1); + const payee1Token2Before = await balanceOf(token2, payee1); + const feeToken1Before = await balanceOf(token1, feeAddress); + const feeToken2Before = await balanceOf(token2, feeAddress); + + await batch.batchERC20PaymentsMultiTokensWithReference( + [ + ...Array(nbPaymentsPerToken).fill(token1.address), + ...Array(nbPaymentsPerToken).fill(token2.address), + ], + Array(nbPaymentsPerToken * 2).fill(payee1), + Array(nbPaymentsPerToken * 2).fill(amount), + Array(nbPaymentsPerToken * 2).fill(REF_A), + Array(nbPaymentsPerToken * 2).fill(feeAmount), + TRON_ZERO_ADDRESS, + { from: payer }, + ); + await waitForConfirmation(3000); + + assert.equal( + diff(await balanceOf(token1, payee1), payee1Token1Before).toString(), + mulString(amount, nbPaymentsPerToken), + ); + assert.equal( + diff(await balanceOf(token2, payee1), payee1Token2Before).toString(), + mulString(amount, nbPaymentsPerToken), + ); + assert.equal( + diff(await balanceOf(token1, feeAddress), feeToken1Before).toString(), + feeAmount, + ); + assert.equal( + diff(await balanceOf(token2, feeAddress), feeToken2Before).toString(), + feeAmount, + ); + }); + + it('should leave no token balance on the batch contract after a successful payment', async () => { + const amount1 = '100'; + const amount2 = '200'; + const fee1 = '10'; + const fee2 = '20'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + await makeTokenApproval(token2, payer, batch.address, getApprovalAmount([amount2], [fee2])); + + await batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token2.address], + [payee1, payee2], + [amount1, amount2], + [REF_A, REF_B], + [fee1, fee2], + feeAddress, + { from: payer }, + ); + await waitForConfirmation(3000); + + await assertBatchTokenBalancesZero(batch, [token1, token2]); + }); + + it('should pay a multi-token ERC20 payment with BadTRC20', async () => { + const badToken = await deployBadTRC20(payer); + const paymentAmount = '100'; + const feeAmount = '10'; + const amount1 = '50'; + const fee1 = '5'; + + try { + await badToken.approve(batch.address, getApprovalAmount([paymentAmount], [feeAmount]), { + from: payer, + }); + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1], [fee1]), + ); + await waitForConfirmation(3000); + + const badPayeeBefore = await balanceOf(badToken, payee1); + const payee1Before = await balanceOf(token1, payee2); + + await batch.batchERC20PaymentsMultiTokensWithReference( + [badToken.address, token1.address], + [payee1, payee2], + [paymentAmount, amount1], + [REF_A, REF_B], + [feeAmount, fee1], + feeAddress, + { from: payer }, + ); + await waitForConfirmation(3000); + + const badPayeeAfter = await balanceOf(badToken, payee1); + const payee1After = await balanceOf(token1, payee2); + assert( + badPayeeAfter > badPayeeBefore || payee1After > payee1Before, + 'BadTRC20 multi-token: at least one payee balance should increase when batch succeeds', + ); + } catch (_error) { + console.log( + 'BadTRC20 multi-token batch payment rejected by Tron (acceptable for non-standard tokens)', + ); + } + }); + }); + }); + + describe('Error cases scenarios', () => { + describe('batchERC20PaymentsWithReference', () => { + it('should revert when the payer does not have enough funds to pay', async () => { + const amount1 = '5'; + const amount2 = '30'; + const amount3 = '400'; + const fee1 = '1'; + const fee2 = '2'; + const fee3 = '3'; + + const lowToken = await deployTokenWithSupply('100', payer); + await makeTokenApproval( + lowToken, + payer, + batch.address, + getApprovalAmount([amount1, amount2, amount3], [fee1, fee2, fee3]), + ); + + const payee3Before = await balanceOf(lowToken, payee3); + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsWithReference( + lowToken.address, + [payee1, payee2, payee3], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(lowToken, payee3)], + ); + + assert(unchanged, 'should not transfer when funds insufficient'); + assert.equal((await balanceOf(lowToken, payee3)).toString(), payee3Before.toString()); + }); + + it('should revert when the payer does not have enough funds to pay the fees', async () => { + const amount1 = '100'; + const amount2 = '200'; + const fee1 = '50'; + const fee2 = '50'; + + const lowToken = await deployTokenWithSupply('300', payer); + await makeTokenApproval( + lowToken, + payer, + batch.address, + getApprovalAmount([amount1, amount2], [fee1, fee2]), + ); + + const payee1Before = await balanceOf(lowToken, payee1); + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsWithReference( + lowToken.address, + [payee1, payee2], + [amount1, amount2], + [REF_A, REF_B], + [fee1, fee2], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(lowToken, payee1)], + ); + + assert(unchanged, 'should not transfer when fees cannot be paid'); + assert.equal((await balanceOf(lowToken, payee1)).toString(), payee1Before.toString()); + }); + + it('should revert when the payer did not approve the batch contract to spend the tokens', async () => { + const amount1 = '20'; + const amount2 = '30'; + const amount3 = '40'; + const fee1 = '1'; + const fee2 = '2'; + const fee3 = '3'; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1, amount2, amount3], [fee1, fee2, fee3]), + ); + await token1.approve(batch.address, '10', { from: payer }); + await waitForConfirmation(2000); + + const payee1Before = await balanceOf(token1, payee1); + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsWithReference( + token1.address, + [payee1, payee2, payee3], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + ); + + assert(unchanged, 'should not transfer without allowance'); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + }); + + it('should revert when input arrays have different lengths', async () => { + const amount1 = '100'; + const fee1 = '1'; + const fee2 = '2'; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1], [fee1, fee2]), + ); + + const payee1Before = await balanceOf(token1, payee1); + const payee2Before = await balanceOf(token1, payee2); + + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsWithReference( + token1.address, + [payee1, payee2], + [amount1], + [REF_A, REF_B], + [fee1, fee2], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1), await balanceOf(token1, payee2)], + ); + + assert(unchanged, 'should not transfer when array lengths mismatch'); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + assert.equal((await balanceOf(token1, payee2)).toString(), payee2Before.toString()); + }); + }); + + describe('batchERC20PaymentsMultiTokensWithReference', () => { + it('should revert when the payer does not have enough funds to pay in at least one of the tokens', async () => { + const amount1 = '5'; + const amount2 = '30'; + const amount3 = '400'; + const fee1 = '1'; + const fee2 = '2'; + const fee3 = '3'; + + const lowToken = await deployTokenWithSupply('400', payer); + await makeTokenApproval( + lowToken, + payer, + batch.address, + getApprovalAmount([amount1, amount2, amount3], [fee1, fee2, fee3]), + ); + + const payee3Before = await balanceOf(lowToken, payee3); + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [lowToken.address, lowToken.address, lowToken.address], + [payee1, payee2, payee3], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(lowToken, payee3)], + ); + + assert(unchanged, 'multi-token batch should not transfer when funds insufficient'); + assert.equal((await balanceOf(lowToken, payee3)).toString(), payee3Before.toString()); + }); + + it('should revert when the payer did not approve the batch contract to spend the tokens in at least one of the tokens', async () => { + const amount1 = '100'; + const amount2 = '200'; + const amount3 = '300'; + const fee1 = '1'; + const fee2 = '2'; + const fee3 = '3'; + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1, amount2, amount3], [fee1, fee2, fee3]), + ); + await token1.approve(batch.address, '10', { from: payer }); + await waitForConfirmation(2000); + + const payee1Before = await balanceOf(token1, payee1); + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token1.address, token1.address], + [payee1, payee2, payee3], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + ); + + assert(unchanged, 'multi-token batch should not transfer without allowance'); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + }); + + it('should revert when the payer did not approve the batch contract for one of the tokens', async () => { + const amount1 = '100'; + const amount2 = '200'; + const fee1 = '1'; + const fee2 = '2'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const payee2Token2Before = await balanceOf(token2, payee2); + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token2.address], + [payee1, payee2], + [amount1, amount2], + [REF_A, REF_B], + [fee1, fee2], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token2, payee2)], + ); + + assert(unchanged, 'should not transfer when one token lacks approval'); + assert.equal((await balanceOf(token2, payee2)).toString(), payee2Token2Before.toString()); + }); + + it('should revert when input arrays have different lengths', async () => { + const amount1 = '100'; + const fee1 = '1'; + const fee2 = '2'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + await makeTokenApproval(token2, payer, batch.address, getApprovalAmount([amount1], [fee2])); + + const payee1Before = await balanceOf(token1, payee1); + const payee2Before = await balanceOf(token2, payee2); + + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token2.address], + [payee1, payee2], + [amount1], + [REF_A, REF_B], + [fee1, fee2], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1), await balanceOf(token2, payee2)], + ); + + assert(unchanged, 'should not transfer when array lengths mismatch'); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + assert.equal((await balanceOf(token2, payee2)).toString(), payee2Before.toString()); + }); + }); + }); +}); + +contract('ERC20BatchPayments constructor', () => { + const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + + it('should revert when paymentErc20FeeProxy is the zero address', async () => { + let reverted = false; + let errorMessage = ''; + + try { + await ERC20BatchPayments.new(ZERO_ADDRESS); + } catch (error) { + reverted = true; + errorMessage = error.message || String(error); + } + + assert(reverted, 'deployment should revert when paymentErc20FeeProxy is address(0)'); + assert( + errorMessage.includes('paymentErc20FeeProxy cannot be 0x'), + `expected zero-address revert, got: ${errorMessage}`, + ); + }); +}); diff --git a/packages/smart-contracts/test/tron/helpers.js b/packages/smart-contracts/test/tron/helpers.js new file mode 100644 index 0000000000..3fd05d4242 --- /dev/null +++ b/packages/smart-contracts/test/tron/helpers.js @@ -0,0 +1,149 @@ +const INITIAL_SUPPLY = '10000000000'; + +const REF_A = '0xaaaa'; +const REF_B = '0xbbbb'; +const REF_C = '0xcccc'; + +/** Tron base58 zero address (unset EthFeeProxy on Tron deployments). */ +const TRON_ZERO_ADDRESS = '410000000000000000000000000000000000000000'; + +/** 1 TRX = 1_000_000 sun on Tron. */ +const ONE_TRX_SUN = 1_000_000; + +const waitForConfirmation = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const balanceOf = async (token, account) => { + const value = await token.balanceOf(account); + return BigInt(value.toString()); +}; + +const trxBalance = async (address) => { + const balance = await tronWeb.trx.getBalance(address); + return BigInt(balance); +}; + +const diff = (after, before) => after - before; + +const sumStrings = (values) => values.reduce((acc, value) => acc + BigInt(value), 0n).toString(); + +const mulString = (value, count) => (BigInt(value) * BigInt(count)).toString(); + +const computeBatchFee = (totalPaymentAmount, bps) => + ((BigInt(totalPaymentAmount) * BigInt(bps)) / 1000n).toString(); + +const getApprovalAmount = (amountList, feeList, batchFee = '0') => + sumStrings([...amountList, ...feeList, batchFee]); + +/** + * Deploy ERC20FeeProxy, optional batch contract, and one or more TestTRC20 tokens. + */ +const deployBaseSetup = async ({ accounts, batchDeployFn, batchFee, tokenCount = 3 }) => { + const ERC20FeeProxy = artifacts.require('ERC20FeeProxy'); + const TestTRC20 = artifacts.require('TestTRC20'); + + const owner = accounts[0]; + const erc20FeeProxy = await ERC20FeeProxy.new(); + const dummyEthProxy = TRON_ZERO_ADDRESS; + + let batch = null; + if (batchDeployFn) { + batch = await batchDeployFn(erc20FeeProxy, owner, dummyEthProxy); + if (batchFee !== undefined && batch.setBatchFee) { + await batch.setBatchFee(batchFee, { from: owner }); + } + } + + const tokens = []; + for (let i = 0; i < tokenCount; i++) { + const token = await TestTRC20.new(INITIAL_SUPPLY, `Test TRC20 ${i + 1}`, `TT${i + 1}`, 18); + tokens.push(token); + } + + return { erc20FeeProxy, batch, tokens, dummyEthProxy }; +}; + +/** + * Approve contract to spend payer tokens. + */ +const makeTokenApproval = async (token, payer, batchAddress, amount) => { + await token.approve(batchAddress, amount, { from: payer }); + await waitForConfirmation(2000); +}; + +/** + * Deploy a TestTRC20 with a specific initial supply assigned to payer. + */ +const deployTokenWithSupply = async (supply, payer) => { + const TestTRC20 = artifacts.require('TestTRC20'); + return TestTRC20.new(supply, 'Test TRC20', 'TTRC', 18, { from: payer }); +}; + +/** + * Runs fn and asserts tracked balances are unchanged (source of truth Tron when Tron tx reverts). + */ +const expectRevertOrNoBalanceChange = async (fn, getBalances) => { + const before = await getBalances(); + try { + await fn(); + } catch (_error) {} + await waitForConfirmation(2000); + const after = await getBalances(); + const unchanged = before.every((value, index) => value === after[index]); + return { unchanged }; +}; + +/** + * Asserts the batch contract holds zero balance for each token. + */ +const assertBatchTokenBalancesZero = async (batch, tokens) => { + for (const token of tokens) { + const bal = await balanceOf(token, batch.address); + assert.equal(bal.toString(), '0', `batch should have zero token balance for ${token.address}`); + } +}; + +/** + * Expects fn to revert; optionally asserts getState() is unchanged. + */ +const expectNonOwnerReverts = async (fn, getState) => { + const before = await getState(); + try { + await fn(); + } catch (_error) {} + await waitForConfirmation(2000); + + const after = await getState(); + assert.equal(after, before, 'state should be unchanged after failed non-owner call'); +}; + +/** + * Deploy BadTRC20 with migration-style constructor args. + */ +const deployBadTRC20 = async (payer) => { + const BadTRC20 = artifacts.require('BadTRC20'); + return BadTRC20.new('1000000000000', 'BadTRC20', 'BAD', 8, { from: payer }); +}; + +module.exports = { + INITIAL_SUPPLY, + REF_A, + REF_B, + REF_C, + TRON_ZERO_ADDRESS, + ONE_TRX_SUN, + waitForConfirmation, + balanceOf, + trxBalance, + diff, + sumStrings, + mulString, + computeBatchFee, + getApprovalAmount, + deployBaseSetup, + makeTokenApproval, + deployTokenWithSupply, + expectRevertOrNoBalanceChange, + assertBatchTokenBalancesZero, + expectNonOwnerReverts, + deployBadTRC20, +}; diff --git a/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol b/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol new file mode 100644 index 0000000000..4a190b0d60 --- /dev/null +++ b/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import './interfaces/ERC20FeeProxy.sol'; +import './lib/SafeERC20.sol'; + +/** + * @title ERC20BatchPayments + * @notice Tron-only batch contract that routes each payment through ERC20FeeProxy. + * If one payment fails, the whole batch reverts. + * @dev Uses ERC20FeeProxy to pay an invoice and fees, with a payment reference. + * Make sure this contract has allowance to spend the payer's tokens. + * Make sure the payer has enough tokens to pay the amounts and fees. + */ +contract ERC20BatchPayments { + using SafeERC20 for IERC20; + + IERC20FeeProxy public immutable paymentErc20FeeProxy; + + struct Token { + address tokenAddress; + uint256 amountAndFee; + } + + /** + * @param _paymentErc20FeeProxy The address of the ERC20FeeProxy to use. + */ + constructor(address _paymentErc20FeeProxy) { + require(_paymentErc20FeeProxy != address(0), 'ERC20BatchPayments: paymentErc20FeeProxy cannot be 0x'); + paymentErc20FeeProxy = IERC20FeeProxy(_paymentErc20FeeProxy); + } + + /** + * @notice Send a batch of ERC20 payments with fees and payment references to multiple accounts. + * @param _tokenAddress Token to transact with. + * @param _recipients List of recipient accounts. + * @param _amounts List of amounts, corresponding to recipients[]. + * @param _paymentReferences List of payment references, corresponding to recipients[]. + * @param _feeAmounts List of fee amounts, corresponding to recipients[]. + * @param _feeAddress The fee recipient. + */ + function batchERC20PaymentsWithReference( + address _tokenAddress, + address[] calldata _recipients, + uint256[] calldata _amounts, + bytes[] calldata _paymentReferences, + uint256[] calldata _feeAmounts, + address _feeAddress + ) external { + require( + _recipients.length == _amounts.length && + _recipients.length == _paymentReferences.length && + _recipients.length == _feeAmounts.length, + 'the input arrays must have the same length' + ); + + uint256 amountAndFee = 0; + for (uint256 i = 0; i < _recipients.length; i++) { + amountAndFee += _amounts[i] + _feeAmounts[i]; + } + + _transferToContractAndApproveProxy(IERC20(_tokenAddress), amountAndFee); + + for (uint256 i = 0; i < _recipients.length; i++) { + paymentErc20FeeProxy.transferFromWithReferenceAndFee( + _tokenAddress, + _recipients[i], + _amounts[i], + _paymentReferences[i], + _feeAmounts[i], + _feeAddress + ); + } + } + + /** + * @notice Send a batch of ERC20 payments on multiple tokens with fees and payment references. + * @param _tokenAddresses List of tokens to transact with. + * @param _recipients List of recipient accounts. + * @param _amounts List of amounts, corresponding to recipients[]. + * @param _paymentReferences List of payment references, corresponding to recipients[]. + * @param _feeAmounts List of fee amounts, corresponding to recipients[]. + * @param _feeAddress The fee recipient. + */ + function batchERC20PaymentsMultiTokensWithReference( + address[] calldata _tokenAddresses, + address[] calldata _recipients, + uint256[] calldata _amounts, + bytes[] calldata _paymentReferences, + uint256[] calldata _feeAmounts, + address _feeAddress + ) external { + require( + _tokenAddresses.length == _recipients.length && + _tokenAddresses.length == _amounts.length && + _tokenAddresses.length == _paymentReferences.length && + _tokenAddresses.length == _feeAmounts.length, + 'the input arrays must have the same length' + ); + + Token[] memory uniqueTokens = new Token[](_tokenAddresses.length); + for (uint256 i = 0; i < _tokenAddresses.length; i++) { + for (uint256 j = 0; j < _tokenAddresses.length; j++) { + if (uniqueTokens[j].tokenAddress == _tokenAddresses[i]) { + uniqueTokens[j].amountAndFee += _amounts[i] + _feeAmounts[i]; + break; + } + if (uniqueTokens[j].amountAndFee == 0 && (_amounts[i] + _feeAmounts[i]) > 0) { + uniqueTokens[j].tokenAddress = _tokenAddresses[i]; + uniqueTokens[j].amountAndFee = _amounts[i] + _feeAmounts[i]; + break; + } + } + } + + for (uint256 i = 0; i < uniqueTokens.length && uniqueTokens[i].amountAndFee > 0; i++) { + _transferToContractAndApproveProxy( + IERC20(uniqueTokens[i].tokenAddress), + uniqueTokens[i].amountAndFee + ); + } + + for (uint256 i = 0; i < _recipients.length; i++) { + paymentErc20FeeProxy.transferFromWithReferenceAndFee( + _tokenAddresses[i], + _recipients[i], + _amounts[i], + _paymentReferences[i], + _feeAmounts[i], + _feeAddress + ); + } + } + + /** + * @notice Authorizes the proxy to spend a request currency (ERC20). + * @param _erc20Address Address of an ERC20 used as the request currency. + */ + function approvePaymentProxyToSpend(address _erc20Address) internal { + IERC20 erc20 = IERC20(_erc20Address); + uint256 max = type(uint256).max; + require(erc20.safeApprove(address(paymentErc20FeeProxy), max), 'approve() failed'); + } + + /** + * @notice Pulls tokens from the payer to this contract and approves the proxy to spend them. + * @param requestedToken The token to pay. + * @param amountAndFee The sum of payment amounts and fees for this token. + */ + function _transferToContractAndApproveProxy( + IERC20 requestedToken, + uint256 amountAndFee + ) internal { + require( + requestedToken.allowance(msg.sender, address(this)) >= amountAndFee, + 'Not sufficient allowance for batch to pay' + ); + require(requestedToken.balanceOf(msg.sender) >= amountAndFee, 'not enough funds'); + require( + requestedToken.safeTransferFrom(msg.sender, address(this), amountAndFee), + 'payment transferFrom() failed' + ); + + if (requestedToken.allowance(address(this), address(paymentErc20FeeProxy)) < amountAndFee) { + approvePaymentProxyToSpend(address(requestedToken)); + } + } +} diff --git a/packages/smart-contracts/tron/contracts/interfaces/ERC20FeeProxy.sol b/packages/smart-contracts/tron/contracts/interfaces/ERC20FeeProxy.sol new file mode 120000 index 0000000000..88ec30138c --- /dev/null +++ b/packages/smart-contracts/tron/contracts/interfaces/ERC20FeeProxy.sol @@ -0,0 +1 @@ +../../../src/contracts/interfaces/ERC20FeeProxy.sol \ No newline at end of file diff --git a/packages/smart-contracts/tron/contracts/interfaces/EthereumFeeProxy.sol b/packages/smart-contracts/tron/contracts/interfaces/EthereumFeeProxy.sol new file mode 120000 index 0000000000..2ba444a117 --- /dev/null +++ b/packages/smart-contracts/tron/contracts/interfaces/EthereumFeeProxy.sol @@ -0,0 +1 @@ +../../../src/contracts/interfaces/EthereumFeeProxy.sol \ No newline at end of file diff --git a/packages/smart-contracts/tron/contracts/lib/SafeERC20.sol b/packages/smart-contracts/tron/contracts/lib/SafeERC20.sol new file mode 120000 index 0000000000..4003968ec2 --- /dev/null +++ b/packages/smart-contracts/tron/contracts/lib/SafeERC20.sol @@ -0,0 +1 @@ +../../../src/contracts/lib/SafeERC20.sol \ No newline at end of file