diff --git a/.changeset/poor-ways-do.md b/.changeset/poor-ways-do.md new file mode 100644 index 00000000000..7a1e882bcbb --- /dev/null +++ b/.changeset/poor-ways-do.md @@ -0,0 +1,6 @@ +--- +"@fuel-ts/account": minor +"@fuel-ts/program": minor +--- + +feat!: transfer for multiple addresses diff --git a/apps/docs-snippets/src/guide/contracts/add-transfer.test.ts b/apps/docs-snippets/src/guide/contracts/add-transfer.test.ts index c5cb3b95bdb..edbc77cba49 100644 --- a/apps/docs-snippets/src/guide/contracts/add-transfer.test.ts +++ b/apps/docs-snippets/src/guide/contracts/add-transfer.test.ts @@ -1,5 +1,5 @@ import { ASSET_A, ASSET_B } from '@fuel-ts/utils/test-utils'; -import type { Account, Contract, Provider } from 'fuels'; +import type { Account, Contract, Provider, TransferParams } from 'fuels'; import { Wallet } from 'fuels'; import { DocSnippetProjectsEnum } from '../../../test/fixtures/forc-projects'; @@ -31,7 +31,14 @@ describe(__filename, () => { // #region add-transfer-1 const recipient = Wallet.generate({ provider }); - await contract.functions.echo_u64(100).addTransfer(recipient.address, 100, baseAssetId).call(); + await contract.functions + .echo_u64(100) + .addTransfer({ + destination: recipient.address, + amount: 100, + assetId: baseAssetId, + }) + .call(); // #endregion add-transfer-1 const recipientBalance = await recipient.getBalance(baseAssetId); @@ -44,12 +51,13 @@ describe(__filename, () => { const recipient1 = Wallet.generate({ provider }); const recipient2 = Wallet.generate({ provider }); - await contract.functions - .echo_u64(100) - .addTransfer(recipient1.address, 100, baseAssetId) - .addTransfer(recipient1.address, 400, ASSET_A) - .addTransfer(recipient2.address, 300, ASSET_B) - .call(); + const transferParams: TransferParams[] = [ + { destination: recipient1.address, amount: 100, assetId: baseAssetId }, + { destination: recipient1.address, amount: 400, assetId: ASSET_A }, + { destination: recipient2.address, amount: 300, assetId: ASSET_B }, + ]; + + await contract.functions.echo_u64(100).addBatchTransfer(transferParams).call(); // #endregion add-transfer-2 const recipient1BalanceBaseAsset = await recipient1.getBalance(baseAssetId); diff --git a/apps/docs-snippets/src/guide/cookbook/custom-transactions-contract-calls.test.ts b/apps/docs-snippets/src/guide/cookbook/custom-transactions-contract-calls.test.ts index 9e7754f2355..ce7ad9ec73a 100644 --- a/apps/docs-snippets/src/guide/cookbook/custom-transactions-contract-calls.test.ts +++ b/apps/docs-snippets/src/guide/cookbook/custom-transactions-contract-calls.test.ts @@ -41,9 +41,11 @@ describe('Custom Transactions from Contract Calls', () => { // Connect to the contract const contractInstance = new Contract(contract.id, abi, senderWallet); // Create an invocation scope for the contract function you'd like to call in the transaction - const scope = contractInstance.functions - .increment_count(amountToRecipient) - .addTransfer(receiverWallet.address, amountToRecipient, baseAssetId); + const scope = contractInstance.functions.increment_count(amountToRecipient).addTransfer({ + amount: amountToRecipient, + destination: receiverWallet.address, + assetId: baseAssetId, + }); // Build a transaction request from the invocation scope const transactionRequest = await scope.getTransactionRequest(); diff --git a/apps/docs-snippets/src/guide/cookbook/signing-transactions.test.ts b/apps/docs-snippets/src/guide/cookbook/signing-transactions.test.ts index 2d3ffef4fe4..43ad68fbd8a 100644 --- a/apps/docs-snippets/src/guide/cookbook/signing-transactions.test.ts +++ b/apps/docs-snippets/src/guide/cookbook/signing-transactions.test.ts @@ -52,7 +52,11 @@ describe('Signing transactions', () => { const script = new Script(bytecode, abi, sender); const { value } = await script.functions .main(signer.address.toB256()) - .addTransfer(receiver.address, amountToReceiver, baseAssetId) + .addTransfer({ + destination: receiver.address, + amount: amountToReceiver, + assetId: baseAssetId, + }) .addSigners(signer) .call(); // #endregion multiple-signers-2 diff --git a/apps/docs-snippets/src/guide/wallets/wallet-transferring.test.ts b/apps/docs-snippets/src/guide/wallets/wallet-transferring.test.ts index 9df8e752835..7f4c8382235 100644 --- a/apps/docs-snippets/src/guide/wallets/wallet-transferring.test.ts +++ b/apps/docs-snippets/src/guide/wallets/wallet-transferring.test.ts @@ -1,6 +1,6 @@ import { generateTestWallet } from '@fuel-ts/account/test-utils'; import { ASSET_A } from '@fuel-ts/utils/test-utils'; -import type { Contract } from 'fuels'; +import type { Contract, TransferParams } from 'fuels'; import { FUEL_NETWORK_URL, Provider, Wallet } from 'fuels'; import { DocSnippetProjectsEnum } from '../../../test/fixtures/forc-projects'; @@ -74,6 +74,28 @@ describe(__filename, () => { expect(newBalance.toNumber()).toBeGreaterThan(0); }); + it('should successfully multi transfer to more than one receiver', async () => { + const someOtherAssetId = ASSET_A; + + // #region wallet-transferring-6 + const myWallet = Wallet.fromPrivateKey(privateKey, provider); + + const recipient1 = Wallet.generate({ provider }); + const recipient2 = Wallet.generate({ provider }); + + const transfersToMake: TransferParams[] = [ + { amount: 100, destination: recipient1.address, assetId: baseAssetId }, + { amount: 200, destination: recipient2.address, assetId: baseAssetId }, + { amount: 300, destination: recipient2.address, assetId: someOtherAssetId }, + ]; + + const tx = await myWallet.batchTransfer(transfersToMake); + const { isStatusSuccess } = await tx.waitForResult(); + // #endregion wallet-transferring-6 + + expect(isStatusSuccess).toBeTruthy(); + }); + it('should transfer assets to a deployed contract instance just fine', async () => { // #region wallet-transferring-4 const myWallet = Wallet.fromPrivateKey(privateKey, provider); diff --git a/apps/docs/src/guide/contracts/transferring-assets.md b/apps/docs/src/guide/contracts/transferring-assets.md index 527d2f4bcfa..eeae1dd343b 100644 --- a/apps/docs/src/guide/contracts/transferring-assets.md +++ b/apps/docs/src/guide/contracts/transferring-assets.md @@ -8,8 +8,8 @@ The `addTransfer` method allows you to append an asset transfer to your contract In the previous example, we first use a contract call to the `echo_u64` function. Following this, `addTransfer` is added to chain call to include a transfer of `100` units of the `BaseAssetId` in the transaction. -## Multiple Transfers +## Batch Transfer -You can chain multiple `addTransfer` calls to include various transfers in a single transaction. Here's how you can concatenate these calls: +You can add a batch of transfers into a single transaction by using `addBatchTransfer`: <<< @/../../docs-snippets/src/guide/contracts/add-transfer.test.ts#add-transfer-2{ts:line-numbers} diff --git a/apps/docs/src/guide/wallets/wallet-transferring.md b/apps/docs/src/guide/wallets/wallet-transferring.md index 7db602b84fc..6a0f5783530 100644 --- a/apps/docs/src/guide/wallets/wallet-transferring.md +++ b/apps/docs/src/guide/wallets/wallet-transferring.md @@ -18,6 +18,12 @@ When transferring the base chain coin like ETH, you can omit the `assetId`: <<< @/../../docs-snippets/src/guide/wallets/wallet-transferring.test.ts#wallet-transferring-3{ts:line-numbers} +## Transferring To Multiple Wallets + +To transfer assets to multiple wallets, use the `Account.batchTransfer` method: + +<<< @/../../docs-snippets/src/guide/wallets/wallet-transferring.test.ts#wallet-transferring-6{ts:line-numbers} + ## Transferring To Contracts Transferring assets from your wallet to a deployed contract is straightforward. All you need is the contract's address. diff --git a/packages/account/src/account.test.ts b/packages/account/src/account.test.ts index 54b3a2995ab..8b827b4edf4 100644 --- a/packages/account/src/account.test.ts +++ b/packages/account/src/account.test.ts @@ -6,6 +6,7 @@ import { bn } from '@fuel-ts/math'; import { PolicyType } from '@fuel-ts/transactions'; import { ASSET_A, ASSET_B } from '@fuel-ts/utils/test-utils'; +import type { TransferParams } from './account'; import { Account } from './account'; import { FUEL_NETWORK_URL } from './configs'; import { ScriptTransactionRequest, Provider } from './providers'; @@ -14,22 +15,23 @@ import type { Coin, CoinQuantity, Message, Resource } from './providers'; import { generateTestWallet, seedTestWallet } from './test-utils'; import { Wallet } from './wallet'; -let provider: Provider; -let baseAssetId: string; -afterEach(() => { - vi.restoreAllMocks(); -}); - -beforeAll(async () => { - provider = await Provider.create(FUEL_NETWORK_URL); - baseAssetId = provider.getBaseAssetId(); -}); - /** * @group node */ + describe('Account', () => { const assets = [ASSET_A, ASSET_B, ZeroBytes32]; + let provider: Provider; + let baseAssetId: string; + + beforeAll(async () => { + provider = await Provider.create(FUEL_NETWORK_URL); + baseAssetId = provider.getBaseAssetId(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); it('should create account using an address, with a provider', () => { const account = new Account( @@ -391,6 +393,77 @@ describe('Account', () => { expect(receiverBalances).toEqual([{ assetId: baseAssetId, amount: bn(1) }]); }); + it('can transfer to multiple destinations', async () => { + const sender = await generateTestWallet(provider, [ + [900_000, baseAssetId], + [900_000, ASSET_A], + [900_000, ASSET_B], + ]); + + const amounts = [100, 200, 300, 400]; + + const receivers = [ + Wallet.generate({ provider }), + Wallet.generate({ provider }), + Wallet.generate({ provider }), + ]; + + const transferConfig: TransferParams[] = [ + { amount: amounts[0], destination: receivers[0].address, assetId: baseAssetId }, + { amount: amounts[1], destination: receivers[1].address, assetId: ASSET_A }, + { amount: amounts[2], destination: receivers[2].address, assetId: ASSET_B }, + { amount: amounts[3], destination: receivers[2].address, assetId: ASSET_A }, + ]; + + const response1 = await sender.batchTransfer(transferConfig); + const { isStatusSuccess } = await response1.waitForResult(); + expect(isStatusSuccess).toBeTruthy(); + + const expectedBalances = [ + { receiver: receivers[0], assetId: baseAssetId, expectedBalance: amounts[0] }, + { receiver: receivers[1], assetId: ASSET_A, expectedBalance: amounts[1] }, + { receiver: receivers[2], assetId: ASSET_B, expectedBalance: amounts[2] }, + { receiver: receivers[2], assetId: ASSET_A, expectedBalance: amounts[3] }, + ]; + + for (const { receiver, assetId, expectedBalance } of expectedBalances) { + const balance = await receiver.getBalance(assetId); + expect(balance.toNumber()).toBe(expectedBalance); + } + + // Test with custom TX Params + const gasLimit = 100_000; + const maxFee = 120_000; + const tip = 1_000; + const witnessLimit = 10_000; + const maturity = 1; + + const response = await sender.batchTransfer(transferConfig, { + gasLimit, + maxFee, + tip, + witnessLimit, + maturity, + }); + + const { + transaction: { policies, scriptGasLimit }, + isStatusSuccess: isStatusSuccess2, + } = await response.waitForResult(); + + expect(isStatusSuccess2).toBeTruthy(); + expect(scriptGasLimit?.toNumber()).toBe(gasLimit); + expect(bn(policies?.[0].data).toNumber()).toBe(tip); + expect(bn(policies?.[1].data).toNumber()).toBe(witnessLimit); + expect(policies?.[2].data).toBe(maturity); + expect(bn(policies?.[3].data).toNumber()).toBe(maxFee); + + for (const { receiver, assetId, expectedBalance } of expectedBalances) { + const balance = await receiver.getBalance(assetId); + expect(balance.toNumber()).toBe(expectedBalance * 2); + } + }); + it('can create transfer request just fine', async () => { const sender = await generateTestWallet(provider, [[500_000, baseAssetId]]); const receiver = await generateTestWallet(provider); @@ -575,9 +648,7 @@ describe('Account', () => { }); // seed wallet with 3 distinct utxos - await seedTestWallet(sender, [[500_000, baseAssetId]]); - await seedTestWallet(sender, [[500_000, baseAssetId]]); - await seedTestWallet(sender, [[500_000, baseAssetId]]); + await seedTestWallet(sender, [[1_500_000, baseAssetId]], 3); const transfer = await sender.transfer(receiver.address, 110, baseAssetId, { gasLimit: 10_000, @@ -593,9 +664,7 @@ describe('Account', () => { provider, }); // seed wallet with 3 distinct utxos - await seedTestWallet(sender, [[500_000, baseAssetId]]); - await seedTestWallet(sender, [[500_000, baseAssetId]]); - await seedTestWallet(sender, [[500_000, baseAssetId]]); + await seedTestWallet(sender, [[1_500_000, baseAssetId]], 3); const recipient = Address.fromB256( '0x00000000000000000000000047ba61eec8e5e65247d717ff236f504cf3b0a263' ); diff --git a/packages/account/src/account.ts b/packages/account/src/account.ts index d62faa37dc3..78068b920f5 100644 --- a/packages/account/src/account.ts +++ b/packages/account/src/account.ts @@ -44,6 +44,12 @@ export type TxParamsType = Pick< 'gasLimit' | 'tip' | 'maturity' | 'maxFee' | 'witnessLimit' >; +export type TransferParams = { + destination: string | AbstractAddress; + amount: BigNumberish; + assetId?: BytesLike; +}; + export type EstimatedTxParams = Pick< TransactionCost, 'estimatedPredicates' | 'addedSignatures' | 'requiredQuantities' | 'updateMaxFee' @@ -379,22 +385,8 @@ export class Account extends AbstractAccount { txParams: TxParamsType = {} ): Promise { let request = new ScriptTransactionRequest(txParams); - const assetIdToTransfer = assetId ?? this.provider.getBaseAssetId(); - request.addCoinOutput(Address.fromAddressOrString(destination), amount, assetIdToTransfer); - const txCost = await this.provider.getTransactionCost(request, { - estimateTxDependencies: true, - resourcesOwner: this, - }); - - request = this.validateGasLimitAndMaxFee({ - transactionRequest: request, - gasUsed: txCost.gasUsed, - maxFee: txCost.maxFee, - txParams, - }); - - await this.fund(request, txCost); - + request = this.addTransfer(request, { destination, amount, assetId }); + request = await this.estimateAndFundTransaction(request, txParams); return request; } @@ -417,17 +409,64 @@ export class Account extends AbstractAccount { /** Tx Params */ txParams: TxParamsType = {} ): Promise { - if (bn(amount).lte(0)) { - throw new FuelError( - ErrorCode.INVALID_TRANSFER_AMOUNT, - 'Transfer amount must be a positive number.' - ); - } - const assetIdToTransfer = assetId ?? this.provider.getBaseAssetId(); - const request = await this.createTransfer(destination, amount, assetIdToTransfer, txParams); + const request = await this.createTransfer(destination, amount, assetId, txParams); return this.sendTransaction(request, { estimateTxDependencies: false }); } + /** + * Transfers multiple amounts of a token to multiple recipients. + * + * @param transferParams - An array of `TransferParams` objects representing the transfers to be made. + * @param txParams - Optional transaction parameters. + * @returns A promise that resolves to a `TransactionResponse` object representing the transaction result. + */ + async batchTransfer( + transferParams: TransferParams[], + txParams: TxParamsType = {} + ): Promise { + let request = new ScriptTransactionRequest(txParams); + request = this.addBatchTransfer(request, transferParams); + request = await this.estimateAndFundTransaction(request, txParams); + return this.sendTransaction(request, { estimateTxDependencies: false }); + } + + /** + * Adds a transfer to the given transaction request. + * + * @param request - The script transaction request to add transfers to. + * @param transferParams - The object representing the transfer to be made. + * @returns The updated transaction request with the added transfer. + */ + addTransfer(request: ScriptTransactionRequest, transferParams: TransferParams) { + const { destination, amount, assetId } = transferParams; + this.validateTransferAmount(amount); + request.addCoinOutput( + Address.fromAddressOrString(destination), + amount, + assetId ?? this.provider.getBaseAssetId() + ); + return request; + } + + /** + * Adds multiple transfers to a script transaction request. + * + * @param request - The script transaction request to add transfers to. + * @param transferParams - An array of `TransferParams` objects representing the transfers to be made. + * @returns The updated script transaction request. + */ + addBatchTransfer(request: ScriptTransactionRequest, transferParams: TransferParams[]) { + const baseAssetId = this.provider.getBaseAssetId(); + transferParams.forEach(({ destination, amount, assetId }) => { + this.addTransfer(request, { + destination, + amount, + assetId: assetId ?? baseAssetId, + }); + }); + return request; + } + /** * Transfers coins to a contract address. * @@ -537,6 +576,7 @@ export class Account extends AbstractAccount { return this.sendTransaction(request); } + /** @hidden * */ async signMessage(message: string): Promise { if (!this._connector) { throw new FuelError(ErrorCode.MISSING_CONNECTOR, 'A connector is required to sign messages.'); @@ -602,6 +642,36 @@ export class Account extends AbstractAccount { return this.provider.simulate(transactionRequest, { estimateTxDependencies: false }); } + /** @hidden * */ + private validateTransferAmount(amount: BigNumberish) { + if (bn(amount).lte(0)) { + throw new FuelError( + ErrorCode.INVALID_TRANSFER_AMOUNT, + 'Transfer amount must be a positive number.' + ); + } + } + + /** @hidden * */ + private async estimateAndFundTransaction( + transactionRequest: ScriptTransactionRequest, + txParams: TxParamsType + ) { + let request = transactionRequest; + const txCost = await this.provider.getTransactionCost(request, { + resourcesOwner: this, + }); + request = this.validateGasLimitAndMaxFee({ + transactionRequest: request, + gasUsed: txCost.gasUsed, + maxFee: txCost.maxFee, + txParams, + }); + request = await this.fund(request, txCost); + return request; + } + + /** @hidden * */ private validateGasLimitAndMaxFee({ gasUsed, maxFee, diff --git a/packages/fuel-gauge/src/contract.test.ts b/packages/fuel-gauge/src/contract.test.ts index 2dbb929bb1f..231c4fdf3e5 100644 --- a/packages/fuel-gauge/src/contract.test.ts +++ b/packages/fuel-gauge/src/contract.test.ts @@ -8,6 +8,7 @@ import type { TransactionType, JsonAbi, ScriptTransactionRequest, + TransferParams, } from 'fuels'; import { BN, @@ -97,6 +98,7 @@ const jsonFragment: JsonAbi = { attributes: [], }, ], + messagesTypes: [], }; const complexFragment: JsonAbi = { @@ -157,6 +159,7 @@ const complexFragment: JsonAbi = { attributes: [], }, ], + messagesTypes: [], }; const txPointer = '0x00000000000000000000000000000000'; @@ -959,7 +962,11 @@ describe('Contract', () => { await contract.functions .sum(40, 50) - .addTransfer(receiver.address, amountToTransfer, baseAssetId) + .addTransfer({ + destination: receiver.address, + amount: amountToTransfer, + assetId: baseAssetId, + }) .call(); const finalBalance = await receiver.getBalance(); @@ -990,12 +997,13 @@ describe('Contract', () => { const amountToTransfer2 = 699; const amountToTransfer3 = 122; - await contract.functions - .sum(40, 50) - .addTransfer(receiver1.address, amountToTransfer1, baseAssetId) - .addTransfer(receiver2.address, amountToTransfer2, ASSET_A) - .addTransfer(receiver3.address, amountToTransfer3, ASSET_B) - .call(); + const transferParams: TransferParams[] = [ + { destination: receiver1.address, amount: amountToTransfer1, assetId: baseAssetId }, + { destination: receiver2.address, amount: amountToTransfer2, assetId: ASSET_A }, + { destination: receiver3.address, amount: amountToTransfer3, assetId: ASSET_B }, + ]; + + await contract.functions.sum(40, 50).addBatchTransfer(transferParams).call(); const finalBalance1 = await receiver1.getBalance(baseAssetId); const finalBalance2 = await receiver2.getBalance(ASSET_A); diff --git a/packages/program/src/functions/base-invocation-scope.ts b/packages/program/src/functions/base-invocation-scope.ts index 43affcdbed3..e86f3c07fbc 100644 --- a/packages/program/src/functions/base-invocation-scope.ts +++ b/packages/program/src/functions/base-invocation-scope.ts @@ -1,17 +1,12 @@ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { InputValue, JsonAbi } from '@fuel-ts/abi-coder'; -import type { Provider, CoinQuantity, CallResult, Account } from '@fuel-ts/account'; +import type { Provider, CoinQuantity, CallResult, Account, TransferParams } from '@fuel-ts/account'; import { ScriptTransactionRequest } from '@fuel-ts/account'; import { Address } from '@fuel-ts/address'; import { ErrorCode, FuelError } from '@fuel-ts/errors'; -import type { - AbstractAccount, - AbstractAddress, - AbstractContract, - AbstractProgram, -} from '@fuel-ts/interfaces'; -import type { BN, BigNumberish } from '@fuel-ts/math'; +import type { AbstractAccount, AbstractContract, AbstractProgram } from '@fuel-ts/interfaces'; +import type { BN } from '@fuel-ts/math'; import { bn } from '@fuel-ts/math'; import { InputType, TransactionType } from '@fuel-ts/transactions'; import { isDefined } from '@fuel-ts/utils'; @@ -310,21 +305,40 @@ export class BaseInvocationScope { /** * Adds an asset transfer to an Account on the contract call transaction request. * - * @param destination - The address of the destination. - * @param amount - The amount of coins to transfer. - * @param assetId - The asset ID of the coins to transfer. + * @param transferParams - The object representing the transfer to be made. * @returns The current instance of the class. */ - addTransfer(destination: string | AbstractAddress, amount: BigNumberish, assetId: string) { + addTransfer(transferParams: TransferParams) { + const { amount, destination, assetId } = transferParams; + const baseAssetId = this.getProvider().getBaseAssetId(); this.transactionRequest = this.transactionRequest.addCoinOutput( Address.fromAddressOrString(destination), amount, - assetId + assetId || baseAssetId ); return this; } + /** + * Adds multiple transfers to the contract call transaction request. + * + * @param transferParams - An array of `TransferParams` objects representing the transfers to be made. + * @returns The current instance of the class. + */ + addBatchTransfer(transferParams: TransferParams[]) { + const baseAssetId = this.getProvider().getBaseAssetId(); + transferParams.forEach(({ destination, amount, assetId }) => { + this.transactionRequest = this.transactionRequest.addCoinOutput( + Address.fromAddressOrString(destination), + amount, + assetId || baseAssetId + ); + }); + + return this; + } + addSigners(signers: Account | Account[]) { this.addSignersCallback = async (transactionRequest) => transactionRequest.addAccountWitnesses(signers);