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/test/tron/BatchPayments.test.js b/packages/smart-contracts/test/tron/BatchPayments.test.js new file mode 100644 index 0000000000..a024230ad8 --- /dev/null +++ b/packages/smart-contracts/test/tron/BatchPayments.test.js @@ -0,0 +1,942 @@ +const BatchPayments = artifacts.require('BatchPayments'); +const { + REF_A, + REF_B, + REF_C, + waitForConfirmation, + balanceOf, + diff, + deployBaseSetup, + makeTokenApproval, + deployTokenWithSupply, + expectRevertOrNoBalanceChange, + assertBatchTokenBalancesZero, + expectNonOwnerReverts, + deployBadTRC20, + sumStrings, + mulString, + computeBatchFee, + getApprovalAmount, + trxBalance, + ONE_TRX_SUN, + TRON_ZERO_ADDRESS, +} = require('./helpers'); + +contract('BatchPayments 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'; + + const BATCH_FEE_BPS = 10; + + let batch; + let token1; + let token2; + let token3; + + before(async () => { + const setup = await deployBaseSetup({ + accounts, + batchDeployFn: (erc20FeeProxy, owner, ethProxy) => + BatchPayments.new(erc20FeeProxy.address, ethProxy, owner), + batchFee: BATCH_FEE_BPS, + }); + batch = setup.batch; + [token1, token2, token3] = setup.tokens; + + console.log('\n=== BatchPayments (main) Test Setup ==='); + console.log('Batch:', batch.address); + console.log('Token1:', token1.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'; + + const batchFee = computeBatchFee(sumStrings([amount1, amount2, amount3]), BATCH_FEE_BPS); + const totalPaymentAndFees = sumStrings([ + amount1, + amount2, + amount3, + fee1, + fee2, + fee3, + batchFee, + ]); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1, amount2, amount3], [fee1, fee2, fee3], batchFee), + ); + + const payee1Before = await balanceOf(token1, payee1); + const payee2Before = await balanceOf(token1, payee2); + const payerBefore = await balanceOf(token1, payer); + 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 }, + ); + await waitForConfirmation(3000); + + const payee1After = await balanceOf(token1, payee1); + const payee2After = await balanceOf(token1, payee2); + const payerAfter = await balanceOf(token1, payer); + const feeAfter = await balanceOf(token1, feeAddress); + + assert.equal(diff(payee1After, payee1Before).toString(), amount1); + assert.equal(diff(payee2After, payee2Before).toString(), sumStrings([amount2, amount3])); + assert.equal( + diff(feeAfter, feeBefore).toString(), + sumStrings([fee1, fee2, fee3, batchFee]), + ); + assert( + diff(payerBefore, payerAfter) >= BigInt(totalPaymentAndFees), + 'payer should pay amounts, fees, and batch fee', + ); + }); + + it('should pay 10 ERC20 payments', async () => { + const amount = '200'; + const feeAmount = '100'; + const nbTxs = 10; + + const batchFeeTotal = computeBatchFee(mulString(amount, nbTxs), BATCH_FEE_BPS); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount(Array(nbTxs).fill(amount), Array(nbTxs).fill(feeAmount), batchFeeTotal), + ); + + 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 }, + ); + await waitForConfirmation(3000); + + assert.equal( + diff(await balanceOf(token1, payee1), payee1Before).toString(), + mulString(amount, nbTxs), + ); + assert.equal( + diff(await balanceOf(token1, feeAddress), feeBefore).toString(), + sumStrings([mulString(feeAmount, nbTxs), batchFeeTotal]), + ); + }); + + 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], computeBatchFee(amount1, BATCH_FEE_BPS)), + ); + + await batch.batchERC20PaymentsWithReference( + token1.address, + [payee1], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ); + await waitForConfirmation(3000); + + await assertBatchTokenBalancesZero(batch, [token1]); + }); + + it('should apply an updated batch fee on the next payment', async () => { + const newBatchFeeBps = 50; + await batch.setBatchFee(newBatchFeeBps, { from: payer }); + + const amount1 = '1000'; + const fee1 = '10'; + const batchFee = computeBatchFee(amount1, newBatchFeeBps); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1], [fee1], batchFee), + ); + + const feeBefore = await balanceOf(token1, feeAddress); + + await batch.batchERC20PaymentsWithReference( + token1.address, + [payee1], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ); + await waitForConfirmation(3000); + + assert.equal( + diff(await balanceOf(token1, feeAddress), feeBefore).toString(), + sumStrings([fee1, batchFee]), + ); + + await batch.setBatchFee(BATCH_FEE_BPS, { from: payer }); + }); + + it('should pay ERC20 payments with no batch fee when batch fee is zero', async () => { + await batch.setBatchFee(0, { from: payer }); + + const amount1 = '500'; + const fee1 = '25'; + + await makeTokenApproval(token1, payer, batch.address, getApprovalAmount([amount1], [fee1])); + + const feeBefore = await balanceOf(token1, feeAddress); + + await batch.batchERC20PaymentsWithReference( + token1.address, + [payee1], + [amount1], + [REF_A], + [fee1], + feeAddress, + { from: payer }, + ); + await waitForConfirmation(3000); + + assert.equal(diff(await balanceOf(token1, feeAddress), feeBefore).toString(), fee1); + + await batch.setBatchFee(BATCH_FEE_BPS, { from: payer }); + }); + }); + + 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'; + + const batchFee1 = computeBatchFee(amount1, BATCH_FEE_BPS); + const batchFee2 = computeBatchFee(amount2, BATCH_FEE_BPS); + const batchFee3 = computeBatchFee(amount3, BATCH_FEE_BPS); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1], [fee1], batchFee1), + ); + await makeTokenApproval( + token2, + payer, + batch.address, + getApprovalAmount([amount2], [fee2], batchFee2), + ); + await makeTokenApproval( + token3, + payer, + batch.address, + getApprovalAmount([amount3], [fee3], batchFee3), + ); + + 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); + const payerToken1Before = await balanceOf(token1, payer); + const payerToken2Before = await balanceOf(token2, payer); + const payerToken3Before = await balanceOf(token3, payer); + + 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 }, + ); + await waitForConfirmation(3000); + + 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(), + sumStrings([fee1, batchFee1]), + ); + assert.equal( + diff(await balanceOf(token2, feeAddress), feeToken2Before).toString(), + sumStrings([fee2, batchFee2]), + ); + assert.equal( + diff(await balanceOf(token3, feeAddress), feeToken3Before).toString(), + sumStrings([fee3, batchFee3]), + ); + + const total1 = sumStrings([amount1, fee1, batchFee1]); + const total2 = sumStrings([amount2, fee2, batchFee2]); + const total3 = sumStrings([amount3, fee3, batchFee3]); + assert( + diff(payerToken1Before, await balanceOf(token1, payer)) >= BigInt(total1), + 'payer should pay token1 amounts, fees, and batch fee', + ); + assert( + diff(payerToken2Before, await balanceOf(token2, payer)) >= BigInt(total2), + 'payer should pay token2 amounts, fees, and batch fee', + ); + assert( + diff(payerToken3Before, await balanceOf(token3, payer)) >= BigInt(total3), + 'payer should pay token3 amounts, fees, and batch fee', + ); + }); + + 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'; + + const batchFee1 = computeBatchFee(amount1, BATCH_FEE_BPS); + const batchFee3 = computeBatchFee(amount3, BATCH_FEE_BPS); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1], [fee1], batchFee1), + ); + await makeTokenApproval(token2, payer, batch.address, getApprovalAmount([amount2], [fee2])); + await makeTokenApproval( + token3, + payer, + batch.address, + getApprovalAmount([amount3], [fee3], batchFee3), + ); + + 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 }, + ); + await waitForConfirmation(3000); + + 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'; + + const batchFee1 = computeBatchFee(sumStrings([amount1, amount2]), BATCH_FEE_BPS); + const batchFee2 = computeBatchFee(sumStrings([amount3, amount4]), BATCH_FEE_BPS); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1, amount2], [fee1, fee2], batchFee1), + ); + await makeTokenApproval( + token2, + payer, + batch.address, + getApprovalAmount([amount3, amount4], [fee3, fee4], batchFee2), + ); + + 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 }, + ); + await waitForConfirmation(3000); + + 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; + + const batchFee1 = computeBatchFee(mulString(amount, nbPaymentsPerToken), BATCH_FEE_BPS); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount( + Array(nbPaymentsPerToken).fill(amount), + Array(nbPaymentsPerToken).fill(feeAmount), + batchFee1, + ), + ); + await makeTokenApproval( + token2, + payer, + batch.address, + getApprovalAmount( + Array(nbPaymentsPerToken).fill(amount), + Array(nbPaymentsPerToken).fill(feeAmount), + batchFee1, + ), + ); + + 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 }, + ); + 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), + ); + }); + + 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'; + + const batchFee1 = computeBatchFee(amount1, BATCH_FEE_BPS); + const batchFee2 = computeBatchFee(amount2, BATCH_FEE_BPS); + + await makeTokenApproval( + token1, + payer, + batch.address, + getApprovalAmount([amount1], [fee1], batchFee1), + ); + await makeTokenApproval( + token2, + payer, + batch.address, + getApprovalAmount([amount2], [fee2], batchFee2), + ); + + 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], computeBatchFee(amount1, BATCH_FEE_BPS)), + ); + 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 batch fee', async () => { + const amount1 = '100'; + const amount2 = '200'; + const fee1 = '1'; + const fee2 = '2'; + const paymentTotal = sumStrings([amount1, amount2, fee1, fee2]); + + const lowToken = await deployTokenWithSupply(paymentTotal, payer); + await makeTokenApproval(lowToken, payer, batch.address, paymentTotal); + + 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 batch fee 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 does not have enough funds to pay the batch fee 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'; + + const lowToken = await deployTokenWithSupply('607', payer); + await makeTokenApproval( + lowToken, + payer, + batch.address, + getApprovalAmount([amount1, amount2, amount3], [fee1, fee2, fee3]), + ); + + const payee2Before = await balanceOf(lowToken, payee2); + const { unchanged } = await expectRevertOrNoBalanceChange( + () => + batch.batchERC20PaymentsMultiTokensWithReference( + [lowToken.address, lowToken.address, lowToken.address], + [payee1, payee2, payee2], + [amount1, amount2, amount3], + [REF_A, REF_B, REF_C], + [fee1, fee2, fee3], + feeAddress, + { from: payer }, + ), + async () => [await balanceOf(lowToken, payee2)], + ); + + assert(unchanged, 'multi-token batch should not transfer when batch fee cannot be paid'); + assert.equal((await balanceOf(lowToken, payee2)).toString(), payee2Before.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()); + }); + }); + + describe('batchEthPaymentsWithReference', () => { + it('should revert when calling batchEthPaymentsWithReference when EthFeeProxy is not set', async () => { + assert.equal(await batch.paymentEthFeeProxy(), TRON_ZERO_ADDRESS); + + const paymentAmount = String(10 * ONE_TRX_SUN); + const feeAmount = '0'; + const payeeBefore = await trxBalance(payee1); + const payerBefore = await trxBalance(payer); + + try { + await batch.batchEthPaymentsWithReference( + [payee1], + [paymentAmount], + [REF_A], + [feeAmount], + feeAddress, + { from: payer, callValue: Number(paymentAmount) }, + ); + } catch (_error) {} + await waitForConfirmation(2000); + + assert.equal((await trxBalance(payee1)).toString(), payeeBefore.toString()); + + const payerSpent = payerBefore - (await trxBalance(payer)); + assert( + payerSpent < BigInt(paymentAmount), + `payer should only spend tx fees, not ${paymentAmount} sun (spent ${payerSpent})`, + ); + }); + }); + }); + + describe('Admin', () => { + describe('setPaymentErc20FeeProxy', () => { + it('should allow owner to update proxy addresses', async () => { + const ERC20FeeProxy = artifacts.require('ERC20FeeProxy'); + const newProxy = await ERC20FeeProxy.new(); + await batch.setPaymentErc20FeeProxy(newProxy.address, { from: payer }); + assert.equal(await batch.paymentErc20FeeProxy(), newProxy.address); + }); + + it('should revert when a non-owner tries to update proxy addresses', async () => { + await expectNonOwnerReverts( + () => batch.setPaymentErc20FeeProxy(payee1, { from: payee1 }), + async () => await batch.paymentErc20FeeProxy(), + ); + }); + }); + + describe('setBatchFee', () => { + it('should allow owner to update the batch fee', async () => { + const newBatchFee = 50; + await batch.setBatchFee(newBatchFee, { from: payer }); + assert.equal((await batch.batchFee()).toString(), String(newBatchFee)); + await batch.setBatchFee(BATCH_FEE_BPS, { from: payer }); + }); + + it('should revert when a non-owner tries to set the batch fee', async () => { + await expectNonOwnerReverts( + () => batch.setBatchFee(99, { from: payee1 }), + async () => (await batch.batchFee()).toString(), + ); + }); + }); + + describe('setPaymentEthFeeProxy', () => { + it('should revert when a non-owner tries to set the EthFeeProxy address', async () => { + await expectNonOwnerReverts( + () => batch.setPaymentEthFeeProxy(TRON_ZERO_ADDRESS, { from: payee1 }), + async () => await batch.paymentEthFeeProxy(), + ); + }); + }); + }); +}); 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..866a6d20a3 --- /dev/null +++ b/packages/smart-contracts/test/tron/ERC20BatchPayments.test.js @@ -0,0 +1,771 @@ +const ERC20BatchPayments = artifacts.require('ERC20BatchPayments'); +const { + REF_A, + REF_B, + REF_C, + TRON_ZERO_ADDRESS, + waitForConfirmation, + balanceOf, + diff, + deployBaseSetup, + makeTokenApproval, + deployTokenWithSupply, + expectRevertOrNoBalanceChange, + assertBatchTokenBalancesZero, + expectNonOwnerReverts, + 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, owner) => ERC20BatchPayments.new(erc20FeeProxy.address, owner), + }); + 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()); + }); + }); + }); + + describe('Admin', () => { + describe('setPaymentErc20FeeProxy', () => { + it('should allow owner to update proxy addresses', async () => { + const ERC20FeeProxy = artifacts.require('ERC20FeeProxy'); + const newProxy = await ERC20FeeProxy.new(); + await batch.setPaymentErc20FeeProxy(newProxy.address, { from: payer }); + assert.equal(await batch.paymentErc20FeeProxy(), newProxy.address); + }); + + it('should revert when a non-owner tries to update proxy addresses', async () => { + await expectNonOwnerReverts( + () => batch.setPaymentErc20FeeProxy(payee1, { from: payee1 }), + async () => await batch.paymentErc20FeeProxy(), + ); + }); + }); + }); +}); 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/BatchPayments.sol b/packages/smart-contracts/tron/contracts/BatchPayments.sol new file mode 120000 index 0000000000..5f16a2c12b --- /dev/null +++ b/packages/smart-contracts/tron/contracts/BatchPayments.sol @@ -0,0 +1 @@ +../../src/contracts/BatchPayments.sol \ No newline at end of file diff --git a/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol b/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol new file mode 100644 index 0000000000..0d1b85fcba --- /dev/null +++ b/packages/smart-contracts/tron/contracts/ERC20BatchPayments.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import '@openzeppelin/contracts/access/Ownable.sol'; +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 is Ownable { + using SafeERC20 for IERC20; + + IERC20FeeProxy public paymentErc20FeeProxy; + + struct Token { + address tokenAddress; + uint256 amountAndFee; + } + + /** + * @param _paymentErc20FeeProxy The address of the ERC20FeeProxy to use. + * @param _owner Owner of the contract. + */ + constructor(address _paymentErc20FeeProxy, address _owner) { + paymentErc20FeeProxy = IERC20FeeProxy(_paymentErc20FeeProxy); + transferOwnership(_owner); + } + + /** + * @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) public { + IERC20 erc20 = IERC20(_erc20Address); + uint256 max = type(uint256).max; + require(erc20.safeApprove(address(paymentErc20FeeProxy), max), 'approve() failed'); + } + + /** + * @notice Updates the ERC20FeeProxy address. + * @param _paymentErc20FeeProxy The address of the ERC20FeeProxy to use. + */ + function setPaymentErc20FeeProxy(address _paymentErc20FeeProxy) public onlyOwner { + paymentErc20FeeProxy = IERC20FeeProxy(_paymentErc20FeeProxy); + } + + /** + * @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