From 159fe33c93bc2b3a96148f28e20cdd3d58e41fe8 Mon Sep 17 00:00:00 2001 From: Daniel Bate Date: Wed, 28 Aug 2024 17:32:11 +0100 Subject: [PATCH 1/7] feat: validate blob IDs against chain --- .changeset/gorgeous-laws-search.md | 6 ++++ .../account/src/providers/operations.graphql | 6 ++++ packages/account/src/providers/provider.ts | 11 +++++++ packages/contract/src/contract-factory.ts | 17 +++++++--- .../fuel-gauge/src/contract-factory.test.ts | 31 +++++++++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 .changeset/gorgeous-laws-search.md 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/operations.graphql b/packages/account/src/providers/operations.graphql index c40006dbef1..1f9959a8880 100644 --- a/packages/account/src/providers/operations.graphql +++ b/packages/account/src/providers/operations.graphql @@ -897,6 +897,12 @@ query getMessageByNonce($nonce: Nonce!) { } } +query getBlob($blobId: BlobId!) { + blob(id: $blobId) { + id + } +} + subscription submitAndAwait($encodedTransaction: HexString!) { submitAndAwait(tx: $encodedTransaction) { ...transactionStatusSubscriptionFragment diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index c19359886b6..2ab2c67cb96 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -1330,6 +1330,17 @@ Supported fuel-core version: ${supportedVersion}.` return coins; } + /** + * Returns a blob ID if it exists. + * + * @param blobId - the blob ID to check. + * @returns A promise that resolves to the blob ID or null. + */ + async getBlob(blobId: string): Promise { + const { blob } = await this.operations.getBlob({ blobId }); + return blob?.id; + } + /** * 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..dcd2a9f6caf 100644 --- a/packages/contract/src/contract-factory.ts +++ b/packages/contract/src/contract-factory.ts @@ -281,15 +281,24 @@ export default class ContractFactory { ...deployOptions, }); + // BlobIDs only need to be uploaded once and we can check if they exist on chain + const blobsToUpload = [...new Set(blobIds)]; + for (const [index, blobId] of blobsToUpload.entries()) { + // Todo: refactor to a single call + const id = await account.provider.getBlob(blobId); + if (id) { + blobsToUpload.splice(index, 1); + } + } + // 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 (blobsToUpload.includes(blobId)) { const minGas = transactionRequest.calculateMinGas(chainInfo); const minFee = calculateGasFee({ gasPrice, @@ -299,7 +308,6 @@ export default class ContractFactory { }).add(1); totalCost = totalCost.add(minFee); - estimatedBlobIds.push(blobId); } const createMinGas = createRequest.calculateMinGas(chainInfo); const createMinFee = calculateGasFee({ @@ -325,7 +333,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) && blobsToUpload.includes(blobId)) { const fundedBlobRequest = await this.fundTransactionRequest( transactionRequest, deployOptions @@ -340,6 +348,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); }); From 306f4b7d59c89148d7a22e30739c1faffb81a501 Mon Sep 17 00:00:00 2001 From: Daniel Bate Date: Fri, 30 Aug 2024 15:59:42 +0100 Subject: [PATCH 2/7] feat: implement get blob as custom query --- packages/account/src/providers/provider.ts | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 88895ca5d19..bdb2e229308 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>; + getBlobCustom: (variables: { blobId: string }) => Promise<{ blob?: { id: string } | null }>; }; /** @@ -603,8 +606,22 @@ Supported fuel-core version: ${supportedVersion}.` return gqlClient.request(query, vars); }; + const customOperations = (requester: Requester) => ({ + getBlobCustom(variables: { blobId: string }) { + const document = gql` + query getBlob($blobId: BlobId!) { + blob(id: $blobId) { + id + } + } + `; + + return requester(document, variables); + }, + }); + // @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) }; } /** @@ -1337,8 +1354,8 @@ Supported fuel-core version: ${supportedVersion}.` * @returns A promise that resolves to the blob ID or null. */ async getBlob(blobId: string): Promise { - const { blob } = await this.operations.getBlob({ blobId }); - return blob?.id; + const { blob } = await this.operations.getBlobCustom({ blobId }); + return blob?.id ?? null; } /** From 19e2cafa36239fe9cdd79b0b8e62abcd2ac33b90 Mon Sep 17 00:00:00 2001 From: Daniel Bate Date: Mon, 2 Sep 2024 12:10:36 +0100 Subject: [PATCH 3/7] feat: query for any number of blob IDs --- packages/account/src/providers/provider.ts | 49 ++++++++++++++++------ packages/contract/src/contract-factory.ts | 15 +++---- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index bdb2e229308..bdae7b94aad 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -378,7 +378,7 @@ type SdkOperations = Omit & { statusChange: ( ...args: Parameters ) => Promise>; - getBlobCustom: (variables: { blobId: string }) => Promise<{ blob?: { id: string } | null }>; + getBlobs: (variables: { blobIds: string[] }) => Promise<{ blob: { id: string } | null }[]>; }; /** @@ -607,16 +607,31 @@ Supported fuel-core version: ${supportedVersion}.` }; const customOperations = (requester: Requester) => ({ - getBlobCustom(variables: { blobId: string }) { + 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 getBlob($blobId: BlobId!) { - blob(id: $blobId) { - id - } + query getBlobs(${queryParams}) { + ${blobParams} } `; - return requester(document, variables); + return requester(document, updatedVariables); }, }); @@ -1348,14 +1363,22 @@ Supported fuel-core version: ${supportedVersion}.` } /** - * Returns a blob ID if it exists. + * Returns an array of blobIds if they exist on chain, for a given array of blobIds. * - * @param blobId - the blob ID to check. - * @returns A promise that resolves to the blob ID or null. + * @param blobIds - blobIds to check. + * @returns - A promise that resolves to an array of blobIds that exist on chain. */ - async getBlob(blobId: string): Promise { - const { blob } = await this.operations.getBlobCustom({ blobId }); - return blob?.id ?? null; + async getBlobs(blobIds: string[]): Promise<(string | null)[]> { + 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); } /** diff --git a/packages/contract/src/contract-factory.ts b/packages/contract/src/contract-factory.ts index dcd2a9f6caf..ed1aa573829 100644 --- a/packages/contract/src/contract-factory.ts +++ b/packages/contract/src/contract-factory.ts @@ -282,14 +282,9 @@ export default class ContractFactory { }); // BlobIDs only need to be uploaded once and we can check if they exist on chain - const blobsToUpload = [...new Set(blobIds)]; - for (const [index, blobId] of blobsToUpload.entries()) { - // Todo: refactor to a single call - const id = await account.provider.getBlob(blobId); - if (id) { - blobsToUpload.splice(index, 1); - } - } + 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); @@ -298,7 +293,7 @@ export default class ContractFactory { const priceFactor = chainInfo.consensusParameters.feeParameters.gasPriceFactor; for (const { transactionRequest, blobId } of chunks) { - if (blobsToUpload.includes(blobId)) { + if (blobIdsToUpload.includes(blobId)) { const minGas = transactionRequest.calculateMinGas(chainInfo); const minFee = calculateGasFee({ gasPrice, @@ -333,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) && blobsToUpload.includes(blobId)) { + if (!uploadedBlobs.includes(blobId) && blobIdsToUpload.includes(blobId)) { const fundedBlobRequest = await this.fundTransactionRequest( transactionRequest, deployOptions From fef57cb30a0d548fe511fa4c329aa532cac3a662 Mon Sep 17 00:00:00 2001 From: Daniel Bate Date: Mon, 2 Sep 2024 16:25:27 +0100 Subject: [PATCH 4/7] chore: code style Co-authored-by: Anderson Arboleya --- packages/account/src/providers/provider.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index bdae7b94aad..5086a6929cb 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -610,11 +610,7 @@ Supported fuel-core version: ${supportedVersion}.` 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 - }` - ) + .map((_, i) => `blob${i}: blob(id: $blobId${i}) { id }`) .join('\n'); const updatedVariables = variables.blobIds.reduce( From 93f5f057d7310ccac86c02305b72b0f3f10160c1 Mon Sep 17 00:00:00 2001 From: Daniel Bate Date: Mon, 2 Sep 2024 16:25:51 +0100 Subject: [PATCH 5/7] chore: doc and return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nedim Salkić --- packages/account/src/providers/provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 5086a6929cb..1b5c18705c1 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -1359,12 +1359,12 @@ Supported fuel-core version: ${supportedVersion}.` } /** - * Returns an array of blobIds if they exist on chain, for a given array of blobIds. + * 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<(string | null)[]> { + async getBlobs(blobIds: string[]): Promise { const res = await this.operations.getBlobs({ blobIds }); const blobs: (string | null)[] = []; From 55be939efccd4268caa46bffd3b9bcd9b96294ea Mon Sep 17 00:00:00 2001 From: Daniel Bate Date: Mon, 2 Sep 2024 16:31:34 +0100 Subject: [PATCH 6/7] chore: cast after filter --- packages/account/src/providers/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts index 1b5c18705c1..f9926adda2b 100644 --- a/packages/account/src/providers/provider.ts +++ b/packages/account/src/providers/provider.ts @@ -1374,7 +1374,7 @@ Supported fuel-core version: ${supportedVersion}.` blobs.push(val?.id ?? null); }); - return blobs.filter((v) => v); + return blobs.filter((v) => v) as string[]; } /** From 03d54f300b208adea31b3950bd6809795607d17d Mon Sep 17 00:00:00 2001 From: Daniel Bate Date: Tue, 3 Sep 2024 09:08:41 +0100 Subject: [PATCH 7/7] chore: remove redundant query Co-authored-by: Peter Smith --- packages/account/src/providers/operations.graphql | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/account/src/providers/operations.graphql b/packages/account/src/providers/operations.graphql index 1f9959a8880..c40006dbef1 100644 --- a/packages/account/src/providers/operations.graphql +++ b/packages/account/src/providers/operations.graphql @@ -897,12 +897,6 @@ query getMessageByNonce($nonce: Nonce!) { } } -query getBlob($blobId: BlobId!) { - blob(id: $blobId) { - id - } -} - subscription submitAndAwait($encodedTransaction: HexString!) { submitAndAwait(tx: $encodedTransaction) { ...transactionStatusSubscriptionFragment