Skip to content

Commit

Permalink
fix!: fee estimation for multicall (#2034)
Browse files Browse the repository at this point in the history
  • Loading branch information
Torres-ssf authored Apr 11, 2024
1 parent ae2b72f commit 498cffe
Show file tree
Hide file tree
Showing 10 changed files with 139 additions and 50 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-apricots-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fuel-ts/account": minor
---

fix!: fee estimation for multicall
2 changes: 2 additions & 0 deletions packages/abi-coder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export {
WORD_SIZE,
ASSET_ID_LEN,
CONTRACT_ID_LEN,
UTXO_ID_LEN,
BYTES_32,
calculateVmTxMemory,
type EncodingVersion,
ENCODING_V0,
Expand Down
1 change: 1 addition & 0 deletions packages/abi-coder/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const ENCODING_V1 = '1';
*/
export const WORD_SIZE = 8;
export const BYTES_32 = 32;
export const UTXO_ID_LEN = BYTES_32 + 1;
export const MAX_INPUTS = 255;
export const ASSET_ID_LEN = BYTES_32;
export const CONTRACT_ID_LEN = BYTES_32;
Expand Down
5 changes: 2 additions & 3 deletions packages/account/src/providers/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,12 +1007,11 @@ describe('Provider', () => {
.spyOn(gasMod, 'calculatePriceWithFactor')
.mockReturnValue(bn(0));

const { minFee, maxFee, usedFee } = await provider.getTransactionCost(request);
const { minFee, maxFee } = await provider.getTransactionCost(request);

expect(calculatePriceWithFactorMock).toHaveBeenCalledTimes(3);
expect(calculatePriceWithFactorMock).toHaveBeenCalled();

expect(maxFee.eq(0)).not.toBeTruthy();
expect(usedFee.eq(0)).not.toBeTruthy();
expect(minFee.eq(0)).not.toBeTruthy();
});

Expand Down
99 changes: 72 additions & 27 deletions packages/account/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ export type TransactionCost = {
gasUsed: BN;
minFee: BN;
maxFee: BN;
usedFee: BN;
outputVariables: number;
missingContractIds: string[];
estimatedInputs: TransactionRequest['inputs'];
Expand Down Expand Up @@ -775,6 +774,58 @@ export default class Provider {
};
}

/**
* Estimates the transaction gas and fee based on the provided transaction request.
* @param transactionRequest - The transaction request object.
* @returns An object containing the estimated minimum gas, minimum fee, maximum gas, and maximum fee.
*/
estimateTxGasAndFee(params: { transactionRequest: TransactionRequest }) {
const { transactionRequest } = params;
const { gasPriceFactor, minGasPrice, maxGasPerTx } = this.getGasConfig();

const chainInfo = this.getChain();

const gasPrice = transactionRequest.gasPrice.eq(0) ? minGasPrice : transactionRequest.gasPrice;
transactionRequest.gasPrice = gasPrice;

const minGas = transactionRequest.calculateMinGas(chainInfo);
const minFee = calculatePriceWithFactor(minGas, gasPrice, gasPriceFactor).normalizeZeroToOne();

// Only Script transactions consume gas
if (transactionRequest.type === TransactionType.Script) {
// If the gasLimit is set to 0, it means we need to estimate it.
if (transactionRequest.gasLimit.eq(0)) {
transactionRequest.gasLimit = minGas;

/*
* Adjusting the gasLimit of a transaction (TX) impacts its maxGas.
* Consequently, this affects the maxFee, as it is derived from the maxGas. To accurately estimate the
* gasLimit for a transaction, especially when the exact gas consumption is uncertain (as in an estimation dry-run),
* the following steps are required:
* 1 - Initially, set the gasLimit using the calculated minGas.
* 2 - Based on this initial gasLimit, calculate the maxGas.
* 3 - Get the maximum gas per transaction allowed by the chain, and subtract the previously calculated maxGas from this limit.
* 4 - The result of this subtraction should then be adopted as the new, definitive gasLimit.
* 5 - Recalculate the maxGas with the updated gasLimit. This new maxGas is then used to compute the maxFee.
* 6 - The calculated maxFee represents the safe, estimated cost required to fund the transaction.
*/
transactionRequest.gasLimit = maxGasPerTx.sub(
transactionRequest.calculateMaxGas(chainInfo, minGas)
);
}
}

const maxGas = transactionRequest.calculateMaxGas(chainInfo, minGas);
const maxFee = calculatePriceWithFactor(maxGas, gasPrice, gasPriceFactor).normalizeZeroToOne();

return {
minGas,
minFee,
maxGas,
maxFee,
};
}

/**
* Executes a signed transaction without applying the states changes
* on the chain.
Expand Down Expand Up @@ -830,9 +881,8 @@ export default class Provider {
}: TransactionCostParams = {}
): Promise<TransactionCost> {
const txRequestClone = clone(transactionRequestify(transactionRequestLike));
const chainInfo = this.getChain();
const { gasPriceFactor, minGasPrice, maxGasPerTx } = this.getGasConfig();
const gasPrice = max(txRequestClone.gasPrice, minGasPrice);
const { minGasPrice } = this.getGasConfig();
const setGasPrice = max(txRequestClone.gasPrice, minGasPrice);
const isScriptTransaction = txRequestClone.type === TransactionType.Script;

// Fund with fake UTXOs to avoid not enough funds error
Expand All @@ -843,15 +893,14 @@ export default class Provider {
// Funding transaction with fake utxos
txRequestClone.fundWithFakeUtxos(allQuantities, resourcesOwner?.address);

if (isScriptTransaction) {
txRequestClone.gasLimit = bn(0);
}

/**
* Estimate predicates gasUsed
*/
if (estimatePredicates) {
// Remove gasLimit to avoid gasLimit when estimating predicates
if (isScriptTransaction) {
txRequestClone.gasLimit = bn(0);
}

/**
* The fake utxos added above can be from a predicate
* If the resources owner is a predicate,
Expand All @@ -871,16 +920,17 @@ export default class Provider {
/**
* Calculate minGas and maxGas based on the real transaction
*/
const minGas = txRequestClone.calculateMinGas(chainInfo);
const maxGas = txRequestClone.calculateMaxGas(chainInfo, minGas);
let { maxFee, maxGas, minFee, minGas } = this.estimateTxGasAndFee({
transactionRequest: txRequestClone,
});

/**
* Estimate gasUsed for script transactions
*/

let receipts: TransactionResultReceipt[] = [];
let missingContractIds: string[] = [];
let outputVariables = 0;
let gasUsed = bn(0);
// Transactions of type Create does not consume any gas so we can the dryRun
if (isScriptTransaction && estimateTxDependencies) {
/**
Expand All @@ -889,39 +939,34 @@ export default class Provider {
* will only be amounts being transferred (coin outputs) and amounts being forwarded
* to contract calls.
*/
// Calculate the gasLimit again as we insert a fake UTXO and signer

txRequestClone.gasPrice = bn(0);
txRequestClone.gasLimit = bn(maxGasPerTx.sub(maxGas).toNumber() * 0.9);

// Executing dryRun with fake utxos to get gasUsed
const result = await this.estimateTxDependencies(txRequestClone);

receipts = result.receipts;
outputVariables = result.outputVariables;
missingContractIds = result.missingContractIds;
}

// For CreateTransaction the gasUsed is going to be the minGas
const gasUsed = isScriptTransaction ? getGasUsedFromReceipts(receipts) : minGas;
gasUsed = isScriptTransaction ? getGasUsedFromReceipts(receipts) : gasUsed;

const usedFee = calculatePriceWithFactor(
gasUsed,
gasPrice,
gasPriceFactor
).normalizeZeroToOne();
const minFee = calculatePriceWithFactor(minGas, gasPrice, gasPriceFactor).normalizeZeroToOne();
const maxFee = calculatePriceWithFactor(maxGas, gasPrice, gasPriceFactor).normalizeZeroToOne();
txRequestClone.gasLimit = gasUsed;
txRequestClone.gasPrice = setGasPrice;

// Estimating fee again with the real gas consumed and set gasPrice
({ maxFee, maxGas, minFee, minGas } = this.estimateTxGasAndFee({
transactionRequest: txRequestClone,
}));
}

return {
requiredQuantities: allQuantities,
receipts,
gasUsed,
minGasPrice,
gasPrice,
gasPrice: setGasPrice,
minGas,
maxGas,
usedFee,
minFee,
maxFee,
estimatedInputs: txRequestClone.inputs,
Expand Down
5 changes: 3 additions & 2 deletions packages/account/src/providers/transaction-request/input.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BYTES_32, UTXO_ID_LEN } from '@fuel-ts/abi-coder';
import { ZeroBytes32 } from '@fuel-ts/address/configs';
import { ErrorCode, FuelError } from '@fuel-ts/errors';
import type { BytesLike } from '@fuel-ts/interfaces';
Expand Down Expand Up @@ -96,8 +97,8 @@ export const inputify = (value: TransactionRequestInput): Input => {
const predicateData = arrayify(value.predicateData ?? '0x');
return {
type: InputType.Coin,
txID: hexlify(arrayify(value.id).slice(0, 32)),
outputIndex: arrayify(value.id)[32],
txID: hexlify(arrayify(value.id).slice(0, BYTES_32)),
outputIndex: toNumber(arrayify(value.id).slice(BYTES_32, UTXO_ID_LEN)),
owner: hexlify(value.owner),
amount: bn(value.amount),
assetId: hexlify(value.assetId),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { InputValue } from '@fuel-ts/abi-coder';
import { UTXO_ID_LEN } from '@fuel-ts/abi-coder';
import { Address, addressify } from '@fuel-ts/address';
import { BaseAssetId, ZeroBytes32 } from '@fuel-ts/address/configs';
import { randomBytes } from '@fuel-ts/crypto';
import type { AddressLike, AbstractAddress, BytesLike } from '@fuel-ts/interfaces';
import type { BN, BigNumberish } from '@fuel-ts/math';
import { bn } from '@fuel-ts/math';
Expand Down Expand Up @@ -596,13 +598,6 @@ export abstract class BaseTransactionRequest implements BaseTransactionRequestLi
* @param quantities - CoinQuantity Array.
*/
fundWithFakeUtxos(quantities: CoinQuantity[], resourcesOwner?: AbstractAddress) {
let idCounter = 0;
const generateId = (): string => {
const counterString = String(idCounter++);
const id = ZeroBytes32.slice(0, -counterString.length).concat(counterString);
return id;
};

const findAssetInput = (assetId: string) =>
this.inputs.find((input) => {
if ('assetId' in input) {
Expand All @@ -615,12 +610,12 @@ export abstract class BaseTransactionRequest implements BaseTransactionRequestLi
const assetInput = findAssetInput(assetId);

if (assetInput && 'assetId' in assetInput) {
assetInput.id = generateId();
assetInput.id = hexlify(randomBytes(UTXO_ID_LEN));
assetInput.amount = quantity;
} else {
this.addResources([
{
id: generateId(),
id: hexlify(randomBytes(UTXO_ID_LEN)),
amount: quantity,
assetId,
owner: resourcesOwner || Address.fromRandom(),
Expand Down
37 changes: 37 additions & 0 deletions packages/fuel-gauge/src/fee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ScriptTransactionRequest,
Wallet,
bn,
getRandomB256,
} from 'fuels';

import { FuelGaugeProjectsEnum, getFuelGaugeForcProject } from '../test/fixtures';
Expand Down Expand Up @@ -254,6 +255,42 @@ describe('Fee', () => {
});
});

it('should ensure fee is properly calculated in a multi call [MINT TO 15 ADDRESSES]', async () => {
const { binHexlified, abiContents } = getFuelGaugeForcProject(
FuelGaugeProjectsEnum.MULTI_TOKEN_CONTRACT
);

const factory = new ContractFactory(binHexlified, abiContents, wallet);
const contract = await factory.deployContract({ gasPrice: minGasPrice });

const subId = '0x4a778acfad1abc155a009dc976d2cf0db6197d3d360194d74b1fb92b96986b00';

const genAddresses = () => Array.from({ length: 3 }, () => ({ value: getRandomB256() }));

const calls = Array.from({ length: 15 }).map(() =>
contract.functions.mint_to_addresses(genAddresses(), subId, 100)
);

const balanceBefore = await wallet.getBalance();

const {
transactionResult: { fee },
} = await contract
.multiCall(calls)
.txParams({ variableOutputs: calls.length * 3 })
.call();

const balanceAfter = await wallet.getBalance();

const balanceDiff = balanceBefore.sub(balanceAfter).toNumber();

expectToBeInRange({
value: fee.toNumber(),
min: balanceDiff - 1,
max: balanceDiff + 1,
});
});

it('should ensure fee is properly calculated on transactions with predicate', async () => {
const { binHexlified, abiContents } = getFuelGaugeForcProject(
FuelGaugeProjectsEnum.PREDICATE_TRUE
Expand Down
11 changes: 7 additions & 4 deletions packages/fuel-gauge/src/funding-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ describe(__filename, () => {

request.addResources(lowResources);

const { maxFee, requiredQuantities } = await provider.getTransactionCost(request);
const { maxFee, requiredQuantities, gasUsed } = await provider.getTransactionCost(request);

request.gasLimit = gasUsed;

// TX request already does NOT carries enough resources, it needs to be funded
expect(request.inputs.length).toBe(1);
expect(bn((<CoinTransactionRequestInput>request.inputs[0]).amount).toNumber()).toBe(300);
expect(maxFee.gt(300)).toBeTruthy();

const getResourcesToSpendSpy = vi.spyOn(sender, 'getResourcesToSpend');

Expand Down Expand Up @@ -161,19 +162,21 @@ describe(__filename, () => {

const request = new ScriptTransactionRequest({
gasLimit: 1_000,
gasPrice: bn(10),
gasPrice: bn(1),
});

const amountToTransfer = 1000;
request.addCoinOutput(receiver.address, amountToTransfer, BaseAssetId);

const { maxFee, requiredQuantities } = await provider.getTransactionCost(request);
const { maxFee, requiredQuantities, gasUsed } = await provider.getTransactionCost(request);

// TX request does NOT carry any resources, it needs to be funded
expect(request.inputs.length).toBe(0);

const getResourcesToSpendSpy = vi.spyOn(sender, 'getResourcesToSpend');

request.gasLimit = gasUsed;

await sender.fund(request, requiredQuantities, maxFee);

const tx = await sender.sendTransaction(request);
Expand Down
11 changes: 6 additions & 5 deletions packages/fuel-gauge/src/min-gas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,13 @@ describe(__filename, () => {
/**
* Get the transaction cost to set a strict gasLimit and min gasPrice
*/
const costs = await provider.getTransactionCost(request);
const { maxFee, gasUsed, requiredQuantities, minGasPrice } =
await provider.getTransactionCost(request);

await sender.fund(request, costs.requiredQuantities, costs.maxFee);
request.gasLimit = gasUsed;
request.gasPrice = minGasPrice;

request.gasLimit = bn(20_000);
request.gasPrice = costs.gasPrice;
await sender.fund(request, requiredQuantities, maxFee);

/**
* Send transaction
Expand All @@ -100,7 +101,7 @@ describe(__filename, () => {
const { status, gasUsed: txGasUsed } = await result.wait();

expect(status).toBe(TransactionStatus.success);
expect(costs.gasUsed.toString()).toBe(txGasUsed.toString());
expect(gasUsed.toString()).toBe(txGasUsed.toString());
});

it('sets gas requirements (predicate)', async () => {
Expand Down

0 comments on commit 498cffe

Please sign in to comment.