diff --git a/.changeset/cuddly-lobsters-warn.md b/.changeset/cuddly-lobsters-warn.md new file mode 100644 index 00000000000..55d863e6efa --- /dev/null +++ b/.changeset/cuddly-lobsters-warn.md @@ -0,0 +1,5 @@ +--- +"@fuel-ts/account": minor +--- + +feat!: consider message on resources cache diff --git a/apps/docs-snippets/src/guide/provider/provider.test.ts b/apps/docs-snippets/src/guide/provider/provider.test.ts index 0750663e3ae..df5631a9755 100644 --- a/apps/docs-snippets/src/guide/provider/provider.test.ts +++ b/apps/docs-snippets/src/guide/provider/provider.test.ts @@ -107,10 +107,10 @@ describe('Provider', () => { // #endregion options-fetch }); - it('options: cacheUtxo', async () => { + it('options: resourceCacheTTL', async () => { // #region options-cache-utxo const provider = await Provider.create(FUEL_NETWORK_URL, { - cacheUtxo: 5000, // cache UTXO for 5 seconds + resourceCacheTTL: 5000, // cache resources (Coin's and Message's) for 5 seconds }); // #endregion options-cache-utxo diff --git a/apps/docs/src/guide/provider/provider-options.md b/apps/docs/src/guide/provider/provider-options.md index f16818f9a30..2623095aca2 100644 --- a/apps/docs/src/guide/provider/provider-options.md +++ b/apps/docs/src/guide/provider/provider-options.md @@ -40,21 +40,25 @@ _Note: If defined, `requestMiddleware`, `timeout` and `retryOptions` are applied <<< @/../../docs-snippets/src/guide/provider/provider.test.ts#options-fetch{ts:line-numbers} -### `cacheUtxo` +### `resourceCacheTTL` When using the SDK, it may be necessary to submit multiple transactions from the same account in a short period. In such cases, the SDK creates and funds these transactions, then submits them to the node. -However, if a second transaction is created before the first one is processed, there is a chance of using the same UTXO(s) for both transactions. This happens because the UTXO(s) used in the first transaction are still unspent until the transaction is fully processed. +However, if a second transaction is created before the first one is processed, there is a chance of using the same resources (UTXOs or Messages) for both transactions. This happens because the resources used in the first transaction are still unspent until the transaction is fully processed. -If the second transaction attempts to use the same UTXO(s) that the first transaction has already spent, it will result in the following error: +If the second transaction attempts to use the same resources that the first transaction has already spent, it will result in one of the following error: ```console -Transaction is not inserted. UTXO does not exist: 0xf5... +Transaction is not inserted. Hash is already known + +Transaction is not inserted. UTXO does not exist: {{utxoID}} + +Transaction is not inserted. A higher priced tx {{txID}} is already spending this message: {{messageNonce}} ``` -This error indicates that the UTXO(s) used by the second transaction no longer exist, as the first transaction already spent them. +This error indicates that the resources used by the second transaction no longer exist, as the first transaction already spent them. -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. +To prevent this issue, the SDK sets a default cache for resources to 20 seconds. This default caching mechanism ensures that resources 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 `resourceCacheTTL` flag. If you would like to disable caching, you can pass a value of `-1` to the `resourceCacheTTL` parameter. <<< @/../../docs-snippets/src/guide/provider/provider.test.ts#options-cache-utxo{ts:line-numbers} @@ -62,4 +66,4 @@ To prevent this issue, the SDK sets a default cache for UTXO(s) to 20 seconds. T If you would like to submit multiple transactions without waiting for each transaction to be completed, your account must have multiple UTXOs available. If you only have one UTXO, the first transaction will spend it, and any remaining amount will be converted into a new UTXO with a different ID. -By ensuring your account has multiple UTXOs, you can effectively use the `cacheUtxo` flag to manage transactions without conflicts. For more information on UTXOs, refer to the [UTXOs guide](../the-utxo-model/index.md). +By ensuring your account has multiple UTXOs, you can effectively use the `resourceCacheTTL` flag to manage transactions without conflicts. For more information on UTXOs, refer to the [UTXOs guide](../the-utxo-model/index.md). diff --git a/packages/account/src/providers/memory-cache.test.ts b/packages/account/src/providers/memory-cache.test.ts deleted file mode 100644 index 11c0571d208..00000000000 --- a/packages/account/src/providers/memory-cache.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { randomBytes } from '@fuel-ts/crypto'; -import type { BytesLike } from '@fuel-ts/interfaces'; -import { hexlify } from '@fuel-ts/utils'; - -import { MemoryCache } from './memory-cache'; - -/** - * @group node - * @group browser - */ -describe('Memory Cache', () => { - it('can construct [valid numerical ttl]', () => { - const memCache = new MemoryCache(1000); - - expect(memCache.ttl).toEqual(1000); - }); - - it('can construct [invalid numerical ttl]', () => { - expect(() => new MemoryCache(-1)).toThrow(/Invalid TTL: -1. Use a value greater than zero./); - }); - - it('can construct [invalid mistyped ttl]', () => { - // @ts-expect-error intentional invalid input - expect(() => new MemoryCache('bogus')).toThrow( - /Invalid TTL: bogus. Use a value greater than zero./ - ); - }); - - it('can construct [missing ttl]', () => { - const memCache = new MemoryCache(); - - expect(memCache.ttl).toEqual(30_000); - }); - - it('can get [unknown key]', () => { - const memCache = new MemoryCache(1000); - - expect( - memCache.get('0xda5d131c490db33333333333333333334444444444444444444455555555556666') - ).toEqual(undefined); - }); - - it('can get active [no data]', () => { - const EXPECTED: BytesLike[] = []; - const memCache = new MemoryCache(100); - - expect(memCache.getActiveData()).toStrictEqual(EXPECTED); - }); - - it('can set', () => { - const ttl = 1000; - const expiresAt = Date.now() + ttl; - const memCache = new MemoryCache(ttl); - const value = randomBytes(8); - - expect(memCache.set(value)).toBeGreaterThanOrEqual(expiresAt); - }); - - it('can get [valid key]', () => { - const value = randomBytes(8); - const memCache = new MemoryCache(100); - - memCache.set(value); - - expect(memCache.get(value)).toEqual(value); - }); - - it('can get [valid key bytes like]', () => { - const value = randomBytes(8); - const memCache = new MemoryCache(100); - - memCache.set(value); - - expect(memCache.get(value)).toEqual(value); - }); - - it('can get [valid key, expired content]', async () => { - const value = randomBytes(8); - const memCache = new MemoryCache(1); - - memCache.set(value); - - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - expect(memCache.get(value)).toEqual(undefined); - }); - - it('can get, disabling auto deletion [valid key, expired content]', async () => { - const value = randomBytes(8); - const memCache = new MemoryCache(1); - - memCache.set(value); - - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - expect(memCache.get(value, false)).toEqual(value); - }); - - it('can delete', () => { - const value = randomBytes(8); - const memCache = new MemoryCache(100); - - memCache.set(value); - memCache.del(value); - - expect(memCache.get(value)).toEqual(undefined); - }); - - it('can get active [with data]', () => { - const value1 = randomBytes(8); - const value2 = randomBytes(8); - const value3 = hexlify(randomBytes(8)); - const EXPECTED: BytesLike[] = [value1, value2, value3]; - - const memCache = new MemoryCache(100); - - memCache.set(value1); - memCache.set(value2); - memCache.set(value3); - - expect(memCache.getActiveData()).containSubset(EXPECTED); - }); - - it('can get all [with data + expired data]', async () => { - const oldValue = randomBytes(8); - const value1 = randomBytes(8); - const value2 = randomBytes(8); - const EXPECTED: BytesLike[] = [value1, value2, oldValue]; - - let memCache = new MemoryCache(500); - memCache.set(value1); - memCache.set(value2); - - memCache = new MemoryCache(1); - memCache.set(oldValue); - - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - /* - MemoryCache uses a global cache with values from - several instances, all returned by `getActiveData()`. - However, we only want to check the ones from this - test, so we use `containSubset`. - */ - expect(memCache.getAllData()).containSubset(EXPECTED); - }); - - it('should validate that MemoryCache uses a global cache', async () => { - const oldValue = randomBytes(8); - - const instance1 = new MemoryCache(1000); - instance1.set(oldValue); - - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - const newValue = randomBytes(8); - - const instance2 = new MemoryCache(100); - instance2.set(newValue); - - const activeData = instance2.getActiveData(); - - expect(activeData).toContain(oldValue); - expect(activeData).toContain(newValue); - }); -}); diff --git a/packages/account/src/providers/memory-cache.ts b/packages/account/src/providers/memory-cache.ts deleted file mode 100644 index e0050a2513b..00000000000 --- a/packages/account/src/providers/memory-cache.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ErrorCode, FuelError } from '@fuel-ts/errors'; -import type { BytesLike } from '@fuel-ts/interfaces'; -import { hexlify } from '@fuel-ts/utils'; - -type Cache = { - [key: string]: { - expires: number; - value: BytesLike; - }; -}; -const cache: Cache = {}; // it's a cache hash ~~> cash? - -const DEFAULT_TTL_IN_MS = 30 * 1000; // 30seconds - -export class MemoryCache { - ttl: number; - constructor(ttlInMs: number = DEFAULT_TTL_IN_MS) { - this.ttl = ttlInMs; - - if (typeof ttlInMs !== 'number' || this.ttl <= 0) { - throw new FuelError( - ErrorCode.INVALID_TTL, - `Invalid TTL: ${this.ttl}. Use a value greater than zero.` - ); - } - } - - get(value: BytesLike, isAutoExpiring = true): BytesLike | undefined { - const key = hexlify(value); - if (cache[key]) { - if (!isAutoExpiring || cache[key].expires > Date.now()) { - return cache[key].value; - } - - this.del(value); - } - - return undefined; - } - - set(value: BytesLike): number { - const expiresAt = Date.now() + this.ttl; - const key = hexlify(value); - cache[key] = { - expires: expiresAt, - value, - }; - - return expiresAt; - } - - getAllData(): BytesLike[] { - return Object.keys(cache).reduce((list, key) => { - const data = this.get(key, false); - if (data) { - list.push(data); - } - - return list; - }, [] as BytesLike[]); - } - - getActiveData(): BytesLike[] { - return Object.keys(cache).reduce((list, key) => { - const data = this.get(key); - if (data) { - list.push(data); - } - - return list; - }, [] as BytesLike[]); - } - - del(value: BytesLike) { - const key = hexlify(value); - delete cache[key]; - } -} diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index e857d36ccd0..a87c419ed95 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -3,7 +3,6 @@ import { ZeroBytes32 } from '@fuel-ts/address/configs'; import { randomBytes } from '@fuel-ts/crypto'; import { FuelError, ErrorCode } from '@fuel-ts/errors'; import { expectToThrowFuelError, safeExec } from '@fuel-ts/errors/test-utils'; -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'; @@ -27,12 +26,15 @@ import { Wallet } from '../wallet'; import type { Coin } from './coin'; import { coinQuantityfy } from './coin-quantity'; +import type { Message } from './message'; import type { ChainInfo, CursorPaginationArgs, NodeInfo } from './provider'; import Provider, { BLOCKS_PAGE_SIZE_LIMIT, - DEFAULT_UTXOS_CACHE_TTL, + DEFAULT_RESOURCE_CACHE_TTL, RESOURCES_PAGE_SIZE_LIMIT, } from './provider'; +import type { ExcludeResourcesOption } from './resource'; +import { isCoin } from './resource'; import type { CoinTransactionRequestInput } from './transaction-request'; import { CreateTransactionRequest, ScriptTransactionRequest } from './transaction-request'; import { TransactionResponse } from './transaction-response'; @@ -376,11 +378,11 @@ describe('Provider', () => { expect(producedBlocks).toEqual(expectedBlocks); }); - it('can cacheUtxo', async () => { + it('can set cache ttl', async () => { const ttl = 10000; using launched = await setupTestProviderAndWallets({ providerOptions: { - cacheUtxo: ttl, + resourceCacheTTL: ttl, }, }); const { provider } = launched; @@ -388,16 +390,16 @@ describe('Provider', () => { expect(provider.cache?.ttl).toEqual(ttl); }); - it('should use utxos cache by default', async () => { + it('should use resource cache by default', async () => { using launched = await setupTestProviderAndWallets(); const { provider } = launched; - expect(provider.cache?.ttl).toEqual(DEFAULT_UTXOS_CACHE_TTL); + expect(provider.cache?.ttl).toEqual(DEFAULT_RESOURCE_CACHE_TTL); }); - it('should validate cacheUtxo value [invalid numerical]', async () => { + it('should validate resource cache value [invalid numerical]', async () => { const { error } = await safeExec(async () => { - await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: -500 } }); + await setupTestProviderAndWallets({ providerOptions: { resourceCacheTTL: -500 } }); }); expect(error?.message).toMatch(/Invalid TTL: -500\. Use a value greater than zero/); }); @@ -405,7 +407,7 @@ describe('Provider', () => { it('should be possible to disable the cache by using -1', async () => { using launched = await setupTestProviderAndWallets({ providerOptions: { - cacheUtxo: -1, + resourceCacheTTL: -1, }, }); const { provider } = launched; @@ -413,14 +415,20 @@ describe('Provider', () => { expect(provider.cache).toBeUndefined(); }); - it('should cache UTXOs only when TX is successfully submitted', async () => { + it('should cache resources only when TX is successfully submitted', async () => { + const resourceAmount = 50_000; + const utxosAmount = 2; + + const testMessage = new TestMessage({ amount: resourceAmount }); + using launched = await setupTestProviderAndWallets({ nodeOptions: { args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], }, walletsConfig: { - coinsPerAsset: 3, - amountPerCoin: 50_000, + coinsPerAsset: utxosAmount, + amountPerCoin: resourceAmount, + messages: [testMessage], }, }); const { @@ -430,21 +438,24 @@ describe('Provider', () => { const baseAssetId = provider.getBaseAssetId(); const { coins } = await wallet.getCoins(baseAssetId); - const EXPECTED: BytesLike[] = coins.map((coin) => coin.id); - await wallet.transfer(receiver.address, 10_000); + expect(coins.length).toBe(utxosAmount); - const cachedCoins = provider.cache?.getActiveData() || []; - expect(new Set(cachedCoins)).toEqual(new Set(EXPECTED)); + const EXPECTED = { + utxos: coins.map((coin) => coin.id), + messages: [testMessage.nonce], + }; - // clear cache - const activeData = provider.cache?.getActiveData() || []; - activeData.forEach((coin) => { - provider.cache?.del(coin); - }); + await wallet.transfer(receiver.address, 10_000); + + const cachedResources = provider.cache?.getActiveData(); + expect(new Set(cachedResources?.utxos)).toEqual(new Set(EXPECTED.utxos)); + expect(new Set(cachedResources?.messages)).toEqual(new Set(EXPECTED.messages)); }); - it('should NOT cache UTXOs when TX submission fails', async () => { + it('should NOT cache resources when TX submission fails', async () => { + const message = new TestMessage({ amount: 100_000 }); + using launched = await setupTestProviderAndWallets({ nodeOptions: { args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], @@ -452,6 +463,7 @@ describe('Provider', () => { walletsConfig: { coinsPerAsset: 2, amountPerCoin: 20_000, + messages: [message], }, }); const { @@ -463,6 +475,10 @@ describe('Provider', () => { const maxFee = 100_000; const transferAmount = 10_000; + const { coins } = await wallet.getCoins(baseAssetId); + const utxos = coins.map((c) => c.id); + const messages = [message.nonce]; + // No enough funds to pay for the TX fee const resources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); @@ -478,21 +494,80 @@ describe('Provider', () => { { code: ErrorCode.INVALID_REQUEST } ); - // No UTXOs were cached since the TX submission failed - const cachedCoins = provider.cache?.getActiveData() || []; - expect(cachedCoins).lengthOf(0); + // No resources were cached since the TX submission failed + [...utxos, ...messages].forEach((key) => { + expect(provider.cache?.isCached(key)).toBeFalsy(); + }); }); - it('should ensure cached UTXOs are not being queried', async () => { - // Fund the wallet with 2 UTXOs - const totalUtxos = 2; + it('should unset cached resources when TX execution fails', async () => { + const message = new TestMessage({ amount: 100_000 }); + using launched = await setupTestProviderAndWallets({ nodeOptions: { args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], }, walletsConfig: { - coinsPerAsset: totalUtxos, + coinsPerAsset: 1, + amountPerCoin: 100_000, + messages: [message], + }, + }); + const { + provider, + wallets: [wallet, receiver], + } = launched; + + const baseAssetId = provider.getBaseAssetId(); + const maxFee = 100_000; + const transferAmount = 10_000; + + const { coins } = await wallet.getCoins(baseAssetId); + const utxos = coins.map((c) => c.id); + const messages = [message.nonce]; + + // Should fetch resources enough to pay for the TX fee and transfer amount + const resources = await wallet.getResourcesToSpend([[maxFee + transferAmount, baseAssetId]]); + + const request = new ScriptTransactionRequest({ + maxFee, + // No enough gas to execute the TX + gasLimit: 0, + }); + + request.addCoinOutput(receiver.address, transferAmount, baseAssetId); + request.addResources(resources); + + // TX submission will succeed + const submitted = await wallet.sendTransaction(request, { estimateTxDependencies: false }); + + // Resources were cached since the TX submission succeeded + [...utxos, ...messages].forEach((key) => { + expect(provider.cache?.isCached(key)).toBeTruthy(); + }); + + // TX execution will fail + await expectToThrowFuelError(() => submitted.waitForResult(), { + code: ErrorCode.SCRIPT_REVERTED, + }); + + // Ensure user's resouces were unset from the cache + [...utxos, ...messages].forEach((key) => { + expect(provider.cache?.isCached(key)).toBeFalsy(); + }); + }); + + it('should ensure cached resources are not being queried', async () => { + // Fund the wallet with 2 resources + const testMessage = new TestMessage({ amount: 100_000_000_000 }); + using launched = await setupTestProviderAndWallets({ + nodeOptions: { + args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], + }, + walletsConfig: { + coinsPerAsset: 1, amountPerCoin: 100_000_000_000, + messages: [testMessage], }, }); @@ -503,29 +578,44 @@ describe('Provider', () => { const baseAssetId = provider.getBaseAssetId(); const transferAmount = 10_000; - const { coins } = await wallet.getCoins(baseAssetId); - expect(coins.length).toBe(totalUtxos); + const { + coins: [coin], + } = await wallet.getCoins(baseAssetId); - // One of the UTXOs will be cached as the TX submission was successful - await wallet.transfer(receiver.address, transferAmount); + const { + messages: [message], + } = await wallet.getMessages(); - const cachedUtxos = provider.cache?.getActiveData() || []; + // One of the resources will be cached as the TX submission was successful + await wallet.transfer(receiver.address, transferAmount); - // Ensure the cached UTXO is the only one in the cache - expect(cachedUtxos.length).toBe(1); + // Determine the used and unused resource + const cachedResource = provider.cache?.isCached(coin.id) ? coin : message; + const uncachedResource = provider.cache?.isCached(coin.id) ? message : coin; - // Determine the used UTXO and the unused UTXO - const usedUtxo = cachedUtxos[0]; - const unusedUtxos = coins.filter((coin) => coin.id !== usedUtxo); + expect(cachedResource).toBeDefined(); + expect(uncachedResource).toBeDefined(); - // Spy on the getCoinsToSpend method to ensure the cached UTXO is not being queried + // Spy on the getCoinsToSpend method to ensure the cached resource is not being queried const resourcesToSpendSpy = vi.spyOn(provider.operations, 'getCoinsToSpend'); - const resources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); - - // Ensure the returned UTXO is the unused UTXO - expect((resources[0]).id).toEqual(unusedUtxos[0].id); + const fetchedResources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); + + // Only one resource is available as the other one was cached + expect(fetchedResources.length).toBe(1); + + // Ensure the returned resource is the non-cached one + const excludedIds: Required = { messages: [], utxos: [] }; + if (isCoin(fetchedResources[0])) { + excludedIds.messages = expect.arrayContaining([(cachedResource).nonce]); + excludedIds.utxos = expect.arrayContaining([]); + expect(fetchedResources[0].id).toEqual((uncachedResource).id); + } else { + excludedIds.utxos = expect.arrayContaining([(cachedResource).id]); + excludedIds.messages = expect.arrayContaining([]); + expect(fetchedResources[0].nonce).toEqual((uncachedResource).nonce); + } - // Ensure the getCoinsToSpend query was called excluding the cached UTXO + // Ensure the getCoinsToSpend query was called excluding the cached resource expect(resourcesToSpendSpy).toHaveBeenCalledWith({ owner: wallet.address.toB256(), queryPerAsset: [ @@ -535,10 +625,7 @@ describe('Provider', () => { max: undefined, }, ], - excludedIds: { - messages: [], - utxos: [usedUtxo], - }, + excludedIds, }); }); diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 0d176b4518b..b699cbfae38 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -37,9 +37,9 @@ import type { Coin } from './coin'; import type { CoinQuantity, CoinQuantityLike } from './coin-quantity'; import { coinQuantityfy } from './coin-quantity'; import { FuelGraphqlSubscriber } from './fuel-graphql-subscriber'; -import { MemoryCache } from './memory-cache'; import type { Message, MessageCoin, MessageProof, MessageStatus } from './message'; import type { ExcludeResourcesOption, Resource } from './resource'; +import { ResourceCache } from './resource-cache'; import type { TransactionRequestLike, TransactionRequest, @@ -65,7 +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 const DEFAULT_RESOURCE_CACHE_TTL = 20_000; // 20 seconds export type DryRunFailureStatusFragment = GqlDryRunFailureStatusFragment; export type DryRunSuccessStatusFragment = GqlDryRunSuccessStatusFragment; @@ -302,9 +302,9 @@ export type ProviderOptions = { */ timeout?: number; /** - * Cache UTXOs for the given time [ms]. + * Resources cache for the given time [ms]. If set to -1, the cache will be disabled. */ - cacheUtxo?: number; + resourceCacheTTL?: number; /** * Retry options to use when fetching data from the node. */ @@ -373,7 +373,7 @@ type NodeInfoCache = Record; */ export default class Provider { operations: ReturnType; - cache?: MemoryCache; + cache?: ResourceCache; /** @hidden */ static clearChainAndNodeCaches() { @@ -388,7 +388,7 @@ export default class Provider { options: ProviderOptions = { timeout: undefined, - cacheUtxo: undefined, + resourceCacheTTL: undefined, fetch: undefined, retryOptions: undefined, }; @@ -429,15 +429,15 @@ export default class Provider { this.url = url; this.operations = this.createOperations(); - const { cacheUtxo } = this.options; - if (isDefined(cacheUtxo)) { - if (cacheUtxo !== -1) { - this.cache = new MemoryCache(cacheUtxo); + const { resourceCacheTTL } = this.options; + if (isDefined(resourceCacheTTL)) { + if (resourceCacheTTL !== -1) { + this.cache = new ResourceCache(resourceCacheTTL); } else { this.cache = undefined; } } else { - this.cache = new MemoryCache(DEFAULT_UTXOS_CACHE_TTL); + this.cache = new ResourceCache(DEFAULT_RESOURCE_CACHE_TTL); } } @@ -683,16 +683,24 @@ Supported fuel-core version: ${supportedVersion}.` /** * @hidden */ - #cacheInputs(inputs: TransactionRequestInput[]): void { + #cacheInputs(inputs: TransactionRequestInput[], transactionId: string): void { if (!this.cache) { return; } - inputs.forEach((input) => { - if (input.type === InputType.Coin) { - this.cache?.set(input.id); - } - }); + const inputsToCache = inputs.reduce( + (acc, input) => { + if (input.type === InputType.Coin) { + acc.utxos.push(input.id); + } else if (input.type === InputType.Message) { + acc.messages.push(input.nonce); + } + return acc; + }, + { utxos: [], messages: [] } as Required + ); + + this.cache.set(transactionId, inputsToCache); } private validateTransaction(tx: TransactionRequest, consensusParameters: ConsensusParameters) { @@ -748,7 +756,7 @@ Supported fuel-core version: ${supportedVersion}.` const { submit: { id: transactionId }, } = await this.operations.submit({ encodedTransaction }); - this.#cacheInputs(transactionRequest.inputs); + this.#cacheInputs(transactionRequest.inputs, transactionId); return new TransactionResponse(transactionId, this, abis); } @@ -1263,11 +1271,11 @@ Supported fuel-core version: ${supportedVersion}.` }; if (this.cache) { - const uniqueUtxos = new Set( - excludeInput.utxos.concat(this.cache?.getActiveData().map((id) => hexlify(id))) - ); - excludeInput.utxos = Array.from(uniqueUtxos); + const cached = this.cache.getActiveData(); + excludeInput.messages.push(...cached.messages); + excludeInput.utxos.push(...cached.utxos); } + const coinsQuery = { owner: ownerAddress.toB256(), queryPerAsset: quantities diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts new file mode 100644 index 00000000000..5ee63be5054 --- /dev/null +++ b/packages/account/src/providers/resource-cache.test.ts @@ -0,0 +1,198 @@ +import { randomBytes } from '@fuel-ts/crypto'; +import { hexlify, sleep } from '@fuel-ts/utils'; + +import { ResourceCache } from './resource-cache'; + +/** + * @group node + * @group browser + */ +describe('Resource Cache', () => { + const randomValue = () => hexlify(randomBytes(32)); + + it('can instantiate [valid numerical ttl]', () => { + const memCache = new ResourceCache(1000); + + expect(memCache.ttl).toEqual(1000); + }); + + it('can memCache [invalid numerical ttl]', () => { + expect(() => new ResourceCache(-1)).toThrow(/Invalid TTL: -1. Use a value greater than zero./); + }); + + it('can memCache [invalid mistyped ttl]', () => { + // @ts-expect-error intentional invalid input + expect(() => new ResourceCache('bogus')).toThrow( + /Invalid TTL: bogus. Use a value greater than zero./ + ); + }); + + it('can validade if it is cached [UTXO]', () => { + const resourceCache = new ResourceCache(1000); + const utxoId = randomValue(); + + expect(resourceCache.isCached(utxoId)).toBeFalsy(); + + const txID = randomValue(); + resourceCache.set(txID, { utxos: [utxoId], messages: [] }); + + expect(resourceCache.isCached(utxoId)).toBeTruthy(); + }); + + it('can validade if it is cached [Message]', () => { + const resourceCache = new ResourceCache(1000); + const messageNonce = randomValue(); + + expect(resourceCache.isCached(messageNonce)).toBeFalsy(); + + const txID = randomValue(); + resourceCache.set(txID, { utxos: [], messages: [messageNonce] }); + + expect(resourceCache.isCached(messageNonce)).toBeTruthy(); + }); + + it('can get active [no data]', async () => { + const EXPECTED = { utxos: [], messages: [] }; + const resourceCache = new ResourceCache(1); + + await sleep(1); + + expect(resourceCache.getActiveData()).toStrictEqual(EXPECTED); + }); + + it('can get active', () => { + const EXPECTED = { + utxos: [randomValue(), randomValue()], + messages: [randomValue(), randomValue(), randomValue()], + }; + const resourceCache = new ResourceCache(1000); + + const txId = randomValue(); + resourceCache.set(txId, EXPECTED); + + const activeData = resourceCache.getActiveData(); + + expect(activeData.messages).containSubset(EXPECTED.messages); + expect(activeData.utxos).containSubset(EXPECTED.utxos); + }); + + it('should remove expired when getting active data', async () => { + const ttl = 1000; + const resourceCache = new ResourceCache(ttl); + + const txId1 = randomValue(); + const txId1Resources = { + utxos: [randomValue()], + messages: [randomValue()], + }; + + resourceCache.set(txId1, txId1Resources); + let activeData = resourceCache.getActiveData(); + + expect(activeData.utxos).containSubset(txId1Resources.utxos); + expect(activeData.messages).containSubset(txId1Resources.messages); + + await sleep(ttl); + + activeData = resourceCache.getActiveData(); + + expect(activeData.utxos.length).toEqual(0); + expect(activeData.messages.length).toEqual(0); + }); + + it('should remove cached data based on transaction ID', () => { + const ttl = 1000; + const resourceCache = new ResourceCache(ttl); + + const txId1 = randomValue(); + const txId2 = randomValue(); + + const txId1Resources = { + utxos: [randomValue()], + messages: [randomValue(), randomValue()], + }; + + const txId2Resources = { + utxos: [randomValue(), randomValue()], + messages: [randomValue()], + }; + + resourceCache.set(txId1, txId1Resources); + resourceCache.set(txId2, txId2Resources); + + let activeData = resourceCache.getActiveData(); + + expect(activeData.utxos).containSubset([...txId1Resources.utxos, ...txId2Resources.utxos]); + expect(activeData.messages).containSubset([ + ...txId1Resources.messages, + ...txId2Resources.messages, + ]); + + resourceCache.unset(txId1); + + activeData = resourceCache.getActiveData(); + + expect(activeData.utxos).not.containSubset(txId1Resources.utxos); + expect(activeData.messages).not.containSubset(txId1Resources.messages); + + expect(activeData.utxos).containSubset(txId2Resources.utxos); + expect(activeData.messages).containSubset(txId2Resources.messages); + }); + + it('can clear cache', () => { + const resourceCache = new ResourceCache(1000); + + const txId1 = randomValue(); + const txId2 = randomValue(); + + const txId1Resources = { + utxos: [randomValue()], + messages: [randomValue(), randomValue()], + }; + + const txId2Resources = { + utxos: [randomValue(), randomValue()], + messages: [randomValue()], + }; + + resourceCache.set(txId1, txId1Resources); + resourceCache.set(txId2, txId2Resources); + + const activeData = resourceCache.getActiveData(); + + expect(activeData.utxos).containSubset([...txId1Resources.utxos, ...txId2Resources.utxos]); + expect(activeData.messages).containSubset([ + ...txId1Resources.messages, + ...txId2Resources.messages, + ]); + + resourceCache.clear(); + + expect(resourceCache.getActiveData()).toStrictEqual({ utxos: [], messages: [] }); + }); + + it('should validate that ResourceCache uses a global cache', () => { + const oldTxId = randomValue(); + const oldCache = { + utxos: [randomValue(), randomValue()], + messages: [randomValue()], + }; + + const oldInstance = new ResourceCache(800); + oldInstance.set(oldTxId, oldCache); + + const newTxId = randomValue(); + const newCache = { + utxos: [randomValue()], + messages: [randomValue(), randomValue()], + }; + + const newInstance = new ResourceCache(300); + newInstance.set(newTxId, newCache); + + const activeData = newInstance.getActiveData(); + + expect(activeData.utxos).containSubset([...oldCache.utxos, ...newCache.utxos]); + expect(activeData.messages).containSubset([...oldCache.messages, ...newCache.messages]); + }); +}); diff --git a/packages/account/src/providers/resource-cache.ts b/packages/account/src/providers/resource-cache.ts new file mode 100644 index 00000000000..b4fd5f95add --- /dev/null +++ b/packages/account/src/providers/resource-cache.ts @@ -0,0 +1,79 @@ +import { ErrorCode, FuelError } from '@fuel-ts/errors'; +import { hexlify } from '@fuel-ts/utils'; + +import type { ExcludeResourcesOption } from './resource'; + +interface CachedResource { + utxos: Set; + messages: Set; + timestamp: number; +} + +const cache = new Map(); + +export class ResourceCache { + readonly ttl: number; + + constructor(ttl: number) { + this.ttl = ttl; // TTL in milliseconds + + if (typeof ttl !== 'number' || this.ttl <= 0) { + throw new FuelError( + ErrorCode.INVALID_TTL, + `Invalid TTL: ${this.ttl}. Use a value greater than zero.` + ); + } + } + + // Add resources to the cache + set(transactionId: string, resources: Required): void { + const currentTime = Date.now(); + const existingResources = cache.get(transactionId) || { + utxos: new Set(), + messages: new Set(), + timestamp: currentTime, + }; + + resources.utxos.forEach((utxo) => existingResources.utxos.add(hexlify(utxo))); + resources.messages.forEach((message) => existingResources.messages.add(hexlify(message))); + + cache.set(transactionId, existingResources); + } + + // Remove resources from the cache for a given transaction ID + unset(transactionId: string): void { + cache.delete(transactionId); + } + + // Get all cached resources and remove expired ones + getActiveData() { + const allResources: { utxos: string[]; messages: string[] } = { utxos: [], messages: [] }; + const currentTime = Date.now(); + cache.forEach((resource, transactionId) => { + if (currentTime - resource.timestamp < this.ttl) { + allResources.utxos.push(...resource.utxos); + allResources.messages.push(...resource.messages); + } else { + cache.delete(transactionId); + } + }); + return allResources; + } + + // Check if a UTXO ID or message nonce is already cached and not expired + isCached(key: string): boolean { + const currentTime = Date.now(); + for (const [transactionId, resourceData] of cache.entries()) { + if (currentTime - resourceData.timestamp > this.ttl) { + cache.delete(transactionId); + } else if (resourceData.utxos.has(key) || resourceData.messages.has(key)) { + return true; + } + } + return false; + } + + clear() { + cache.clear(); + } +} diff --git a/packages/account/src/providers/transaction-response/transaction-response.ts b/packages/account/src/providers/transaction-response/transaction-response.ts index 201e5b53dec..11840da632d 100644 --- a/packages/account/src/providers/transaction-response/transaction-response.ts +++ b/packages/account/src/providers/transaction-response/transaction-response.ts @@ -228,6 +228,7 @@ export class TransactionResponse { for await (const { statusChange } of subscription) { if (statusChange.type === 'SqueezedOutStatus') { + this.unsetResourceCache(); throw new FuelError( ErrorCode.TRANSACTION_SQUEEZED_OUT, `Transaction Squeezed Out with reason: ${statusChange.reason}` @@ -278,6 +279,7 @@ export class TransactionResponse { const { gqlTransaction, receipts } = transactionResult; if (gqlTransaction.status?.type === 'FailureStatus') { + this.unsetResourceCache(); const { reason } = gqlTransaction.status; throw extractTxError({ receipts, @@ -311,4 +313,8 @@ export class TransactionResponse { ): Promise> { return this.waitForResult(contractsAbiMap); } + + private unsetResourceCache() { + this.provider.cache?.unset(this.id); + } } diff --git a/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts b/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts index c53b4ebeccc..6bf20942b8f 100644 --- a/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts +++ b/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts @@ -13,7 +13,7 @@ describe('launchNode', () => { test('launchNodeAndGetWallets - empty config', async () => { const { stop, provider, wallets } = await launchNodeAndGetWallets({ providerOptions: { - cacheUtxo: 1, + resourceCacheTTL: 1, }, launchNodeOptions: { loggingEnabled: false, @@ -32,7 +32,7 @@ describe('launchNode', () => { const { stop, provider } = await launchNodeAndGetWallets({ providerOptions: { - cacheUtxo: 1, + resourceCacheTTL: 1, }, launchNodeOptions: { args: ['--snapshot', snapshotDir], @@ -57,7 +57,7 @@ describe('launchNode', () => { const { stop, wallets } = await launchNodeAndGetWallets({ walletCount: 5, providerOptions: { - cacheUtxo: 1, + resourceCacheTTL: 1, }, launchNodeOptions: { loggingEnabled: false, @@ -85,7 +85,7 @@ describe('launchNode', () => { test('launchNodeAndGetWallets - empty config', async () => { const { stop, provider, wallets } = await launchNodeAndGetWallets({ providerOptions: { - cacheUtxo: 1, + resourceCacheTTL: 1, }, launchNodeOptions: { loggingEnabled: false, diff --git a/packages/account/src/test-utils/setup-test-provider-and-wallets.test.ts b/packages/account/src/test-utils/setup-test-provider-and-wallets.test.ts index 81d5e3cd297..88899aadd9a 100644 --- a/packages/account/src/test-utils/setup-test-provider-and-wallets.test.ts +++ b/packages/account/src/test-utils/setup-test-provider-and-wallets.test.ts @@ -47,7 +47,7 @@ describe('setupTestProviderAndWallets', () => { await expectToThrowFuelError( async () => { - await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: -500 } }); + await setupTestProviderAndWallets({ providerOptions: { resourceCacheTTL: -500 } }); }, { code: ErrorCode.INVALID_TTL } ); diff --git a/packages/fuel-gauge/src/funding-transaction.test.ts b/packages/fuel-gauge/src/funding-transaction.test.ts index 9b9be3bde58..1b48dce51a4 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 { DEFAULT_UTXOS_CACHE_TTL, ScriptTransactionRequest, Wallet, bn, sleep } from 'fuels'; +import { DEFAULT_RESOURCE_CACHE_TTL, ScriptTransactionRequest, Wallet, bn, sleep } from 'fuels'; import { launchTestNode } from 'fuels/test-utils'; /** @@ -457,7 +457,7 @@ describe('Funding Transactions', () => { expect(result1.blockId).toBe(result2.blockId); expect(provider.cache).toBeTruthy(); - expect(provider.cache?.ttl).toBe(DEFAULT_UTXOS_CACHE_TTL); + expect(provider.cache?.ttl).toBe(DEFAULT_RESOURCE_CACHE_TTL); }, 15_000); it('should fail when trying to use the same UTXO in multiple TXs without cache', async () => { @@ -468,7 +468,7 @@ describe('Funding Transactions', () => { }, providerOptions: { // Cache will last for 1 millisecond - cacheUtxo: 1, + resourceCacheTTL: 1, }, walletsConfig: { coinsPerAsset: 1,