diff --git a/.changeset/clever-mirrors-jam.md b/.changeset/clever-mirrors-jam.md new file mode 100644 index 00000000000..3e5d98da72a --- /dev/null +++ b/.changeset/clever-mirrors-jam.md @@ -0,0 +1,6 @@ +--- +"@fuel-ts/account": patch +"@fuel-ts/program": patch +--- + +feat: allow passing `gasPrice` to `getTransactionCost` diff --git a/packages/account/src/account.ts b/packages/account/src/account.ts index 8ab6ecf20e6..e41050c1b41 100644 --- a/packages/account/src/account.ts +++ b/packages/account/src/account.ts @@ -552,7 +552,7 @@ export class Account extends AbstractAccount implements WithAddress { */ async getTransactionCost( transactionRequestLike: TransactionRequestLike, - { signatureCallback, quantities = [] }: TransactionCostParams = {} + { signatureCallback, quantities = [], gasPrice }: TransactionCostParams = {} ): Promise { const txRequestClone = clone(transactionRequestify(transactionRequestLike)); const baseAssetId = await this.provider.getBaseAssetId(); @@ -603,6 +603,7 @@ export class Account extends AbstractAccount implements WithAddress { const txCost = await this.provider.getTransactionCost(txRequestClone, { signatureCallback, + gasPrice, }); return { diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index cd8b95da0bb..7d7b6849aac 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -354,6 +354,30 @@ export type TransactionCostParams = EstimateTransactionParams & { * @returns A promise that resolves to the signed transaction request. */ signatureCallback?: (request: ScriptTransactionRequest) => Promise; + + /** + * The gas price to use for the transaction. + */ + gasPrice?: BN; +}; + +export type EstimateTxDependenciesParams = { + /** + * The gas price to use for the transaction. + */ + gasPrice?: BN; +}; + +export type EstimateTxGasAndFeeParams = { + /** + * The transaction request to estimate the gas and fee for. + */ + transactionRequest: TransactionRequest; + + /** + * The gas price to use for the transaction. + */ + gasPrice?: BN; }; /** @@ -971,10 +995,12 @@ export default class Provider { * `addVariableOutputs` is called on the transaction. * * @param transactionRequest - The transaction request object. + * @param gasPrice - The gas price to use for the transaction, if not provided it will be fetched. * @returns A promise that resolves to the estimate transaction dependencies. */ async estimateTxDependencies( - transactionRequest: TransactionRequest + transactionRequest: TransactionRequest, + { gasPrice: gasPriceParam }: EstimateTxDependenciesParams = {} ): Promise { if (isTransactionTypeCreate(transactionRequest)) { return { @@ -991,13 +1017,15 @@ export default class Provider { await this.validateTransaction(transactionRequest); + const gasPrice = gasPriceParam ?? (await this.estimateGasPrice(10)); + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { const { dryRun: [{ receipts: rawReceipts, status }], } = await this.operations.dryRun({ encodedTransactions: [hexlify(transactionRequest.toTransactionBytes())], utxoValidation: false, - gasPrice: '0', + gasPrice: gasPrice.toString(), }); receipts = rawReceipts.map(processGqlReceipt); @@ -1019,7 +1047,7 @@ export default class Provider { const { maxFee } = await this.estimateTxGasAndFee({ transactionRequest, - gasPrice: bn(0), + gasPrice, }); // eslint-disable-next-line no-param-reassign @@ -1186,12 +1214,12 @@ export default class Provider { /** * Estimates the transaction gas and fee based on the provided transaction request. - * @param transactionRequest - The transaction request object. + * @param params - The parameters for estimating the transaction gas and fee. * @returns An object containing the estimated minimum gas, minimum fee, maximum gas, and maximum fee. */ - async estimateTxGasAndFee(params: { transactionRequest: TransactionRequest; gasPrice?: BN }) { - const { transactionRequest } = params; - let { gasPrice } = params; + async estimateTxGasAndFee(params: EstimateTxGasAndFeeParams) { + const { transactionRequest, gasPrice: gasPriceParam } = params; + let gasPrice = gasPriceParam; await this.autoRefetchConfigs(); @@ -1307,7 +1335,7 @@ export default class Provider { */ async getTransactionCost( transactionRequestLike: TransactionRequestLike, - { signatureCallback }: TransactionCostParams = {} + { signatureCallback, gasPrice: gasPriceParam }: TransactionCostParams = {} ): Promise> { const txRequestClone = clone(transactionRequestify(transactionRequestLike)); const updateMaxFee = txRequestClone.maxFee.eq(0); @@ -1329,12 +1357,16 @@ export default class Provider { await this.estimatePredicates(signedRequest); txRequestClone.updatePredicateGasUsed(signedRequest.inputs); + const gasPrice = gasPriceParam ?? (await this.estimateGasPrice(10)); + /** * Calculate minGas and maxGas based on the real transaction */ // eslint-disable-next-line prefer-const - let { maxFee, maxGas, minFee, minGas, gasPrice, gasLimit } = await this.estimateTxGasAndFee({ + let { maxFee, maxGas, minFee, minGas, gasLimit } = await this.estimateTxGasAndFee({ + // Fetches and returns a gas price transactionRequest: signedRequest, + gasPrice, }); let receipts: TransactionResultReceipt[] = []; @@ -1351,7 +1383,7 @@ export default class Provider { } ({ receipts, missingContractIds, outputVariables, dryRunStatus } = - await this.estimateTxDependencies(txRequestClone)); + await this.estimateTxDependencies(txRequestClone, { gasPrice })); if (dryRunStatus && 'reason' in dryRunStatus) { throw this.extractDryRunError(txRequestClone, receipts, dryRunStatus); @@ -1363,7 +1395,7 @@ export default class Provider { gasUsed = bn(pristineGasUsed.muln(GAS_USED_MODIFIER)).max(maxGasPerTx.sub(minGas)); txRequestClone.gasLimit = gasUsed; - ({ maxFee, maxGas, minFee, minGas, gasPrice } = await this.estimateTxGasAndFee({ + ({ maxFee, maxGas, minFee, minGas } = await this.estimateTxGasAndFee({ transactionRequest: txRequestClone, gasPrice, })); diff --git a/packages/fuel-gauge/src/fee.test.ts b/packages/fuel-gauge/src/fee.test.ts index 20ee3b45dcf..0994d1903b4 100644 --- a/packages/fuel-gauge/src/fee.test.ts +++ b/packages/fuel-gauge/src/fee.test.ts @@ -348,6 +348,158 @@ describe('Fee', () => { }); }); + it('should not run estimateGasPrice in between estimateTxDependencies dry run attempts', async () => { + using launched = await launchTestNode({ + contractsConfigs: [ + { + factory: MultiTokenContractFactory, + }, + ], + }); + + const { + contracts: [contract], + wallets: [wallet], + provider, + } = launched; + + const assetId = getMintedAssetId(contract.id.toB256(), SUB_ID); + + // Minting coins first + const mintCall = await contract.functions.mint_coins(SUB_ID, 10_000).call(); + await mintCall.waitForResult(); + + const estimateGasPrice = vi.spyOn(provider, 'estimateGasPrice'); + const dryRun = vi.spyOn(provider.operations, 'dryRun'); + + /** + * Sway transfer without adding `OutputVariable` which will result in + * 2 dry runs at the `Provider.estimateTxDependencies` method: + * - 1st dry run will fail due to missing `OutputVariable` + * - 2nd dry run will succeed + */ + const transferCall = await contract.functions + .transfer_to_address({ bits: wallet.address.toB256() }, { bits: assetId }, 10_000) + .call(); + + await transferCall.waitForResult(); + + expect(estimateGasPrice).toHaveBeenCalledOnce(); + expect(dryRun).toHaveBeenCalledTimes(2); + }); + + it('should ensure estimateGasPrice runs only once when funding a transaction.', async () => { + const amountPerCoin = 100; + + using launched = await launchTestNode({ + walletsConfig: { + amountPerCoin, // Funding with multiple UTXOs so the fee will change after funding the TX. + coinsPerAsset: 250, + }, + contractsConfigs: [ + { + factory: MultiTokenContractFactory, + }, + ], + }); + + const { + wallets: [wallet], + provider, + } = launched; + + const fund = vi.spyOn(wallet, 'fund'); + const estimateGasPrice = vi.spyOn(provider, 'estimateGasPrice'); + + const tx = await wallet.transfer( + wallet.address, + amountPerCoin * 20, + await provider.getBaseAssetId() + ); + const { isStatusSuccess } = await tx.waitForResult(); + + expect(fund).toHaveBeenCalledOnce(); + expect(estimateGasPrice).toHaveBeenCalledOnce(); + + expect(isStatusSuccess).toBeTruthy(); + }); + + it('ensures estimateGasPrice runs only once when getting transaction cost [w/ gas price]', async () => { + using launched = await launchTestNode({ + contractsConfigs: [ + { + factory: CallTestContractFactory, + }, + ], + }); + + const { + contracts: [contract], + provider, + wallets: [wallet], + } = launched; + + const estimateGasPrice = vi.spyOn(provider, 'estimateGasPrice'); + + const txRequest = await contract.functions.foo(10).getTransactionRequest(); + const cost = await wallet.getTransactionCost(txRequest); + + expect(cost.gasUsed.toNumber()).toBeGreaterThan(0); + expect(estimateGasPrice).toHaveBeenCalledOnce(); + }); + + it('ensures estimateGasPrice runs twice when getting transaction cost with estimate gas and fee [w/o gas price]', async () => { + using launched = await launchTestNode({ + contractsConfigs: [ + { + factory: CallTestContractFactory, + }, + ], + }); + + const { + contracts: [contract], + provider, + wallets: [wallet], + } = launched; + + const estimateGasPrice = vi.spyOn(provider, 'estimateGasPrice'); + + const txRequest = await contract.functions.foo(10).getTransactionRequest(); + const { gasPrice } = await provider.estimateTxGasAndFee({ transactionRequest: txRequest }); + const { gasUsed } = await wallet.getTransactionCost(txRequest); + + expect(estimateGasPrice).toHaveBeenCalledTimes(2); + expect(gasPrice.toNumber()).toBeGreaterThan(0); + expect(gasUsed.toNumber()).toBeGreaterThan(0); + }); + + it('ensures estimateGasPrice runs only once when getting transaction cost with estimate gas and fee', async () => { + using launched = await launchTestNode({ + contractsConfigs: [ + { + factory: CallTestContractFactory, + }, + ], + }); + + const { + contracts: [contract], + provider, + wallets: [wallet], + } = launched; + + const estimateGasPrice = vi.spyOn(provider, 'estimateGasPrice'); + + const txRequest = await contract.functions.foo(10).getTransactionRequest(); + const { gasPrice } = await provider.estimateTxGasAndFee({ transactionRequest: txRequest }); + const { gasUsed } = await wallet.getTransactionCost(txRequest, { gasPrice }); + + expect(gasPrice.toNumber()).toBeGreaterThan(0); + expect(gasUsed.toNumber()).toBeGreaterThan(0); + expect(estimateGasPrice).toHaveBeenCalledOnce(); + }); + describe('Estimation with Message containing data within TX request inputs', () => { // Message with data and amount const testMessage1 = new TestMessage({ @@ -437,81 +589,5 @@ describe('Fee', () => { expect(cost.dryRunStatus?.type).toBe('DryRunSuccessStatus'); }); - - it('should not run estimateGasPrice in between estimateTxDependencies dry run attempts', async () => { - using launched = await launchTestNode({ - contractsConfigs: [ - { - factory: MultiTokenContractFactory, - }, - ], - }); - - const { - contracts: [contract], - wallets: [wallet], - provider, - } = launched; - - const assetId = getMintedAssetId(contract.id.toB256(), SUB_ID); - - // Minting coins first - const mintCall = await contract.functions.mint_coins(SUB_ID, 10_000).call(); - await mintCall.waitForResult(); - - const estimateGasPrice = vi.spyOn(provider, 'estimateGasPrice'); - const dryRun = vi.spyOn(provider.operations, 'dryRun'); - - /** - * Sway transfer without adding `OutputVariable` which will result in - * 2 dry runs at the `Provider.estimateTxDependencies` method: - * - 1st dry run will fail due to missing `OutputVariable` - * - 2nd dry run will succeed - */ - const transferCall = await contract.functions - .transfer_to_address({ bits: wallet.address.toB256() }, { bits: assetId }, 10_000) - .call(); - - await transferCall.waitForResult(); - - expect(estimateGasPrice).toHaveBeenCalledOnce(); - expect(dryRun).toHaveBeenCalledTimes(2); - }); - - it('should ensure estimateGasPrice runs only once when funding a transaction.', async () => { - const amountPerCoin = 100; - - using launched = await launchTestNode({ - walletsConfig: { - amountPerCoin, // Funding with multiple UTXOs so the fee will change after funding the TX. - coinsPerAsset: 250, - }, - contractsConfigs: [ - { - factory: MultiTokenContractFactory, - }, - ], - }); - - const { - wallets: [wallet], - provider, - } = launched; - - const fund = vi.spyOn(wallet, 'fund'); - const estimateGasPrice = vi.spyOn(provider, 'estimateGasPrice'); - - const tx = await wallet.transfer( - wallet.address, - amountPerCoin * 20, - await provider.getBaseAssetId() - ); - const { isStatusSuccess } = await tx.waitForResult(); - - expect(fund).toHaveBeenCalledOnce(); - expect(estimateGasPrice).toHaveBeenCalledOnce(); - - expect(isStatusSuccess).toBeTruthy(); - }); }); }); diff --git a/packages/program/src/functions/base-invocation-scope.ts b/packages/program/src/functions/base-invocation-scope.ts index 118ad9944b2..5e10e131e85 100644 --- a/packages/program/src/functions/base-invocation-scope.ts +++ b/packages/program/src/functions/base-invocation-scope.ts @@ -224,7 +224,6 @@ export class BaseInvocationScope { /** * Gets the transaction cost for dry running the transaction. * - * @param options - Optional transaction cost options. * @returns The transaction cost details. */ async getTransactionCost(): Promise {