diff --git a/.changeset/thirty-monkeys-hug.md b/.changeset/thirty-monkeys-hug.md new file mode 100644 index 00000000000..cb09f42bbf6 --- /dev/null +++ b/.changeset/thirty-monkeys-hug.md @@ -0,0 +1,11 @@ +--- +"@fuel-ts/docs-snippets": minor +"@fuel-ts/contract": minor +"@fuel-ts/interfaces": minor +"@fuel-ts/program": minor +"@fuel-ts/providers": minor +"@fuel-ts/script": minor +"@fuel-ts/wallet": minor +--- + +Fixing transaction funding diff --git a/apps/docs-snippets/src/guide/contracts/cost-estimation.test.ts b/apps/docs-snippets/src/guide/contracts/cost-estimation.test.ts index 7c0808b2999..614df9def9c 100644 --- a/apps/docs-snippets/src/guide/contracts/cost-estimation.test.ts +++ b/apps/docs-snippets/src/guide/contracts/cost-estimation.test.ts @@ -28,7 +28,8 @@ describe(__filename, () => { }) .getTransactionCost(); - expect(cost.fee).toBeDefined(); + expect(cost.minFee).toBeDefined(); + expect(cost.maxFee).toBeDefined(); expect(cost.gasPrice).toBeDefined(); expect(cost.gasUsed).toBeDefined(); expect(cost.minGasPrice).toBeDefined(); @@ -55,7 +56,8 @@ describe(__filename, () => { const cost = await scope.getTransactionCost(); - expect(cost.fee).toBeDefined(); + expect(cost.minFee).toBeDefined(); + expect(cost.maxFee).toBeDefined(); expect(cost.gasPrice).toBeDefined(); expect(cost.gasUsed).toBeDefined(); expect(cost.minGasPrice).toBeDefined(); diff --git a/apps/docs-snippets/src/utils.ts b/apps/docs-snippets/src/utils.ts index ffca6d6bc28..2069679557d 100644 --- a/apps/docs-snippets/src/utils.ts +++ b/apps/docs-snippets/src/utils.ts @@ -20,17 +20,6 @@ export const getTestWallet = async (seedQuantities?: CoinQuantityLike[]) => { // instantiate the genesis wallet with its secret key const genesisWallet = new WalletUnlocked(process.env.GENESIS_SECRET || '0x01', provider); - // define the quantity of assets to transfer to the test wallet - const quantities: CoinQuantityLike[] = seedQuantities || [ - { - amount: 1_000_000, - assetId: BaseAssetId, - }, - ]; - - // retrieve resources needed to spend the specified quantities - const resources = await genesisWallet.getResourcesToSpend(quantities); - // create a new test wallet const testWallet = Wallet.generate({ provider }); @@ -42,14 +31,20 @@ export const getTestWallet = async (seedQuantities?: CoinQuantityLike[]) => { gasPrice: minGasPrice, }); - // add the UTXO inputs to the transaction request - request.addResources(resources); - // add the transaction outputs (coins to be sent to the test wallet) - quantities + (seedQuantities || [[1_000_000, BaseAssetId]]) .map(coinQuantityfy) .forEach(({ amount, assetId }) => request.addCoinOutput(testWallet.address, amount, assetId)); + // get the cost of the transaction + const { minFee, requiredQuantities, gasUsed } = + await genesisWallet.provider.getTransactionCost(request); + + request.gasLimit = gasUsed; + + // funding the transaction with the required quantities + await genesisWallet.fund(request, requiredQuantities, minFee); + // execute the transaction, transferring resources to the test wallet const response = await genesisWallet.sendTransaction(request); @@ -73,6 +68,7 @@ export const createAndDeployContractFromProject = async ( return contractFactory.deployContract({ storageSlots, gasPrice: minGasPrice, + gasLimit: 0, }); }; diff --git a/packages/contract/package.json b/packages/contract/package.json index 78b8344319a..18d02ae2d89 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -26,6 +26,7 @@ "license": "Apache-2.0", "dependencies": { "@fuel-ts/abi-coder": "workspace:*", + "@fuel-ts/address": "workspace:*", "@fuel-ts/crypto": "workspace:*", "@fuel-ts/merkle": "workspace:*", "@fuel-ts/program": "workspace:*", diff --git a/packages/contract/src/contract-factory.ts b/packages/contract/src/contract-factory.ts index 93d9a1c3974..5ae3782ebb8 100644 --- a/packages/contract/src/contract-factory.ts +++ b/packages/contract/src/contract-factory.ts @@ -145,7 +145,13 @@ export default class ContractFactory { } const { contractId, transactionRequest } = this.createTransactionRequest(deployContractOptions); - await this.account.fund(transactionRequest); + + const { requiredQuantities, maxFee, gasUsed } = + await this.account.provider.getTransactionCost(transactionRequest); + + transactionRequest.gasLimit = gasUsed; + + await this.account.fund(transactionRequest, requiredQuantities, maxFee); const response = await this.account.sendTransaction(transactionRequest); await response.wait(); diff --git a/packages/fuel-gauge/src/contract.test.ts b/packages/fuel-gauge/src/contract.test.ts index 5dac2be19ac..85c075c1d50 100644 --- a/packages/fuel-gauge/src/contract.test.ts +++ b/packages/fuel-gauge/src/contract.test.ts @@ -516,7 +516,7 @@ describe('Contract', () => { const transactionCost = await invocationScope.getTransactionCost(); expect(toNumber(transactionCost.gasPrice)).toBe(gasPrice.toNumber()); - expect(toNumber(transactionCost.fee)).toBeGreaterThanOrEqual(0); + expect(toNumber(transactionCost.minFee)).toBeGreaterThanOrEqual(0); expect(toNumber(transactionCost.gasUsed)).toBeGreaterThan(300); const { value } = await invocationScope @@ -549,7 +549,7 @@ describe('Contract', () => { const transactionCost = await invocationScope.getTransactionCost(); expect(toNumber(transactionCost.gasPrice)).toBe(minGasPrice.toNumber()); - expect(toNumber(transactionCost.fee)).toBeGreaterThanOrEqual(1); + expect(toNumber(transactionCost.minFee)).toBeGreaterThanOrEqual(1); expect(toNumber(transactionCost.gasUsed)).toBeGreaterThan(300); // Test that gasUsed is correctly calculated @@ -706,6 +706,8 @@ describe('Contract', () => { const struct = { a: true, b: 1337 }; const invocationScopes = [contract.functions.foo(num), contract.functions.boo(struct)]; const multiCallScope = contract.multiCall(invocationScopes).txParams({ gasPrice }); + const { maxFee } = await multiCallScope.getTransactionCost(); + await multiCallScope.fundWithRequiredCoins(maxFee); const transactionRequest = await multiCallScope.getTransactionRequest(); @@ -745,8 +747,12 @@ describe('Contract', () => { const transactionRequestParsed = transactionRequestify(txRequestParsed); + const { requiredQuantities, maxFee } = + await provider.getTransactionCost(transactionRequestParsed); + // Fund tx - await wallet.fund(transactionRequestParsed); + await wallet.fund(transactionRequestParsed, requiredQuantities, maxFee); + // Send tx const response = await wallet.sendTransaction(transactionRequestParsed); const result = await response.waitForResult(); @@ -804,6 +810,13 @@ describe('Contract', () => { const transactionRequestParsed = transactionRequestify(txRequestParsed); + const { gasUsed, minFee, requiredQuantities } = + await contract.provider.getTransactionCost(transactionRequestParsed); + + transactionRequestParsed.gasLimit = gasUsed; + + await contract.account.fund(transactionRequestParsed, requiredQuantities, minFee); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const response = await contract.account!.sendTransaction(transactionRequestParsed); const { diff --git a/packages/fuel-gauge/src/fee.test.ts b/packages/fuel-gauge/src/fee.test.ts index 9ba0473fd8d..3cda3aba83b 100644 --- a/packages/fuel-gauge/src/fee.test.ts +++ b/packages/fuel-gauge/src/fee.test.ts @@ -160,7 +160,9 @@ describe('Fee', () => { const gasPrice = randomGasPrice(minGasPrice, 15); const factory = new ContractFactory(binHexlified, abiContents, wallet); const { transactionRequest } = factory.createTransactionRequest({ gasPrice }); - await wallet.fund(transactionRequest); + const { maxFee, requiredQuantities } = await provider.getTransactionCost(transactionRequest); + + await wallet.fund(transactionRequest, requiredQuantities, maxFee); const tx = await wallet.sendTransaction(transactionRequest); const { fee } = await tx.wait(); diff --git a/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts b/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts index e8eab759c52..eb195406c2e 100644 --- a/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts +++ b/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts @@ -21,7 +21,7 @@ describe('Predicate', () => { }); it('calls a no argument predicate and returns true', async () => { - const amountToPredicate = 100_000; + const amountToPredicate = 200_000; const amountToReceiver = 50; const initialReceiverBalance = await receiver.getBalance(); @@ -45,7 +45,7 @@ describe('Predicate', () => { }); it('calls a no argument predicate and returns false', async () => { - const amountToPredicate = 100; + const amountToPredicate = 200_000; const amountToReceiver = 50; predicate = new Predicate(predicateBytesFalse, provider); diff --git a/packages/fuel-gauge/src/predicate/utils/predicate/fundPredicate.ts b/packages/fuel-gauge/src/predicate/utils/predicate/fundPredicate.ts index 94d851a4f63..427a53b77ff 100644 --- a/packages/fuel-gauge/src/predicate/utils/predicate/fundPredicate.ts +++ b/packages/fuel-gauge/src/predicate/utils/predicate/fundPredicate.ts @@ -1,4 +1,4 @@ -import { BaseAssetId } from 'fuels'; +import { BaseAssetId, ScriptTransactionRequest } from 'fuels'; import type { InputValue, BN, BigNumberish, WalletUnlocked, Predicate } from 'fuels'; export const fundPredicate = async ( @@ -7,9 +7,17 @@ export const fundPredicate = async ( amountToPredicate: BigNumberish ): Promise => { const { minGasPrice } = wallet.provider.getGasConfig(); - const tx = await wallet.transfer(predicate.address, amountToPredicate, BaseAssetId, { + + const request = new ScriptTransactionRequest({ gasPrice: minGasPrice, }); + + request.addCoinOutput(predicate.address, amountToPredicate, BaseAssetId); + const { minFee, requiredQuantities, gasUsed } = await wallet.provider.getTransactionCost(request); + request.gasLimit = gasUsed; + await wallet.fund(request, requiredQuantities, minFee); + + const tx = await wallet.sendTransaction(request); await tx.waitForResult(); return predicate.getBalance(); diff --git a/packages/interfaces/src/index.ts b/packages/interfaces/src/index.ts index a91be53076a..e1b9a721c95 100644 --- a/packages/interfaces/src/index.ts +++ b/packages/interfaces/src/index.ts @@ -53,8 +53,8 @@ export abstract class AbstractAccount { abstract getResourcesToSpend(quantities: any[], options?: any): any; abstract sendTransaction(transactionRequest: any): any; abstract simulateTransaction(transactionRequest: any): any; + abstract fund(transactionRequest: any, quantities: any, fee: any): Promise; } - /** * @hidden */ diff --git a/packages/program/src/functions/base-invocation-scope.ts b/packages/program/src/functions/base-invocation-scope.ts index d04dfce344d..85ec87d1134 100644 --- a/packages/program/src/functions/base-invocation-scope.ts +++ b/packages/program/src/functions/base-invocation-scope.ts @@ -2,9 +2,10 @@ import type { InputValue } from '@fuel-ts/abi-coder'; import { ErrorCode, FuelError } from '@fuel-ts/errors'; import type { AbstractContract, AbstractProgram } from '@fuel-ts/interfaces'; +import type { BN } from '@fuel-ts/math'; import { bn, toNumber } from '@fuel-ts/math'; import type { Provider, CoinQuantity } from '@fuel-ts/providers'; -import { transactionRequestify, ScriptTransactionRequest } from '@fuel-ts/providers'; +import { ScriptTransactionRequest } from '@fuel-ts/providers'; import { InputType } from '@fuel-ts/transactions'; import type { BaseWalletUnlocked } from '@fuel-ts/wallet'; import * as asm from '@fuels/vm-asm'; @@ -118,16 +119,13 @@ export class BaseInvocationScope { * @returns An array of required coin quantities. */ protected getRequiredCoins(): Array { - const { gasPriceFactor } = this.getProvider().getGasConfig(); - - const assets = this.calls + const forwardingAssets = this.calls .map((call) => ({ assetId: String(call.assetId), amount: bn(call.amount || 0), })) - .concat(this.transactionRequest.calculateFee(gasPriceFactor)) .filter(({ assetId, amount }) => assetId && !bn(amount).isZero()); - return assets; + return forwardingAssets; } /** @@ -191,10 +189,6 @@ export class BaseInvocationScope { // Check if gasLimit is less than the // sum of all call gasLimits this.checkGasLimitTotal(); - - if (this.program.account) { - await this.fundWithRequiredCoins(); - } } /** @@ -219,10 +213,9 @@ export class BaseInvocationScope { async getTransactionCost(options?: TransactionCostOptions) { const provider = this.getProvider(); - await this.prepareTransaction(); - const request = transactionRequestify(this.transactionRequest); + const request = await this.getTransactionRequest(); request.gasPrice = bn(toNumber(request.gasPrice) || toNumber(options?.gasPrice || 0)); - const txCost = await provider.getTransactionCost(request); + const txCost = await provider.getTransactionCost(request, this.getRequiredCoins()); return txCost; } @@ -232,13 +225,14 @@ export class BaseInvocationScope { * * @returns The current instance of the class. */ - async fundWithRequiredCoins() { + async fundWithRequiredCoins(fee: BN) { // Clean coin inputs before add new coins to the request this.transactionRequest.inputs = this.transactionRequest.inputs.filter( (i) => i.type !== InputType.Coin ); - const resources = await this.program.account?.getResourcesToSpend(this.requiredCoins); - this.transactionRequest.addResources(resources || []); + + await this.program.account?.fund(this.transactionRequest, this.requiredCoins, fee); + return this; } @@ -292,6 +286,18 @@ export class BaseInvocationScope { assert(this.program.account, 'Wallet is required!'); const transactionRequest = await this.getTransactionRequest(); + + const { maxFee, gasUsed } = await this.getTransactionCost(); + + if (gasUsed.gt(bn(transactionRequest.gasLimit))) { + throw new FuelError( + ErrorCode.GAS_LIMIT_TOO_LOW, + `Gas limit '${transactionRequest.gasLimit}' is lower than the required: '${gasUsed}'.` + ); + } + + await this.fundWithRequiredCoins(maxFee); + const response = await this.program.account.sendTransaction(transactionRequest); return FunctionInvocationResult.build( @@ -324,6 +330,11 @@ export class BaseInvocationScope { } const transactionRequest = await this.getTransactionRequest(); + + const { maxFee } = await this.getTransactionCost(); + + await this.fundWithRequiredCoins(maxFee); + const result = await this.program.account.simulateTransaction(transactionRequest); return InvocationCallResult.build(this.functionInvocationScopes, result, this.isMultiCall); @@ -335,11 +346,15 @@ export class BaseInvocationScope { * @returns The result of the invocation call. */ async dryRun(): Promise> { + assert(this.program.account, 'Wallet is required!'); + const provider = this.getProvider(); + const { maxFee } = await this.getTransactionCost(); + + await this.fundWithRequiredCoins(maxFee); const transactionRequest = await this.getTransactionRequest(); - const request = transactionRequestify(transactionRequest); - const response = await provider.call(request, { + const response = await provider.call(transactionRequest, { utxoValidation: false, }); diff --git a/packages/providers/src/coin-quantity.ts b/packages/providers/src/coin-quantity.ts index 567d870440b..241e5eeedbc 100644 --- a/packages/providers/src/coin-quantity.ts +++ b/packages/providers/src/coin-quantity.ts @@ -30,3 +30,23 @@ export const coinQuantityfy = (coinQuantityLike: CoinQuantityLike): CoinQuantity max: max ? bn(max) : undefined, }; }; + +export interface IAddAmountToAssetParams { + assetId: string; + amount: BN; + coinQuantities: CoinQuantity[]; +} + +export const addAmountToAsset = (params: IAddAmountToAssetParams): CoinQuantity[] => { + const { amount, assetId, coinQuantities } = params; + + const assetIdx = coinQuantities.findIndex((coinQuantity) => coinQuantity.assetId === assetId); + + if (assetIdx !== -1) { + coinQuantities[assetIdx].amount = coinQuantities[assetIdx].amount.add(amount); + } else { + coinQuantities.push({ assetId, amount }); + } + + return coinQuantities; +}; diff --git a/packages/providers/src/provider.ts b/packages/providers/src/provider.ts index 880ab356108..6ce1deac790 100644 --- a/packages/providers/src/provider.ts +++ b/packages/providers/src/provider.ts @@ -2,7 +2,7 @@ import { Address } from '@fuel-ts/address'; import { ErrorCode, FuelError } from '@fuel-ts/errors'; import type { AbstractAddress } from '@fuel-ts/interfaces'; import type { BN } from '@fuel-ts/math'; -import { max, bn } from '@fuel-ts/math'; +import { bn, max } from '@fuel-ts/math'; import type { Transaction } from '@fuel-ts/transactions'; import { InputType, @@ -44,6 +44,7 @@ import { getGasUsedFromReceipts, getReceiptsWithMissingData, } from './utils'; +import { mergeQuantities } from './utils/merge-quantities'; const MAX_RETRIES = 10; @@ -125,10 +126,13 @@ export type NodeInfoAndConsensusParameters = { // #region cost-estimation-1 export type TransactionCost = { + requiredQuantities: CoinQuantity[]; + receipts: TransactionResultReceipt[]; minGasPrice: BN; gasPrice: BN; gasUsed: BN; - fee: BN; + minFee: BN; + maxFee: BN; }; // #endregion cost-estimation-1 @@ -653,32 +657,52 @@ export default class Provider { * @returns A promise that resolves to the transaction cost object. */ async getTransactionCost( - transactionRequestLike: TransactionRequestLike + transactionRequestLike: TransactionRequestLike, + forwardingQuantities: CoinQuantity[] = [] ): Promise { - const transactionRequest = transactionRequestify(clone(transactionRequestLike)); + const clonedTransactionRequest = transactionRequestify(clone(transactionRequestLike)); + + const { gasLimit } = clonedTransactionRequest; + let { gasPrice } = clonedTransactionRequest; const { minGasPrice, gasPerByte, gasPriceFactor, maxGasPerTx } = this.getGasConfig(); - const gasPrice = max(transactionRequest.gasPrice, minGasPrice); - const gasLimit = maxGasPerTx; - // const margin = 1 + tolerance; - // Set gasLimit to the maximum of the chain - // and gasPrice to 0 for measure - // Transaction without arrive to OutOfGas - transactionRequest.gasLimit = maxGasPerTx; - transactionRequest.gasPrice = bn(0); + gasPrice = max(gasPrice, minGasPrice); - // Execute dryRun not validated transaction to query gasUsed - const { receipts } = await this.call(transactionRequest); - const transaction = transactionRequest.toTransaction(); + // Getting coin quantities from amounts being transferred + const coinOutputsQuantitites = clonedTransactionRequest.getCoinOutputsQuantities(); + // Combining coin quantities from amounts being transferred and forwarding to contracts + const allQuantities = mergeQuantities(coinOutputsQuantitites, forwardingQuantities); + // Funding transaction with fake utxos + clonedTransactionRequest.fundWithFakeUtxos(allQuantities); + const transactionBytes = clonedTransactionRequest.toTransactionBytes(); const chargeableBytes = calculateTxChargeableBytes({ - transactionBytes: transactionRequest.toTransactionBytes(), - transactionWitnesses: transaction.witnesses, + transactionBytes, + transactionWitnesses: new TransactionCoder().decode(transactionBytes, 0)[0].witnesses, }); - const gasUsed = getGasUsedFromReceipts(receipts); + let gasUsed = bn(0); + let receipts: TransactionResultReceipt[] = []; + const isTransactionCreate = clonedTransactionRequest.type === TransactionType.Create; + + // Transactions of type Create does not consume any gas so we can the dryRun + if (!isTransactionCreate) { + /** + * Setting the gasPrice to 0 on a dryRun will result in no fees being charged. + * This simplifies the funding with fake utxos, since the coin quantities required + * will only be amounts being transferred (coin outputs) and amounts being forwarded + * to contract calls. + */ + clonedTransactionRequest.gasPrice = bn(0); + clonedTransactionRequest.gasLimit = maxGasPerTx; + + // Executing dryRun with fake utxos to get gasUsed + const result = await this.call(clonedTransactionRequest); + receipts = result.receipts; + gasUsed = getGasUsedFromReceipts(receipts); + } - const { minFee } = calculateTransactionFee({ + const { minFee, maxFee } = calculateTransactionFee({ gasPrice, gasPerByte, gasPriceFactor, @@ -688,10 +712,13 @@ export default class Provider { }); return { + requiredQuantities: allQuantities, minGasPrice, + receipts, gasPrice, gasUsed, - fee: minFee, + minFee, + maxFee, }; } diff --git a/packages/providers/src/transaction-request/transaction-request.test.ts b/packages/providers/src/transaction-request/transaction-request.test.ts index 4f3702fbd76..97882462c5a 100644 --- a/packages/providers/src/transaction-request/transaction-request.test.ts +++ b/packages/providers/src/transaction-request/transaction-request.test.ts @@ -1,10 +1,27 @@ -import { toNumber } from '@fuel-ts/math'; -import { TransactionType } from '@fuel-ts/transactions'; +import { Address } from '@fuel-ts/address'; +import { BaseAssetId } from '@fuel-ts/address/configs'; +import { bn, toNumber } from '@fuel-ts/math'; +import { InputType, OutputType, TransactionType } from '@fuel-ts/transactions'; +import { + MOCK_REQUEST_CHANGE_OUTPUT, + MOCK_REQUEST_COIN_INPUT, + MOCK_REQUEST_COIN_OUTPUT, + MOCK_REQUEST_CONTRACT_INPUT, + MOCK_REQUEST_CONTRACT_OUTPUT, + MOCK_REQUEST_MESSAGE_INPUT, +} from '../../test/fixtures/inputs-and-outputs'; +import type { CoinQuantity } from '../coin-quantity'; + +import type { CoinTransactionRequestInput } from './input'; +import { ScriptTransactionRequest } from './script-transaction-request'; import type { TransactionRequestLike } from './types'; import { transactionRequestify } from './utils'; describe('TransactionRequest', () => { + const assetIdA = '0x0101010101010101010101010101010101010101010101010101010101010101'; + const assetIdB = '0x0202020202020202020202020202020202020202020202020202020202020202'; + describe('transactionRequestify', () => { it('should keep data from input in transaction request created', () => { const script = Uint8Array.from([1, 2, 3, 4]); @@ -45,4 +62,129 @@ describe('TransactionRequest', () => { expect(() => transactionRequestify(txRequestLike)).toThrow('Invalid transaction type: 5'); }); }); + + describe('getCoinOutputsQuantities', () => { + it('should correctly map all the coin outputs to CoinQuantity', () => { + const transactionRequest = new ScriptTransactionRequest(); + + const address1 = Address.fromRandom(); + const address2 = Address.fromRandom(); + + const amount1 = 100; + const amount2 = 300; + + transactionRequest.addCoinOutput(address1, amount1, assetIdB); + transactionRequest.addCoinOutput(address2, amount2, assetIdA); + + const result = transactionRequest.getCoinOutputsQuantities(); + + expect(result).toEqual([ + { + amount: bn(amount1), + assetId: assetIdB, + }, + { + amount: bn(amount2), + assetId: assetIdA, + }, + ]); + }); + + it('should return an empty array if there are no coin outputs', () => { + // Mock the getCoinOutputs method + const transactionRequest = new ScriptTransactionRequest(); + + const result = transactionRequest.getCoinOutputsQuantities(); + + expect(result).toEqual([]); + }); + }); + + describe('fundWithFakeUtxos', () => { + it('should fund with the expected quantities', () => { + const transactionRequest = new ScriptTransactionRequest(); + + const amountBase = bn(500); + const amountA = bn(700); + const amountB = bn(300); + + const quantities: CoinQuantity[] = [ + { assetId: BaseAssetId, amount: amountBase }, + { assetId: assetIdA, amount: amountA }, + { assetId: assetIdB, amount: amountB }, + ]; + + transactionRequest.fundWithFakeUtxos(quantities); + + const inputs = transactionRequest.inputs as CoinTransactionRequestInput[]; + + const inputA = inputs.find((i) => i.assetId === assetIdA); + const inputB = inputs.find((i) => i.assetId === assetIdB); + const inputBase = inputs.find((i) => i.assetId === BaseAssetId); + + expect(inputA?.amount).toEqual(bn(700)); + expect(inputB?.amount).toEqual(bn(300)); + expect(inputBase?.amount).toEqual(bn(500)); + }); + + it('should add BaseAssetId with amount bn(1) if not present in quantities', () => { + const transactionRequest = new ScriptTransactionRequest(); + + const quantities: CoinQuantity[] = [{ assetId: assetIdB, amount: bn(10) }]; + + transactionRequest.fundWithFakeUtxos(quantities); + + const baseAssetEntry = quantities.find((q) => q.assetId === BaseAssetId); + expect(baseAssetEntry).not.toBeNull(); + expect(baseAssetEntry?.amount).toEqual(bn(1)); + }); + + it('should not add BaseAssetId if it is already present in quantities', () => { + const transactionRequest = new ScriptTransactionRequest(); + + const quantities = [{ assetId: BaseAssetId, amount: bn(10) }]; + transactionRequest.fundWithFakeUtxos(quantities); + const baseAssetEntries = quantities.filter((q) => q.assetId === BaseAssetId); + expect(baseAssetEntries.length).toBe(1); + }); + + it('should filter inputs and outputs accordingly', () => { + const transactionRequest = new ScriptTransactionRequest(); + + transactionRequest.inputs = [ + MOCK_REQUEST_COIN_INPUT, + MOCK_REQUEST_MESSAGE_INPUT, + MOCK_REQUEST_CONTRACT_INPUT, + ]; + transactionRequest.outputs = [ + MOCK_REQUEST_COIN_OUTPUT, + MOCK_REQUEST_CONTRACT_OUTPUT, + MOCK_REQUEST_CHANGE_OUTPUT, + ]; + + transactionRequest.fundWithFakeUtxos([]); + + const contractInput = transactionRequest.inputs.find((i) => i.type === InputType.Contract); + const coinInput = transactionRequest.inputs.find((i) => i.type === InputType.Coin); + const messageInput = transactionRequest.inputs.find((i) => i.type === InputType.Message); + + // Contract inputs should not be filtered out + expect(contractInput).toBe(MOCK_REQUEST_CONTRACT_INPUT); + + // Coin and Message inputs should be filtered out + expect(coinInput).not.toBe(MOCK_REQUEST_COIN_INPUT); + expect(messageInput).not.toBe(MOCK_REQUEST_MESSAGE_INPUT); + + const coinOutput = transactionRequest.outputs.find((o) => o.type === OutputType.Coin); + const contractOutput = transactionRequest.outputs.find((o) => o.type === OutputType.Contract); + const changeOutput = transactionRequest.outputs.find((o) => o.type === OutputType.Change); + + // Coin and Contract outputs should not be filtered out + expect(coinOutput).toBe(MOCK_REQUEST_COIN_OUTPUT); + expect(contractOutput).toBe(MOCK_REQUEST_CONTRACT_OUTPUT); + + // Change output should be filtered out + expect(changeOutput).not.toBe(MOCK_REQUEST_CHANGE_OUTPUT); + }); + }); }); diff --git a/packages/providers/src/transaction-request/transaction-request.ts b/packages/providers/src/transaction-request/transaction-request.ts index ce527b7d832..c5e38d3605c 100644 --- a/packages/providers/src/transaction-request/transaction-request.ts +++ b/packages/providers/src/transaction-request/transaction-request.ts @@ -1,12 +1,12 @@ -import { addressify } from '@fuel-ts/address'; -import { BaseAssetId } from '@fuel-ts/address/configs'; +import { Address, addressify, getRandomB256 } from '@fuel-ts/address'; +import { BaseAssetId, ZeroBytes32 } from '@fuel-ts/address/configs'; import type { AddressLike, AbstractAddress, AbstractPredicate } from '@fuel-ts/interfaces'; -import type { BigNumberish, BN } from '@fuel-ts/math'; +import type { BN, BigNumberish } from '@fuel-ts/math'; import { bn } from '@fuel-ts/math'; import type { TransactionCreate, TransactionScript } from '@fuel-ts/transactions'; import { TransactionType, TransactionCoder, InputType, OutputType } from '@fuel-ts/transactions'; -import { getBytesCopy, hexlify } from 'ethers'; import type { BytesLike } from 'ethers'; +import { getBytesCopy, hexlify } from 'ethers'; import type { Coin } from '../coin'; import type { CoinQuantity, CoinQuantityLike } from '../coin-quantity'; @@ -479,6 +479,52 @@ export abstract class BaseTransactionRequest implements BaseTransactionRequestLi }; } + /** + * Funds the transaction with fake UTXOs for each assetId and amount in the + * quantities array. + * + * @param quantities - CoinQuantity Array. + */ + fundWithFakeUtxos(quantities: CoinQuantity[]) { + const hasBaseAssetId = quantities.some(({ assetId }) => assetId === BaseAssetId); + + if (!hasBaseAssetId) { + quantities.push({ assetId: BaseAssetId, amount: bn(1) }); + } + + const owner = getRandomB256(); + + this.inputs = this.inputs.filter((input) => input.type === InputType.Contract); + this.outputs = this.outputs.filter((output) => output.type !== OutputType.Change); + + const fakeResources = quantities.map(({ assetId, amount }, idx) => ({ + id: `${ZeroBytes32}0${idx}`, + amount, + assetId, + owner: Address.fromB256(owner), + maturity: 0, + blockCreated: bn(1), + txCreatedIdx: bn(1), + })); + + this.addResources(fakeResources); + } + + /** + * Retrieves an array of CoinQuantity for each coin output present in the transaction. + * a transaction. + * + * @returns CoinQuantity array. + */ + getCoinOutputsQuantities(): CoinQuantity[] { + const coinsQuantities = this.getCoinOutputs().map(({ amount, assetId }) => ({ + amount: bn(amount), + assetId: assetId.toString(), + })); + + return coinsQuantities; + } + /** * Return the minimum amount in native coins required to create * a transaction. diff --git a/packages/providers/src/utils/merge-quantities.test.ts b/packages/providers/src/utils/merge-quantities.test.ts new file mode 100644 index 00000000000..6c818d5a31d --- /dev/null +++ b/packages/providers/src/utils/merge-quantities.test.ts @@ -0,0 +1,45 @@ +import { bn } from '@fuel-ts/math'; + +import type { CoinQuantity } from '../coin-quantity'; + +import { mergeQuantities } from './merge-quantities'; + +describe('mergeQuantities', () => { + const assetIdA = '0x0101010101010101010101010101010101010101010101010101010101010101'; + const assetIdB = '0x0202020202020202020202020202020202020202020202020202020202020202'; + + it('combines non-overlapping coin quantities', () => { + const arr1: CoinQuantity[] = [{ assetId: assetIdA, amount: bn(10) }]; + const arr2: CoinQuantity[] = [{ assetId: assetIdB, amount: bn(20) }]; + + const result = mergeQuantities(arr1, arr2); + expect(result).toEqual([ + { assetId: assetIdA, amount: bn(10) }, + { assetId: assetIdB, amount: bn(20) }, + ]); + }); + + it('combines overlapping coin quantities', () => { + const arr1: CoinQuantity[] = [{ assetId: assetIdA, amount: bn(10) }]; + const arr2: CoinQuantity[] = [{ assetId: assetIdA, amount: bn(20) }]; + + const result = mergeQuantities(arr1, arr2); + expect(result).toEqual([{ assetId: assetIdA, amount: bn(10).add(20) }]); + }); + + it('handles one empty array', () => { + const arr1: CoinQuantity[] = []; + const arr2: CoinQuantity[] = [{ assetId: assetIdB, amount: bn(20) }]; + + const result = mergeQuantities(arr1, arr2); + expect(result).toEqual([{ assetId: assetIdB, amount: bn(20) }]); + }); + + it('handles two empty arrays', () => { + const arr1: CoinQuantity[] = []; + const arr2: CoinQuantity[] = []; + + const result = mergeQuantities(arr1, arr2); + expect(result).toEqual([]); + }); +}); diff --git a/packages/providers/src/utils/merge-quantities.ts b/packages/providers/src/utils/merge-quantities.ts new file mode 100644 index 00000000000..7fd18a8995e --- /dev/null +++ b/packages/providers/src/utils/merge-quantities.ts @@ -0,0 +1,22 @@ +import type { BN } from '@fuel-ts/math'; + +import type { CoinQuantity } from '../coin-quantity'; + +export const mergeQuantities = (arr1: CoinQuantity[], arr2: CoinQuantity[]): CoinQuantity[] => { + const resultMap: { [key: string]: BN } = {}; + + function addToMap({ amount, assetId }: CoinQuantity) { + if (resultMap[assetId]) { + resultMap[assetId] = resultMap[assetId].add(amount); + } else { + resultMap[assetId] = amount; + } + } + + // Process both arrays + arr1.forEach(addToMap); + arr2.forEach(addToMap); + + // Convert the resultMap back to an array + return Object.entries(resultMap).map(([assetId, amount]) => ({ assetId, amount })); +}; diff --git a/packages/providers/test/fixtures/inputs-and-outputs.ts b/packages/providers/test/fixtures/inputs-and-outputs.ts new file mode 100644 index 00000000000..e0c7f1f8e61 --- /dev/null +++ b/packages/providers/test/fixtures/inputs-and-outputs.ts @@ -0,0 +1,59 @@ +import { getRandomB256 } from '@fuel-ts/address'; +import { ZeroBytes32 } from '@fuel-ts/address/configs'; +import { bn } from '@fuel-ts/math'; +import { InputType, OutputType } from '@fuel-ts/transactions'; + +import type { + CoinTransactionRequestInput, + ContractTransactionRequestInput, + MessageTransactionRequestInput, +} from '../../src/transaction-request/input'; +import type { + ChangeTransactionRequestOutput, + CoinTransactionRequestOutput, + ContractTransactionRequestOutput, +} from '../../src/transaction-request/output'; + +export const MOCK_REQUEST_COIN_INPUT: CoinTransactionRequestInput = { + type: InputType.Coin, + id: ZeroBytes32, + amount: bn(100), + assetId: '0x0101010101010101010101010101010101010101010101010101010101010101', + owner: getRandomB256(), + txPointer: '0x00000000000000000000000000000000', + witnessIndex: 0, + maturity: 0, +}; + +export const MOCK_REQUEST_MESSAGE_INPUT: MessageTransactionRequestInput = { + type: InputType.Message, + amount: bn(100), + witnessIndex: 0, + sender: getRandomB256(), + recipient: getRandomB256(), + nonce: getRandomB256(), + data: '', +}; + +export const MOCK_REQUEST_CONTRACT_INPUT: ContractTransactionRequestInput = { + type: InputType.Contract, + contractId: getRandomB256(), + txPointer: '0x00000000000000000000000000000000', +}; + +export const MOCK_REQUEST_COIN_OUTPUT: CoinTransactionRequestOutput = { + type: OutputType.Coin, + to: getRandomB256(), + amount: 100, + assetId: '0x0101010101010101010101010101010101010101010101010101010101010101', +}; +export const MOCK_REQUEST_CONTRACT_OUTPUT: ContractTransactionRequestOutput = { + type: OutputType.Contract, + inputIndex: 0, +}; + +export const MOCK_REQUEST_CHANGE_OUTPUT: ChangeTransactionRequestOutput = { + type: OutputType.Change, + to: getRandomB256(), + assetId: '0x0101010101010101010101010101010101010101010101010101010101010101', +}; diff --git a/packages/providers/test/provider.test.ts b/packages/providers/test/provider.test.ts index 42f06b5da33..2ffa40e4a99 100644 --- a/packages/providers/test/provider.test.ts +++ b/packages/providers/test/provider.test.ts @@ -12,6 +12,7 @@ import { getBytesCopy, hexlify } from 'ethers'; import type { BytesLike } from 'ethers'; import * as GraphQL from 'graphql-request'; +import type { TransactionCost } from '../src/provider'; import Provider from '../src/provider'; import type { CoinTransactionRequestInput, @@ -867,18 +868,21 @@ describe('Provider', () => { const provider = await Provider.create(FUEL_NETWORK_URL); const gasLimit = 1; const gasUsed = bn(1000); - const transactionParams = { - minGasPrice: bn(1), - gasPrice: bn(1), + const transactionCost: TransactionCost = { gasUsed, - fee: bn(1), + gasPrice: bn(1), + minGasPrice: bn(1), + maxFee: bn(2), + minFee: bn(1), + receipts: [], + requiredQuantities: [], }; const estimateTxSpy = jest.spyOn(provider, 'estimateTxDependencies').mockImplementation(); const txCostSpy = jest .spyOn(provider, 'getTransactionCost') - .mockReturnValue(Promise.resolve(transactionParams)); + .mockReturnValue(Promise.resolve(transactionCost)); await expectToThrowFuelError( () => provider.sendTransaction(new ScriptTransactionRequest({ gasPrice: 1, gasLimit })), @@ -896,18 +900,21 @@ describe('Provider', () => { const provider = await Provider.create(FUEL_NETWORK_URL); const gasPrice = 1; const minGasPrice = bn(1000); - const transactionParams = { + const transactionCost: TransactionCost = { minGasPrice, gasPrice: bn(1), gasUsed: bn(1), - fee: bn(1), + maxFee: bn(2), + minFee: bn(1), + receipts: [], + requiredQuantities: [], }; const estimateTxSpy = jest.spyOn(provider, 'estimateTxDependencies').mockImplementation(); const txCostSpy = jest .spyOn(provider, 'getTransactionCost') - .mockReturnValue(Promise.resolve(transactionParams)); + .mockReturnValue(Promise.resolve(transactionCost)); await expectToThrowFuelError( () => provider.sendTransaction(new ScriptTransactionRequest({ gasPrice, gasLimit: 1000 })), diff --git a/packages/script/src/script-invocation-scope.ts b/packages/script/src/script-invocation-scope.ts index aeece58460c..e4b4ff409a4 100644 --- a/packages/script/src/script-invocation-scope.ts +++ b/packages/script/src/script-invocation-scope.ts @@ -57,6 +57,9 @@ export class ScriptInvocationScope< assert(this.program.account, 'Provider is required!'); const transactionRequest = await this.getTransactionRequest(); + const { maxFee } = await this.getTransactionCost(); + await this.fundWithRequiredCoins(maxFee); + const response = await this.program.account.sendTransaction(transactionRequest); return FunctionInvocationResult.build( diff --git a/packages/wallet/src/account.test.ts b/packages/wallet/src/account.test.ts index 6599738ba2d..7c245eab712 100644 --- a/packages/wallet/src/account.test.ts +++ b/packages/wallet/src/account.test.ts @@ -7,12 +7,10 @@ import type { CoinQuantity, Message, Resource, - ScriptTransactionRequest, TransactionRequest, TransactionRequestLike, - TransactionResponse, } from '@fuel-ts/providers'; -import { Provider } from '@fuel-ts/providers'; +import { TransactionResponse, ScriptTransactionRequest, Provider } from '@fuel-ts/providers'; import * as providersMod from '@fuel-ts/providers'; import { Account } from './account'; @@ -207,39 +205,45 @@ describe('Account', () => { }); it('should execute fund just as fine', async () => { - const fee = { - amount: bn(1), - assetId: '0x0101010101010101010101010101010101010101010101010101010101010101', - }; - - const resources: Resource[] = []; - - const calculateFee = jest.fn(() => fee); - const addResources = jest.fn(); + const quantities: CoinQuantity[] = [ + { + amount: bn(10), + assetId: '0x0101010101010101010101010101010101010101010101010101010101010101', + }, + ]; + const fee = bn(29); - const request = { - calculateFee, - addResources, - } as unknown as TransactionRequest; + const request = new ScriptTransactionRequest(); + const resourcesToSpend: Resource[] = []; const getResourcesToSpendSpy = jest .spyOn(Account.prototype, 'getResourcesToSpend') - .mockImplementationOnce(() => Promise.resolve([])); + .mockImplementationOnce(() => Promise.resolve(resourcesToSpend)); + + const addResourcesSpy = jest.spyOn(request, 'addResources'); + + const addAmountToAssetSpy = jest.spyOn(providersMod, 'addAmountToAsset'); const account = new Account( '0x09c0b2d1a486c439a87bcba6b46a7a1a23f3897cc83a94521a96da5c23bc58db', provider ); - await account.fund(request); + await account.fund(request, quantities, fee); - expect(calculateFee.mock.calls.length).toBe(1); + expect(addAmountToAssetSpy).toBeCalledTimes(1); + expect(addAmountToAssetSpy).toHaveBeenCalledWith({ + amount: fee, + assetId: BaseAssetId, + coinQuantities: quantities, + }); - expect(getResourcesToSpendSpy.mock.calls.length).toBe(1); - expect(getResourcesToSpendSpy.mock.calls[0][0]).toEqual([fee]); + const expectedTotalResources = [quantities[0], { amount: fee, assetId: BaseAssetId }]; + expect(getResourcesToSpendSpy).toBeCalledTimes(1); + expect(getResourcesToSpendSpy).toBeCalledWith(expectedTotalResources); - expect(addResources.mock.calls.length).toBe(1); - expect(addResources.mock.calls[0][0]).toEqual(resources); + expect(addResourcesSpy).toBeCalledTimes(1); + expect(addResourcesSpy).toHaveBeenCalledWith(resourcesToSpend); }); it('should execute transfer just as fine', async () => { @@ -252,77 +256,56 @@ describe('Account', () => { maturity: 1, }; - const fee: CoinQuantity = { - amount, - assetId, + const transactionCost: providersMod.TransactionCost = { + gasUsed: bn(234), + gasPrice: bn(1), + minGasPrice: bn(1), + maxFee: bn(2), + minFee: bn(1), + receipts: [], + requiredQuantities: [], }; - const calculateFee = jest.fn(() => fee); - const addCoinOutput = jest.fn(); - const addResources = jest.fn(); + const request = new ScriptTransactionRequest(); + jest.spyOn(providersMod, 'ScriptTransactionRequest').mockImplementation(() => request); - const request = { - calculateFee, - addCoinOutput, - addResources, - } as unknown as ScriptTransactionRequest; + const transactionResponse = new TransactionResponse('transactionId', provider); - const resources: Resource[] = []; + const addCoinOutputSpy = jest.spyOn(request, 'addCoinOutput'); - const getResourcesToSpend = jest - .spyOn(Account.prototype, 'getResourcesToSpend') - .mockImplementation(() => Promise.resolve(resources)); + const fundSpy = jest + .spyOn(Account.prototype, 'fund') + .mockImplementation(() => Promise.resolve()); - const sendTransaction = jest + const sendTransactionSpy = jest .spyOn(Account.prototype, 'sendTransaction') - .mockImplementation(() => Promise.resolve({} as unknown as TransactionResponse)); + .mockImplementation(() => Promise.resolve(transactionResponse)); - jest.spyOn(providersMod, 'ScriptTransactionRequest').mockImplementation(() => request); + const getTransactionCost = jest + .spyOn(Provider.prototype, 'getTransactionCost') + .mockImplementation(() => Promise.resolve(transactionCost)); const account = new Account( '0x09c0b2d1a486c439a87bcba6b46a7a1a23f3897cc83a94521a96da5c23bc58db', provider ); - // asset id already hexlified - await account.transfer(destination, amount, assetId, txParam); - - expect(addCoinOutput.mock.calls.length).toBe(1); - expect(addCoinOutput.mock.calls[0]).toEqual([destination, amount, assetId]); - expect(calculateFee.mock.calls.length).toBe(1); - - expect(getResourcesToSpend.mock.calls.length).toBe(1); - expect(getResourcesToSpend.mock.calls[0][0]).toEqual([fee]); - - expect(addResources.mock.calls.length).toBe(1); - expect(addResources.mock.calls[0][0]).toEqual(resources); - - expect(sendTransaction.mock.calls.length).toBe(1); - expect(sendTransaction.mock.calls[0][0]).toEqual(request); - - // asset id not hexlified - await account.transfer(destination, amount, BaseAssetId, txParam); - - expect(addCoinOutput.mock.calls.length).toBe(2); - expect(addCoinOutput.mock.calls[1]).toEqual([ - destination, - amount, - '0x0000000000000000000000000000000000000000000000000000000000000000', - ]); + await account.transfer(destination, amount, assetId, txParam); - expect(calculateFee.mock.calls.length).toBe(2); + expect(addCoinOutputSpy).toHaveBeenCalledTimes(1); + expect(addCoinOutputSpy).toHaveBeenCalledWith(destination, amount, assetId); - expect(getResourcesToSpend.mock.calls.length).toBe(2); - expect(getResourcesToSpend.mock.calls[1][0]).toEqual([ - [amount, '0x0000000000000000000000000000000000000000000000000000000000000000'], - fee, - ]); + expect(getTransactionCost).toHaveBeenCalledTimes(1); - expect(addResources.mock.calls.length).toBe(2); - expect(addResources.mock.calls[1][0]).toEqual(resources); + expect(fundSpy).toHaveBeenCalledTimes(1); + expect(fundSpy).toHaveBeenCalledWith( + request, + transactionCost.requiredQuantities, + transactionCost.maxFee + ); - expect(sendTransaction.mock.calls.length).toBe(2); - expect(sendTransaction.mock.calls[1][0]).toEqual(request); + expect(sendTransactionSpy).toHaveBeenCalledTimes(1); + expect(sendTransactionSpy).toHaveBeenCalledWith(request); }); it('should execute withdrawToBaseLayer just fine', async () => { diff --git a/packages/wallet/src/account.ts b/packages/wallet/src/account.ts index c6abe7ba02a..12695799a6b 100644 --- a/packages/wallet/src/account.ts +++ b/packages/wallet/src/account.ts @@ -22,8 +22,9 @@ import { withdrawScript, ScriptTransactionRequest, transactionRequestify, + addAmountToAsset, } from '@fuel-ts/providers'; -import { getBytesCopy, hexlify } from 'ethers'; +import { getBytesCopy } from 'ethers'; import type { BytesLike } from 'ethers'; import { @@ -204,11 +205,18 @@ export class Account extends AbstractAccount { * @param request - The transaction request. * @returns A promise that resolves when the resources are added to the transaction. */ - async fund(request: T): Promise { - const { gasPriceFactor } = this.provider.getGasConfig(); - const fee = request.calculateFee(gasPriceFactor); - const resources = await this.getResourcesToSpend([fee]); + async fund( + request: T, + quantities: CoinQuantity[], + fee: BN + ): Promise { + addAmountToAsset({ + amount: fee, + assetId: BaseAssetId, + coinQuantities: quantities, + }); + const resources = await this.getResourcesToSpend(quantities); request.addResources(resources); } @@ -236,20 +244,9 @@ export class Account extends AbstractAccount { const request = new ScriptTransactionRequest(params); request.addCoinOutput(destination, amount, assetId); - const { gasPriceFactor } = this.provider.getGasConfig(); + const { maxFee, requiredQuantities } = await this.provider.getTransactionCost(request); - const fee = request.calculateFee(gasPriceFactor); - let quantities: CoinQuantityLike[] = []; - - if (fee.assetId === hexlify(assetId)) { - fee.amount = fee.amount.add(amount); - quantities = [fee]; - } else { - quantities = [[amount, assetId], fee]; - } - - const resources = await this.getResourcesToSpend(quantities); - request.addResources(resources); + await this.fund(request, requiredQuantities, maxFee); return this.sendTransaction(request); } @@ -291,21 +288,11 @@ export class Account extends AbstractAccount { request.addContractInputAndOutput(contractId); - const { gasPriceFactor } = this.provider.getGasConfig(); - - const fee = request.calculateFee(gasPriceFactor); - - let quantities: CoinQuantityLike[] = []; - - if (fee.assetId === hexlify(assetId)) { - fee.amount = fee.amount.add(amount); - quantities = [fee]; - } else { - quantities = [[amount, assetId], fee]; - } + const { maxFee, requiredQuantities } = await this.provider.getTransactionCost(request, [ + { amount: bn(amount), assetId: String(assetId) }, + ]); - const resources = await this.getResourcesToSpend(quantities); - request.addResources(resources); + await this.fund(request, requiredQuantities, maxFee); return this.sendTransaction(request); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67951a2b436..29cb1774544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -543,6 +543,9 @@ importers: '@fuel-ts/abi-coder': specifier: workspace:* version: link:../abi-coder + '@fuel-ts/address': + specifier: workspace:* + version: link:../address '@fuel-ts/crypto': specifier: workspace:* version: link:../crypto