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

feat: validate blob IDs against chain in chunk deploys #3047

Merged
merged 12 commits into from
Sep 3, 2024
Merged
6 changes: 6 additions & 0 deletions .changeset/gorgeous-laws-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@fuel-ts/contract": patch
"@fuel-ts/account": patch
---

feat: validate blob IDs against chain in chunk deploys
6 changes: 6 additions & 0 deletions packages/account/src/providers/operations.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,12 @@ query getMessageByNonce($nonce: Nonce!) {
}
}

query getBlob($blobId: BlobId!) {
blob(id: $blobId) {
id
}
}

danielbate marked this conversation as resolved.
Show resolved Hide resolved
subscription submitAndAwait($encodedTransaction: HexString!) {
submitAndAwait(tx: $encodedTransaction) {
...transactionStatusSubscriptionFragment
Expand Down
49 changes: 48 additions & 1 deletion packages/account/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -376,6 +378,7 @@ type SdkOperations = Omit<Operations, 'submitAndAwait' | 'statusChange'> & {
statusChange: (
...args: Parameters<Operations['statusChange']>
) => Promise<ReturnType<Operations['statusChange']>>;
getBlobs: (variables: { blobIds: string[] }) => Promise<{ blob: { id: string } | null }[]>;
};

/**
Expand Down Expand Up @@ -603,8 +606,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<string, string>
);

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

/**
Expand Down Expand Up @@ -1330,6 +1358,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<string[]> {
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.
*
Expand Down
12 changes: 8 additions & 4 deletions packages/contract/src/contract-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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
Expand All @@ -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 ((<Error>err).message.indexOf(`BlobId is already taken ${blobId}`) > -1) {
uploadedBlobs.push(blobId);
// eslint-disable-next-line no-continue
continue;
}
Expand Down
31 changes: 31 additions & 0 deletions packages/fuel-gauge/src/contract-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LargeContract>({
salt: concat(['0x01', new Uint8Array(31)]),
});
const { contract: firstContract } = await firstDeploy.waitForResult();
const firstDeployCalls = sendTransactionSpy.mock.calls.length;
const secondDeploy = await factory.deployAsBlobTx<LargeContract>({
salt: concat(['0x02', new Uint8Array(31)]),
});
const { contract: secondContract } = await secondDeploy.waitForResult();
const secondDeployCalls = sendTransactionSpy.mock.calls.length;
expect(secondDeployCalls - firstDeployCalls).toBeLessThan(firstDeployCalls);
danielbate marked this conversation as resolved.
Show resolved Hide resolved

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