diff --git a/.changeset/full-carpets-lose.md b/.changeset/full-carpets-lose.md new file mode 100644 index 00000000000..90b55d28e1d --- /dev/null +++ b/.changeset/full-carpets-lose.md @@ -0,0 +1,11 @@ +--- +"@internal/fuel-core": patch +"@fuel-ts/account": patch +"@fuel-ts/contract": patch +"fuels": patch +"@fuel-ts/program": patch +"@fuel-ts/utils": patch +"@fuel-ts/versions": patch +--- + +chore: upgrade `fuel-core` to `0.44.0` diff --git a/.fuel-core/configs/chainConfig.json b/.fuel-core/configs/chainConfig.json index 243b9249015..a8659a8b974 100644 --- a/.fuel-core/configs/chainConfig.json +++ b/.fuel-core/configs/chainConfig.json @@ -87,6 +87,7 @@ "mul": 2, "muli": 2, "mldv": 3, + "niop": 2, "noop": 1, "not": 2, "or": 1, diff --git a/apps/create-fuels-counter-guide/fuel-toolchain.toml b/apps/create-fuels-counter-guide/fuel-toolchain.toml index 245b2c1f162..e07f1ec9f6f 100644 --- a/apps/create-fuels-counter-guide/fuel-toolchain.toml +++ b/apps/create-fuels-counter-guide/fuel-toolchain.toml @@ -3,4 +3,4 @@ channel = "testnet" [components] forc = "0.68.7" -fuel-core = "0.43.1" +fuel-core = "0.44.0" diff --git a/apps/demo-bun-fuels/src/bun.test.ts b/apps/demo-bun-fuels/src/bun.test.ts index a14426c5dae..625dbb47680 100644 --- a/apps/demo-bun-fuels/src/bun.test.ts +++ b/apps/demo-bun-fuels/src/bun.test.ts @@ -72,6 +72,7 @@ describe('ExampleContract', () => { } = launched; const unfundedWallet = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); const deploy = await SampleFactory.deploy(fundedWallet); const { contract } = await deploy.waitForResult(); @@ -81,8 +82,8 @@ describe('ExampleContract', () => { await expectToThrowFuelError( () => contractInstance.functions.return_input(1337).simulate(), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${unfundedWallet.address.toB256()}'.` ) ); }); diff --git a/apps/demo-fuels/src/index.test.ts b/apps/demo-fuels/src/index.test.ts index 7fd17d78908..305e97a474c 100644 --- a/apps/demo-fuels/src/index.test.ts +++ b/apps/demo-fuels/src/index.test.ts @@ -65,6 +65,7 @@ describe('ExampleContract', () => { } = launched; const unfundedWallet = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); const deploy = await SampleFactory.deploy(fundedWallet); const { contract } = await deploy.waitForResult(); @@ -74,8 +75,8 @@ describe('ExampleContract', () => { await expectToThrowFuelError( () => contractInstance.functions.return_input(1337).simulate(), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${unfundedWallet.address.toB256()}'.` ) ); }); diff --git a/apps/demo-nextjs/package.json b/apps/demo-nextjs/package.json index 3a6b621afbd..f0546eb0876 100644 --- a/apps/demo-nextjs/package.json +++ b/apps/demo-nextjs/package.json @@ -10,7 +10,7 @@ "pretest": "pnpm original:build" }, "dependencies": { - "@fuels/vm-asm": "0.60.2", + "@fuels/vm-asm": "0.62.0", "@types/node": "22.13.5", "@types/react-dom": "19.0.4", "@types/react": "19.0.10", diff --git a/apps/demo-react-vite/package.json b/apps/demo-react-vite/package.json index 9f6ec6260ab..03495d8328c 100644 --- a/apps/demo-react-vite/package.json +++ b/apps/demo-react-vite/package.json @@ -11,7 +11,7 @@ "pretest": "pnpm original:build" }, "dependencies": { - "@fuels/vm-asm": "0.60.2", + "@fuels/vm-asm": "0.62.0", "fuels": "workspace:*", "react-dom": "19.0.0", "react": "19.0.0" diff --git a/apps/demo-typegen/src/demo.test.ts b/apps/demo-typegen/src/demo.test.ts index 030ae91e8a4..4502cff693f 100644 --- a/apps/demo-typegen/src/demo.test.ts +++ b/apps/demo-typegen/src/demo.test.ts @@ -99,6 +99,7 @@ it('should throw when simulating via contract factory with wallet with no resour } = launched; const unfundedWallet = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); const factory = new DemoContractFactory(fundedWallet); const { waitForResult } = await factory.deploy(); @@ -108,8 +109,8 @@ it('should throw when simulating via contract factory with wallet with no resour await expectToThrowFuelError( () => contractInstance.functions.return_input(1337).simulate(), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${unfundedWallet.address.toB256()}'.` ) ); }); diff --git a/apps/docs/src/guide/predicates/snippets/cookbook/failure-not-enough-funds.ts b/apps/docs/src/guide/predicates/snippets/cookbook/failure-not-enough-funds.ts index 37c79c14f87..c1173b4c7b5 100644 --- a/apps/docs/src/guide/predicates/snippets/cookbook/failure-not-enough-funds.ts +++ b/apps/docs/src/guide/predicates/snippets/cookbook/failure-not-enough-funds.ts @@ -16,16 +16,13 @@ const predicate = new SimplePredicate({ // Any amount coins will fail as the predicate is unfunded const amountOfCoinsToFail = 1000; -const { error } = await safeExec(async () => - predicate.transfer( - receiver.address, - amountOfCoinsToFail, - await provider.getBaseAssetId() - ) +const baseAssetId = await provider.getBaseAssetId(); +const { error } = await safeExec(() => + predicate.transfer(receiver.address, amountOfCoinsToFail, baseAssetId) ); // #region send-and-spend-funds-from-predicates-6 -const errorMessage = `Insufficient funds or too many small value coins. Consider combining UTXOs.`; +const errorMessage = `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${predicate.address.toB256()}'.`; // #endregion send-and-spend-funds-from-predicates-6 const actualErrorMessage = (error).message; diff --git a/internal/fuel-core/VERSION b/internal/fuel-core/VERSION index f8287cf9564..d9a8a8d3edb 100644 --- a/internal/fuel-core/VERSION +++ b/internal/fuel-core/VERSION @@ -1 +1 @@ -0.43.1 +git:ps/fix/max-coins-error \ No newline at end of file diff --git a/packages/account/package.json b/packages/account/package.json index 89153f49bed..c6f9bf0b809 100644 --- a/packages/account/package.json +++ b/packages/account/package.json @@ -58,7 +58,7 @@ "@fuel-ts/transactions": "workspace:*", "@fuel-ts/utils": "workspace:*", "@fuel-ts/versions": "workspace:*", - "@fuels/vm-asm": "0.60.2", + "@fuels/vm-asm": "0.62.0", "@noble/curves": "1.8.1", "events": "3.3.0", "graphql": "16.10.0", diff --git a/packages/account/src/account.test.ts b/packages/account/src/account.test.ts index 409d91fc4c4..1ca9eaca255 100644 --- a/packages/account/src/account.test.ts +++ b/packages/account/src/account.test.ts @@ -602,8 +602,8 @@ describe('Account', () => { await expectToThrowFuelError( () => user.getResourcesToSpend([[1, ASSET_A]], { utxos: [assetAUTXO.id] }), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${ASSET_A}'.\n\tOwner: '${user.address.toB256()}'.` ) ); }); @@ -975,13 +975,43 @@ describe('Account', () => { wallets: [wallet], provider, } = launched; + const baseAssetId = await provider.getBaseAssetId(); const request = new ScriptTransactionRequest(); - request.addCoinOutput(wallet.address, 30_000, await provider.getBaseAssetId()); + request.addCoinOutput(wallet.address, 30_000, baseAssetId); await expectToThrowFuelError(() => request.estimateAndFund(wallet), { - code: ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - message: 'Insufficient funds or too many small value coins. Consider combining UTXOs.', + code: ErrorCode.MAX_COINS_REACHED, + message: `You have too many small value coins - consider combining UTXOs.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${wallet.address.toB256()}'.`, + }); + }); + + it('throws when funding with more than 255 coins for an input', async () => { + using launched = await setupTestProviderAndWallets({ + walletsConfig: { + amountPerCoin: 100, + coinsPerAsset: 400, + }, + }); + const { + wallets: [wallet], + provider, + } = launched; + const baseAssetId = await provider.getBaseAssetId(); + + const request = new ScriptTransactionRequest(); + request.addCoinOutput(wallet.address, 30_000, baseAssetId); + + const assembleTx = () => + provider.assembleTx({ + request, + feePayerAccount: wallet, + accountCoinQuantities: [{ amount: 30_000, assetId: baseAssetId }], + }); + + await expectToThrowFuelError(assembleTx, { + code: ErrorCode.MAX_COINS_REACHED, + message: `You have too many small value coins - consider combining UTXOs.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${wallet.address.toB256()}'.`, }); }); diff --git a/packages/account/src/account.ts b/packages/account/src/account.ts index f9789247b8a..c8c9ee7305c 100644 --- a/packages/account/src/account.ts +++ b/packages/account/src/account.ts @@ -55,6 +55,7 @@ import { import { mergeQuantities } from './providers/utils/merge-quantities'; import { serializeProviderCache } from './providers/utils/serialization'; import { AbstractAccount } from './types'; +import { consolidateCoins } from './utils/consolidate-coins'; import { assembleTransferToContractScript } from './utils/formatTransferToContractScriptData'; import { splitCoinsIntoBatches } from './utils/split-coins-into-batches'; @@ -99,7 +100,18 @@ export type SubmitAllCallbackResponse = { errors: FuelError[]; }; -export type SubmitAllCallback = () => Promise; +export type SubmitAllListenerTransactionStartData = { + tx: ScriptTransactionRequest; + step: number; + transactionId: string; + assetId: string; +}; + +export type SubmitAllListener = { + onTransactionStart?: (data: SubmitAllListenerTransactionStartData) => void; +}; + +export type SubmitAllCallback = (opts?: SubmitAllListener) => Promise; export type AssembleConsolidationTxsParams = { assetId: string; @@ -114,6 +126,11 @@ export type ConsolidateCoins = { outputNum?: number; }; +export type StartConsolidateCoins = { + owner: string; + assetId: string; +}; + const MAX_FUNDING_ATTEMPTS = 5; export type FakeResources = Partial & Required>; @@ -197,7 +214,10 @@ export class Account extends AbstractAccount implements WithAddress { quantities: CoinQuantityLike[], resourcesIdsToIgnore?: ResourcesIdsToIgnore ): Promise { - return this.provider.getResourcesToSpend(this.address, quantities, resourcesIdsToIgnore); + return this.autoConsolidateCoin({ + callback: () => + this.provider.getResourcesToSpend(this.address, quantities, resourcesIdsToIgnore), + }); } /** @@ -359,7 +379,7 @@ export class Account extends AbstractAccount implements WithAddress { // If the transaction still needs to be funded after the maximum number of attempts if (needsToBeFunded) { throw new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, + ErrorCode.INSUFFICIENT_FUNDS, `The account ${this.address} does not have enough base asset funds to cover the transaction execution.` ); } @@ -619,6 +639,32 @@ export class Account extends AbstractAccount implements WithAddress { return this.sendTransaction(request); } + /** + * Start the consolidation process + * + * @param owner - The B256 address of the owner. + * @param assetId - The asset ID that requires consolidation. + */ + async startConsolidation(opts: StartConsolidateCoins): Promise { + if (this._connector) { + await this._connector.startConsolidation(opts); + return false; + } + + const { owner, assetId } = opts; + if (owner !== this.address.toB256()) { + throw new FuelError( + ErrorCode.UNABLE_TO_CONSOLIDATE_COINS, + `Unable to consolidate coins. You're attempting to consolidate assets that don't belong to this account.\n\tOwner: '${owner}'\n\tCurrent: '${this.address.toB256()}'` + ); + } + + const { submitAll } = await consolidateCoins({ account: this, assetId }); + await submitAll(); + + return true; + } + /** * Consolidates base asset UTXOs into fewer, larger ones. * @@ -1085,6 +1131,41 @@ export class Account extends AbstractAccount implements WithAddress { })); } + /** @hidden */ + public async autoConsolidateCoin(params: { + callback: () => Promise; + shouldAutoConsolidate?: boolean; + }): Promise { + const { callback, shouldAutoConsolidate = true } = params; + + try { + return await callback(); + } catch (e: unknown) { + const error = FuelError.parse(e); + + const CONSOLIDATION_CODES = [ + ErrorCode.MAX_COINS_REACHED, + // TODO: plumb in for MAX_INPUTS_EXCEEDED + // ErrorCode.MAX_INPUTS_EXCEEDED + ]; + + if (shouldAutoConsolidate && CONSOLIDATION_CODES.includes(error.code)) { + const { assetId, owner } = error.metadata as { + assetId: string; + owner: string; + }; + const shouldRetryOperation = await this.startConsolidation({ + owner, + assetId, + }); + if (shouldRetryOperation) { + return await callback(); + } + } + throw e; + } + } + /** @hidden */ private async prepareTransactionForSend( request: TransactionRequest @@ -1125,8 +1206,10 @@ export class Account extends AbstractAccount implements WithAddress { /** @hidden * */ private async assembleTx( transactionRequest: ScriptTransactionRequest, - quantities: CoinQuantity[] = [] + quantities: CoinQuantity[] = [], + options: { shouldAutoConsolidate?: boolean } = {} ): Promise<{ transactionRequest: ScriptTransactionRequest; gasPrice: BN }> { + const { shouldAutoConsolidate } = options; const outputQuantities = transactionRequest.outputs .filter((o) => o.type === OutputType.Coin) .map(({ amount, assetId }) => ({ assetId: String(assetId), amount: bn(amount) })); @@ -1134,10 +1217,14 @@ export class Account extends AbstractAccount implements WithAddress { transactionRequest.gasLimit = bn(0); transactionRequest.maxFee = bn(0); - const { assembledRequest, gasPrice } = await this.provider.assembleTx({ - request: transactionRequest, - accountCoinQuantities: mergeQuantities(outputQuantities, quantities), - feePayerAccount: this, + const { assembledRequest, gasPrice } = await this.autoConsolidateCoin({ + shouldAutoConsolidate, + callback: () => + this.provider.assembleTx({ + request: transactionRequest, + accountCoinQuantities: mergeQuantities(outputQuantities, quantities), + feePayerAccount: this, + }), }); return { transactionRequest: assembledRequest as ScriptTransactionRequest, gasPrice }; @@ -1153,59 +1240,6 @@ export class Account extends AbstractAccount implements WithAddress { } } - /** @hidden * */ - private async estimateAndFundTransaction( - transactionRequest: ScriptTransactionRequest, - txParams: TxParamsType, - costParams?: TransactionCostParams - ) { - let request = transactionRequest; - const txCost = await this.getTransactionCost(request, costParams); - request = this.validateGasLimitAndMaxFee({ - transactionRequest: request, - gasUsed: txCost.gasUsed, - maxFee: txCost.maxFee, - txParams, - }); - request = await this.fund(request, txCost); - return request; - } - - /** @hidden * */ - private validateGasLimitAndMaxFee({ - gasUsed, - maxFee, - transactionRequest, - txParams: { gasLimit: setGasLimit, maxFee: setMaxFee }, - }: { - gasUsed: BN; - maxFee: BN; - transactionRequest: ScriptTransactionRequest; - txParams: Pick; - }) { - const request = transactionRequestify(transactionRequest) as ScriptTransactionRequest; - - if (!isDefined(setGasLimit)) { - request.gasLimit = gasUsed; - } else if (gasUsed.gt(setGasLimit)) { - throw new FuelError( - ErrorCode.GAS_LIMIT_TOO_LOW, - `Gas limit '${setGasLimit}' is lower than the required: '${gasUsed}'.` - ); - } - - if (!isDefined(setMaxFee)) { - request.maxFee = maxFee; - } else if (maxFee.gt(setMaxFee)) { - throw new FuelError( - ErrorCode.MAX_FEE_TOO_LOW, - `Max fee '${setMaxFee}' is lower than the required: '${maxFee}'.` - ); - } - - return request; - } - /** @hidden * */ private validateConsolidationTxsCoins(coins: Coin[], assetId: string) { if (coins.length <= 1) { diff --git a/packages/account/src/connectors/fuel-connector.ts b/packages/account/src/connectors/fuel-connector.ts index 6a3d670fd54..fbbd7b7135f 100644 --- a/packages/account/src/connectors/fuel-connector.ts +++ b/packages/account/src/connectors/fuel-connector.ts @@ -3,6 +3,7 @@ import { FuelError } from '@fuel-ts/errors'; import type { HashableMessage } from '@fuel-ts/hasher'; import { EventEmitter } from 'events'; +import type { StartConsolidateCoins } from '../account'; import type { Asset } from '../assets/types'; import type { TransactionRequest, TransactionRequestLike, TransactionResponse } from '../providers'; @@ -342,6 +343,16 @@ export abstract class FuelConnector extends EventEmitter implements Connector { throw new FuelError(FuelError.CODES.NOT_IMPLEMENTED, 'Method not implemented.'); } + /** + * Start the consolidation of coins process + * + * @param owner - The B256 address of the owner. + * @param assetId - The asset ID that requires consolidation. + */ + async startConsolidation(_opts: StartConsolidateCoins): Promise { + throw new FuelError(FuelError.CODES.NOT_IMPLEMENTED, 'Method not implemented.'); + } + /** * Event listener for the connector. * diff --git a/packages/account/src/connectors/types/connector-types.ts b/packages/account/src/connectors/types/connector-types.ts index f1cfdc0e964..7872efa8f5c 100644 --- a/packages/account/src/connectors/types/connector-types.ts +++ b/packages/account/src/connectors/types/connector-types.ts @@ -26,6 +26,8 @@ export enum FuelConnectorMethods { addABI = 'addABI', getABI = 'getABI', hasABI = 'hasABI', + // Coin consolidation + startConsolidation = 'startConsolidation', } export enum FuelConnectorEventTypes { @@ -38,6 +40,7 @@ export enum FuelConnectorEventTypes { currentNetwork = 'currentNetwork', assets = 'assets', abis = 'abis', + consolidateCoins = 'consolidateCoins', } export const FuelConnectorEventType = 'FuelConnector'; diff --git a/packages/account/src/connectors/types/events.ts b/packages/account/src/connectors/types/events.ts index 5d6e2103bfc..9f8b0fe30ee 100644 --- a/packages/account/src/connectors/types/events.ts +++ b/packages/account/src/connectors/types/events.ts @@ -1,3 +1,4 @@ +import type { StartConsolidateCoins } from '../../account'; import type { Asset } from '../../assets/types'; import type { FuelConnector } from '../fuel-connector'; @@ -116,6 +117,11 @@ export type AssetsEvent = { data: Array; }; +export type ConsolidateCoinsEvent = { + type: FuelConnectorEventTypes.consolidateCoins; + data: StartConsolidateCoins; +}; + /** * All the events available to the connector. */ @@ -127,6 +133,7 @@ export type FuelConnectorEvents = | AccountsEvent | ConnectorsEvent | ConnectorEvent - | AssetsEvent; + | AssetsEvent + | ConsolidateCoinsEvent; export type FuelConnectorEventsType = FuelConnectorEvents['type']; diff --git a/packages/account/src/consolidate-coins.test.ts b/packages/account/src/consolidate-coins.test.ts index b7386bd394e..996a84708ac 100644 --- a/packages/account/src/consolidate-coins.test.ts +++ b/packages/account/src/consolidate-coins.test.ts @@ -2,11 +2,12 @@ import { ErrorCode, FuelError } from '@fuel-ts/errors'; import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; import type { BigNumberish } from '@fuel-ts/math'; import { type SnapshotConfigs } from '@fuel-ts/utils'; +import type { PartialDeep } from 'type-fest'; import { type Account } from '.'; import type { Coin } from './providers'; import { ScriptTransactionRequest } from './providers'; -import type { WalletsConfigOptions } from './test-utils'; +import type { LaunchNodeOptions, WalletsConfigOptions } from './test-utils'; import { setupTestProviderAndWallets, TestAssetId } from './test-utils'; import type { WalletUnlocked } from './wallet'; import { Wallet } from './wallet'; @@ -94,14 +95,16 @@ describe('consolidate-coins', () => { feeParams?: Partial< SnapshotConfigs['chainConfig']['consensus_parameters']['V2']['fee_params']['V1'] >; + startingGasPrice?: number; } = {} ) => { - const { maxInputs, coinsPerAsset, amountPerCoin, count, feeParams } = params; - let nodeOptions = {}; + const { maxInputs, coinsPerAsset, amountPerCoin, count, feeParams, startingGasPrice } = params; + let nodeOptions: PartialDeep = {}; let walletsConfig: Partial = {}; if (maxInputs) { nodeOptions = { + args: startingGasPrice ? ['--starting-gas-price', startingGasPrice.toString() ?? '1'] : [], snapshotConfig: { chainConfig: { consensus_parameters: { @@ -752,4 +755,50 @@ describe('consolidate-coins', () => { ); }); }); + + describe('Automatic consolidation', () => { + it('should automatically consolidate coins and re-trigger operation [transfer]', async () => { + const maxInputs = 255; + const totalCoins = maxInputs + 100; + const { + provider, + wallets: [sender, recipient], + } = await setupTest({ + maxInputs, + coinsPerAsset: totalCoins, + amountPerCoin: 1_000, + }); + const baseAssetId = await provider.getBaseAssetId(); + + const { coins } = await sender.getCoins(baseAssetId); + expect(coins.length).toEqual(totalCoins); + + const startConsolidationSpy = vi.spyOn(sender, 'startConsolidation'); + const sendAmount = (maxInputs + 1) * 1_000; + await sender.transfer(recipient.address.toB256(), sendAmount); + expect(startConsolidationSpy).toBeCalledTimes(1); + }); + + it('should automatically consolidate coins and re-trigger operation [getResourcesToSpend]', async () => { + const maxInputs = 255; + const totalCoins = maxInputs + 100; + const { + provider, + wallets: [sender], + } = await setupTest({ + maxInputs, + coinsPerAsset: totalCoins, + amountPerCoin: 1_000, + }); + const baseAssetId = await provider.getBaseAssetId(); + + const { coins } = await sender.getCoins(baseAssetId); + expect(coins.length).toEqual(totalCoins); + + const startConsolidationSpy = vi.spyOn(sender, 'startConsolidation'); + const sendAmount = (maxInputs + 1) * 1_000; + await sender.getResourcesToSpend([{ amount: sendAmount, assetId: baseAssetId }]); + expect(startConsolidationSpy).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/account/src/index.ts b/packages/account/src/index.ts index d9e7fdd1755..e561b6c804b 100644 --- a/packages/account/src/index.ts +++ b/packages/account/src/index.ts @@ -10,6 +10,7 @@ export * from './wallet-manager'; export * from './predicate'; export * from './providers'; export * from './connectors'; +export { consolidateCoins, getAllCoins } from './utils/consolidate-coins'; export { deployScriptOrPredicate } from './utils/deployScriptOrPredicate'; export { getBytecodeId, diff --git a/packages/account/src/providers/fuel-core-schema.graphql b/packages/account/src/providers/fuel-core-schema.graphql index abfe211ac22..07644cbfffd 100644 --- a/packages/account/src/providers/fuel-core-schema.graphql +++ b/packages/account/src/providers/fuel-core-schema.graphql @@ -473,6 +473,7 @@ type GasCosts { mul: U64! muli: U64! mldv: U64! + niop: U64 noop: U64! not: U64! or: U64! @@ -1171,6 +1172,11 @@ type Query { """ estimatePredicates(tx: HexString!): Transaction! + """ + Returns all possible receipts for test purposes. + """ + allReceipts: [Receipt!]! + """ Execute a dry-run of multiple transactions using a fork of current state, no changes are committed. """ @@ -1370,7 +1376,7 @@ type Receipt { Set in the case of a Panic receipt to indicate a missing contract input id """ contractId: ContractId - subId: Bytes32 + subId: SubId } enum ReceiptType { diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index 7f167cb5fb1..fd445ff2816 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -1016,11 +1016,12 @@ describe('Provider', () => { const provider = await new Provider(url).init(); const sender = Wallet.generate({ provider }); const receiver = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); await expectToThrowFuelError(() => sender.transfer(receiver.address, 1), { - code: ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, + code: ErrorCode.INSUFFICIENT_FUNDS, message: [ - `Insufficient funds or too many small value coins. Consider combining UTXOs.`, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${sender.address.toB256()}'.`, ``, `The Fuel Node that you are trying to connect to is using fuel-core version ${current.FUEL_CORE}.`, `The TS SDK currently supports fuel-core version ${supported.FUEL_CORE}.`, @@ -1043,11 +1044,12 @@ describe('Provider', () => { const provider = await new Provider(url).init(); const sender = Wallet.generate({ provider }); const receiver = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); await expectToThrowFuelError(() => sender.transfer(receiver.address, 1), { - code: ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, + code: ErrorCode.INSUFFICIENT_FUNDS, message: [ - `Insufficient funds or too many small value coins. Consider combining UTXOs.`, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${sender.address.toB256()}'.`, ``, `The Fuel Node that you are trying to connect to is using fuel-core version ${current.FUEL_CORE}.`, `The TS SDK currently supports fuel-core version ${supported.FUEL_CORE}.`, diff --git a/packages/account/src/providers/utils/handle-gql-error-message.test.ts b/packages/account/src/providers/utils/handle-gql-error-message.test.ts new file mode 100644 index 00000000000..c5e1ddba706 --- /dev/null +++ b/packages/account/src/providers/utils/handle-gql-error-message.test.ts @@ -0,0 +1,57 @@ +import { ErrorCode } from '@fuel-ts/errors'; +import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; + +import { setupTestProviderAndWallets } from '../../test-utils'; +import { Wallet } from '../../wallet'; +import { ScriptTransactionRequest } from '../transaction-request'; + +/** + * @group node + * @group browser + */ +describe('mapped error messages', () => { + it('should throw not enough coins error', async () => { + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; + + const assetId = await provider.getBaseAssetId(); + const sender = Wallet.generate({ provider }); + const recipient = Wallet.generate({ provider }); + + await expectToThrowFuelError(() => sender.transfer(recipient.address, 1_000_000, assetId), { + code: ErrorCode.INSUFFICIENT_FUNDS, + message: `Insufficient funds.\n\tAsset ID: '${assetId}'.\n\tOwner: '${sender.address.toB256()}'.`, + metadata: { assetId, owner: sender.address.toB256() }, + }); + }); + + it('should throw max coins reached error', async () => { + using launched = await setupTestProviderAndWallets({ + walletsConfig: { + amountPerCoin: 1, + coinsPerAsset: 256, + }, + }); + const { + provider, + wallets: [wallet], + } = launched; + const baseAssetId = await provider.getBaseAssetId(); + + const request = new ScriptTransactionRequest(); + request.addCoinOutput(wallet.address, 256, baseAssetId); + + const assembleTx = () => + provider.assembleTx({ + request, + feePayerAccount: wallet, + accountCoinQuantities: [{ amount: 256, assetId: baseAssetId }], + }); + + await expectToThrowFuelError(assembleTx, { + code: ErrorCode.MAX_COINS_REACHED, + message: `You have too many small value coins - consider combining UTXOs.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${wallet.address.toB256()}'.`, + metadata: { assetId: baseAssetId, owner: wallet.address.toB256() }, + }); + }); +}); diff --git a/packages/account/src/providers/utils/handle-gql-error-message.ts b/packages/account/src/providers/utils/handle-gql-error-message.ts index 64b5a333615..fa3ba16e6aa 100644 --- a/packages/account/src/providers/utils/handle-gql-error-message.ts +++ b/packages/account/src/providers/utils/handle-gql-error-message.ts @@ -1,11 +1,15 @@ import { ErrorCode, FuelError } from '@fuel-ts/errors'; import type { GraphQLError } from 'graphql'; +const ASSET_ID_REGEX: RegExp = /[0-9a-fA-F]{32,64}/g; + const gqlErrorMessage = { RPC_CONSISTENCY: /The required fuel block height is higher than the current block height. Required: \d+, Current: \d+/, - NOT_ENOUGH_COINS_MAX_COINS: - /the target cannot be met due to no coins available or exceeding the \d+ coin limit./, + INSUFFICIENT_FUNDS: + /the target cannot be met due to insufficient coins available for [0-9a-fA-F]{32,64}. Collected: \d+/, + MAX_COINS_REACHED: + /the target for [0-9a-fA-F]{32,64} cannot be met due to exceeding the \d+ coin limit. Collected: \d+./, ASSET_NOT_FOUND: /resource was not found in table/, MULTIPLE_CHANGE_POLICIES: /The asset ([a-fA-F0-9]{64}) has multiple change policies/, DUPLICATE_CHANGE_OUTPUT_ACCOUNT: /required balances contain duplicate \(asset, account\) pair/, @@ -15,11 +19,42 @@ const gqlErrorMessage = { type GqlError = { message: string } | GraphQLError; const mapGqlErrorMessage = (error: GqlError): FuelError => { - if (gqlErrorMessage.NOT_ENOUGH_COINS_MAX_COINS.test(error.message)) { + if (gqlErrorMessage.MAX_COINS_REACHED.test(error.message)) { + const matches = error.message.match(ASSET_ID_REGEX); + const assetId = matches ? `0x${matches[0]}` : null; + const owner = matches ? `0x${matches[1]}` : null; + let suffix = ''; + if (assetId) { + suffix += `\n\tAsset ID: '${assetId}'.`; + } + if (owner) { + suffix += `\n\tOwner: '${owner}'.`; + } + return new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.`, - {}, + ErrorCode.MAX_COINS_REACHED, + `You have too many small value coins - consider combining UTXOs.${suffix}`, + { assetId, owner }, + error + ); + } + + if (gqlErrorMessage.INSUFFICIENT_FUNDS.test(error.message)) { + const matches = error.message.match(ASSET_ID_REGEX); + const assetId = matches ? `0x${matches[0]}` : null; + const owner = matches ? `0x${matches[1]}` : null; + let suffix = ''; + if (assetId) { + suffix += `\n\tAsset ID: '${assetId}'.`; + } + if (owner) { + suffix += `\n\tOwner: '${owner}'.`; + } + + return new FuelError( + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.${suffix}`, + { assetId, owner }, error ); } diff --git a/packages/account/src/test-utils/launchNode.ts b/packages/account/src/test-utils/launchNode.ts index c3c7bc39245..3a502fa916b 100644 --- a/packages/account/src/test-utils/launchNode.ts +++ b/packages/account/src/test-utils/launchNode.ts @@ -154,8 +154,9 @@ export const launchNode = async ({ '--consensus-key', '--db-type', '--poa-instant', - '--min-gas-price', '--native-executor-version', + '--min-gas-price', + '--starting-gas-price', ]); const snapshotDir = getFlagValueFromArgs(args, '--snapshot'); diff --git a/packages/account/src/utils/consolidate-coins.test.ts b/packages/account/src/utils/consolidate-coins.test.ts new file mode 100644 index 00000000000..0b9cd856741 --- /dev/null +++ b/packages/account/src/utils/consolidate-coins.test.ts @@ -0,0 +1,260 @@ +import { ErrorCode } from '@fuel-ts/errors'; +import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; +import { bn } from '@fuel-ts/math'; +import { randomInt } from 'crypto'; + +import type { CoinQuantity, Provider } from '..'; +import { Account, Fuel, ScriptTransactionRequest } from '..'; +import { MockConnector } from '../../test/fixtures/mocked-connector'; +import { PredicateMultiArgs } from '../../test/fixtures/predicate-multi-args'; +import { setupTestProviderAndWallets, TestAssetId } from '../test-utils'; +import type { WalletUnlocked } from '../wallet'; +import { Wallet } from '../wallet'; + +import { consolidateCoins, getAllCoins } from './consolidate-coins'; + +const seedWallet = async (opts: { + adminWallet: WalletUnlocked; + wallet: Account; + coins: CoinQuantity[]; +}) => { + const { adminWallet, wallet, coins } = opts; + const maxOutputs = 253; + + // eslint-disable-next-line no-constant-condition + while (true) { + const request = new ScriptTransactionRequest({ + scriptData: '0x', + }); + + while (request.outputs.length < maxOutputs) { + const coin = coins.shift(); + if (!coin) { + break; + } + request.addCoinOutput(wallet.address, coin.amount, coin.assetId); + } + + await request.estimateAndFund(adminWallet); + const transfer = await adminWallet.sendTransaction(request); + await transfer.waitForResult(); + + if (coins.length === 0) { + break; + } + } +}; + +describe('Consolidate coins', { timeout: 1000000 }, () => { + const MIN_COINS = 500; + const MAX_COINS = 1000; + + const setupTest = async (opts: { + account: ({ provider }: { provider: Provider }) => Account; + coins: (opts: { baseAssetId: string; nonBaseAssetId: string }) => CoinQuantity[]; + }) => { + const { account, coins } = opts; + const launched = await setupTestProviderAndWallets({ + nodeOptions: { args: ['--starting-gas-price', '2500'] }, + }); + const { + provider, + wallets: [adminWallet], + } = launched; + + const chain = await provider.getChain(); + const maxInputs = chain.consensusParameters.txParameters.maxInputs; + const baseAssetId = await provider.getBaseAssetId(); + const nonBaseAssetId = TestAssetId.A.value; + + const accountToSeed = account({ provider }); + + await seedWallet({ + adminWallet, + wallet: accountToSeed, + coins: coins({ baseAssetId, nonBaseAssetId }), + }); + + return { + provider, + account: accountToSeed, + baseAssetId, + nonBaseAssetId, + maxInputs: maxInputs.toNumber(), + [Symbol.dispose]: () => launched.cleanup(), + }; + }; + + const happyAccounts: Record Account> = { + // Simple Wallet + 'wallet-unlocked': Wallet.generate, + // Valid predicate with multi args + 'predicate-multi-args': (opts) => + new PredicateMultiArgs({ provider: opts.provider, data: [12, 31] }), + // Simple Fuel Connector + 'fuel-connector': (opts) => { + const wallet = Wallet.generate({ provider: opts.provider }); + const mockConnector = new MockConnector({ wallets: [wallet] }); + const fuel = new Fuel({ + connectors: [mockConnector], + }); + return new Account(wallet.address, opts.provider, fuel); + }, + // Predicate Fuel Connector + }; + + const unhappyAccounts: Record Account> = { + // Locked wallet + 'wallet-locked': (opts) => Wallet.generate({ provider: opts.provider }).lock(), + // Invalid predicate with multi args + 'predicate-multi-args-invalid': (opts) => + new PredicateMultiArgs({ provider: opts.provider, data: [12, 12] }), + }; + + describe.each(Object.entries(happyAccounts))( + '[Simple manual consolidations: %s]', + (_, account) => { + it('Should consolidate base assets', async () => { + using launched = await setupTest({ + account, + coins: ({ baseAssetId }) => [ + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: baseAssetId, + amount: bn(1), + })), + ...Array.from({ length: 1 }, () => ({ assetId: baseAssetId, amount: bn(1_000_000) })), + ], + }); + + const { account: accountToConsolidate, baseAssetId, maxInputs } = launched; + + // Given we have more than the max inputs, we should consolidate + const { coins: beforeConsolidation } = await getAllCoins(accountToConsolidate, baseAssetId); + expect(beforeConsolidation.length).toBeGreaterThan(maxInputs); + + // When we consolidate + const { submitAll } = await consolidateCoins({ + account: accountToConsolidate, + assetId: baseAssetId, + }); + const { txResponses } = await submitAll(); + + // Then we should be successful + expect(txResponses.length).toBeGreaterThan(0); + // Then we should have less than the max inputs + const { coins: afterConsolidation } = await getAllCoins(accountToConsolidate, baseAssetId); + expect(afterConsolidation.length).toBeLessThan(maxInputs); + }); + + it('Should consolidate non-base assets', async () => { + using launched = await setupTest({ + account, + coins: ({ nonBaseAssetId, baseAssetId }) => [ + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: nonBaseAssetId, + amount: bn(1), + })), + ...Array.from({ length: 1 }, () => ({ assetId: baseAssetId, amount: bn(1_000_000) })), + ], + }); + const { account: accountToConsolidate, nonBaseAssetId, maxInputs } = launched; + + // Given we have more than the max inputs, we should consolidate + const { coins: beforeConsolidation } = await getAllCoins( + accountToConsolidate, + nonBaseAssetId + ); + expect(beforeConsolidation.length).toBeGreaterThan(maxInputs); + + // When we consolidate + const { submitAll } = await consolidateCoins({ + account: accountToConsolidate, + assetId: nonBaseAssetId, + }); + const { txResponses } = await submitAll(); + + // Then we should be successful + expect(txResponses.length).toBeGreaterThan(0); + // Then we should have less than the max inputs + const { coins: afterConsolidation } = await getAllCoins( + accountToConsolidate, + nonBaseAssetId + ); + expect(afterConsolidation.length).toBeLessThan(maxInputs); + }); + + it('Should throw if the account has insufficient funds', async () => { + using launched = await setupTest({ + account, + coins: ({ baseAssetId }) => [ + // Only dust coins + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: baseAssetId, + amount: bn(1), + })), + ], + }); + const { account: accountToConsolidate, baseAssetId } = launched; + + const { submitAll } = await consolidateCoins({ + account: accountToConsolidate, + assetId: baseAssetId, + }); + + await expectToThrowFuelError(() => submitAll(), { code: ErrorCode.FUNDS_TOO_LOW }); + }); + } + ); + + describe.each(Object.entries(unhappyAccounts))('[Failure cases: %s]', (_, account) => { + it('Should throw if unable to consolidate base assets', async () => { + using launched = await setupTest({ + account, + coins: ({ baseAssetId }) => [ + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: baseAssetId, + amount: bn(100), + })), + ...Array.from({ length: 1 }, () => ({ + assetId: baseAssetId, + amount: bn(1_000_000), + })), + ], + }); + const { account: accountToConsolidate, baseAssetId } = launched; + + const { submitAll } = await consolidateCoins({ + account: accountToConsolidate, + assetId: baseAssetId, + }); + + const call = () => submitAll(); + await expect(call).rejects.toThrowError(/InputInvalidSignature|PredicateReturnedNonOne/); + }); + + it('Should throw error if unable to consolidate non-base assets', async () => { + using launched = await setupTest({ + account, + coins: ({ nonBaseAssetId, baseAssetId }) => [ + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: nonBaseAssetId, + amount: bn(100), + })), + ...Array.from({ length: 1 }, () => ({ + assetId: baseAssetId, + amount: bn(1_000_000), + })), + ], + }); + const { account: accountToConsolidate, nonBaseAssetId } = launched; + + const { submitAll } = await consolidateCoins({ + account: accountToConsolidate, + assetId: nonBaseAssetId, + }); + + const call = () => submitAll(); + await expect(call).rejects.toThrowError(/InputInvalidSignature|PredicateReturnedNonOne/); + }); + }); +}); diff --git a/packages/account/src/utils/consolidate-coins.ts b/packages/account/src/utils/consolidate-coins.ts new file mode 100644 index 00000000000..ceafd10cb6f --- /dev/null +++ b/packages/account/src/utils/consolidate-coins.ts @@ -0,0 +1,186 @@ +import { Address } from '@fuel-ts/address'; +import { ErrorCode, FuelError } from '@fuel-ts/errors'; +import { bn } from '@fuel-ts/math'; +import { OutputType } from '@fuel-ts/transactions'; +import type { TransactionType, OutputChange } from '@fuel-ts/transactions'; +import { splitEvery } from 'ramda'; + +import { type SubmitAllCallback, type Account } from '../account'; +import { calculateGasFee, ScriptTransactionRequest } from '../providers'; +import type { TransactionRequest, Coin, TransactionResult } from '../providers'; + +export const getAllCoins = async (account: Account, assetId?: string) => { + const all: Coin[] = []; + let coins: Coin[]; + let hasNextPage = true; + let after: string | undefined; + + while (hasNextPage) { + ({ + coins, + pageInfo: { hasNextPage }, + } = await account.getCoins(assetId, { after })); + all.push(...coins); + after = coins.pop()?.id; + } + + return { coins: all }; +}; + +const sortCoins = ({ coins }: { coins: Coin[] }) => coins.sort((a, b) => b.amount.cmp(a.amount)); + +const createOuputCoin = (opts: { + request: TransactionRequest; + baseAssetId: string; + chainId: number; +}): Coin => { + const { request, baseAssetId, chainId } = opts; + + const outputChangeIndex = request.outputs.findIndex( + (output) => output.type === OutputType.Change && output.assetId === baseAssetId + ); + if (outputChangeIndex === -1) { + throw new FuelError(ErrorCode.UNKNOWN, 'No change output found'); + } + + const outputCoin = request.outputs[outputChangeIndex] as OutputChange; + + // Format the UTXO ID + const transactionId = request.getTransactionId(chainId); + const outputIndexPadded = Number(outputChangeIndex).toString().padStart(4, '0'); + + return { + id: `${transactionId}${outputIndexPadded}`, + assetId: outputCoin.assetId, + amount: outputCoin.amount, + owner: new Address(outputCoin.to), + blockCreated: bn(0), + txCreatedIdx: bn(0), + }; +}; + +export const consolidateCoins = async ({ + account, + assetId, +}: { + account: Account; + assetId: string; +}) => { + const chainInfo = await account.provider.getChain(); + const chainId = chainInfo.consensusParameters.chainId.toNumber(); + const gasPrice = await account.provider.estimateGasPrice(10); + const maxInputs = chainInfo.consensusParameters.txParameters.maxInputs.toNumber(); + const baseAssetId = await account.provider.getBaseAssetId(); + const isBaseAsset = assetId === baseAssetId; + + const batchSize = maxInputs; + const numberOfFundingCoins = maxInputs; + + let funding: Coin[] = []; + let dust: Coin[] = []; + + // We get the largest coin/s for funding purposes + if (isBaseAsset) { + const coins = await getAllCoins(account, baseAssetId).then(sortCoins); + funding = coins.slice(0, numberOfFundingCoins); + dust = coins.slice(numberOfFundingCoins); + } else { + funding = await getAllCoins(account, baseAssetId) + .then(sortCoins) + .then((coins) => coins.slice(0, numberOfFundingCoins)); + dust = await getAllCoins(account, assetId).then(({ coins }) => coins); + } + + // There a better way of detecting whether the account has enough funds to consolidate + if (funding.length === 0) { + throw new FuelError( + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds to consolidate.\n\tAsset ID: ${baseAssetId}\n\tOwner: ${account.address.toB256()}` + ); + } + + const batches = [ + ...splitEvery(batchSize, funding), + // We leave one coin for the funding coin + ...splitEvery(batchSize - 1, dust), + ]; + + const txs: ScriptTransactionRequest[] = batches.map((batch) => { + const request = new ScriptTransactionRequest({ + scriptData: '0x', + }); + + // Add our dust coins as inputs + request.addResources(batch); + + return request; + }); + + const submitAll: SubmitAllCallback = async (opts = {}) => { + const txResponses: TransactionResult[] = []; + let previousTx: ScriptTransactionRequest | undefined; + + for (let i = 0; i < txs.length; i++) { + let currentTx = txs[i]; + const step = i + 1; + + if (previousTx) { + const coin = createOuputCoin({ + request: previousTx, + baseAssetId, + chainId, + }); + + // Add the funding coin to the current tx + currentTx.addResource(coin); + } + + // Populate the tx with predicate data + if ( + 'populateTransactionPredicateData' in account && + typeof account.populateTransactionPredicateData === 'function' + ) { + currentTx = account.populateTransactionPredicateData(currentTx); + currentTx = await account.provider.estimatePredicates(currentTx); + } + + // Calculate gas and fee + const fee = calculateGasFee({ + gasPrice, + gas: currentTx.calculateMinGas(chainInfo), + priceFactor: chainInfo.consensusParameters.feeParameters.gasPriceFactor, + tip: currentTx.tip, + }); + + currentTx.maxFee = fee; + currentTx.gasLimit = bn(1000); + + opts.onTransactionStart?.({ + tx: currentTx, + step, + assetId, + transactionId: currentTx.getTransactionId(chainId), + }); + + // Send the tx + const { waitForResult } = await account.sendTransaction(currentTx); + const response = await waitForResult(); + txResponses.push(response); + + // Update the previous tx + previousTx = currentTx; + previousTx.outputs = response.transaction.outputs; + } + + return { + txResponses, + errors: [], + }; + }; + + return { + txs, + totalFeeCost: txs.reduce((acc, request) => acc.add(request.maxFee), bn(0)), + submitAll, + }; +}; diff --git a/packages/account/test/fixtures/mocked-connector.ts b/packages/account/test/fixtures/mocked-connector.ts index 1d39d527019..5c4b74ddbad 100644 --- a/packages/account/test/fixtures/mocked-connector.ts +++ b/packages/account/test/fixtures/mocked-connector.ts @@ -2,6 +2,7 @@ import type { HashableMessage } from '@fuel-ts/hasher'; import { setTimeout } from 'timers/promises'; +import type { PartialDeep } from 'type-fest'; import type { TransactionRequestLike, @@ -12,6 +13,7 @@ import type { SelectNetworkArguments, AccountSendTxParams, TransactionResponse, + StartConsolidateCoins, } from '../../src'; import type { Asset } from '../../src/assets/types'; import { FuelConnector } from '../../src/connectors/fuel-connector'; @@ -26,6 +28,7 @@ type MockConnectorOptions = { wallets?: Array; pingDelay?: number; metadata?: Partial; + mocks?: PartialDeep>; }; export class MockConnector extends FuelConnector { @@ -33,6 +36,7 @@ export class MockConnector extends FuelConnector { _networks: Array; _wallets: Array; _pingDelay: number; + _mocks: MockConnectorOptions['mocks']; override name = 'Fuel Wallet'; override metadata: ConnectorMetadata = { image: '/connectors/fuel-wallet.svg', @@ -64,6 +68,7 @@ export class MockConnector extends FuelConnector { ...this.metadata, ...options.metadata, }; + this._mocks = options.mocks ?? {}; } override async ping() { @@ -172,4 +177,16 @@ export class MockConnector extends FuelConnector { override async hasABI(_id: string) { return true; } + + override async startConsolidation(_opts: StartConsolidateCoins): Promise { + if (!this._mocks?.startConsolidation) { + const wallet = this._wallets.find((w) => w.address.toB256() === _opts.owner); + if (!wallet) { + throw new Error('Wallet is not found!'); + } + await wallet.startConsolidation(_opts); + } else { + await this._mocks?.startConsolidation(_opts); + } + } } diff --git a/packages/account/test/fixtures/predicate-multi-args.ts b/packages/account/test/fixtures/predicate-multi-args.ts new file mode 100644 index 00000000000..72cb4038277 --- /dev/null +++ b/packages/account/test/fixtures/predicate-multi-args.ts @@ -0,0 +1,58 @@ +import type { BigNumberish } from '@fuel-ts/math'; +import { decompressBytecode } from '@fuel-ts/utils'; + +import { Predicate } from '../../src'; +import type { Provider } from '../../src'; + +const abi = { + programType: 'predicate', + specVersion: '1', + encodingVersion: '1', + concreteTypes: [ + { + type: 'bool', + concreteTypeId: 'b760f44fa5965c2474a3b471467a22c43185152129295af588b022ae50b50903', + }, + { + type: 'u64', + concreteTypeId: '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + ], + metadataTypes: [], + functions: [ + { + name: 'main', + inputs: [ + { + name: 'arg1', + concreteTypeId: '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + { + name: 'arg2', + concreteTypeId: '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + ], + output: 'b760f44fa5965c2474a3b471467a22c43185152129295af588b022ae50b50903', + attributes: null, + }, + ], + loggedTypes: [], + messagesTypes: [], + configurables: [], + errorCodes: {}, +}; + +const bytecode = decompressBytecode( + 'H4sIAAAAAAAAA5P6YMBQwsDAwgAGjBAKSMf+P8Ao8P8/g9SbAIaJDAwchQ4MzImuTAzCngIMZR4MbEUeDEzCriINZS4MjGZAHYmOTF5AcxiBNE/8WwYGqdcbGBR+GDNE/H7AFPD7AQtQjlfK+QI2cXapl0D1zy+giwuofGFgmMrAwD+NAygPdIeU8wOGWPcFDLGuAgyx3gsYinwYOAQ8VQ7Ev1RgkPoqwCD1k4FhBlDtdKAerx9gvYwwvcLO6xyEHYHsr3A1jCA1AOxRRIgAAQAA' +); + +export class PredicateMultiArgs extends Predicate<[BigNumberish, BigNumberish]> { + constructor({ provider, data }: { provider: Provider; data: [BigNumberish, BigNumberish] }) { + super({ + bytecode, + abi, + provider, + data, + }); + } +} diff --git a/packages/account/test/fuel-wallet-connector.test.ts b/packages/account/test/fuel-wallet-connector.test.ts index b9153f87fa9..b1bf0eee010 100644 --- a/packages/account/test/fuel-wallet-connector.test.ts +++ b/packages/account/test/fuel-wallet-connector.test.ts @@ -911,4 +911,32 @@ describe('Fuel Connector', () => { expect(jsonSummary.transaction).toBeDefined(); expect(jsonSummary.gasUsed).toBeDefined(); }); + + it('should start consolidation process', async () => { + const startConsolidation = vi.fn(); + using launched = await setupTestProviderAndWallets({ + walletsConfig: { amountPerCoin: 1, coinsPerAsset: 2_000 }, + }); + const { + provider, + wallets: [connectorWallet, receiver], + } = launched; + const connector = new MockConnector({ + wallets: [connectorWallet], + mocks: { startConsolidation }, + }); + const fuel = await new Fuel({ connectors: [connector] }); + const account = new Account(connectorWallet.address.toB256(), provider, fuel); + const baseAssetId = await provider.getBaseAssetId(); + + // Send transfer that will trigger auto consolidation + // Note: we still throw an error, this is due to the nature of the connector implementation. + await expectToThrowFuelError(() => account.transfer(receiver.address, 1_000, baseAssetId), { + code: ErrorCode.MAX_COINS_REACHED, + }); + expect(startConsolidation).toBeCalledWith({ + owner: account.address.toB256(), + assetId: baseAssetId, + }); + }); }); diff --git a/packages/contract/package.json b/packages/contract/package.json index 2b02ab9f8e7..2e2a16ddc4d 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -49,7 +49,7 @@ "@fuel-ts/program": "workspace:*", "@fuel-ts/transactions": "workspace:*", "@fuel-ts/utils": "workspace:*", - "@fuels/vm-asm": "0.60.2", + "@fuels/vm-asm": "0.62.0", "ramda": "0.30.1" }, "devDependencies": { diff --git a/packages/errors/src/error-codes.ts b/packages/errors/src/error-codes.ts index d6294707280..2fd7d153f3d 100644 --- a/packages/errors/src/error-codes.ts +++ b/packages/errors/src/error-codes.ts @@ -59,7 +59,8 @@ export enum ErrorCode { MISSING_REQUIRED_PARAMETER = 'missing-required-parameter', INVALID_REQUEST = 'invalid-request', INVALID_TRANSFER_AMOUNT = 'invalid-transfer-amount', - INSUFFICIENT_FUNDS_OR_MAX_COINS = 'not-enough-funds-or-max-coins-reached', + INSUFFICIENT_FUNDS = 'not-enough-funds', + MAX_COINS_REACHED = 'max-coins-reached', // crypto INVALID_CREDENTIALS = 'invalid-credentials', @@ -105,6 +106,7 @@ export enum ErrorCode { INVALID_PASSWORD = 'invalid-password', ACCOUNT_REQUIRED = 'account-required', UNLOCKED_WALLET_REQUIRED = 'unlocked-wallet-required', + UNABLE_TO_CONSOLIDATE_COINS = 'unable-to-consolidate-coins', NO_COINS_TO_CONSOLIDATE = 'no-coins-to-consolidate', COINS_ASSET_ID_MISMATCH = 'coins-asset-id-mismatch', diff --git a/packages/errors/src/fuel-error.ts b/packages/errors/src/fuel-error.ts index 3cd21ce7e71..78c76d7befd 100644 --- a/packages/errors/src/fuel-error.ts +++ b/packages/errors/src/fuel-error.ts @@ -28,7 +28,7 @@ export class FuelError extends Error { ); } - return new FuelError(error.code, error.message); + return new FuelError(error.code, error.message, error.metadata, error.rawError); } code: ErrorCode; diff --git a/packages/fuel-gauge/src/consolidate-coins.test.ts b/packages/fuel-gauge/src/consolidate-coins.test.ts new file mode 100644 index 00000000000..244e05eb148 --- /dev/null +++ b/packages/fuel-gauge/src/consolidate-coins.test.ts @@ -0,0 +1,430 @@ +import { randomInt } from 'crypto'; +import { + bn, + ScriptTransactionRequest, + Wallet, + getAllCoins, + consolidateCoins, + ErrorCode, +} from 'fuels'; +import type { Account, CoinQuantity, Provider, WalletUnlocked } from 'fuels'; +import { expectToThrowFuelError, launchTestNode, TestAssetId } from 'fuels/test-utils'; + +import { + CallTestContractFactory, + PredicateMultiArgs, + ScriptMainArgBool, + TokenContractFactory, + TransferContractFactory, +} from '../test/typegen'; + +const seedWallet = async (opts: { + adminWallet: WalletUnlocked; + wallet: Account; + coins: CoinQuantity[]; +}) => { + const { adminWallet, wallet, coins } = opts; + const maxOutputs = 253; + + // eslint-disable-next-line no-constant-condition + while (true) { + const request = new ScriptTransactionRequest({ + scriptData: '0x', + }); + + while (request.outputs.length < maxOutputs) { + const coin = coins.shift(); + if (!coin) { + break; + } + request.addCoinOutput(wallet.address, coin.amount, coin.assetId); + } + + await request.estimateAndFund(adminWallet); + const transfer = await adminWallet.sendTransaction(request); + await transfer.waitForResult(); + + if (coins.length === 0) { + break; + } + } +}; + +describe('Consolidate coins', { timeout: 1000000 }, () => { + const MIN_COINS = 500; + const MAX_COINS = 1000; + + const setupTest = async (opts: { + account: ({ provider }: { provider: Provider }) => Account; + coins: (opts: { baseAssetId: string; nonBaseAssetId: string }) => CoinQuantity[]; + }) => { + const { account, coins } = opts; + const launched = await launchTestNode({ + nodeOptions: { args: ['--starting-gas-price', '2500'] }, + }); + const { + provider, + wallets: [adminWallet], + } = launched; + + const chain = await provider.getChain(); + const maxInputs = chain.consensusParameters.txParameters.maxInputs; + const baseAssetId = await provider.getBaseAssetId(); + const nonBaseAssetId = TestAssetId.A.value; + + const accountToSeed = account({ provider }); + + await seedWallet({ + adminWallet, + wallet: accountToSeed, + coins: coins({ baseAssetId, nonBaseAssetId }), + }); + + return { + provider, + account: accountToSeed, + baseAssetId, + nonBaseAssetId, + maxInputs: maxInputs.toNumber(), + [Symbol.dispose]: () => launched.cleanup(), + }; + }; + + const happyAccounts: Record Account> = { + // Simple Wallet + 'wallet-unlocked': Wallet.generate, + // Valid predicate with multi args + 'predicate-multi-args': (opts) => + new PredicateMultiArgs({ provider: opts.provider, data: [12, 31] }), + // Simple Fuel Connector + // TODO: Reimplement MockConnector + // 'fuel-connector': (opts) => { + // const wallet = Wallet.generate({ provider: opts.provider }); + // const mockConnector = new MockConnector({ wallets: [wallet] }); + // const fuel = new Fuel({ + // connectors: [mockConnector], + // }); + // return new Account(wallet.address, opts.provider, fuel); + // }, + // Predicate Fuel Connector + }; + + // Simple manual consolidations + describe.each(Object.entries(happyAccounts))( + '[Simple manual consolidations: %s]', + (_, account) => { + it('Should consolidate base assets', async () => { + using launched = await setupTest({ + account, + coins: ({ baseAssetId }) => [ + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: baseAssetId, + amount: bn(1), + })), + ...Array.from({ length: 1 }, () => ({ assetId: baseAssetId, amount: bn(1_000_000) })), + ], + }); + + const { account: accountToConsolidate, baseAssetId, maxInputs } = launched; + + // Given we have more than the max inputs, we should consolidate + const { coins: beforeConsolidation } = await getAllCoins(accountToConsolidate, baseAssetId); + expect(beforeConsolidation.length).toBeGreaterThan(maxInputs); + + // When we consolidate + const steps = await consolidateCoins({ + account: accountToConsolidate, + assetId: baseAssetId, + }); + await steps.submitAll(); + + // Then we should have less than the max inputs + const { coins: afterConsolidation } = await getAllCoins(accountToConsolidate, baseAssetId); + expect(afterConsolidation.length).toBeLessThan(maxInputs); + }); + + it('Should consolidate non-base assets', async () => { + using launched = await setupTest({ + account, + coins: ({ nonBaseAssetId, baseAssetId }) => [ + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: nonBaseAssetId, + amount: bn(1), + })), + ...Array.from({ length: 1 }, () => ({ assetId: baseAssetId, amount: bn(1_000_000) })), + ], + }); + const { account: accountToConsolidate, nonBaseAssetId, maxInputs } = launched; + + // Given we have more than the max inputs, we should consolidate + const { coins: beforeConsolidation } = await getAllCoins( + accountToConsolidate, + nonBaseAssetId + ); + expect(beforeConsolidation.length).toBeGreaterThan(maxInputs); + + // When we consolidate + const collection = await consolidateCoins({ + account: accountToConsolidate, + assetId: nonBaseAssetId, + }); + await collection.submitAll(); + + // Then we should have less than the max inputs + const { coins: afterConsolidation } = await getAllCoins( + accountToConsolidate, + nonBaseAssetId + ); + expect(afterConsolidation.length).toBeLessThan(maxInputs); + }); + + it('Should throw if the account has insufficient funds', async () => { + using launched = await setupTest({ + account, + coins: ({ baseAssetId }) => [ + // Only dust coins + ...Array.from({ length: randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: baseAssetId, + amount: bn(1), + })), + ], + }); + const { account: accountToConsolidate, baseAssetId } = launched; + + const { submitAll } = await consolidateCoins({ + account: accountToConsolidate, + assetId: baseAssetId, + }); + + await expectToThrowFuelError(() => submitAll(), { code: ErrorCode.FUNDS_TOO_LOW }); + }); + } + ); + + // Automatic consolidations for base assets + describe.each(Object.entries(happyAccounts))( + '[Automatic consolidations for base assets: %s]', + (_, account) => { + const setupBaseAssetTest = (opts: { + funding?: Partial<{ amount: number; count: number }>; + dust?: Partial<{ amount: number; count: number }>; + }) => + setupTest({ + account, + coins: ({ baseAssetId }) => [ + ...Array.from({ length: opts.dust?.count ?? randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: baseAssetId, + amount: bn(opts.dust?.amount ?? 1), + })), + ...Array.from({ length: opts.funding?.count ?? 1 }, () => ({ + assetId: baseAssetId, + amount: bn(opts.funding?.amount ?? 1_000), + })), + ], + }); + + it('Should automatically consolidate [transfer-funds]', async () => { + using launched = await setupBaseAssetTest({ + funding: { count: 0 }, + dust: { count: 1200, amount: 1_000 }, + }); + const { provider, account: accountToConsolidate, baseAssetId } = launched; + const recipient = Wallet.generate({ provider }); + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const { waitForResult } = await accountToConsolidate.transfer( + recipient.address, + 1_000_000, + baseAssetId + ); + + const { isStatusSuccess } = await waitForResult(); + expect(isStatusSuccess).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + + it('Should automatically consolidate [transfer-to-contract]', async () => { + using launched = await setupBaseAssetTest({ + funding: { count: 200, amount: 1000 }, + dust: { count: 2000, amount: 100 }, + }); + const { account: accountToConsolidate, baseAssetId } = launched; + const deploy = await CallTestContractFactory.deploy(accountToConsolidate); + const { contract } = await deploy.waitForResult(); + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const tx = await accountToConsolidate.transferToContract(contract.id, 200_000, baseAssetId); + const { isStatusSuccess } = await tx.waitForResult(); + + expect(isStatusSuccess).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + + it('Should automatically consolidate [script-call]', async () => { + using launched = await setupBaseAssetTest({ + funding: { count: 0 }, + dust: { count: 1200, amount: 1_000 }, + }); + const { account: accountToConsolidate } = launched; + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const script = new ScriptMainArgBool(accountToConsolidate); + const { waitForResult } = await script.functions + .main(true) + .txParams({ tip: bn(500_000) }) + .call(); + const { value } = await waitForResult(); + + expect(value).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + + it('Should automatically consolidate [contract-call]', async () => { + using launched = await setupBaseAssetTest({ + funding: { count: 100, amount: 10000 }, + dust: { count: 1200, amount: 100 }, + }); + const { account: accountToConsolidate } = launched; + + const deploy = await TokenContractFactory.deploy(accountToConsolidate); + const { contract } = await deploy.waitForResult(); + + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const { waitForResult } = await contract.functions + .mint_coins(1000) + .txParams({ tip: bn(1_000_000) }) + .call(); + const { + transactionResult: { isStatusSuccess }, + } = await waitForResult(); + + expect(isStatusSuccess).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + + it('Should automatically consolidate [contract-call-with-forwarded-assets]', async () => { + using launched = await setupBaseAssetTest({ + funding: { count: 200, amount: 1000 }, + dust: { count: 1200, amount: 100 }, + }); + const { account: accountToConsolidate, baseAssetId } = launched; + const recipient = Wallet.generate({ provider: accountToConsolidate.provider }); + const deploy = await TransferContractFactory.deploy(accountToConsolidate); + const { contract } = await deploy.waitForResult(); + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const { waitForResult } = await contract.functions + .execute_transfer([ + { + recipient: { Address: { bits: recipient.address.toB256() } }, + asset_id: { bits: baseAssetId }, + amount: 200_000, + }, + ]) + .callParams({ forward: { amount: 200_000, assetId: baseAssetId } }) + .call(); + const { + transactionResult: { isStatusSuccess }, + } = await waitForResult(); + + expect(isStatusSuccess).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + } + ); + + // Automatic consolidations for non-base assets + describe.each(Object.entries(happyAccounts))( + '[Automatic consolidations for non-base assets: %s]', + (_, account) => { + const setupNonBaseAssetTest = (opts: { + funding?: Partial<{ amount: number; count: number }>; + dust?: Partial<{ amount: number; count: number }>; + }) => + setupTest({ + account, + coins: ({ baseAssetId, nonBaseAssetId }) => [ + ...Array.from({ length: opts.dust?.count ?? randomInt(MIN_COINS, MAX_COINS) }, () => ({ + assetId: nonBaseAssetId, + amount: bn(opts.dust?.amount ?? 1), + })), + ...Array.from({ length: opts.funding?.count ?? 1 }, () => ({ + assetId: baseAssetId, + amount: bn(opts.funding?.amount ?? 1_000), + })), + ], + }); + + it('Should automatically consolidate [transfer-funds]', async () => { + using launched = await setupNonBaseAssetTest({ + funding: { count: 30, amount: 10_000 }, + dust: { count: 1200, amount: 1_000 }, + }); + const { provider, account: accountToConsolidate, nonBaseAssetId } = launched; + const recipient = Wallet.generate({ provider }); + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const { waitForResult } = await accountToConsolidate.transfer( + recipient.address, + 1_000_000, + nonBaseAssetId + ); + + const { isStatusSuccess } = await waitForResult(); + expect(isStatusSuccess).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + + it('Should automatically consolidate [transfer-to-contract]', async () => { + using launched = await setupNonBaseAssetTest({ + funding: { count: 200, amount: 10000 }, + dust: { count: 2000, amount: 100 }, + }); + const { account: accountToConsolidate, nonBaseAssetId } = launched; + const deploy = await CallTestContractFactory.deploy(accountToConsolidate); + const { contract } = await deploy.waitForResult(); + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const tx = await accountToConsolidate.transferToContract( + contract.id, + 200_000, + nonBaseAssetId + ); + const { isStatusSuccess } = await tx.waitForResult(); + + expect(isStatusSuccess).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + + it('Should automatically consolidate [contract-call-with-forwarded-assets]', async () => { + using launched = await setupNonBaseAssetTest({ + funding: { count: 200, amount: 10000 }, + dust: { count: 2000, amount: 100 }, + }); + const { account: accountToConsolidate, nonBaseAssetId } = launched; + const recipient = Wallet.generate({ provider: accountToConsolidate.provider }); + const deploy = await TransferContractFactory.deploy(accountToConsolidate); + const { contract } = await deploy.waitForResult(); + const startConsolidationSpy = vi.spyOn(accountToConsolidate, 'startConsolidation'); + + const { waitForResult } = await contract.functions + .execute_transfer([ + { + recipient: { Address: { bits: recipient.address.toB256() } }, + asset_id: { bits: nonBaseAssetId }, + amount: 200_000, + }, + ]) + .callParams({ forward: { amount: 200_000, assetId: nonBaseAssetId } }) + .call(); + const { + transactionResult: { isStatusSuccess }, + } = await waitForResult(); + + expect(isStatusSuccess).toBe(true); + expect(startConsolidationSpy).toHaveBeenCalled(); + }); + } + ); +}); diff --git a/packages/fuel-gauge/src/contract.test.ts b/packages/fuel-gauge/src/contract.test.ts index e7c8bdb4284..f6caba55f6d 100644 --- a/packages/fuel-gauge/src/contract.test.ts +++ b/packages/fuel-gauge/src/contract.test.ts @@ -1107,18 +1107,19 @@ describe('Contract', () => { using contract = await setupTestContract(); contract.account = Wallet.generate({ provider: contract.provider }); + const baseAssetId = await contract.provider.getBaseAssetId(); await expectToThrowFuelError( async () => contract.functions .return_context_amount() .callParams({ - forward: [100, await contract.provider.getBaseAssetId()], + forward: [100, baseAssetId], }) .simulate(), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${contract.account.address.toB256()}'.` ) ); }); diff --git a/packages/fuel-gauge/src/edge-cases.test.ts b/packages/fuel-gauge/src/edge-cases.test.ts index 4a0c84b2694..1ac04dceb97 100644 --- a/packages/fuel-gauge/src/edge-cases.test.ts +++ b/packages/fuel-gauge/src/edge-cases.test.ts @@ -75,7 +75,7 @@ describe('Edge Cases', () => { }), new FuelError( ErrorCode.INVALID_REQUEST, - 'Invalid transaction data: PredicateVerificationFailed(Panic(PredicateReturnedNonOne))' + 'Invalid transaction data: PredicateVerificationFailed(Panic { index: 0, reason: PredicateReturnedNonOne })' ) ); }); diff --git a/packages/fuel-gauge/src/funding-transaction.test.ts b/packages/fuel-gauge/src/funding-transaction.test.ts index 69a8bce7fa5..314d6b8aa6e 100644 --- a/packages/fuel-gauge/src/funding-transaction.test.ts +++ b/packages/fuel-gauge/src/funding-transaction.test.ts @@ -230,6 +230,7 @@ describe('Funding Transactions', () => { const splitIn = 254; const sender = Wallet.generate({ provider }); const receiver = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); /** * Splitting funds in 254 UTXOs to result in the transaction become more expensive @@ -239,14 +240,14 @@ describe('Funding Transactions', () => { account: sender, totalAmount: 1524, splitIn, - baseAssetId: await provider.getBaseAssetId(), + baseAssetId, mainWallet: funded, }); const request = new ScriptTransactionRequest(); const amountToTransfer = 1522; - request.addCoinOutput(receiver.address, amountToTransfer, await provider.getBaseAssetId()); + request.addCoinOutput(receiver.address, amountToTransfer, baseAssetId); const txCost = await sender.getTransactionCost(request); @@ -264,8 +265,8 @@ describe('Funding Transactions', () => { await expectToThrowFuelError( () => sender.fund(request, txCost), new FuelError( - FuelError.CODES.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + FuelError.CODES.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${sender.address.toB256()}'.` ) ); diff --git a/packages/fuel-gauge/src/mapped-error-messages.test.ts b/packages/fuel-gauge/src/mapped-error-messages.test.ts deleted file mode 100644 index 7be7b9673a0..00000000000 --- a/packages/fuel-gauge/src/mapped-error-messages.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Contract, ErrorCode, ScriptTransactionRequest, Wallet } from 'fuels'; -import { expectToThrowFuelError, launchTestNode } from 'fuels/test-utils'; - -import { CallTestContractFactory } from '../test/typegen/contracts'; - -import { launchTestContract } from './utils'; - -/** - * @group node - * @group browser - */ -describe('mapped error messages', () => { - it('should throw not enough coins error', async () => { - using contract = await launchTestContract({ factory: CallTestContractFactory }); - - const emptyWallet = Wallet.generate({ provider: contract.provider }); - - const emptyWalletContract = new Contract(contract.id, contract.interface.jsonAbi, emptyWallet); - - await expectToThrowFuelError(() => emptyWalletContract.functions.return_void().call(), { - code: ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - message: `Insufficient funds or too many small value coins. Consider combining UTXOs.`, - }); - }); - - it('should throw max coins reached error', async () => { - using launched = await launchTestNode({ - walletsConfig: { - amountPerCoin: 1, - coinsPerAsset: 256, - }, - }); - const { - wallets: [wallet], - } = launched; - - const request = new ScriptTransactionRequest(); - request.addCoinOutput(wallet.address, 256, await wallet.provider.getBaseAssetId()); - const txCost = await wallet.getTransactionCost(request); - - request.gasLimit = txCost.gasUsed; - request.maxFee = txCost.maxFee; - - await expectToThrowFuelError(() => wallet.fund(request, txCost), { - code: ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - message: 'Insufficient funds or too many small value coins. Consider combining UTXOs.', - }); - }); -}); diff --git a/packages/fuel-gauge/src/predicate/predicate-invalidations.test.ts b/packages/fuel-gauge/src/predicate/predicate-invalidations.test.ts index 80e92fa8350..56c8f626e36 100644 --- a/packages/fuel-gauge/src/predicate/predicate-invalidations.test.ts +++ b/packages/fuel-gauge/src/predicate/predicate-invalidations.test.ts @@ -34,20 +34,16 @@ describe('Predicate', () => { await fundAccount(wallet, predicate, 1000); const receiver = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); await expectToThrowFuelError( async () => - predicate.transfer( - receiver.address, - await predicate.getBalance(), - await provider.getBaseAssetId(), - { - gasLimit: 100_000_000, - } - ), + predicate.transfer(receiver.address, await predicate.getBalance(), baseAssetId, { + gasLimit: 100_000_000, + }), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${predicate.address.toB256()}'.` ) ); }); diff --git a/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts b/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts index 5ad5bd57958..c5895958add 100644 --- a/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts +++ b/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts @@ -56,6 +56,7 @@ describe('Predicate', () => { const receiver = Wallet.generate({ provider }); const receiverInitialBalance = await receiver.getBalance(); + const baseAssetId = await provider.getBaseAssetId(); // calling the contract with the receiver account (no resources) contract.account = receiver; @@ -63,8 +64,8 @@ describe('Predicate', () => { await expectToThrowFuelError( () => contract.functions.mint_coins(200).call(), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${receiver.address.toB256()}'.` ) ); diff --git a/packages/fuel-gauge/src/predicate/predicate-with-script.test.ts b/packages/fuel-gauge/src/predicate/predicate-with-script.test.ts index 2a78545bd63..5ee9e1b7884 100644 --- a/packages/fuel-gauge/src/predicate/predicate-with-script.test.ts +++ b/packages/fuel-gauge/src/predicate/predicate-with-script.test.ts @@ -21,6 +21,7 @@ describe('Predicate', () => { } = launched; const receiver = Wallet.generate({ provider }); + const baseAssetId = await provider.getBaseAssetId(); const initialReceiverBalance = toNumber(await receiver.getBalance()); const scriptInstance = new Script( @@ -36,8 +37,8 @@ describe('Predicate', () => { await expectToThrowFuelError( () => scriptInstance.functions.main(scriptInput).call(), new FuelError( - ErrorCode.INSUFFICIENT_FUNDS_OR_MAX_COINS, - `Insufficient funds or too many small value coins. Consider combining UTXOs.` + ErrorCode.INSUFFICIENT_FUNDS, + `Insufficient funds.\n\tAsset ID: '${baseAssetId}'.\n\tOwner: '${receiver.address.toB256()}'.` ) ); diff --git a/packages/fuel-gauge/src/transaction-upgrade.test.ts b/packages/fuel-gauge/src/transaction-upgrade.test.ts index a1368bdd497..747220f3d23 100644 --- a/packages/fuel-gauge/src/transaction-upgrade.test.ts +++ b/packages/fuel-gauge/src/transaction-upgrade.test.ts @@ -71,7 +71,7 @@ const upgradeConsensusParameters = async (wallet: WalletUnlocked, bytecode: Byte * @group node * @group browser */ -describe('Transaction upgrade consensus', () => { +describe('Transaction upgrade consensus', { timeout: 60_000 }, () => { it('should correctly update the privileged address in consensus data', async () => { // Consensus parameters with other privileged address const CONSENSUS_BYTECODE = decompressBytecode( diff --git a/packages/fuels/package.json b/packages/fuels/package.json index edf3315764a..fb1f02be1c9 100644 --- a/packages/fuels/package.json +++ b/packages/fuels/package.json @@ -62,7 +62,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@fuels/vm-asm": "0.60.2", + "@fuels/vm-asm": "0.62.0", "@fuel-ts/abi-coder": "workspace:*", "@fuel-ts/abi-typegen": "workspace:*", "@fuel-ts/account": "workspace:*", diff --git a/packages/fuels/test/fixtures/project/.fuels/chainConfig.json b/packages/fuels/test/fixtures/project/.fuels/chainConfig.json index a2e587c3401..beed154d735 100644 --- a/packages/fuels/test/fixtures/project/.fuels/chainConfig.json +++ b/packages/fuels/test/fixtures/project/.fuels/chainConfig.json @@ -90,6 +90,7 @@ "mul": 2, "muli": 2, "mldv": 4, + "niop": 2, "noop": 1, "not": 2, "or": 2, diff --git a/packages/program/package.json b/packages/program/package.json index 8dd7be55d24..5a5d9efa67f 100644 --- a/packages/program/package.json +++ b/packages/program/package.json @@ -33,7 +33,7 @@ "@fuel-ts/math": "workspace:*", "@fuel-ts/transactions": "workspace:*", "@fuel-ts/utils": "workspace:*", - "@fuels/vm-asm": "0.60.2" + "@fuels/vm-asm": "0.62.0" }, "devDependencies": { "@types/ramda": "0.30.2" diff --git a/packages/program/src/functions/base-invocation-scope.ts b/packages/program/src/functions/base-invocation-scope.ts index 731afdbdc72..4d2c6ba379b 100644 --- a/packages/program/src/functions/base-invocation-scope.ts +++ b/packages/program/src/functions/base-invocation-scope.ts @@ -291,11 +291,14 @@ export class BaseInvocationScope { } // eslint-disable-next-line prefer-const - let { assembledRequest, gasPrice } = await provider.assembleTx({ - request, - feePayerAccount, - accountCoinQuantities, - ...restAssembleTxParams, + let { assembledRequest, gasPrice } = await feePayerAccount.autoConsolidateCoin({ + callback: () => + provider.assembleTx({ + request, + feePayerAccount, + accountCoinQuantities, + ...restAssembleTxParams, + }), }); assembledRequest = assembledRequest as ScriptTransactionRequest; diff --git a/packages/utils/src/utils/defaultSnapshots/chainConfig.json b/packages/utils/src/utils/defaultSnapshots/chainConfig.json index 73c2fac49fe..b63e51f4148 100644 --- a/packages/utils/src/utils/defaultSnapshots/chainConfig.json +++ b/packages/utils/src/utils/defaultSnapshots/chainConfig.json @@ -87,6 +87,7 @@ "mul": 2, "muli": 2, "mldv": 3, + "niop": 2, "noop": 1, "not": 2, "or": 1, diff --git a/packages/utils/src/utils/types.ts b/packages/utils/src/utils/types.ts index 662649b8dd8..66341f8d74b 100644 --- a/packages/utils/src/utils/types.ts +++ b/packages/utils/src/utils/types.ts @@ -85,6 +85,7 @@ interface GasCosts { mul: number; muli: number; mldv: number; + niop: number | undefined; noop: number; not: number; or: number; diff --git a/packages/versions/src/lib/getBuiltinVersions.ts b/packages/versions/src/lib/getBuiltinVersions.ts index fd40bb6ab4a..fa07e0cbe2f 100644 --- a/packages/versions/src/lib/getBuiltinVersions.ts +++ b/packages/versions/src/lib/getBuiltinVersions.ts @@ -2,7 +2,7 @@ import type { Versions } from './types'; export function getBuiltinVersions(): Versions { return { - FUEL_CORE: '0.43.1', + FUEL_CORE: '0.44.0', FORC: '0.68.7', FUELS: '0.101.2', }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66bd4eb9e18..569ec3e4ed2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,8 +279,8 @@ importers: apps/demo-nextjs: dependencies: '@fuels/vm-asm': - specifier: 0.60.2 - version: 0.60.2 + specifier: 0.62.0 + version: 0.62.0 '@types/node': specifier: 22.13.5 version: 22.13.5 @@ -315,8 +315,8 @@ importers: apps/demo-react-vite: dependencies: '@fuels/vm-asm': - specifier: 0.60.2 - version: 0.60.2 + specifier: 0.62.0 + version: 0.62.0 fuels: specifier: workspace:* version: link:../../packages/fuels @@ -630,8 +630,8 @@ importers: specifier: workspace:* version: link:../versions '@fuels/vm-asm': - specifier: 0.60.2 - version: 0.60.2 + specifier: 0.62.0 + version: 0.62.0 '@noble/curves': specifier: 1.8.1 version: 1.8.1 @@ -721,8 +721,8 @@ importers: specifier: workspace:* version: link:../utils '@fuels/vm-asm': - specifier: 0.60.2 - version: 0.60.2 + specifier: 0.62.0 + version: 0.62.0 ramda: specifier: 0.30.1 version: 0.30.1 @@ -856,8 +856,8 @@ importers: specifier: workspace:* version: link:../versions '@fuels/vm-asm': - specifier: 0.60.2 - version: 0.60.2 + specifier: 0.62.0 + version: 0.62.0 bundle-require: specifier: 5.1.0 version: 5.1.0(esbuild@0.25.3) @@ -981,8 +981,8 @@ importers: specifier: workspace:* version: link:../utils '@fuels/vm-asm': - specifier: 0.60.2 - version: 0.60.2 + specifier: 0.62.0 + version: 0.62.0 ramda: specifier: 0.30.1 version: 0.30.1 @@ -3075,8 +3075,8 @@ packages: fuels: 0.100.0 react: '>=18.0.0' - '@fuels/vm-asm@0.60.2': - resolution: {integrity: sha512-wkCu63jTGJWpRZQirTaB8S4/gyoebEJLk3AKfnykt/lgWp1U9iHOcCICVHQP547i+y8jEVKwk18+huINFyYVFQ==} + '@fuels/vm-asm@0.62.0': + resolution: {integrity: sha512-W2XoFIzHtHmWaTKzGKhDzkuQHCJO3j/wU3x1ngyleucsjCv3nUUqkRoSPhEo6wTGRqwiq6cmoqOk+FEi4vmxww==} '@graphql-codegen/add@5.0.3': resolution: {integrity: sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==} @@ -15387,7 +15387,7 @@ snapshots: - '@types/react-dom' - react-dom - '@fuels/vm-asm@0.60.2': {} + '@fuels/vm-asm@0.62.0': {} '@graphql-codegen/add@5.0.3(graphql@16.10.0)': dependencies: diff --git a/templates/nextjs/fuel-toolchain.toml b/templates/nextjs/fuel-toolchain.toml index 245b2c1f162..e07f1ec9f6f 100644 --- a/templates/nextjs/fuel-toolchain.toml +++ b/templates/nextjs/fuel-toolchain.toml @@ -3,4 +3,4 @@ channel = "testnet" [components] forc = "0.68.7" -fuel-core = "0.43.1" +fuel-core = "0.44.0" diff --git a/templates/vite/fuel-toolchain.toml b/templates/vite/fuel-toolchain.toml index 245b2c1f162..e07f1ec9f6f 100644 --- a/templates/vite/fuel-toolchain.toml +++ b/templates/vite/fuel-toolchain.toml @@ -3,4 +3,4 @@ channel = "testnet" [components] forc = "0.68.7" -fuel-core = "0.43.1" +fuel-core = "0.44.0"