Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore!: made ResourceCache consider resource owner #3697

Open
wants to merge 19 commits into
base: st/chore/[email protected]
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/famous-candles-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fuel-ts/account": minor
---

chore!: made `ResourceCache` consider resource owner
265 changes: 0 additions & 265 deletions packages/account/src/providers/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,13 @@ import { mockIncompatibleVersions } from '../../test/utils/mockIncompabileVersio
import { setupTestProviderAndWallets, launchNode, TestMessage } from '../test-utils';

import type { GqlPageInfo } from './__generated__/operations';
import type { Coin } from './coin';
import type { Message } from './message';
import type { Block, ChainInfo, CursorPaginationArgs, NodeInfo } from './provider';
import Provider, {
BALANCES_PAGE_SIZE_LIMIT,
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,
Expand Down Expand Up @@ -590,266 +585,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();
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);

// Forcing TX submission to fail at submission state
vi.spyOn(wallet.provider, 'sendTransaction').mockImplementationOnce(() =>
Promise.reject(new FuelError(ErrorCode.INVALID_REQUEST, 'Tx failed'))
);

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(key)).toBeFalsy();
});

vi.restoreAllMocks();
});

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(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(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();

// 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;

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<ExcludeResourcesOption> = { messages: [], utxos: [] };
if (isCoin(fetchedResources[0])) {
excludedIds.messages = expect.arrayContaining([(<Message>cachedResource).nonce]);
excludedIds.utxos = expect.arrayContaining([]);
expect(fetchedResources[0].id).toEqual((<Coin>uncachedResource).id);
} else {
excludedIds.utxos = expect.arrayContaining([(<Coin>cachedResource).id]);
excludedIds.messages = expect.arrayContaining([]);
expect(fetchedResources[0].nonce).toEqual((<Message>uncachedResource).nonce);
}

// Ensure the getCoinsToSpend query was called excluding the cached resource
expect(resourcesToSpendSpy).toHaveBeenCalledWith({
owner: wallet.address.toB256(),
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({
Expand Down
37 changes: 18 additions & 19 deletions packages/account/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ErrorCode, FuelError } from '@fuel-ts/errors';
import type { BigNumberish, 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, gte, versions } from '@fuel-ts/versions';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -883,19 +884,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<ExcludeResourcesOption>
);

this.cache.set(transactionId, inputsToCache);
this.cache.set(transactionId, inputs);
}

/**
Expand Down Expand Up @@ -1559,15 +1548,25 @@ export default class Provider {
excludedIds?: ExcludeResourcesOption
): Promise<Resource[]> {
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();
excludeInput.messages.push(...cached.messages);
excludeInput.utxos.push(...cached.utxos);
const cached = this.cache.getActiveData(ownerAddress.toB256());
if (cached.utxos.length || cached.messages.length) {
const {
consensusParameters: {
txParameters: { maxInputs },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we questions the client team about using the maxInputs as the configurable for the maximum number of excludedIds?

This could become an issue when attempting to consolidate UTXOs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

},
} = await this.getChain();
idsToExclude = adjustResourcesToExclude({
userInput: idsToExclude,
cached,
maxInputs: maxInputs.toNumber(),
});
}
}

const coinsQuery = {
Expand All @@ -1579,7 +1578,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);
Expand Down
Loading
Loading