diff --git a/.changeset/tricky-mangos-appear.md b/.changeset/tricky-mangos-appear.md new file mode 100644 index 0000000000..8982ddf6f2 --- /dev/null +++ b/.changeset/tricky-mangos-appear.md @@ -0,0 +1,5 @@ +--- +"@fuel-ts/account": patch +--- + +chore: cache UTXOs by default upon TX submission diff --git a/apps/docs/src/guide/provider/provider-options.md b/apps/docs/src/guide/provider/provider-options.md index 44832e2237..f16818f9a3 100644 --- a/apps/docs/src/guide/provider/provider-options.md +++ b/apps/docs/src/guide/provider/provider-options.md @@ -54,7 +54,7 @@ Transaction is not inserted. UTXO does not exist: 0xf5... This error indicates that the UTXO(s) used by the second transaction no longer exist, as the first transaction already spent them. -To prevent this issue, you can use the `cacheUtxo` flag. This flag sets a TTL (Time-To-Live) for caching UTXO(s) used in a transaction, preventing them from being reused in subsequent transactions within the specified time. +To prevent this issue, the SDK sets a default cache for UTXO(s) to 20 seconds. This default caching mechanism ensures that UTXO(s) used in a submitted transaction are not reused in subsequent transactions within the specified time. You can control the duration of this cache using the `cacheUtxo` flag. If you would like to disable caching, you can pass a value of `-1` to the `cacheUtxo` parameter. <<< @/../../docs-snippets/src/guide/provider/provider.test.ts#options-cache-utxo{ts:line-numbers} diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index 1969e7b70e..167a58308e 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -7,7 +7,7 @@ import type { BytesLike } from '@fuel-ts/interfaces'; import { BN, bn } from '@fuel-ts/math'; import type { Receipt } from '@fuel-ts/transactions'; import { InputType, ReceiptType, TransactionType } from '@fuel-ts/transactions'; -import { DateTime, arrayify, hexlify, sleep } from '@fuel-ts/utils'; +import { DateTime, arrayify, sleep } from '@fuel-ts/utils'; import { ASSET_A } from '@fuel-ts/utils/test-utils'; import { versions } from '@fuel-ts/versions'; import * as fuelTsVersionsMod from '@fuel-ts/versions'; @@ -25,12 +25,14 @@ import { } from '../test-utils'; import { Wallet } from '../wallet'; +import type { Coin } from './coin'; import type { ChainInfo, CursorPaginationArgs, NodeInfo } from './provider'; -import Provider, { BLOCKS_PAGE_SIZE_LIMIT, RESOURCES_PAGE_SIZE_LIMIT } from './provider'; -import type { - CoinTransactionRequestInput, - MessageTransactionRequestInput, -} from './transaction-request'; +import Provider, { + BLOCKS_PAGE_SIZE_LIMIT, + DEFAULT_UTXOS_CACHE_TTL, + RESOURCES_PAGE_SIZE_LIMIT, +} from './provider'; +import type { CoinTransactionRequestInput } from './transaction-request'; import { ScriptTransactionRequest, CreateTransactionRequest } from './transaction-request'; import { TransactionResponse } from './transaction-response'; import type { SubmittedStatus } from './transaction-summary/types'; @@ -373,349 +375,170 @@ describe('Provider', () => { expect(producedBlocks).toEqual(expectedBlocks); }); - it('can cacheUtxo [undefined]', async () => { - using launched = await setupTestProviderAndWallets(); + it('can cacheUtxo', async () => { + const ttl = 10000; + using launched = await setupTestProviderAndWallets({ + providerOptions: { + cacheUtxo: ttl, + }, + }); const { provider } = launched; - expect(provider.cache).toEqual(undefined); + expect(provider.cache?.ttl).toEqual(ttl); }); - it('can cacheUtxo [numerical]', async () => { - using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 2500 } }); + it('should use utxos cache by default', async () => { + using launched = await setupTestProviderAndWallets(); const { provider } = launched; - expect(provider.cache).toBeTruthy(); - expect(provider.cache?.ttl).toEqual(2_500); + expect(provider.cache?.ttl).toEqual(DEFAULT_UTXOS_CACHE_TTL); }); - it('can cacheUtxo [invalid numerical]', async () => { + it('should validate cacheUtxo value [invalid numerical]', async () => { const { error } = await safeExec(async () => { await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: -500 } }); }); expect(error?.message).toMatch(/Invalid TTL: -500\. Use a value greater than zero/); }); - it('can cacheUtxo [will not cache inputs if no cache]', async () => { - using launched = await setupTestProviderAndWallets(); - const { provider } = launched; - const transactionRequest = new ScriptTransactionRequest(); - - const { error } = await safeExec(() => provider.sendTransaction(transactionRequest)); - - expect(error).toBeTruthy(); - expect(provider.cache).toEqual(undefined); - }); - - it('can cacheUtxo [will not cache inputs cache enabled + no coins]', async () => { + it('should be possible to disable the cache by using -1', async () => { using launched = await setupTestProviderAndWallets({ providerOptions: { - cacheUtxo: 1, + cacheUtxo: -1, }, }); const { provider } = launched; - const baseAssetId = provider.getBaseAssetId(); - const MessageInput: MessageTransactionRequestInput = { - type: InputType.Message, - amount: 100, - sender: baseAssetId, - recipient: baseAssetId, - witnessIndex: 1, - nonce: baseAssetId, - }; - const transactionRequest = new ScriptTransactionRequest({ - inputs: [MessageInput], - }); - - const { error } = await safeExec(() => provider.sendTransaction(transactionRequest)); - - expect(error).toBeTruthy(); - expect(provider.cache).toBeTruthy(); - expect(provider.cache?.getActiveData()).toStrictEqual([]); + expect(provider.cache).toBeUndefined(); }); - it('can cacheUtxo [will cache inputs cache enabled + coins]', async () => { - using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); - const { provider } = launched; + it('should cache UTXOs only when TX is successfully submitted', async () => { + using launched = await setupTestProviderAndWallets({ + nodeOptions: { + args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], + }, + walletsConfig: { + coinsPerAsset: 3, + amountPerCoin: 50_000, + }, + }); + const { + provider, + wallets: [wallet, receiver], + } = launched; const baseAssetId = provider.getBaseAssetId(); - const EXPECTED: BytesLike[] = [ - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c500', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c501', - '0xda5d131c490db3868be9f8e228cf279bd98ef1de97129682777ed93fa088bc3f02', - ]; - const MessageInput: MessageTransactionRequestInput = { - type: InputType.Message, - amount: 100, - sender: baseAssetId, - recipient: baseAssetId, - witnessIndex: 1, - nonce: baseAssetId, - }; - const CoinInputA: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[0], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputB: CoinTransactionRequestInput = { - type: InputType.Coin, - id: arrayify(EXPECTED[1]), - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputC: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[2], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const transactionRequest = new ScriptTransactionRequest({ - inputs: [MessageInput, CoinInputA, CoinInputB, CoinInputC], - }); + const { coins } = await wallet.getCoins(baseAssetId); + const EXPECTED: BytesLike[] = coins.map((coin) => coin.id); - const { error } = await safeExec(() => provider.sendTransaction(transactionRequest)); + await wallet.transfer(receiver.address, 10_000); - expect(error).toBeTruthy(); - const EXCLUDED = provider.cache?.getActiveData() || []; - expect(EXCLUDED.length).toEqual(3); - expect(EXCLUDED.map((value) => hexlify(value))).toStrictEqual(EXPECTED); + const cachedCoins = provider.cache?.getActiveData() || []; + expect(new Set(cachedCoins)).toEqual(new Set(EXPECTED)); // clear cache - EXCLUDED.forEach((value) => provider.cache?.del(value)); + const activeData = provider.cache?.getActiveData() || []; + activeData.forEach((coin) => { + provider.cache?.del(coin); + }); }); - it('can cacheUtxo [will cache inputs and also use in exclude list]', async () => { - using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); - const { provider } = launched; + it('should NOT cache UTXOs when TX submission fails', async () => { + using launched = await setupTestProviderAndWallets({ + nodeOptions: { + args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], + }, + walletsConfig: { + coinsPerAsset: 2, + amountPerCoin: 20_000, + }, + }); + const { + provider, + wallets: [wallet, receiver], + } = launched; const baseAssetId = provider.getBaseAssetId(); - const EXPECTED: BytesLike[] = [ - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c503', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c504', - '0xda5d131c490db3868be9f8e228cf279bd98ef1de97129682777ed93fa088bc3505', - ]; - const MessageInput: MessageTransactionRequestInput = { - type: InputType.Message, - amount: 100, - sender: baseAssetId, - recipient: baseAssetId, - witnessIndex: 1, - nonce: baseAssetId, - }; - const CoinInputA: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[0], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputB: CoinTransactionRequestInput = { - type: InputType.Coin, - id: arrayify(EXPECTED[1]), - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputC: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[2], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const transactionRequest = new ScriptTransactionRequest({ - inputs: [MessageInput, CoinInputA, CoinInputB, CoinInputC], + const maxFee = 100_000; + const transferAmount = 10_000; + + // No enough funds to pay for the TX fee + const resources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); + + const request = new ScriptTransactionRequest({ + maxFee, }); - const { error } = await safeExec(() => provider.sendTransaction(transactionRequest)); + request.addCoinOutput(receiver.address, transferAmount, baseAssetId); + request.addResources(resources); - expect(error).toBeTruthy(); - const EXCLUDED = provider.cache?.getActiveData() || []; - expect(EXCLUDED.length).toEqual(3); - expect(EXCLUDED.map((value) => hexlify(value))).toStrictEqual(EXPECTED); + await expectToThrowFuelError( + () => wallet.sendTransaction(request, { estimateTxDependencies: false }), + { code: ErrorCode.INVALID_REQUEST } + ); - const owner = Address.fromRandom(); - const resourcesToSpendMock = vi.fn(() => - Promise.resolve({ coinsToSpend: [] }) - ) as unknown as typeof provider.operations.getCoinsToSpend; - provider.operations.getCoinsToSpend = resourcesToSpendMock; - await provider.getResourcesToSpend(owner, []); + // No UTXOs were cached since the TX submission failed + const cachedCoins = provider.cache?.getActiveData() || []; + expect(cachedCoins).lengthOf(0); + }); - expect(resourcesToSpendMock).toHaveBeenCalledWith({ - owner: owner.toB256(), - queryPerAsset: [], - excludedIds: { - messages: [], - utxos: EXPECTED, + it('should ensure cached UTXOs are not being queried', async () => { + // Fund the wallet with 2 UTXOs + const totalUtxos = 2; + using launched = await setupTestProviderAndWallets({ + nodeOptions: { + args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], + }, + walletsConfig: { + coinsPerAsset: totalUtxos, + amountPerCoin: 100_000_000_000, }, }); - // clear cache - EXCLUDED.forEach((value) => provider.cache?.del(value)); - }); + const { + provider, + wallets: [wallet, receiver], + } = launched; + const baseAssetId = provider.getBaseAssetId(); + const transferAmount = 10_000; - it('can cacheUtxo [will cache inputs cache enabled + coins]', async () => { - using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); - const { provider } = launched; + const { coins } = await wallet.getCoins(baseAssetId); + expect(coins.length).toBe(totalUtxos); - const baseAssetId = provider.getBaseAssetId(); - const EXPECTED: BytesLike[] = [ - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c500', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c501', - '0xda5d131c490db3868be9f8e228cf279bd98ef1de97129682777ed93fa088bc3f02', - ]; - const MessageInput: MessageTransactionRequestInput = { - type: InputType.Message, - amount: 100, - sender: baseAssetId, - recipient: baseAssetId, - witnessIndex: 1, - nonce: baseAssetId, - }; - const CoinInputA: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[0], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputB: CoinTransactionRequestInput = { - type: InputType.Coin, - id: arrayify(EXPECTED[1]), - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputC: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[2], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const transactionRequest = new ScriptTransactionRequest({ - inputs: [MessageInput, CoinInputA, CoinInputB, CoinInputC], - }); + // One of the UTXOs will be cached as the TX submission was successful + await wallet.transfer(receiver.address, transferAmount); - const { error } = await safeExec(() => provider.sendTransaction(transactionRequest)); + const cachedUtxos = provider.cache?.getActiveData() || []; - expect(error).toBeTruthy(); - const EXCLUDED = provider.cache?.getActiveData() || []; - expect(EXCLUDED.length).toEqual(3); - expect(EXCLUDED.map((value) => hexlify(value))).toStrictEqual(EXPECTED); + // Ensure the cached UTXO is the only one in the cache + expect(cachedUtxos.length).toBe(1); - // clear cache - EXCLUDED.forEach((value) => provider.cache?.del(value)); - }); + // Determine the used UTXO and the unused UTXO + const usedUtxo = cachedUtxos[0]; + const unusedUtxos = coins.filter((coin) => coin.id !== usedUtxo); - it('can cacheUtxo [will cache inputs and also merge/de-dupe in exclude list]', async () => { - using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); - const { provider } = launched; + // Spy on the getCoinsToSpend method to ensure the cached UTXO is not being queried + const resourcesToSpendSpy = vi.spyOn(provider.operations, 'getCoinsToSpend'); + const resources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); - const baseAssetId = provider.getBaseAssetId(); - const EXPECTED: BytesLike[] = [ - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c503', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c504', - '0xda5d131c490db3868be9f8e228cf279bd98ef1de97129682777ed93fa088bc3505', - ]; - const MessageInput: MessageTransactionRequestInput = { - type: InputType.Message, - amount: 100, - sender: baseAssetId, - recipient: baseAssetId, - witnessIndex: 1, - nonce: baseAssetId, - }; - const CoinInputA: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[0], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputB: CoinTransactionRequestInput = { - type: InputType.Coin, - id: arrayify(EXPECTED[1]), - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const CoinInputC: CoinTransactionRequestInput = { - type: InputType.Coin, - id: EXPECTED[2], - owner: baseAssetId, - assetId: baseAssetId, - txPointer: baseAssetId, - witnessIndex: 1, - amount: 100, - }; - const transactionRequest = new ScriptTransactionRequest({ - inputs: [MessageInput, CoinInputA, CoinInputB, CoinInputC], - }); + // Ensure the returned UTXO is the unused UTXO + expect((resources[0]).id).toEqual(unusedUtxos[0].id); - const { error } = await safeExec(() => provider.sendTransaction(transactionRequest)); - - expect(error).toBeTruthy(); - const EXCLUDED = provider.cache?.getActiveData() || []; - expect(EXCLUDED.length).toEqual(3); - expect(EXCLUDED.map((value) => hexlify(value))).toStrictEqual(EXPECTED); - - const owner = Address.fromRandom(); - const resourcesToSpendMock = vi.fn(() => - Promise.resolve({ coinsToSpend: [] }) - ) as unknown as typeof provider.operations.getCoinsToSpend; - provider.operations.getCoinsToSpend = resourcesToSpendMock; - await provider.getResourcesToSpend(owner, [], { - utxos: [ - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c503', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c507', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c508', + // Ensure the getCoinsToSpend query was called excluding the cached UTXO + expect(resourcesToSpendSpy).toHaveBeenCalledWith({ + owner: wallet.address.toB256(), + queryPerAsset: [ + { + assetId: baseAssetId, + amount: String(transferAmount), + max: undefined, + }, ], - }); - - expect(resourcesToSpendMock).toHaveBeenCalledWith({ - owner: owner.toB256(), - queryPerAsset: [], excludedIds: { messages: [], - utxos: [ - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c503', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c507', - '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c508', - EXPECTED[1], - EXPECTED[2], - ], + utxos: [usedUtxo], }, }); - - // clear cache - EXCLUDED.forEach((value) => provider.cache?.del(value)); }); it('can getBlocks', async () => { diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index ba256df800..1d514c4ec6 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -9,7 +9,7 @@ import { InputMessageCoder, TransactionCoder, } from '@fuel-ts/transactions'; -import { arrayify, hexlify, DateTime } from '@fuel-ts/utils'; +import { arrayify, hexlify, DateTime, isDefined } from '@fuel-ts/utils'; import { checkFuelCoreVersionCompatibility } from '@fuel-ts/versions'; import { equalBytes } from '@noble/curves/abstract/utils'; import type { DocumentNode } from 'graphql'; @@ -65,6 +65,7 @@ const MAX_RETRIES = 10; export const RESOURCES_PAGE_SIZE_LIMIT = 512; export const BLOCKS_PAGE_SIZE_LIMIT = 5; +export const DEFAULT_UTXOS_CACHE_TTL = 20_000; // 20 seconds export type DryRunFailureStatusFragment = GqlDryRunFailureStatusFragment; export type DryRunSuccessStatusFragment = GqlDryRunSuccessStatusFragment; @@ -426,9 +427,18 @@ export default class Provider { ) { this.options = { ...this.options, ...options }; this.url = url; - this.operations = this.createOperations(); - this.cache = options.cacheUtxo ? new MemoryCache(options.cacheUtxo) : undefined; + + const { cacheUtxo } = this.options; + if (isDefined(cacheUtxo)) { + if (cacheUtxo !== -1) { + this.cache = new MemoryCache(cacheUtxo); + } else { + this.cache = undefined; + } + } else { + this.cache = new MemoryCache(DEFAULT_UTXOS_CACHE_TTL); + } } /** @@ -701,7 +711,6 @@ Supported fuel-core version: ${supportedVersion}.` { estimateTxDependencies = true }: ProviderSendTxParams = {} ): Promise { const transactionRequest = transactionRequestify(transactionRequestLike); - this.#cacheInputs(transactionRequest.inputs); if (estimateTxDependencies) { await this.estimateTxDependencies(transactionRequest); } @@ -718,6 +727,7 @@ Supported fuel-core version: ${supportedVersion}.` const { submit: { id: transactionId }, } = await this.operations.submit({ encodedTransaction }); + this.#cacheInputs(transactionRequest.inputs); return new TransactionResponse(transactionId, this, abis); } diff --git a/packages/account/src/test-utils/launchNode.ts b/packages/account/src/test-utils/launchNode.ts index c4efcc2c60..7c3f49418d 100644 --- a/packages/account/src/test-utils/launchNode.ts +++ b/packages/account/src/test-utils/launchNode.ts @@ -9,6 +9,7 @@ import os from 'os'; import path from 'path'; import { getPortPromise } from 'portfinder'; +import type { ProviderOptions } from '../providers'; import { Provider } from '../providers'; import { Signer } from '../signer'; import type { WalletUnlocked } from '../wallet'; @@ -327,14 +328,16 @@ export type LaunchNodeAndGetWalletsResult = Promise<{ * */ export const launchNodeAndGetWallets = async ({ launchNodeOptions, + providerOptions, walletCount = 10, }: { launchNodeOptions?: Partial; + providerOptions?: Partial; walletCount?: number; } = {}): LaunchNodeAndGetWalletsResult => { const { cleanup: closeNode, ip, port } = await launchNode(launchNodeOptions || {}); - const provider = await Provider.create(`http://${ip}:${port}/v1/graphql`); + const provider = await Provider.create(`http://${ip}:${port}/v1/graphql`, providerOptions); const wallets = await generateWallets(walletCount, provider); const cleanup = () => { diff --git a/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts b/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts index 39b36c91e8..c53b4ebecc 100644 --- a/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts +++ b/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts @@ -12,6 +12,9 @@ import { launchNodeAndGetWallets } from './launchNode'; describe('launchNode', () => { test('launchNodeAndGetWallets - empty config', async () => { const { stop, provider, wallets } = await launchNodeAndGetWallets({ + providerOptions: { + cacheUtxo: 1, + }, launchNodeOptions: { loggingEnabled: false, }, @@ -28,6 +31,9 @@ describe('launchNode', () => { const snapshotDir = path.join(cwd(), '.fuel-core/configs'); const { stop, provider } = await launchNodeAndGetWallets({ + providerOptions: { + cacheUtxo: 1, + }, launchNodeOptions: { args: ['--snapshot', snapshotDir], loggingEnabled: false, @@ -50,6 +56,9 @@ describe('launchNode', () => { test('launchNodeAndGetWallets - custom walletCount', async () => { const { stop, wallets } = await launchNodeAndGetWallets({ walletCount: 5, + providerOptions: { + cacheUtxo: 1, + }, launchNodeOptions: { loggingEnabled: false, }, @@ -75,6 +84,9 @@ describe('launchNode', () => { test('launchNodeAndGetWallets - empty config', async () => { const { stop, provider, wallets } = await launchNodeAndGetWallets({ + providerOptions: { + cacheUtxo: 1, + }, launchNodeOptions: { loggingEnabled: false, }, diff --git a/packages/fuel-gauge/src/funding-transaction.test.ts b/packages/fuel-gauge/src/funding-transaction.test.ts index f566b9e210..9b9be3bde5 100644 --- a/packages/fuel-gauge/src/funding-transaction.test.ts +++ b/packages/fuel-gauge/src/funding-transaction.test.ts @@ -1,7 +1,7 @@ import { FuelError } from '@fuel-ts/errors'; import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; import type { Account, CoinTransactionRequestInput } from 'fuels'; -import { ScriptTransactionRequest, Wallet, bn } from 'fuels'; +import { DEFAULT_UTXOS_CACHE_TTL, ScriptTransactionRequest, Wallet, bn, sleep } from 'fuels'; import { launchTestNode } from 'fuels/test-utils'; /** @@ -413,4 +413,90 @@ describe('Funding Transactions', () => { expect(isStatusSuccess).toBeTruthy(); }); + + it('should cache UTXOs by default upon TX submission', async () => { + using launched = await launchTestNode({ + nodeOptions: { + // A new block will be generated every 5 seconds + args: ['--poa-instant', 'false', '--poa-interval-period', '5s'], + }, + walletsConfig: { + coinsPerAsset: 2, + }, + }); + + const { + provider, + wallets: [fundedWallet], + } = launched; + + const receiver = Wallet.generate({ provider }); + + const transferAmount = 100_000; + + // Submitting TX 1 + const submission1 = await fundedWallet.transfer( + receiver.address, + transferAmount, + provider.getBaseAssetId() + ); + + // Submitting TX 2 before TX 1 finished to process. + const submission2 = await fundedWallet.transfer( + receiver.address, + transferAmount, + provider.getBaseAssetId() + ); + + const result1 = await submission1.waitForResult(); + const result2 = await submission2.waitForResult(); + + expect(result1.isStatusSuccess).toBeTruthy(); + expect(result2.isStatusSuccess).toBeTruthy(); + + expect(result1.blockId).toBe(result2.blockId); + + expect(provider.cache).toBeTruthy(); + expect(provider.cache?.ttl).toBe(DEFAULT_UTXOS_CACHE_TTL); + }, 15_000); + + it('should fail when trying to use the same UTXO in multiple TXs without cache', async () => { + using launched = await launchTestNode({ + nodeOptions: { + // A new block will be generated every 5 seconds + args: ['--poa-instant', 'false', '--poa-interval-period', '5s'], + }, + providerOptions: { + // Cache will last for 1 millisecond + cacheUtxo: 1, + }, + walletsConfig: { + coinsPerAsset: 1, + }, + }); + + const { + provider, + wallets: [fundedWallet], + } = launched; + + const receiver = Wallet.generate({ provider }); + + const transferAmount = 100_000; + + // Submitting TX 1 + await fundedWallet.transfer(receiver.address, transferAmount, provider.getBaseAssetId()); + + // ensure cache is cleared + await sleep(100); + + // Submitting TX 2 before TX 1 finished to process. + await expectToThrowFuelError( + () => fundedWallet.transfer(receiver.address, transferAmount, provider.getBaseAssetId()), + new FuelError( + FuelError.CODES.INVALID_REQUEST, + 'Transaction is not inserted. Hash is already known' + ) + ); + }, 15_000); });