From d5b35b5105ecad8255bd7a03c205094bab0f8753 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Tue, 11 Feb 2025 12:31:49 -0300 Subject: [PATCH 01/13] made ResourceCache consider resource owner --- .../account/src/providers/resource-cache.ts | 112 +++++++++++++----- 1 file changed, 80 insertions(+), 32 deletions(-) diff --git a/packages/account/src/providers/resource-cache.ts b/packages/account/src/providers/resource-cache.ts index b4fd5f95add..77dd99a3f2d 100644 --- a/packages/account/src/providers/resource-cache.ts +++ b/packages/account/src/providers/resource-cache.ts @@ -1,15 +1,21 @@ -import { ErrorCode, FuelError } from '@fuel-ts/errors'; +import { FuelError, ErrorCode } from '@fuel-ts/errors'; import { hexlify } from '@fuel-ts/utils'; -import type { ExcludeResourcesOption } from './resource'; +import { isRequestInputCoin, isRequestInputCoinOrMessage } from './transaction-request'; +import type { + CoinTransactionRequestInput, + MessageTransactionRequestInput, + TransactionRequestInput, +} from './transaction-request'; -interface CachedResource { - utxos: Set; - messages: Set; +type ResourcesOwnersMap = Map; messages: Set }>; + +interface TransactionResourcesCache { + owners: ResourcesOwnersMap; timestamp: number; } -const cache = new Map(); +const cache = new Map(); export class ResourceCache { readonly ttl: number; @@ -26,54 +32,96 @@ export class ResourceCache { } // 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); + set(transactionId: string, inputs: TransactionRequestInput[]): void { + const transactionResourceCache = this.setupResourcesCache(inputs); + cache.set(transactionId, transactionResourceCache); } - // 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() { + getActiveData(owner: string) { const allResources: { utxos: string[]; messages: string[] } = { utxos: [], messages: [] }; const currentTime = Date.now(); + const expired: string[] = []; + cache.forEach((resource, transactionId) => { - if (currentTime - resource.timestamp < this.ttl) { - allResources.utxos.push(...resource.utxos); - allResources.messages.push(...resource.messages); + const isActive = currentTime - resource.timestamp < this.ttl; + + if (isActive) { + const resourcesFromOwner = resource.owners.get(owner); + if (resourcesFromOwner) { + allResources.utxos.push(...resourcesFromOwner.utxos); + allResources.messages.push(...resourcesFromOwner.messages); + } } else { - cache.delete(transactionId); + expired.push(transactionId); } }); + + expired.forEach(this.unset); + return allResources; } - // Check if a UTXO ID or message nonce is already cached and not expired - isCached(key: string): boolean { + isCached(owner: string, key: string): boolean { const currentTime = Date.now(); + let cached = false; + const expired: string[] = []; + 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; + const isActive = currentTime - resourceData.timestamp < this.ttl; + if (isActive) { + const resourcesFromOwner = resourceData.owners.get(owner); + + if (resourcesFromOwner?.utxos.has(key) || resourcesFromOwner?.messages.has(key)) { + cached = true; + break; + } + } else { + expired.push(transactionId); } } - return false; + + expired.forEach(this.unset); + + return cached; } clear() { cache.clear(); } + + private setupResourcesCache(inputs: TransactionRequestInput[]) { + const currentTime = Date.now(); + + const transactionResourcesCache: TransactionResourcesCache = { + owners: new Map() as ResourcesOwnersMap, + timestamp: currentTime, + }; + + inputs.filter(isRequestInputCoinOrMessage).forEach((input) => { + const { owner, key, type } = this.extractResourceData(input); + + if (!transactionResourcesCache.owners.has(owner)) { + transactionResourcesCache.owners.set(owner, { utxos: new Set(), messages: new Set() }); + } + + if (type === 'utxo') { + transactionResourcesCache.owners.get(owner)?.utxos.add(key); + } else { + transactionResourcesCache.owners.get(owner)?.messages.add(key); + } + }); + + return transactionResourcesCache; + } + + private extractResourceData(input: CoinTransactionRequestInput | MessageTransactionRequestInput) { + if (isRequestInputCoin(input)) { + return { owner: hexlify(input.owner), key: hexlify(input.id), type: 'utxo' as const }; + } + return { owner: hexlify(input.recipient), key: hexlify(input.nonce), type: 'message' as const }; + } } From b970b6c1fa8c7d66bd04ebd897dc954827d06cd3 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Tue, 11 Feb 2025 12:32:01 -0300 Subject: [PATCH 02/13] adjust resource cache unit tests --- .../src/providers/resource-cache.test.ts | 305 ++++++++++++------ 1 file changed, 202 insertions(+), 103 deletions(-) diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts index f4f42839162..f7edc4f157c 100644 --- a/packages/account/src/providers/resource-cache.test.ts +++ b/packages/account/src/providers/resource-cache.test.ts @@ -1,14 +1,37 @@ +import { Address } from '@fuel-ts/address'; import { randomBytes } from '@fuel-ts/crypto'; -import { hexlify, sleep } from '@fuel-ts/utils'; +import { hexlify } from '@fuel-ts/utils'; +import { + generateFakeRequestInputCoin, + generateFakeRequestInputMessage, +} from '../test-utils/transactionRequest'; + +import type { ExcludeResourcesOption } from './resource'; import { ResourceCache } from './resource-cache'; +import type { + CoinTransactionRequestInput, + MessageTransactionRequestInput, +} from './transaction-request'; +import { isRequestInputCoin, isRequestInputMessage } from './transaction-request'; /** * @group node * @group browser */ describe('Resource Cache', () => { - const randomValue = () => hexlify(randomBytes(32)); + const randomTxId = () => hexlify(randomBytes(32)); + + const inputsToExcludeResourcesOption = ( + inputs: Array + ): ExcludeResourcesOption => + inputs.reduce( + (acc, input) => { + isRequestInputCoin(input) ? acc.utxos.push(input.id) : acc.messages.push(input.nonce); + return acc; + }, + { utxos: [], messages: [] } as Required + ); afterEach(() => { // Reset the cache after each test @@ -33,91 +56,148 @@ describe('Resource Cache', () => { ); }); - it('can validade if it is cached [UTXO]', () => { + it('can validate if it is cached [UTXO]', () => { const resourceCache = new ResourceCache(1000); - const utxoId = randomValue(); + const owner = Address.fromRandom().b256Address; + const utxo = generateFakeRequestInputCoin({ owner }); + const utxoId = String(utxo.id); - expect(resourceCache.isCached(utxoId)).toBeFalsy(); + expect(resourceCache.isCached(owner, utxoId)).toBeFalsy(); - const txID = randomValue(); - resourceCache.set(txID, { utxos: [utxoId], messages: [] }); + const txID = randomTxId(); + resourceCache.set(txID, [utxo]); - expect(resourceCache.isCached(utxoId)).toBeTruthy(); + expect(resourceCache.isCached(owner, utxoId)).toBeTruthy(); }); - it('can validade if it is cached [Message]', () => { + it('can validate if it is cached [Message]', () => { const resourceCache = new ResourceCache(1000); - const messageNonce = randomValue(); + const owner = Address.fromRandom().b256Address; + const message = generateFakeRequestInputMessage({ recipient: owner }); + const messageNonce = String(message.nonce); - expect(resourceCache.isCached(messageNonce)).toBeFalsy(); + expect(resourceCache.isCached(owner, messageNonce)).toBeFalsy(); - const txID = randomValue(); - resourceCache.set(txID, { utxos: [], messages: [messageNonce] }); + const txID = randomTxId(); + resourceCache.set(txID, [message]); - expect(resourceCache.isCached(messageNonce)).toBeTruthy(); + expect(resourceCache.isCached(owner, messageNonce)).toBeTruthy(); }); - it('can get active [no data]', async () => { - const EXPECTED = { utxos: [], messages: [] }; - const resourceCache = new ResourceCache(1); - - await sleep(1); + it('can get active [no data]', () => { + const expected = { utxos: [], messages: [] }; + const resourceCache = new ResourceCache(1000); + const owner = Address.fromRandom().b256Address; - expect(resourceCache.getActiveData()).toStrictEqual(EXPECTED); + expect(resourceCache.getActiveData(owner)).toStrictEqual(expected); }); it('can get active', () => { - const EXPECTED = { - utxos: [randomValue(), randomValue()], - messages: [randomValue(), randomValue(), randomValue()], - }; + const owner = Address.fromRandom().b256Address; + + const inputs = [ + generateFakeRequestInputCoin({ owner }), + generateFakeRequestInputCoin({ owner }), + generateFakeRequestInputMessage({ recipient: owner }), + generateFakeRequestInputMessage({ recipient: owner }), + generateFakeRequestInputMessage({ recipient: owner }), + ]; + const resourceCache = new ResourceCache(1000); - const txId = randomValue(); - resourceCache.set(txId, EXPECTED); + const txId = randomTxId(); + resourceCache.set(txId, inputs); - const activeData = resourceCache.getActiveData(); + const activeData = resourceCache.getActiveData(owner); - expect(activeData.messages).containSubset(EXPECTED.messages); - expect(activeData.utxos).containSubset(EXPECTED.utxos); + const expected = inputsToExcludeResourcesOption(inputs); + + expect(activeData.messages).containSubset(expected.messages); + expect(activeData.utxos).containSubset(expected.utxos); + }); + + it('should ensure active data is owner specific', () => { + const owner1 = Address.fromRandom().b256Address; + const owner2 = Address.fromRandom().b256Address; + + // Owner 1 inputs + const owner1Inputs = [ + generateFakeRequestInputCoin({ owner: owner1 }), + generateFakeRequestInputCoin({ owner: owner1 }), + generateFakeRequestInputMessage({ recipient: owner1 }), + ]; + + // Owner 2 inputs + const owner2Inputs = [ + generateFakeRequestInputCoin({ owner: owner2 }), + generateFakeRequestInputCoin({ owner: owner2 }), + generateFakeRequestInputMessage({ recipient: owner2 }), + ]; + + const resourceCache = new ResourceCache(1000); + + const txId1 = randomTxId(); + const txId2 = randomTxId(); + + resourceCache.set(txId1, owner1Inputs); + resourceCache.set(txId2, owner2Inputs); + + const activeData = resourceCache.getActiveData(owner1); + + const owner1Expected = inputsToExcludeResourcesOption(owner1Inputs); + const owner2Expected = inputsToExcludeResourcesOption(owner2Inputs); + + expect(activeData.messages).containSubset(owner1Expected.messages); + expect(activeData.utxos).containSubset(owner1Expected.utxos); + + expect(activeData.messages).not.containSubset(owner2Expected.messages); + expect(activeData.utxos).not.containSubset(owner2Expected.utxos); }); it('should remove expired when getting active data', () => { const ttl = 500; const resourceCache = new ResourceCache(ttl); - const utxos = [randomValue(), randomValue()]; - const messages = [randomValue()]; + const owner = Address.fromRandom().b256Address; - const txId1 = randomValue(); - const txId1Resources = { - utxos, - messages, - }; + const inputs = [ + generateFakeRequestInputCoin({ owner }), + generateFakeRequestInputCoin({ owner }), + generateFakeRequestInputMessage({ recipient: owner }), + ]; + const txId1 = randomTxId(); const originalTimeStamp = 946684800; let dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => originalTimeStamp); - resourceCache.set(txId1, txId1Resources); - const oldActiveData = resourceCache.getActiveData(); + resourceCache.set(txId1, inputs); + const oldActiveData = resourceCache.getActiveData(owner); expect(dateSpy).toHaveBeenCalled(); - expect(oldActiveData.utxos).containSubset(txId1Resources.utxos); - expect(oldActiveData.messages).containSubset(txId1Resources.messages); - expect(oldActiveData.messages).containSubset(txId1Resources.messages); + oldActiveData.utxos.forEach((utxo) => { + const match = inputs.filter(isRequestInputCoin).find((input) => input.id === utxo); + expect(match).toBeDefined(); + }); + + oldActiveData.messages.forEach((nonce) => { + const match = inputs.filter(isRequestInputMessage).find((input) => input.nonce === nonce); + expect(match).toBeDefined(); + }); const expiredTimeStamp = originalTimeStamp + ttl; dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => expiredTimeStamp); - const newActiveData = resourceCache.getActiveData(); + const newActiveData = resourceCache.getActiveData(owner); - txId1Resources.utxos.forEach((utxo) => { - expect(newActiveData.utxos).not.includes(utxo); + newActiveData.utxos.forEach((utxo) => { + const match = inputs.filter(isRequestInputCoin).find((input) => input.id === utxo); + expect(match).toBeUndefined(); }); - txId1Resources.messages.forEach((message) => { - expect(newActiveData.utxos).not.includes(message); + newActiveData.messages.forEach((nonce) => { + const match = inputs.filter(isRequestInputMessage).find((input) => input.nonce === nonce); + expect(match).toBeUndefined(); }); vi.restoreAllMocks(); @@ -127,97 +207,116 @@ describe('Resource Cache', () => { // use long ttl to avoid cache expiration const ttl = 10_000; const resourceCache = new ResourceCache(ttl); + const owner = Address.fromRandom().b256Address; - const txId1 = randomValue(); - const txId2 = randomValue(); + const txId1 = randomTxId(); + const txId2 = randomTxId(); - const txId1Resources = { - utxos: [randomValue()], - messages: [randomValue(), randomValue()], - }; + const utxo1 = generateFakeRequestInputCoin({ owner }); + const utxo2 = generateFakeRequestInputCoin({ owner }); + const utxo3 = generateFakeRequestInputCoin({ owner }); - const txId2Resources = { - utxos: [randomValue(), randomValue()], - messages: [randomValue()], - }; + const message1 = generateFakeRequestInputMessage({ recipient: owner }); + const message2 = generateFakeRequestInputMessage({ recipient: owner }); + const message3 = generateFakeRequestInputMessage({ recipient: owner }); - resourceCache.set(txId1, txId1Resources); - resourceCache.set(txId2, txId2Resources); + const tx1Inputs = [utxo1, message1, message2]; + const tx2Inputs = [utxo2, utxo3, message3]; - let activeData = resourceCache.getActiveData(); + resourceCache.set(txId1, tx1Inputs); + resourceCache.set(txId2, tx2Inputs); - expect(activeData.utxos).containSubset([...txId1Resources.utxos, ...txId2Resources.utxos]); - expect(activeData.messages).containSubset([ - ...txId1Resources.messages, - ...txId2Resources.messages, - ]); + let activeData = resourceCache.getActiveData(owner); + + expect(activeData.utxos).containSubset([utxo1.id, utxo2.id, utxo3.id]); + expect(activeData.messages).containSubset([message1.nonce, message2.nonce, message3.nonce]); resourceCache.unset(txId1); - activeData = resourceCache.getActiveData(); + activeData = resourceCache.getActiveData(owner); - expect(activeData.utxos).not.containSubset(txId1Resources.utxos); - expect(activeData.messages).not.containSubset(txId1Resources.messages); + expect(activeData.utxos).not.containSubset([utxo1.id]); + expect(activeData.utxos).containSubset([utxo2.id, utxo3.id]); - expect(activeData.utxos).containSubset(txId2Resources.utxos); - expect(activeData.messages).containSubset(txId2Resources.messages); + expect(activeData.messages).not.containSubset([message1.nonce, message2.nonce]); + expect(activeData.messages).containSubset([message3.nonce]); }); it('can clear cache', () => { // use long ttl to avoid cache expiration const resourceCache = new ResourceCache(10_000); + const owner1 = Address.fromRandom().b256Address; + const owner2 = Address.fromRandom().b256Address; + + const txId1 = randomTxId(); + const txId2 = randomTxId(); + + const tx1Inputs = [ + generateFakeRequestInputCoin({ owner: owner1 }), + generateFakeRequestInputMessage({ recipient: owner1 }), + generateFakeRequestInputMessage({ recipient: owner1 }), + ]; + + const tx2Inputs = [ + generateFakeRequestInputMessage({ recipient: owner2 }), + generateFakeRequestInputMessage({ recipient: owner2 }), + generateFakeRequestInputCoin({ owner: owner2 }), + ]; - const txId1 = randomValue(); - const txId2 = randomValue(); + resourceCache.set(txId1, tx1Inputs); + resourceCache.set(txId2, tx2Inputs); - const txId1Resources = { - utxos: [randomValue()], - messages: [randomValue(), randomValue()], - }; + // Verifies that cached resources from owner 1 is correct + const owner1Cached = resourceCache.getActiveData(owner1); - const txId2Resources = { - utxos: [randomValue(), randomValue()], - messages: [randomValue()], - }; + const owner1Expected = inputsToExcludeResourcesOption(tx1Inputs); - resourceCache.set(txId1, txId1Resources); - resourceCache.set(txId2, txId2Resources); + expect(owner1Cached.utxos).containSubset(owner1Expected.utxos); + expect(owner1Cached.messages).containSubset(owner1Expected.messages); - const activeData = resourceCache.getActiveData(); + // Verifies that cached resources from owner 2 is correct + const owner2Cached = resourceCache.getActiveData(owner2); - expect(activeData.utxos).containSubset([...txId1Resources.utxos, ...txId2Resources.utxos]); - expect(activeData.messages).containSubset([ - ...txId1Resources.messages, - ...txId2Resources.messages, - ]); + const owner2Expected = inputsToExcludeResourcesOption(tx2Inputs); + + expect(owner2Cached.utxos).containSubset(owner2Expected.utxos); + expect(owner2Cached.messages).containSubset(owner2Expected.messages); resourceCache.clear(); - expect(resourceCache.getActiveData()).toStrictEqual({ utxos: [], messages: [] }); + // All cache was cleared + expect(resourceCache.getActiveData(owner1)).toStrictEqual({ utxos: [], messages: [] }); + expect(resourceCache.getActiveData(owner2)).toStrictEqual({ utxos: [], messages: [] }); }); it('should validate that ResourceCache uses a global cache', () => { - const oldTxId = randomValue(); - const oldCache = { - utxos: [randomValue(), randomValue()], - messages: [randomValue()], - }; + const oldTxId = randomTxId(); + const owner = Address.fromRandom().b256Address; + const oldInputs = [ + generateFakeRequestInputCoin({ owner }), + generateFakeRequestInputCoin({ owner }), + generateFakeRequestInputMessage({ recipient: owner }), + ]; const oldInstance = new ResourceCache(800); - oldInstance.set(oldTxId, oldCache); + oldInstance.set(oldTxId, oldInputs); + + const newTxId = randomTxId(); - const newTxId = randomValue(); - const newCache = { - utxos: [randomValue()], - messages: [randomValue(), randomValue()], - }; + const newInputs = [ + generateFakeRequestInputMessage({ recipient: owner }), + generateFakeRequestInputMessage({ recipient: owner }), + generateFakeRequestInputCoin({ owner }), + ]; const newInstance = new ResourceCache(300); - newInstance.set(newTxId, newCache); + newInstance.set(newTxId, newInputs); + + const activeData = newInstance.getActiveData(owner); - const activeData = newInstance.getActiveData(); + const allCached = inputsToExcludeResourcesOption([...oldInputs, ...newInputs]); - expect(activeData.utxos).containSubset([...oldCache.utxos, ...newCache.utxos]); - expect(activeData.messages).containSubset([...oldCache.messages, ...newCache.messages]); + expect(activeData.utxos).containSubset(allCached.utxos); + expect(activeData.messages).containSubset(allCached.messages); }); }); From e9d0353770360374a952430292a3a348b8660278 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Tue, 11 Feb 2025 12:32:23 -0300 Subject: [PATCH 03/13] adjust resource cache use on provider class --- .../account/src/providers/provider.test.ts | 16 +++++++++------- packages/account/src/providers/provider.ts | 18 +++--------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index fdc502c62d6..7cbc585cadf 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -659,7 +659,7 @@ describe('Provider', () => { await wallet.transfer(receiver.address, 10_000); - const cachedResources = provider.cache?.getActiveData(); + const cachedResources = provider.cache?.getActiveData(wallet.address.toB256()); expect(new Set(cachedResources?.utxos)).toEqual(new Set(EXPECTED.utxos)); expect(new Set(cachedResources?.messages)).toEqual(new Set(EXPECTED.messages)); }); @@ -707,7 +707,7 @@ describe('Provider', () => { // No resources were cached since the TX submission failed [...utxos, ...messages].forEach((key) => { - expect(provider.cache?.isCached(key)).toBeFalsy(); + expect(provider.cache?.isCached(wallet.address.toB256(), key)).toBeFalsy(); }); }); @@ -754,7 +754,7 @@ describe('Provider', () => { // Resources were cached since the TX submission succeeded [...utxos, ...messages].forEach((key) => { - expect(provider.cache?.isCached(key)).toBeTruthy(); + expect(provider.cache?.isCached(wallet.address.toB256(), key)).toBeTruthy(); }); // TX execution will fail @@ -764,7 +764,7 @@ describe('Provider', () => { // Ensure user's resouces were unset from the cache [...utxos, ...messages].forEach((key) => { - expect(provider.cache?.isCached(key)).toBeFalsy(); + expect(provider.cache?.isCached(wallet.address.toB256(), key)).toBeFalsy(); }); }); @@ -797,12 +797,14 @@ describe('Provider', () => { messages: [message], } = await wallet.getMessages(); + const owner = wallet.address.toB256(); + // One of the resources will be cached as the TX submission was successful await wallet.transfer(receiver.address, transferAmount); // Determine the used and unused resource - const cachedResource = provider.cache?.isCached(coin.id) ? coin : message; - const uncachedResource = provider.cache?.isCached(coin.id) ? message : coin; + const cachedResource = provider.cache?.isCached(owner, coin.id) ? coin : message; + const uncachedResource = provider.cache?.isCached(owner, coin.id) ? message : coin; expect(cachedResource).toBeDefined(); expect(uncachedResource).toBeDefined(); @@ -828,7 +830,7 @@ describe('Provider', () => { // Ensure the getCoinsToSpend query was called excluding the cached resource expect(resourcesToSpendSpy).toHaveBeenCalledWith({ - owner: wallet.address.toB256(), + owner, queryPerAsset: [ { assetId: baseAssetId, diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 8e7ff4a8d0a..aa2f50c2bbb 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -4,7 +4,7 @@ import { ErrorCode, FuelError } from '@fuel-ts/errors'; import type { BN } from '@fuel-ts/math'; import { bn } from '@fuel-ts/math'; import type { Transaction } from '@fuel-ts/transactions'; -import { InputType, InputMessageCoder, TransactionCoder } from '@fuel-ts/transactions'; +import { InputMessageCoder, TransactionCoder } from '@fuel-ts/transactions'; import type { BytesLike } from '@fuel-ts/utils'; import { arrayify, hexlify, DateTime, isDefined } from '@fuel-ts/utils'; import { checkFuelCoreVersionCompatibility, versions } from '@fuel-ts/versions'; @@ -835,19 +835,7 @@ export default class Provider { return; } - 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); + this.cache.set(transactionId, inputs); } /** @@ -1517,7 +1505,7 @@ export default class Provider { }; if (this.cache) { - const cached = this.cache.getActiveData(); + const cached = this.cache.getActiveData(ownerAddress.toB256()); excludeInput.messages.push(...cached.messages); excludeInput.utxos.push(...cached.utxos); } From 5aa22399a0fbf46e4f3b503a1c36baa90690f67e Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Sat, 15 Feb 2025 16:01:50 -0300 Subject: [PATCH 04/13] moving resources cache to same file --- .../account/src/providers/provider.test.ts | 255 ----------------- .../src/providers/resource-cache.test.ts | 269 +++++++++++++++++- 2 files changed, 267 insertions(+), 257 deletions(-) diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index c6aa9ea7cd9..dc091919b62 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -588,261 +588,6 @@ describe('Provider', () => { expect(producedBlocks).toEqual(expectedBlocks); }); - it('can set cache ttl', async () => { - const ttl = 10000; - using launched = await setupTestProviderAndWallets({ - providerOptions: { - resourceCacheTTL: ttl, - }, - }); - const { provider } = launched; - - expect(provider.cache?.ttl).toEqual(ttl); - }); - - it('should use resource cache by default', async () => { - using launched = await setupTestProviderAndWallets(); - const { provider } = launched; - - expect(provider.cache?.ttl).toEqual(DEFAULT_RESOURCE_CACHE_TTL); - }); - - it('should validate resource cache value [invalid numerical]', async () => { - const { error } = await safeExec(async () => { - await setupTestProviderAndWallets({ providerOptions: { resourceCacheTTL: -500 } }); - }); - expect(error?.message).toMatch(/Invalid TTL: -500\. Use a value greater than zero/); - }); - - it('should be possible to disable the cache by using -1', async () => { - using launched = await setupTestProviderAndWallets({ - providerOptions: { - resourceCacheTTL: -1, - }, - }); - const { provider } = launched; - - expect(provider.cache).toBeUndefined(); - }); - - it('should cache resources only when TX is successfully submitted', async () => { - const resourceAmount = 5_000; - const utxosAmount = 2; - - const testMessage = new TestMessage({ amount: resourceAmount }); - - using launched = await setupTestProviderAndWallets({ - nodeOptions: { - args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], - }, - // 3 resources with a total of 15_000 - walletsConfig: { - coinsPerAsset: utxosAmount, - amountPerCoin: resourceAmount, - messages: [testMessage], - }, - }); - const { - provider, - wallets: [wallet, receiver], - } = launched; - - const baseAssetId = await provider.getBaseAssetId(); - const { coins } = await wallet.getCoins(baseAssetId); - - expect(coins.length).toBe(utxosAmount); - - // Tx will cost 10_000 for the transfer + 1 for fee. All resources will be used - const EXPECTED = { - utxos: coins.map((coin) => coin.id), - messages: [testMessage.nonce], - }; - - await wallet.transfer(receiver.address, 10_000); - - const cachedResources = provider.cache?.getActiveData(wallet.address.toB256()); - expect(new Set(cachedResources?.utxos)).toEqual(new Set(EXPECTED.utxos)); - expect(new Set(cachedResources?.messages)).toEqual(new Set(EXPECTED.messages)); - }); - - 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'], - }, - walletsConfig: { - coinsPerAsset: 2, - amountPerCoin: 20_000, - messages: [message], - }, - }); - const { - provider, - wallets: [wallet, receiver], - } = launched; - - const baseAssetId = await 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]; - - // No enough funds to pay for the TX fee - const resources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); - - const request = new ScriptTransactionRequest({ - maxFee, - }); - - request.addCoinOutput(receiver.address, transferAmount, baseAssetId); - request.addResources(resources); - - await expectToThrowFuelError( - () => wallet.sendTransaction(request, { estimateTxDependencies: false }), - { code: ErrorCode.INVALID_REQUEST } - ); - - // No resources were cached since the TX submission failed - [...utxos, ...messages].forEach((key) => { - expect(provider.cache?.isCached(wallet.address.toB256(), key)).toBeFalsy(); - }); - }); - - 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: 1, - amountPerCoin: 100_000, - messages: [message], - }, - }); - const { - provider, - wallets: [wallet, receiver], - } = launched; - - const baseAssetId = await 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(wallet.address.toB256(), key)).toBeTruthy(); - }); - - // TX execution will fail - await expectToThrowFuelError(() => submitted.waitForResult(), { - code: ErrorCode.SCRIPT_REVERTED, - }); - - // Ensure user's resources were unset from the cache - [...utxos, ...messages].forEach((key) => { - expect(provider.cache?.isCached(wallet.address.toB256(), 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], - }, - }); - - const { - provider, - wallets: [wallet, receiver], - } = launched; - const baseAssetId = await provider.getBaseAssetId(); - const transferAmount = 10_000; - - const { - coins: [coin], - } = await wallet.getCoins(baseAssetId); - - const { - messages: [message], - } = await wallet.getMessages(); - - const owner = wallet.address.toB256(); - - // One of the resources will be cached as the TX submission was successful - await wallet.transfer(receiver.address, transferAmount); - - // Determine the used and unused resource - const cachedResource = provider.cache?.isCached(owner, coin.id) ? coin : message; - const uncachedResource = provider.cache?.isCached(owner, coin.id) ? message : coin; - - expect(cachedResource).toBeDefined(); - expect(uncachedResource).toBeDefined(); - - // Spy on the getCoinsToSpend method to ensure the cached resource is not being queried - const resourcesToSpendSpy = vi.spyOn(provider.operations, 'getCoinsToSpend'); - 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 resource - expect(resourcesToSpendSpy).toHaveBeenCalledWith({ - owner, - queryPerAsset: [ - { - assetId: baseAssetId, - amount: String(transferAmount), - max: undefined, - }, - ], - excludedIds, - }); - }); - it('should validate max number of inputs at sendTransaction method', async () => { const maxInputs = 2; using launched = await setupTestProviderAndWallets({ diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts index f7edc4f157c..b8b39c29ea8 100644 --- a/packages/account/src/providers/resource-cache.test.ts +++ b/packages/account/src/providers/resource-cache.test.ts @@ -1,19 +1,29 @@ import { Address } from '@fuel-ts/address'; import { randomBytes } from '@fuel-ts/crypto'; +import { ErrorCode } from '@fuel-ts/errors'; +import { safeExec, expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; import { hexlify } from '@fuel-ts/utils'; +import { setupTestProviderAndWallets, TestMessage } from '../test-utils'; import { generateFakeRequestInputCoin, generateFakeRequestInputMessage, } from '../test-utils/transactionRequest'; -import type { ExcludeResourcesOption } from './resource'; +import type { Coin } from './coin'; +import type { Message } from './message'; +import { DEFAULT_RESOURCE_CACHE_TTL } from './provider'; +import { isCoin, type ExcludeResourcesOption } from './resource'; import { ResourceCache } from './resource-cache'; import type { CoinTransactionRequestInput, MessageTransactionRequestInput, } from './transaction-request'; -import { isRequestInputCoin, isRequestInputMessage } from './transaction-request'; +import { + isRequestInputCoin, + isRequestInputMessage, + ScriptTransactionRequest, +} from './transaction-request'; /** * @group node @@ -319,4 +329,259 @@ describe('Resource Cache', () => { expect(activeData.utxos).containSubset(allCached.utxos); expect(activeData.messages).containSubset(allCached.messages); }); + + it('can set cache ttl', async () => { + const ttl = 10000; + using launched = await setupTestProviderAndWallets({ + providerOptions: { + resourceCacheTTL: ttl, + }, + }); + const { provider } = launched; + + expect(provider.cache?.ttl).toEqual(ttl); + }); + + it('should use resource cache by default', async () => { + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; + + expect(provider.cache?.ttl).toEqual(DEFAULT_RESOURCE_CACHE_TTL); + }); + + it('should validate resource cache value [invalid numerical]', async () => { + const { error } = await safeExec(async () => { + await setupTestProviderAndWallets({ providerOptions: { resourceCacheTTL: -500 } }); + }); + expect(error?.message).toMatch(/Invalid TTL: -500\. Use a value greater than zero/); + }); + + it('should be possible to disable the cache by using -1', async () => { + using launched = await setupTestProviderAndWallets({ + providerOptions: { + resourceCacheTTL: -1, + }, + }); + const { provider } = launched; + + expect(provider.cache).toBeUndefined(); + }); + + it('should cache resources only when TX is successfully submitted', async () => { + const resourceAmount = 5_000; + const utxosAmount = 2; + + const testMessage = new TestMessage({ amount: resourceAmount }); + + using launched = await setupTestProviderAndWallets({ + nodeOptions: { + args: ['--poa-instant', 'false', '--poa-interval-period', '1s'], + }, + // 3 resources with a total of 15_000 + walletsConfig: { + coinsPerAsset: utxosAmount, + amountPerCoin: resourceAmount, + messages: [testMessage], + }, + }); + const { + provider, + wallets: [wallet, receiver], + } = launched; + + const baseAssetId = await provider.getBaseAssetId(); + const { coins } = await wallet.getCoins(baseAssetId); + + expect(coins.length).toBe(utxosAmount); + + // Tx will cost 10_000 for the transfer + 1 for fee. All resources will be used + const EXPECTED = { + utxos: coins.map((coin) => coin.id), + messages: [testMessage.nonce], + }; + + await wallet.transfer(receiver.address, 10_000); + + const cachedResources = provider.cache?.getActiveData(wallet.address.toB256()); + expect(new Set(cachedResources?.utxos)).toEqual(new Set(EXPECTED.utxos)); + expect(new Set(cachedResources?.messages)).toEqual(new Set(EXPECTED.messages)); + }); + + 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'], + }, + walletsConfig: { + coinsPerAsset: 2, + amountPerCoin: 20_000, + messages: [message], + }, + }); + const { + provider, + wallets: [wallet, receiver], + } = launched; + + const baseAssetId = await 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]; + + // No enough funds to pay for the TX fee + const resources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); + + const request = new ScriptTransactionRequest({ + maxFee, + }); + + request.addCoinOutput(receiver.address, transferAmount, baseAssetId); + request.addResources(resources); + + await expectToThrowFuelError( + () => wallet.sendTransaction(request, { estimateTxDependencies: false }), + { code: ErrorCode.INVALID_REQUEST } + ); + + // No resources were cached since the TX submission failed + [...utxos, ...messages].forEach((key) => { + expect(provider.cache?.isCached(wallet.address.toB256(), key)).toBeFalsy(); + }); + }); + + 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: 1, + amountPerCoin: 100_000, + messages: [message], + }, + }); + const { + provider, + wallets: [wallet, receiver], + } = launched; + + const baseAssetId = await 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(wallet.address.toB256(), key)).toBeTruthy(); + }); + + // TX execution will fail + await expectToThrowFuelError(() => submitted.waitForResult(), { + code: ErrorCode.SCRIPT_REVERTED, + }); + + // Ensure user's resources were unset from the cache + [...utxos, ...messages].forEach((key) => { + expect(provider.cache?.isCached(wallet.address.toB256(), 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], + }, + }); + + const { + provider, + wallets: [wallet, receiver], + } = launched; + const baseAssetId = await provider.getBaseAssetId(); + const transferAmount = 10_000; + + const { + coins: [coin], + } = await wallet.getCoins(baseAssetId); + + const { + messages: [message], + } = await wallet.getMessages(); + + const owner = wallet.address.toB256(); + + // One of the resources will be cached as the TX submission was successful + await wallet.transfer(receiver.address, transferAmount); + + // Determine the used and unused resource + const cachedResource = provider.cache?.isCached(owner, coin.id) ? coin : message; + const uncachedResource = provider.cache?.isCached(owner, coin.id) ? message : coin; + + expect(cachedResource).toBeDefined(); + expect(uncachedResource).toBeDefined(); + + // Spy on the getCoinsToSpend method to ensure the cached resource is not being queried + const resourcesToSpendSpy = vi.spyOn(provider.operations, 'getCoinsToSpend'); + 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 resource + expect(resourcesToSpendSpy).toHaveBeenCalledWith({ + owner, + queryPerAsset: [ + { + assetId: baseAssetId, + amount: String(transferAmount), + max: undefined, + }, + ], + excludedIds, + }); + }); }); From 4eaee560121390960d7f4625b8f44b24de6af33f Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Sat, 15 Feb 2025 17:39:38 -0300 Subject: [PATCH 05/13] rename some vars --- packages/account/src/providers/resource-cache.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/account/src/providers/resource-cache.ts b/packages/account/src/providers/resource-cache.ts index 77dd99a3f2d..d5f809e5ddd 100644 --- a/packages/account/src/providers/resource-cache.ts +++ b/packages/account/src/providers/resource-cache.ts @@ -42,7 +42,7 @@ export class ResourceCache { } getActiveData(owner: string) { - const allResources: { utxos: string[]; messages: string[] } = { utxos: [], messages: [] }; + const activeData: { utxos: string[]; messages: string[] } = { utxos: [], messages: [] }; const currentTime = Date.now(); const expired: string[] = []; @@ -52,8 +52,8 @@ export class ResourceCache { if (isActive) { const resourcesFromOwner = resource.owners.get(owner); if (resourcesFromOwner) { - allResources.utxos.push(...resourcesFromOwner.utxos); - allResources.messages.push(...resourcesFromOwner.messages); + activeData.utxos.push(...resourcesFromOwner.utxos); + activeData.messages.push(...resourcesFromOwner.messages); } } else { expired.push(transactionId); @@ -62,7 +62,7 @@ export class ResourceCache { expired.forEach(this.unset); - return allResources; + return activeData; } isCached(owner: string, key: string): boolean { From 2e1fd8c60429dc3dd8b9d13d04b51d1d5a4907ae Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Sat, 15 Feb 2025 17:40:29 -0300 Subject: [PATCH 06/13] implement helper adjustResourcesToExclude --- packages/account/src/providers/provider.ts | 19 +++++++++--- .../account/src/providers/utils/helpers.ts | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/account/src/providers/utils/helpers.ts diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 13d4303e938..de07b54d0ca 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -66,6 +66,7 @@ import { import type { RetryOptions } from './utils/auto-retry-fetch'; import { autoRetryFetch } from './utils/auto-retry-fetch'; import { assertGqlResponseHasNoErrors } from './utils/handle-gql-error-message'; +import { adjustResourcesToExclude } from './utils/helpers'; import { validatePaginationArgs } from './utils/validate-pagination-args'; const MAX_RETRIES = 10; @@ -1499,15 +1500,25 @@ export default class Provider { excludedIds?: ExcludeResourcesOption ): Promise { const ownerAddress = new Address(owner); - const excludeInput = { + let idsToExclude = { messages: excludedIds?.messages?.map((nonce) => hexlify(nonce)) || [], utxos: excludedIds?.utxos?.map((id) => hexlify(id)) || [], }; if (this.cache) { const cached = this.cache.getActiveData(ownerAddress.toB256()); - excludeInput.messages.push(...cached.messages); - excludeInput.utxos.push(...cached.utxos); + if (cached.utxos.length || cached.messages.length) { + const { + consensusParameters: { + txParameters: { maxInputs }, + }, + } = await this.getChain(); + idsToExclude = adjustResourcesToExclude({ + userInput: idsToExclude, + cached, + maxInputs: maxInputs.toNumber(), + }); + } } const coinsQuery = { @@ -1519,7 +1530,7 @@ export default class Provider { amount: amount.toString(10), max: maxPerAsset ? maxPerAsset.toString(10) : undefined, })), - excludedIds: excludeInput, + excludedIds: idsToExclude, }; const result = await this.operations.getCoinsToSpend(coinsQuery); diff --git a/packages/account/src/providers/utils/helpers.ts b/packages/account/src/providers/utils/helpers.ts new file mode 100644 index 00000000000..3d36f0782df --- /dev/null +++ b/packages/account/src/providers/utils/helpers.ts @@ -0,0 +1,29 @@ +type ExcludeResourcesString = { + utxos: string[]; + messages: string[]; +}; + +export const adjustResourcesToExclude = (params: { + userInput: ExcludeResourcesString; + cached: ExcludeResourcesString; + maxInputs: number; +}) => { + const { userInput, cached, maxInputs } = params; + + const final = { ...userInput }; + + let total = final.utxos.length + final.messages.length; + if (total >= maxInputs) { + return final; + } + + final.utxos = [...final.utxos, ...cached.utxos.slice(0, maxInputs - total)]; + + total = final.utxos.length + final.messages.length; + + if (total < maxInputs) { + final.messages = [...final.messages, ...cached.messages.slice(0, maxInputs - total)]; + } + + return final; +}; From f2bf5f83d1226b59c2cabbafca0354d40c3e23ed Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Sat, 15 Feb 2025 17:40:42 -0300 Subject: [PATCH 07/13] adding tests --- .../src/providers/resource-cache.test.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts index b8b39c29ea8..93549051376 100644 --- a/packages/account/src/providers/resource-cache.test.ts +++ b/packages/account/src/providers/resource-cache.test.ts @@ -12,6 +12,7 @@ import { import type { Coin } from './coin'; import type { Message } from './message'; +import type Provider from './provider'; import { DEFAULT_RESOURCE_CACHE_TTL } from './provider'; import { isCoin, type ExcludeResourcesOption } from './resource'; import { ResourceCache } from './resource-cache'; @@ -43,10 +44,21 @@ describe('Resource Cache', () => { { utxos: [], messages: [] } as Required ); + const extractBaseAssetAndMaxInputs = async (provider: Provider) => { + const baseAssetId = await provider.getBaseAssetId(); + const { + consensusParameters: { + txParameters: { maxInputs }, + }, + } = await provider.getChain(); + return { baseAssetId, maxInputs: maxInputs.toNumber() }; + }; + afterEach(() => { // Reset the cache after each test const resourceCache = new ResourceCache(1000); resourceCache.clear(); + vi.restoreAllMocks(); }); it('can instantiate [valid numerical ttl]', () => { @@ -584,4 +596,159 @@ describe('Resource Cache', () => { excludedIds, }); }); + + it("should consider user's given excluded IDs", async () => { + using launched = await setupTestProviderAndWallets(); + + const { + provider, + wallets: [wallet], + } = launched; + const baseAssetId = await provider.getBaseAssetId(); + const amount = 10; + + const userInput: ExcludeResourcesOption = { + utxos: [hexlify(randomBytes(34)), hexlify(randomBytes(34))], + messages: [hexlify(randomBytes(32))], + }; + + const spy = vi.spyOn(provider.operations, 'getCoinsToSpend'); + + await wallet.getResourcesToSpend([{ amount, assetId: baseAssetId }], userInput); + + expect(spy).toHaveBeenCalledWith({ + owner: wallet.address.toB256(), + queryPerAsset: [ + { + assetId: baseAssetId, + amount: String(amount), + max: undefined, + }, + ], + excludedIds: userInput, + }); + }); + + it("should not add cached IDs when user's input exceeds max_inputs", async () => { + using launched = await setupTestProviderAndWallets(); + + const { + provider, + wallets: [wallet], + } = launched; + + const owner = wallet.address.toB256(); + const amount = 10; + const { baseAssetId, maxInputs } = await extractBaseAssetAndMaxInputs(provider); + + // Generating inputs to be cached + const fakeInputs = Array.from({ length: 10 }, (_, i) => + i % 2 === 0 + ? generateFakeRequestInputCoin({ owner }) + : generateFakeRequestInputMessage({ recipient: owner }) + ); + + // Caching inputs + provider.cache?.set(owner, fakeInputs); + + const activeData = provider.cache?.getActiveData(owner); + + expect(activeData?.utxos.length).toBeGreaterThan(0); + expect(activeData?.messages.length).toBeGreaterThan(0); + + const spy = vi.spyOn(provider.operations, 'getCoinsToSpend'); + + const userInput: ExcludeResourcesOption = { + utxos: Array.from({ length: maxInputs }, () => hexlify(randomBytes(34))), + messages: Array.from({ length: maxInputs }, () => hexlify(randomBytes(32))), + }; + + // TODO: Wrap this test with a expectTOThrowFuelError when upgrading fuel-core to 0.41 + await wallet.getResourcesToSpend([{ amount, assetId: baseAssetId }], userInput); + + expect(spy).toHaveBeenCalledWith({ + owner, + queryPerAsset: [ + { + assetId: baseAssetId, + amount: String(amount), + max: undefined, + }, + ], + excludedIds: userInput, + }); + }); + + it('should ensure that excluded IDs do not exceed max_inputs [W/O USER INPUT]', async () => { + using launched = await setupTestProviderAndWallets(); + + const { + provider, + wallets: [wallet], + } = launched; + + const { baseAssetId, maxInputs } = await extractBaseAssetAndMaxInputs(provider); + const owner = wallet.address.toB256(); + + const fakeInputs = Array.from({ length: maxInputs + 1 }, (_, i) => + i % 2 === 0 + ? generateFakeRequestInputCoin({ owner }) + : generateFakeRequestInputMessage({ recipient: owner }) + ); + + provider.cache?.set(owner, fakeInputs); + const activeData = provider.cache!.getActiveData(owner); + const totalCached = activeData.utxos.length + activeData.messages.length; + + expect(totalCached).toBeGreaterThan(maxInputs); + + const spy = vi.spyOn(provider.operations, 'getCoinsToSpend'); + + await wallet.getResourcesToSpend([{ amount: 1, assetId: baseAssetId }]); + const excludedIds = spy.mock.calls[0][0].excludedIds; + + const totalUsed = excludedIds!.utxos.length + excludedIds!.messages.length; + expect(totalUsed).toBe(maxInputs); + }); + + it('should ensure that excluded IDs do not exceed max_inputs [W/ USER INPUT]', async () => { + using launched = await setupTestProviderAndWallets(); + + const { + provider, + wallets: [wallet], + } = launched; + const { baseAssetId, maxInputs } = await extractBaseAssetAndMaxInputs(provider); + const owner = wallet.address.toB256(); + + const cachedInputs = Array.from({ length: maxInputs + 1 }, (_, i) => + i % 2 === 0 + ? generateFakeRequestInputCoin({ owner }) + : generateFakeRequestInputMessage({ recipient: owner }) + ); + + provider.cache?.set(owner, cachedInputs); + const activeData = provider.cache!.getActiveData(owner); + const totalCached = activeData.utxos.length + activeData.messages.length; + + expect(totalCached).toBeGreaterThan(maxInputs); + + const userInput: ExcludeResourcesOption = { + utxos: Array.from({ length: maxInputs / 3 }, () => hexlify(randomBytes(34))), + messages: Array.from({ length: maxInputs / 3 }, () => hexlify(randomBytes(32))), + }; + + const spy = vi.spyOn(provider.operations, 'getCoinsToSpend'); + + await wallet.getResourcesToSpend([{ amount: 1, assetId: baseAssetId }], userInput); + + const excludedIds = spy.mock.calls[0][0].excludedIds; + + // User input should be considered first + expect(excludedIds?.utxos).containSubset(userInput.utxos); + expect(excludedIds?.messages).containSubset(userInput.messages); + + const totalUsed = excludedIds!.utxos.length + excludedIds!.messages.length; + expect(totalUsed).toBe(maxInputs); + }); }); From 948309a427c51ad756d84798cf2d94442d535815 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Sun, 16 Feb 2025 12:48:38 -0300 Subject: [PATCH 08/13] remove unused imports --- packages/account/src/providers/provider.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index dc091919b62..b5734b29d68 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -24,18 +24,13 @@ import { import { mockIncompatibleVersions } from '../../test/utils/mockIncompabileVersions'; import { setupTestProviderAndWallets, launchNode, TestMessage } from '../test-utils'; -import type { Coin } from './coin'; import { coinQuantityfy } from './coin-quantity'; -import type { Message } from './message'; import type { Block, ChainInfo, CursorPaginationArgs, NodeInfo } from './provider'; import Provider, { BLOCKS_PAGE_SIZE_LIMIT, - DEFAULT_RESOURCE_CACHE_TTL, GAS_USED_MODIFIER, RESOURCES_PAGE_SIZE_LIMIT, } from './provider'; -import type { ExcludeResourcesOption } from './resource'; -import { isCoin } from './resource'; import type { ChangeTransactionRequestOutput, CoinTransactionRequestInput, From 5e7f7556621cfde7b28a8c06fe2ce752e8668df7 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Sun, 16 Feb 2025 13:01:54 -0300 Subject: [PATCH 09/13] make lint happy --- .../account/src/providers/resource-cache.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts index 93549051376..43f5bb665b8 100644 --- a/packages/account/src/providers/resource-cache.test.ts +++ b/packages/account/src/providers/resource-cache.test.ts @@ -697,7 +697,7 @@ describe('Resource Cache', () => { ); provider.cache?.set(owner, fakeInputs); - const activeData = provider.cache!.getActiveData(owner); + const activeData = provider.cache?.getActiveData(owner) as Required; const totalCached = activeData.utxos.length + activeData.messages.length; expect(totalCached).toBeGreaterThan(maxInputs); @@ -705,9 +705,9 @@ describe('Resource Cache', () => { const spy = vi.spyOn(provider.operations, 'getCoinsToSpend'); await wallet.getResourcesToSpend([{ amount: 1, assetId: baseAssetId }]); - const excludedIds = spy.mock.calls[0][0].excludedIds; + const excludedIds = spy.mock.calls[0][0].excludedIds as Required; - const totalUsed = excludedIds!.utxos.length + excludedIds!.messages.length; + const totalUsed = excludedIds.utxos.length + excludedIds.messages.length; expect(totalUsed).toBe(maxInputs); }); @@ -728,7 +728,7 @@ describe('Resource Cache', () => { ); provider.cache?.set(owner, cachedInputs); - const activeData = provider.cache!.getActiveData(owner); + const activeData = provider.cache?.getActiveData(owner) as Required; const totalCached = activeData.utxos.length + activeData.messages.length; expect(totalCached).toBeGreaterThan(maxInputs); @@ -742,13 +742,13 @@ describe('Resource Cache', () => { await wallet.getResourcesToSpend([{ amount: 1, assetId: baseAssetId }], userInput); - const excludedIds = spy.mock.calls[0][0].excludedIds; + const excludedIds = spy.mock.calls[0][0].excludedIds as Required; // User input should be considered first expect(excludedIds?.utxos).containSubset(userInput.utxos); expect(excludedIds?.messages).containSubset(userInput.messages); - const totalUsed = excludedIds!.utxos.length + excludedIds!.messages.length; + const totalUsed = excludedIds.utxos.length + excludedIds.messages.length; expect(totalUsed).toBe(maxInputs); }); }); From 5f0f20897359d982d68b0c2d7f99eacabf1089f0 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Sun, 16 Feb 2025 13:30:50 -0300 Subject: [PATCH 10/13] add changeset --- .changeset/famous-candles-draw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/famous-candles-draw.md diff --git a/.changeset/famous-candles-draw.md b/.changeset/famous-candles-draw.md new file mode 100644 index 00000000000..f8d688354ad --- /dev/null +++ b/.changeset/famous-candles-draw.md @@ -0,0 +1,5 @@ +--- +"@fuel-ts/account": minor +--- + +chore!: made `ResourceCache` consider resource owner From f689858131c03e3ec47c2c489c0121ef58855c0c Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Tue, 18 Feb 2025 12:13:36 -0300 Subject: [PATCH 11/13] adjusting tests --- packages/account/src/providers/resource-cache.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts index 43f5bb665b8..e565cd8b62e 100644 --- a/packages/account/src/providers/resource-cache.test.ts +++ b/packages/account/src/providers/resource-cache.test.ts @@ -438,7 +438,6 @@ describe('Resource Cache', () => { } = launched; const baseAssetId = await provider.getBaseAssetId(); - const maxFee = 100_000; const transferAmount = 10_000; const { coins } = await wallet.getCoins(baseAssetId); @@ -449,7 +448,7 @@ describe('Resource Cache', () => { const resources = await wallet.getResourcesToSpend([[transferAmount, baseAssetId]]); const request = new ScriptTransactionRequest({ - maxFee, + maxFee: 0, }); request.addCoinOutput(receiver.address, transferAmount, baseAssetId); @@ -663,8 +662,10 @@ describe('Resource Cache', () => { messages: Array.from({ length: maxInputs }, () => hexlify(randomBytes(32))), }; - // TODO: Wrap this test with a expectTOThrowFuelError when upgrading fuel-core to 0.41 - await wallet.getResourcesToSpend([{ amount, assetId: baseAssetId }], userInput); + await expectToThrowFuelError( + () => wallet.getResourcesToSpend([{ amount, assetId: baseAssetId }], userInput), + { code: ErrorCode.INVALID_REQUEST } + ); expect(spy).toHaveBeenCalledWith({ owner, From 27b02765a77d6cabba134afe5ffe4f71fb29bb64 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Tue, 18 Feb 2025 14:57:06 -0300 Subject: [PATCH 12/13] prioritize recent cached IDs --- .../src/providers/resource-cache.test.ts | 42 ++++++++++++++++++- .../account/src/providers/resource-cache.ts | 3 ++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts index e565cd8b62e..65dfc4984d4 100644 --- a/packages/account/src/providers/resource-cache.test.ts +++ b/packages/account/src/providers/resource-cache.test.ts @@ -2,7 +2,7 @@ import { Address } from '@fuel-ts/address'; import { randomBytes } from '@fuel-ts/crypto'; import { ErrorCode } from '@fuel-ts/errors'; import { safeExec, expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; -import { hexlify } from '@fuel-ts/utils'; +import { hexlify, sleep } from '@fuel-ts/utils'; import { setupTestProviderAndWallets, TestMessage } from '../test-utils'; import { @@ -596,6 +596,46 @@ describe('Resource Cache', () => { }); }); + it('should prioritize recent IDs entries', async () => { + using launched = await setupTestProviderAndWallets(); + + const { + provider, + wallets: [wallet], + } = launched; + const baseAssetId = await provider.getBaseAssetId(); + const amount = 10; + + const oldTxId = hexlify(randomBytes(32)); + const oldUtxo = generateFakeRequestInputCoin({ owner: wallet.address.toB256() }); + const newUtxo = generateFakeRequestInputCoin({ owner: wallet.address.toB256() }); + + const newTxId = hexlify(randomBytes(32)); + const oldMessage = generateFakeRequestInputMessage({ recipient: wallet.address.toB256() }); + const newMessage = generateFakeRequestInputMessage({ recipient: wallet.address.toB256() }); + + const oldInputs = [oldUtxo, oldMessage]; + const newInputs = [newUtxo, newMessage]; + + const spy = vi.spyOn(provider.operations, 'getCoinsToSpend'); + + // caching inputs1 first (First TX) + provider.cache?.set(oldTxId, oldInputs); + + // caching inputs2 later (Second TX) + provider.cache?.set(newTxId, newInputs); + + await wallet.getResourcesToSpend([{ amount, assetId: baseAssetId }]); + + const excludedIds = spy.mock.calls[0][0].excludedIds as Required; + + expect(excludedIds.utxos[0]).toEqual(newUtxo.id); + expect(excludedIds.utxos[1]).toEqual(oldUtxo.id); + + expect(excludedIds.messages[0]).toEqual(newMessage.nonce); + expect(excludedIds.messages[1]).toEqual(oldMessage.nonce); + }); + it("should consider user's given excluded IDs", async () => { using launched = await setupTestProviderAndWallets(); diff --git a/packages/account/src/providers/resource-cache.ts b/packages/account/src/providers/resource-cache.ts index d5f809e5ddd..3ec6b79996f 100644 --- a/packages/account/src/providers/resource-cache.ts +++ b/packages/account/src/providers/resource-cache.ts @@ -62,6 +62,9 @@ export class ResourceCache { expired.forEach(this.unset); + activeData.utxos.reverse(); + activeData.messages.reverse(); + return activeData; } From 246d8a29a91646433536562665d41282560df570 Mon Sep 17 00:00:00 2001 From: Torres-ssf Date: Wed, 19 Feb 2025 07:58:16 -0300 Subject: [PATCH 13/13] lint fix --- packages/account/src/providers/resource-cache.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/account/src/providers/resource-cache.test.ts b/packages/account/src/providers/resource-cache.test.ts index 65dfc4984d4..ce0ef5cd67b 100644 --- a/packages/account/src/providers/resource-cache.test.ts +++ b/packages/account/src/providers/resource-cache.test.ts @@ -2,7 +2,7 @@ import { Address } from '@fuel-ts/address'; import { randomBytes } from '@fuel-ts/crypto'; import { ErrorCode } from '@fuel-ts/errors'; import { safeExec, expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; -import { hexlify, sleep } from '@fuel-ts/utils'; +import { hexlify } from '@fuel-ts/utils'; import { setupTestProviderAndWallets, TestMessage } from '../test-utils'; import {