diff --git a/.changeset/gorgeous-laws-search.md b/.changeset/gorgeous-laws-search.md new file mode 100644 index 00000000000..d655da7acbe --- /dev/null +++ b/.changeset/gorgeous-laws-search.md @@ -0,0 +1,6 @@ +--- +"@fuel-ts/contract": patch +"@fuel-ts/account": patch +--- + +feat: validate blob IDs against chain in chunk deploys diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index e10cec19cb5..bed4c0a2200 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -10,6 +10,7 @@ import { equalBytes } from '@noble/curves/abstract/utils'; import type { DocumentNode } from 'graphql'; import { GraphQLClient } from 'graphql-request'; import type { GraphQLResponse } from 'graphql-request/src/types'; +import gql from 'graphql-tag'; import { clone } from 'ramda'; import { getSdk as getOperationsSdk } from './__generated__/operations'; @@ -27,6 +28,7 @@ import type { GqlPageInfo, GqlRelayedTransactionFailed, GqlMessage, + Requester, } from './__generated__/operations'; import type { Coin } from './coin'; import type { CoinQuantity, CoinQuantityLike } from './coin-quantity'; @@ -376,6 +378,7 @@ type SdkOperations = Omit & { statusChange: ( ...args: Parameters ) => Promise>; + getBlobs: (variables: { blobIds: string[] }) => Promise<{ blob: { id: string } | null }[]>; }; /** @@ -629,8 +632,33 @@ Supported fuel-core version: ${supportedVersion}.` return gqlClient.request(query, vars); }; + const customOperations = (requester: Requester) => ({ + getBlobs(variables: { blobIds: string[] }) { + const queryParams = variables.blobIds.map((_, i) => `$blobId${i}: BlobId!`).join(', '); + const blobParams = variables.blobIds + .map((_, i) => `blob${i}: blob(id: $blobId${i}) { id }`) + .join('\n'); + + const updatedVariables = variables.blobIds.reduce( + (acc, blobId, i) => { + acc[`blobId${i}`] = blobId; + return acc; + }, + {} as Record + ); + + const document = gql` + query getBlobs(${queryParams}) { + ${blobParams} + } + `; + + return requester(document, updatedVariables); + }, + }); + // @ts-expect-error This is due to this function being generic. Its type is specified when calling a specific operation via provider.operations.xyz. - return getOperationsSdk(executeQuery); + return { ...getOperationsSdk(executeQuery), ...customOperations(executeQuery) }; } /** @@ -1356,6 +1384,25 @@ Supported fuel-core version: ${supportedVersion}.` return coins; } + /** + * Returns an array of blobIds that exist on chain, for a given array of blobIds. + * + * @param blobIds - blobIds to check. + * @returns - A promise that resolves to an array of blobIds that exist on chain. + */ + async getBlobs(blobIds: string[]): Promise { + const res = await this.operations.getBlobs({ blobIds }); + const blobs: (string | null)[] = []; + + Object.keys(res).forEach((key) => { + // @ts-expect-error keys are strings + const val = res[key]; + blobs.push(val?.id ?? null); + }); + + return blobs.filter((v) => v) as string[]; + } + /** * Returns block matching the given ID or height. * diff --git a/packages/contract/src/contract-factory.ts b/packages/contract/src/contract-factory.ts index 6ed7e6ed31a..ed1aa573829 100644 --- a/packages/contract/src/contract-factory.ts +++ b/packages/contract/src/contract-factory.ts @@ -281,15 +281,19 @@ export default class ContractFactory { ...deployOptions, }); + // BlobIDs only need to be uploaded once and we can check if they exist on chain + const uniqueBlobIds = [...new Set(blobIds)]; + const uploadedBlobIds = await account.provider.getBlobs(uniqueBlobIds); + const blobIdsToUpload = uniqueBlobIds.filter((id) => !uploadedBlobIds.includes(id)); + // Check the account can afford to deploy all chunks and loader let totalCost = bn(0); const chainInfo = account.provider.getChain(); const gasPrice = await account.provider.estimateGasPrice(10); const priceFactor = chainInfo.consensusParameters.feeParameters.gasPriceFactor; - const estimatedBlobIds: string[] = []; for (const { transactionRequest, blobId } of chunks) { - if (!estimatedBlobIds.includes(blobId)) { + if (blobIdsToUpload.includes(blobId)) { const minGas = transactionRequest.calculateMinGas(chainInfo); const minFee = calculateGasFee({ gasPrice, @@ -299,7 +303,6 @@ export default class ContractFactory { }).add(1); totalCost = totalCost.add(minFee); - estimatedBlobIds.push(blobId); } const createMinGas = createRequest.calculateMinGas(chainInfo); const createMinFee = calculateGasFee({ @@ -325,7 +328,7 @@ export default class ContractFactory { const uploadedBlobs: string[] = []; // Deploy the chunks as blob txs for (const { blobId, transactionRequest } of chunks) { - if (!uploadedBlobs.includes(blobId)) { + if (!uploadedBlobs.includes(blobId) && blobIdsToUpload.includes(blobId)) { const fundedBlobRequest = await this.fundTransactionRequest( transactionRequest, deployOptions @@ -340,6 +343,7 @@ export default class ContractFactory { // Core will throw for blobs that have already been uploaded, but the blobId // is still valid so we can use this for the loader contract if ((err).message.indexOf(`BlobId is already taken ${blobId}`) > -1) { + uploadedBlobs.push(blobId); // eslint-disable-next-line no-continue continue; } diff --git a/packages/fuel-gauge/src/contract-factory.test.ts b/packages/fuel-gauge/src/contract-factory.test.ts index c2834ed30c5..ac964b70a73 100644 --- a/packages/fuel-gauge/src/contract-factory.test.ts +++ b/packages/fuel-gauge/src/contract-factory.test.ts @@ -518,4 +518,35 @@ describe('Contract Factory', () => { }) ); }); + + it('deploys large contract via blobs twice and only uploads blobs once', async () => { + using launched = await launchTestNode(); + + const { + wallets: [wallet], + } = launched; + + const sendTransactionSpy = vi.spyOn(wallet, 'sendTransaction'); + const factory = new ContractFactory(LargeContractFactory.bytecode, LargeContract.abi, wallet); + + const firstDeploy = await factory.deployAsBlobTx({ + salt: concat(['0x01', new Uint8Array(31)]), + }); + const { contract: firstContract } = await firstDeploy.waitForResult(); + const firstDeployCalls = sendTransactionSpy.mock.calls.length; + const secondDeploy = await factory.deployAsBlobTx({ + salt: concat(['0x02', new Uint8Array(31)]), + }); + const { contract: secondContract } = await secondDeploy.waitForResult(); + const secondDeployCalls = sendTransactionSpy.mock.calls.length; + expect(secondDeployCalls - firstDeployCalls).toBeLessThan(firstDeployCalls); + + const firstCall = await firstContract.functions.something().call(); + const { value: firstValue } = await firstCall.waitForResult(); + expect(firstValue.toNumber()).toBe(1001); + + const secondCall = await secondContract.functions.something().call(); + const { value: secondValue } = await secondCall.waitForResult(); + expect(secondValue.toNumber()).toBe(1001); + }, 25000); });