Skip to content

Commit

Permalink
chore: merge gas price and predicate estimation requests (#3676)
Browse files Browse the repository at this point in the history
  • Loading branch information
Torres-ssf authored Feb 7, 2025
1 parent ff97a6e commit 6668336
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/curly-brooms-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fuel-ts/account": patch
---

chore: merge gas price and predicate estimation requests
12 changes: 12 additions & 0 deletions packages/account/src/providers/operations.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,18 @@ query estimatePredicates($encodedTransaction: HexString!) {
}
}

query estimatePredicatesAndGasPrice(
$encodedTransaction: HexString!
$blockHorizon: U32!
) {
estimatePredicates(tx: $encodedTransaction) {
...transactionEstimatePredicatesFragment
}
estimateGasPrice(blockHorizon: $blockHorizon) {
gasPrice
}
}

query getLatestBlock {
chain {
latestBlock {
Expand Down
114 changes: 86 additions & 28 deletions packages/account/src/providers/provider.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { AddressInput } from '@fuel-ts/address';
import { Address } from '@fuel-ts/address';
import { ErrorCode, FuelError } from '@fuel-ts/errors';
import { BN, bn } from '@fuel-ts/math';
import type { BN } from '@fuel-ts/math';
import { bn } from '@fuel-ts/math';
import type { Transaction } from '@fuel-ts/transactions';
import { InputType, InputMessageCoder, TransactionCoder } from '@fuel-ts/transactions';
import type { BytesLike } from '@fuel-ts/utils';
import { arrayify, hexlify, DateTime, isDefined } from '@fuel-ts/utils';
import { checkFuelCoreVersionCompatibility, versions } from '@fuel-ts/versions';
import { equalBytes } from '@noble/curves/abstract/utils';
import type { DocumentNode } from 'graphql';
import { GraphQLClient } from 'graphql-request';
import type { GraphQLClientResponse, GraphQLResponse } from 'graphql-request/src/types';
Expand All @@ -30,6 +30,7 @@ import type {
GqlRelayedTransactionFailed,
Requester,
GqlBlockFragment,
GqlEstimatePredicatesQuery,
} from './__generated__/operations';
import type { Coin } from './coin';
import type { CoinQuantity, CoinQuantityLike } from './coin-quantity';
Expand All @@ -47,6 +48,7 @@ import type {
ScriptTransactionRequest,
} from './transaction-request';
import {
isPredicate,
isTransactionTypeCreate,
isTransactionTypeScript,
transactionRequestify,
Expand Down Expand Up @@ -947,48 +949,78 @@ export default class Provider {
}

/**
* Verifies whether enough gas is available to complete transaction.
* Estimates the gas usage for predicates in a transaction request.
*
* @template T - The type of the transaction request object.
*
* @param transactionRequest - The transaction request object.
* @returns A promise that resolves to the estimated transaction request object.
* @param transactionRequest - The transaction request to estimate predicates for.
* @returns A promise that resolves to the updated transaction request with estimated gas usage for predicates.
*/
async estimatePredicates<T extends TransactionRequest>(transactionRequest: T): Promise<T> {
const shouldEstimatePredicates = Boolean(
transactionRequest.inputs.find(
(input) =>
'predicate' in input &&
input.predicate &&
!equalBytes(arrayify(input.predicate), arrayify('0x')) &&
new BN(input.predicateGasUsed).isZero()
)
const shouldEstimatePredicates = transactionRequest.inputs.some(
(input) => isPredicate(input) && bn(input.predicateGasUsed).isZero()
);

if (!shouldEstimatePredicates) {
return transactionRequest;
}

const encodedTransaction = hexlify(transactionRequest.toTransactionBytes());

const response = await this.operations.estimatePredicates({
encodedTransaction,
});

const {
estimatePredicates: { inputs },
} = response;
const { estimatePredicates } = response;

if (inputs) {
inputs.forEach((input, index) => {
if ('predicateGasUsed' in input && bn(input.predicateGasUsed).gt(0)) {
// eslint-disable-next-line no-param-reassign
(<CoinTransactionRequestInput>transactionRequest.inputs[index]).predicateGasUsed =
input.predicateGasUsed;
}
});
}
// eslint-disable-next-line no-param-reassign
transactionRequest = this.parseEstimatePredicatesResponse(
transactionRequest,
estimatePredicates
);

return transactionRequest;
}

/**
* Estimates the gas price and predicates for a given transaction request and block horizon.
*
* @param transactionRequest - The transaction request to estimate predicates and gas price for.
* @param blockHorizon - The block horizon to use for gas price estimation.
* @returns A promise that resolves to an object containing the updated transaction
* request and the estimated gas price.
*/
async estimatePredicatesAndGasPrice<T extends TransactionRequest>(
transactionRequest: T,
blockHorizon: number
) {
const shouldEstimatePredicates = transactionRequest.inputs.some(
(input) => isPredicate(input) && bn(input.predicateGasUsed).isZero()
);

if (!shouldEstimatePredicates) {
const gasPrice = await this.estimateGasPrice(blockHorizon);

return { transactionRequest, gasPrice };
}

const {
estimateGasPrice: { gasPrice },
estimatePredicates,
} = await this.operations.estimatePredicatesAndGasPrice({
blockHorizon: String(blockHorizon),
encodedTransaction: hexlify(transactionRequest.toTransactionBytes()),
});

// eslint-disable-next-line no-param-reassign
transactionRequest = this.parseEstimatePredicatesResponse(
transactionRequest,
estimatePredicates
);

return { transactionRequest, gasPrice: bn(gasPrice) };
}

/**
* Will dryRun a transaction and check for missing dependencies.
*
Expand Down Expand Up @@ -1355,10 +1387,16 @@ export default class Provider {
addedSignatures = signedRequest.witnesses.length - lengthBefore;
}

await this.estimatePredicates(signedRequest);
txRequestClone.updatePredicateGasUsed(signedRequest.inputs);
let gasPrice: BN;

const gasPrice = gasPriceParam ?? (await this.estimateGasPrice(10));
if (gasPriceParam) {
gasPrice = gasPriceParam;
await this.estimatePredicates(signedRequest);
} else {
({ gasPrice } = await this.estimatePredicatesAndGasPrice(signedRequest, 10));
}

txRequestClone.updatePredicateGasUsed(signedRequest.inputs);

/**
* Calculate minGas and maxGas based on the real transaction
Expand Down Expand Up @@ -2176,4 +2214,24 @@ export default class Provider {
statusReason: status.reason,
});
}

/**
* @hidden
*/
private parseEstimatePredicatesResponse<T extends TransactionRequest>(
transactionRequest: T,
{ inputs }: GqlEstimatePredicatesQuery['estimatePredicates']
): T {
if (inputs) {
inputs.forEach((input, i) => {
if (input && 'predicateGasUsed' in input && bn(input.predicateGasUsed).gt(0)) {
// eslint-disable-next-line no-param-reassign
(<CoinTransactionRequestInput>transactionRequest.inputs[i]).predicateGasUsed =
input.predicateGasUsed;
}
});
}

return transactionRequest;
}
}
32 changes: 32 additions & 0 deletions packages/account/src/providers/transaction-request/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { getRandomB256, Address } from '@fuel-ts/address';
import { ZeroBytes32 } from '@fuel-ts/address/configs';
import { randomBytes } from '@fuel-ts/crypto';
import { ErrorCode, FuelError } from '@fuel-ts/errors';
import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils';
import { bn } from '@fuel-ts/math';
import { InputType, OutputType } from '@fuel-ts/transactions';
import { arrayify, hexlify } from '@fuel-ts/utils';

import { generateFakeCoin, generateFakeMessageCoin } from '../../test-utils/resources';
import {
Expand All @@ -24,6 +26,7 @@ import {
cacheRequestInputsResourcesFromOwner,
getBurnableAssetCount,
validateTransactionForAssetBurn,
isPredicate,
} from './helpers';
import { ScriptTransactionRequest } from './script-transaction-request';

Expand Down Expand Up @@ -138,6 +141,35 @@ describe('helpers', () => {
expect(result.messages).not.toContain(messageInput2.nonce);
});

describe('isPredicate', () => {
it('should properly identify if request input is a predicate', () => {
const generateFakeResources = [
generateFakeRequestInputCoin,
generateFakeRequestInputMessage,
];

generateFakeResources.forEach((generate) => {
let nonPredicate = generate();
expect(nonPredicate.predicate).toBeUndefined();
expect(isPredicate(nonPredicate)).toBeFalsy();

nonPredicate = generate({ predicate: '0x' });
expect(nonPredicate.predicate).toBeDefined();
expect(isPredicate(nonPredicate)).toBeFalsy();

nonPredicate = generate({ predicate: arrayify('0x') });
expect(nonPredicate.predicate).toBeDefined();
expect(isPredicate(nonPredicate)).toBeFalsy();

let predicate = generate({ predicate: randomBytes(20) });
expect(isPredicate(predicate)).toBeTruthy();

predicate = generate({ predicate: hexlify(randomBytes(30)) });
expect(isPredicate(predicate)).toBeTruthy();
});
});
});

describe('getAssetAmountInRequestInputs', () => {
it('should handle empty inputs array', () => {
const tx = new ScriptTransactionRequest();
Expand Down
16 changes: 15 additions & 1 deletion packages/account/src/providers/transaction-request/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { Address } from '@fuel-ts/address';
import { ErrorCode, FuelError } from '@fuel-ts/errors';
import { bn } from '@fuel-ts/math';
import { InputType, OutputType } from '@fuel-ts/transactions';
import { hexlify } from '@fuel-ts/utils';

import type { ExcludeResourcesOption } from '../resource';
import { type ExcludeResourcesOption } from '../resource';

import type {
TransactionRequestInput,
Expand Down Expand Up @@ -44,6 +45,19 @@ export const isRequestInputResourceFromOwner = (
owner: Address
) => getRequestInputResourceOwner(input) === owner.toB256();

/**
* @hidden
*
* Checks if the given `TransactionRequestInput` is a predicate.
*
* @param input - The `TransactionRequestInput` to check.
* @returns `true` if the input is a predicate, otherwise `false`.
*/
export const isPredicate = (
input: TransactionRequestInput
): input is Required<CoinTransactionRequestInput | MessageTransactionRequestInput> =>
isRequestInputCoinOrMessage(input) && !!input.predicate && hexlify(input.predicate) !== '0x';

export const getAssetAmountInRequestInputs = (
inputs: TransactionRequestInput[],
assetId: string,
Expand Down
68 changes: 68 additions & 0 deletions packages/fuel-gauge/src/fee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
InputMessageCoder,
ScriptTransactionRequest,
Wallet,
bn,
getMintedAssetId,
getRandomB256,
hexlify,
Expand Down Expand Up @@ -474,6 +475,73 @@ describe('Fee', () => {
expect(gasUsed.toNumber()).toBeGreaterThan(0);
});

it('ensures gas price and predicates are estimated on the same request', async () => {
using launched = await launchTestNode();

const { provider } = launched;

const predicate = new PredicateU32({ provider, data: [1078] });

const estimateGasPrice = vi.spyOn(provider.operations, 'estimateGasPrice');
const estimatePredicates = vi.spyOn(provider.operations, 'estimatePredicates');
const estimatePredicatesAndGasPrice = vi.spyOn(
provider.operations,
'estimatePredicatesAndGasPrice'
);

await predicate.getTransactionCost(new ScriptTransactionRequest());

expect(estimateGasPrice).not.toHaveBeenCalledOnce();
expect(estimatePredicates).not.toHaveBeenCalledOnce();

expect(estimatePredicatesAndGasPrice).toHaveBeenCalledOnce();
});

it('ensures gas price is estimated alone when no predicates are present', async () => {
using launched = await launchTestNode();

const {
provider,
wallets: [wallet],
} = launched;

const estimateGasPrice = vi.spyOn(provider.operations, 'estimateGasPrice');
const estimatePredicates = vi.spyOn(provider.operations, 'estimatePredicates');
const estimatePredicatesAndGasPrice = vi.spyOn(
provider.operations,
'estimatePredicatesAndGasPrice'
);

await wallet.getTransactionCost(new ScriptTransactionRequest());

expect(estimatePredicates).not.toHaveBeenCalledOnce();
expect(estimatePredicatesAndGasPrice).not.toHaveBeenCalledOnce();

expect(estimateGasPrice).toHaveBeenCalledOnce();
});

it('ensures predicates are estimated alone when gas price is present', async () => {
using launched = await launchTestNode();

const { provider } = launched;

const predicate = new PredicateU32({ provider, data: [1078] });

const estimateGasPrice = vi.spyOn(provider.operations, 'estimateGasPrice');
const estimatePredicates = vi.spyOn(provider.operations, 'estimatePredicates');
const estimatePredicatesAndGasPrice = vi.spyOn(
provider.operations,
'estimatePredicatesAndGasPrice'
);

await predicate.getTransactionCost(new ScriptTransactionRequest(), { gasPrice: bn(1) });

expect(estimatePredicatesAndGasPrice).not.toHaveBeenCalledOnce();
expect(estimateGasPrice).not.toHaveBeenCalledOnce();

expect(estimatePredicates).toHaveBeenCalledOnce();
});

it('ensures estimateGasPrice runs only once when getting transaction cost with estimate gas and fee', async () => {
using launched = await launchTestNode({
contractsConfigs: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ describe('Predicate', () => {
const initialReceiverBalance = await receiverWallet.getBalance();

const dryRunSpy = vi.spyOn(provider.operations, 'dryRun');
const estimatePredicatesSpy = vi.spyOn(provider.operations, 'estimatePredicates');
const estimatePredicatesSpy = vi.spyOn(provider.operations, 'estimatePredicatesAndGasPrice');

const response = await predicateValidateTransfer.transfer(
receiverWallet.address.toB256(),
Expand Down

0 comments on commit 6668336

Please sign in to comment.