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/.github/workflows/linear-pr-integration.yaml b/.github/workflows/linear-pr-integration.yaml index fc1e8a8c259..9e6f34f5f47 100644 --- a/.github/workflows/linear-pr-integration.yaml +++ b/.github/workflows/linear-pr-integration.yaml @@ -14,4 +14,4 @@ jobs: with: pull_number: ${{ github.event.pull_request.number }} linear_api_key: ${{ secrets.LINEAR_TOKEN }} - github_token: ${{ secrets.REPO_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-validate-changesets.yaml b/.github/workflows/pr-validate-changesets.yaml index 286815b44e9..d15c13cd503 100644 --- a/.github/workflows/pr-validate-changesets.yaml +++ b/.github/workflows/pr-validate-changesets.yaml @@ -21,9 +21,6 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event.client_payload.ref }} - # workaround to ensure changeset file is pushed with REPO_TOKEN owner's account - # see https://github.com/changesets/action/issues/70 - persist-credentials: false - name: Get PR's changeset file run: | @@ -51,7 +48,7 @@ jobs: run: | echo "machine github.com" > $HOME/.netrc echo "login github-actions[bot]" >> $HOME/.netrc - echo "password ${{ secrets.REPO_TOKEN }}" >> $HOME/.netrc + echo "password ${{ secrets.GITHUB_TOKEN }}" >> $HOME/.netrc chmod 600 $HOME/.netrc - name: Commit Changeset @@ -63,7 +60,7 @@ jobs: git commit -m "build: update dependency changeset" git push origin HEAD:${{ github.event.pull_request.head.ref }} env: - GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} validate-changeset: name: Validate PR Changeset diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 98c20fa8953..18fde5f5bb9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,9 +24,6 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.ref }} - # workaround to ensure force pushes to changeset branch use REPO_TOKEN owner's account - # see https://github.com/changesets/action/issues/70 - persist-credentials: false - name: CI Setup uses: ./.github/actions/ci-setup @@ -76,13 +73,13 @@ jobs: githubReleaseName: ${{ env.RELEASE_VERSION }} githubTagName: ${{ env.RELEASE_VERSION }} env: - GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Prettify changelog run: pnpm changeset:update-changelog env: - GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ env.RELEASE_VERSION }} PUBLISHED: ${{ steps.changesets.outputs.published }} REF_NAME: ${{ github.ref_name }} @@ -129,7 +126,7 @@ jobs: workflow: update-nightly.yml ref: master repo: FuelLabs/docs-hub - token: ${{ secrets.REPO_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} - name: Create PR to apply latest release to master if: steps.changesets.outputs.published == 'true' && startsWith(github.ref_name, 'release/') && env.RELEASE_VERSION_HIGHER_THAN_LATEST == 'true' @@ -145,7 +142,7 @@ jobs: gh pr create -B master -H $GITHUB_REF_NAME --title "$PR_TITLE" --body "$PR_BODY" env: - GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ env.RELEASE_VERSION }} LATEST_VERSION: ${{ env.LATEST_VERSION }} @@ -155,7 +152,7 @@ jobs: if: steps.changesets.outputs.published == 'true' && startsWith(github.ref_name, 'release/') && env.RELEASE_VERSION_HIGHER_THAN_LATEST == 'false' run: git push origin --delete ${{ github.ref_name }} env: - GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Upload assets to S3 - uses: unfor19/install-aws-cli-action@v1.0.7 diff --git a/packages/account/src/account.ts b/packages/account/src/account.ts index 57b47d67747..0396c76cc9e 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 d8a9b30b3ad..1bd1017034e 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -367,6 +367,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; }; /** @@ -1019,10 +1043,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 { @@ -1039,13 +1065,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); @@ -1067,7 +1095,7 @@ export default class Provider { const { maxFee } = await this.estimateTxGasAndFee({ transactionRequest, - gasPrice: bn(0), + gasPrice, }); // eslint-disable-next-line no-param-reassign @@ -1234,12 +1262,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(); @@ -1355,7 +1383,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); @@ -1377,12 +1405,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[] = []; @@ -1399,7 +1431,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); @@ -1411,7 +1443,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 63950611222..90756ef48c7 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 {