diff --git a/.github/workflows/tron-smart-contracts.yml b/.github/workflows/tron-smart-contracts.yml index c13fd7c8b5..7ee4dcbcfe 100644 --- a/.github/workflows/tron-smart-contracts.yml +++ b/.github/workflows/tron-smart-contracts.yml @@ -12,6 +12,9 @@ on: - 'packages/smart-contracts/test/tron/**' - 'packages/smart-contracts/tronbox-config.js' - 'packages/smart-contracts/src/lib/artifacts/ERC20FeeProxy/**' + - 'packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/**' + - 'packages/smart-contracts/src/contracts/ERC20FeeProxy.sol' + - 'packages/smart-contracts/src/contracts/ERC20BatchPayments.sol' - 'packages/payment-processor/src/payment/*tron*' - 'packages/payment-processor/test/payment/*tron*' - 'packages/currency/src/chains/tron/**' @@ -27,6 +30,9 @@ on: - 'packages/smart-contracts/test/tron/**' - 'packages/smart-contracts/tronbox-config.js' - 'packages/smart-contracts/src/lib/artifacts/ERC20FeeProxy/**' + - 'packages/smart-contracts/src/lib/artifacts/ERC20BatchPayments/**' + - 'packages/smart-contracts/src/contracts/ERC20FeeProxy.sol' + - 'packages/smart-contracts/src/contracts/ERC20BatchPayments.sol' - 'packages/payment-processor/src/payment/*tron*' - 'packages/payment-processor/test/payment/*tron*' - 'packages/currency/src/chains/tron/**' @@ -76,7 +82,7 @@ jobs: ls -la build/tron/ # Verify key contracts were compiled - for contract in ERC20FeeProxy TestTRC20 BadTRC20 TRC20True TRC20NoReturn TRC20False TRC20Revert; do + for contract in ERC20FeeProxy ERC20BatchPayments TestTRC20 BadTRC20 TRC20True TRC20NoReturn TRC20False TRC20Revert; do if [ ! -f "build/tron/${contract}.json" ]; then echo "ERROR: ${contract}.json not found!" exit 1 @@ -109,6 +115,15 @@ jobs: echo "✓ TestTRC20 has $func" done + echo "Verifying ERC20BatchPayments ABI..." + for func in batchERC20PaymentsWithReference batchERC20PaymentsMultiTokensWithReference paymentErc20FeeProxy; do + if ! grep -q "$func" build/tron/ERC20BatchPayments.json; then + echo "ERROR: ERC20BatchPayments missing $func!" + exit 1 + fi + echo "✓ ERC20BatchPayments has $func" + done + echo "✅ Contract ABI structure verified" - name: Verify deployment files are valid JSON 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..e48ed6eb68 100644 --- a/packages/smart-contracts/deployments/tron/mainnet.json +++ b/packages/smart-contracts/deployments/tron/mainnet.json @@ -9,6 +9,11 @@ "address": "TCUDPYnS9dH3WvFEaE7wN7vnDa51J4R4fd", "hexAddress": "411b6ca35d39842cf8fbe49000653a1505412da659", "creationBlockNumber": 79216121 + }, + "ERC20BatchPayments": { + "address": "TRZbXXuLd3HW5utzVysA3rpLgU7sVBrd1D", + "hexAddress": "41ab0ad52e1d1615ee6bc20ab8f2a4c498fb89fd10", + "creationBlockNumber": 83104290 } } } diff --git a/packages/smart-contracts/deployments/tron/nile.json b/packages/smart-contracts/deployments/tron/nile.json index 77257b8bc6..f42f30de64 100644 --- a/packages/smart-contracts/deployments/tron/nile.json +++ b/packages/smart-contracts/deployments/tron/nile.json @@ -3,12 +3,17 @@ "chainId": "3", "timestamp": "2024-01-01T00:00:00.000Z", "deployer": "TO_BE_FILLED_ON_DEPLOYMENT", - "note": "Existing deployment from handover document. Run 'yarn tron:deploy:nile' to redeploy.", + "note": "Existing deployment from handover document", "contracts": { "ERC20FeeProxy": { "address": "THK5rNmrvCujhmrXa5DB1dASepwXTr9cJs", "hexAddress": "41508b3b4059c40bb3aac5da5ac006ccdd9c4dc957", "creationBlockNumber": 63208782 + }, + "ERC20BatchPayments": { + "address": "TBAtFt46T7LUW5Sya6PNjw7MQrKkzKEFMx", + "hexAddress": "410d2d78623480a4caf18ea157badd9a8a7311b746", + "creationBlockNumber": 67830042 } } } 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..5858affcf0 100644 --- a/packages/smart-contracts/scripts/tron/deploy-nile.js +++ b/packages/smart-contracts/scripts/tron/deploy-nile.js @@ -56,10 +56,19 @@ async function deployContract(contractName, constructorArgs = []) { parameters: constructorArgs, }); - console.log(`${contractName} deployed at: ${contract.address}`); - console.log(`Base58 address: ${tronWeb.address.fromHex(contract.address)}`); + const base58Address = tronWeb.address.fromHex(contract.address); + const block = await tronWeb.trx.getCurrentBlock(); + const creationBlockNumber = block.block_header.raw_data.number; - return contract; + console.log(`${contractName} deployed at: ${contract.address}`); + console.log(`Base58 address: ${base58Address}`); + console.log(`Block: ${creationBlockNumber}`); + + return { + address: base58Address, + hexAddress: contract.address, + creationBlockNumber, + }; } async function main() { @@ -85,30 +94,25 @@ async function main() { try { // 1. Deploy ERC20FeeProxy - const erc20FeeProxy = await deployContract('ERC20FeeProxy'); - deployments.ERC20FeeProxy = { - address: tronWeb.address.fromHex(erc20FeeProxy.address), - hexAddress: erc20FeeProxy.address, - }; + deployments.ERC20FeeProxy = await deployContract('ERC20FeeProxy'); + + // 2. Deploy ERC20BatchPayments + deployments.ERC20BatchPayments = await deployContract('ERC20BatchPayments', [ + deployments.ERC20FeeProxy.address, + ]); - // 2. Deploy TestTRC20 for testing - const testToken = await deployContract('TestTRC20', [ + // 3. Deploy TestTRC20 for testing + deployments.TestTRC20 = await deployContract('TestTRC20', [ '1000000000000000000000000000', // 1 billion tokens 'Nile Test TRC20', 'NTRC20', 18, ]); - deployments.TestTRC20 = { - address: tronWeb.address.fromHex(testToken.address), - hexAddress: testToken.address, - }; - // 3. Deploy test token variants - const trc20NoReturn = await deployContract('TRC20NoReturn', ['1000000000000000000000000000']); - deployments.TRC20NoReturn = { - address: tronWeb.address.fromHex(trc20NoReturn.address), - hexAddress: trc20NoReturn.address, - }; + // 4. Deploy test token variants + deployments.TRC20NoReturn = await deployContract('TRC20NoReturn', [ + '1000000000000000000000000000', + ]); // Print summary console.log('\n╔══════════════════════════════════════════════════════════╗'); @@ -119,6 +123,7 @@ async function main() { console.log(`${name}:`); console.log(` Base58: ${info.address}`); console.log(` Hex: ${info.hexAddress}`); + console.log(` Block: ${info.creationBlockNumber}`); } // Save deployment info @@ -141,6 +146,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/contracts/ERC20BatchPayments.sol b/packages/smart-contracts/src/contracts/ERC20BatchPayments.sol new file mode 100644 index 0000000000..a77c7cc719 --- /dev/null +++ b/packages/smart-contracts/src/contracts/ERC20BatchPayments.sol @@ -0,0 +1,210 @@ +// 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. + * This contract emits no events. Index TransferWithReferenceAndFee on ERC20FeeProxy + * with msg.sender == address(this batch contract). + * The proxy receives a one-time max allowance per token. + */ +contract ERC20BatchPayments { + using SafeERC20 for IERC20; + + IERC20FeeProxy public immutable paymentErc20FeeProxy; + + /// @dev True after unlimited proxy approval was set for a token (avoids repeated approve calls). + mapping(address => bool) private _proxyApproved; + + 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' + ); + require(_tokenAddress != address(0), 'ERC20BatchPayments: token cannot be 0x'); + + uint256 amountAndFee = 0; + for (uint256 i = 0; i < _recipients.length; ) { + amountAndFee += _amounts[i] + _feeAmounts[i]; + unchecked { + ++i; + } + } + + if (amountAndFee > 0) { + _transferToContractAndApproveProxy(IERC20(_tokenAddress), amountAndFee); + } + + for (uint256 i = 0; i < _recipients.length; ) { + uint256 paymentSum = _amounts[i] + _feeAmounts[i]; + if (paymentSum == 0) { + unchecked { + ++i; + } + continue; + } + require(_recipients[i] != address(0), 'ERC20BatchPayments: recipient cannot be 0x'); + if (_feeAmounts[i] > 0) { + require(_feeAddress != address(0), 'ERC20BatchPayments: feeAddress cannot be 0x when fee > 0'); + } + paymentErc20FeeProxy.transferFromWithReferenceAndFee( + _tokenAddress, + _recipients[i], + _amounts[i], + _paymentReferences[i], + _feeAmounts[i], + _feeAddress + ); + unchecked { + ++i; + } + } + } + + /** + * @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; ) { + require(_tokenAddresses[i] != address(0), 'ERC20BatchPayments: token cannot be 0x'); + for (uint256 j = 0; j < _tokenAddresses.length; ) { + 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; + } + unchecked { + ++j; + } + } + unchecked { + ++i; + } + } + + for (uint256 i = 0; i < uniqueTokens.length && uniqueTokens[i].amountAndFee > 0; ) { + _transferToContractAndApproveProxy( + IERC20(uniqueTokens[i].tokenAddress), + uniqueTokens[i].amountAndFee + ); + unchecked { + ++i; + } + } + + for (uint256 i = 0; i < _recipients.length; ) { + uint256 paymentSum = _amounts[i] + _feeAmounts[i]; + if (paymentSum == 0) { + unchecked { + ++i; + } + continue; + } + require(_recipients[i] != address(0), 'ERC20BatchPayments: recipient cannot be 0x'); + if (_feeAmounts[i] > 0) { + require(_feeAddress != address(0), 'ERC20BatchPayments: feeAddress cannot be 0x when fee > 0'); + } + paymentErc20FeeProxy.transferFromWithReferenceAndFee( + _tokenAddresses[i], + _recipients[i], + _amounts[i], + _paymentReferences[i], + _feeAmounts[i], + _feeAddress + ); + unchecked { + ++i; + } + } + } + + /** + * @notice Pulls tokens from the payer to this contract and approves the proxy to spend them. + * @dev Approves the proxy once per token with max allowance; later batches skip approve. + * @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.safeTransferFrom(msg.sender, address(this), amountAndFee), + 'payment transferFrom() failed' + ); + + address token = address(requestedToken); + if (!_proxyApproved[token]) { + require( + requestedToken.safeApprove(address(paymentErc20FeeProxy), type(uint256).max), + 'approve() failed' + ); + _proxyApproved[token] = true; + } + } +} 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..a2834de59b --- /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'; + +export const erc20BatchPaymentsArtifact = new ContractArtifact( + { + tron: { + abi: ABI_0_1_0, + deployment: { + nile: { + address: 'TBAtFt46T7LUW5Sya6PNjw7MQrKkzKEFMx', + creationBlockNumber: 67830042, + }, + tron: { + address: 'TRZbXXuLd3HW5utzVysA3rpLgU7sVBrd1D', + creationBlockNumber: 83104290, + }, + }, + }, + }, + '0.1.0', +); diff --git a/packages/smart-contracts/src/lib/artifacts/index.ts b/packages/smart-contracts/src/lib/artifacts/index.ts index 03aa84d523..d0e4d541df 100644 --- a/packages/smart-contracts/src/lib/artifacts/index.ts +++ b/packages/smart-contracts/src/lib/artifacts/index.ts @@ -4,6 +4,7 @@ export * from './ChainlinkConversionPath'; export * from './Erc20ConversionProxy'; export * from './ERC20FeeProxy'; +export * from './ERC20BatchPayments'; export * from './ERC20Proxy'; export * from './ERC20SwapToPay'; export * from './Erc20SwapConversion'; 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..453292c083 --- /dev/null +++ b/packages/smart-contracts/test/tron/ERC20BatchPayments.test.js @@ -0,0 +1,903 @@ +const ERC20BatchPayments = artifacts.require('ERC20BatchPayments'); +const { + REF_A, + REF_B, + REF_C, + TRON_ZERO_ADDRESS, + ZERO_ADDRESS, + waitForConfirmation, + balanceOf, + diff, + deployBaseSetup, + makeTokenApproval, + deployTokenWithSupply, + assertBalancesUnchanged, + 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); + await assertBalancesUnchanged( + () => + 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)], + '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); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsWithReference( + lowToken.address, + [payee1, payee2], + [amount1, amount2], + [REF_A, REF_B], + [fee1, fee2], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(lowToken, payee1)], + '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); + await assertBalancesUnchanged( + () => + 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)], + '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); + + await assertBalancesUnchanged( + () => + 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)], + '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()); + }); + + it('should revert when token address is zero', async () => { + const amount1 = '100'; + const fee1 = '1'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const payee1Before = await balanceOf(token1, payee1); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsWithReference( + ZERO_ADDRESS, + [payee1], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + 'should not transfer when token address is zero', + ); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + }); + + it('should revert when recipient is zero', async () => { + const amount1 = '100'; + const fee1 = '1'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const payee1Before = await balanceOf(token1, payee1); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsWithReference( + token1.address, + [ZERO_ADDRESS], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + 'should not transfer when recipient is zero', + ); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + }); + + it('should revert when feeAddress is zero and fee is non-zero', async () => { + const amount1 = '100'; + const fee1 = '1'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const payee1Before = await balanceOf(token1, payee1); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsWithReference( + token1.address, + [payee1], + [amount1], + [REF_A], + [fee1], + ZERO_ADDRESS, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + 'should not transfer when feeAddress is zero and fee is non-zero', + ); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.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); + await assertBalancesUnchanged( + () => + 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)], + '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); + await assertBalancesUnchanged( + () => + 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)], + '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); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address, token2.address], + [payee1, payee2], + [amount1, amount2], + [REF_A, REF_B], + [fee1, fee2], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token2, payee2)], + '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); + + await assertBalancesUnchanged( + () => + 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)], + '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()); + }); + + it('should revert when token address is zero', async () => { + const amount1 = '100'; + const fee1 = '1'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const payee1Before = await balanceOf(token1, payee1); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [ZERO_ADDRESS], + [payee1], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + 'multi-token batch should not transfer when token address is zero', + ); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + }); + + it('should revert when recipient is zero', async () => { + const amount1 = '100'; + const fee1 = '1'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const payee1Before = await balanceOf(token1, payee1); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address], + [ZERO_ADDRESS], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + 'multi-token batch should not transfer when recipient is zero', + ); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + }); + + it('should revert when feeAddress is zero and fee is non-zero', async () => { + const amount1 = '100'; + const fee1 = '1'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const payee1Before = await balanceOf(token1, payee1); + await assertBalancesUnchanged( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [token1.address], + [payee1], + [amount1], + [REF_A], + [fee1], + ZERO_ADDRESS, + { from: payer }, + ), + async () => [await balanceOf(token1, payee1)], + 'multi-token batch should not transfer when feeAddress is zero and fee is non-zero', + ); + assert.equal((await balanceOf(token1, payee1)).toString(), payee1Before.toString()); + }); + }); + }); +}); + +contract('ERC20BatchPayments constructor', () => { + it('should revert when paymentErc20FeeProxy is the zero address', async () => { + let reverted = false; + + try { + await ERC20BatchPayments.new(ZERO_ADDRESS); + } catch (error) { + reverted = true; + } + + assert(reverted, 'deployment should revert when paymentErc20FeeProxy is address(0)'); + }); +}); diff --git a/packages/smart-contracts/test/tron/helpers.js b/packages/smart-contracts/test/tron/helpers.js new file mode 100644 index 0000000000..ba15ead179 --- /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'; + +/** EVM-style zero address for revert tests. */ +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +/** 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 getApprovalAmount = (amountList, feeList) => sumStrings([...amountList, ...feeList]); + +/** + * Deploy ERC20FeeProxy, optional batch contract, and one or more TestTRC20 tokens. + */ +const deployBaseSetup = async ({ accounts, batchDeployFn, 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); + } + + 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 (errors propagate) and asserts tracked balances are unchanged. + */ +const assertBalancesUnchanged = async ( + fn, + getBalances, + message = 'balances should be unchanged', +) => { + const before = await getBalances(); + await fn(); + await waitForConfirmation(2000); + const after = await getBalances(); + assert( + before.every((value, index) => value === after[index]), + message, + ); +}; + +/** + * 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, + ZERO_ADDRESS, + ONE_TRX_SUN, + waitForConfirmation, + balanceOf, + trxBalance, + diff, + sumStrings, + mulString, + getApprovalAmount, + deployBaseSetup, + makeTokenApproval, + deployTokenWithSupply, + assertBalancesUnchanged, + assertBatchTokenBalancesZero, + expectNonOwnerReverts, + deployBadTRC20, +}; diff --git a/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol b/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol new file mode 120000 index 0000000000..7ce208dc94 --- /dev/null +++ b/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol @@ -0,0 +1 @@ +../../src/contracts/ERC20BatchPayments.sol \ No newline at end of file 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/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