From 8030e0e5d7d992dd458d42bb3f5d28e661128caa Mon Sep 17 00:00:00 2001 From: Oluwapelumi Benjamin Amuzu <35280873+THErealARETE@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:42:39 +0100 Subject: [PATCH] test: implement contract update/maintenance and optimize contract operations - Add updateContract method for contract maintenance operations - Add getRngSeed() to testdata-provider for centralized RNG seed management - Refactor deployContract, callContract, and updateContract to use testdata-provider - Extract common validation logic (validateDeploymentResult) - Extract common transaction sending logic (sendTransactionFile) - Update GraphQL query to include transaction field for ContractUpdate - Implement all three contract update tests - Follow DRY principles and best practices across all contract methods --- .../e2e/contract-actions-sequential.test.ts | 90 +++++- .../utils/indexer/graphql/contract-queries.ts | 7 + qa/tests/utils/testdata-provider.ts | 35 +++ qa/tests/utils/toolkit/toolkit-wrapper.ts | 269 ++++++++++++------ 4 files changed, 310 insertions(+), 91 deletions(-) diff --git a/qa/tests/tests/e2e/contract-actions-sequential.test.ts b/qa/tests/tests/e2e/contract-actions-sequential.test.ts index 849c3698..446acce3 100644 --- a/qa/tests/tests/e2e/contract-actions-sequential.test.ts +++ b/qa/tests/tests/e2e/contract-actions-sequential.test.ts @@ -26,7 +26,7 @@ import { import { Transaction } from '@utils/indexer/indexer-types'; const TOOLKIT_WRAPPER_TIMEOUT = 60_000; // 1 minute -const CONTRACT_ACTION_TIMEOUT = 150_000; // 2.5 minutes +const CONTRACT_ACTION_TIMEOUT = 300_000; // 5 minutes const TEST_TIMEOUT = 10_000; // 10 seconds describe.sequential('contract actions', () => { @@ -34,6 +34,7 @@ describe.sequential('contract actions', () => { let toolkit: ToolkitWrapper; let contractDeployResult: DeployContractResult; let contractCallResult: ToolkitTransactionResult; + let contractUpdateResult: ToolkitTransactionResult; beforeAll(async () => { indexerHttpClient = new IndexerHttpClient(); @@ -277,10 +278,25 @@ describe.sequential('contract actions', () => { }); describe('a transaction to update a smart contract', () => { + let contractUpdateBlockHash: string; + let contractUpdateTransactionHash: string; + beforeAll(async () => { - // TODO: updateContract method is not yet implemented in ToolkitWrapper - // This section is empty for now until updateContract is implemented - }); + // Use entrypoints from the toolkit container (these paths exist in the toolkit image) + // The script copies increment.verifier to increment2.verifier, but for simplicity + // we'll use the existing increment.verifier as an entrypoint + contractUpdateResult = await toolkit.updateContract(contractDeployResult, { + entryPoints: ['/toolkit-js/test/contract/managed/counter/keys/increment.verifier'], + }); + + expect(contractUpdateResult.status).toBe('confirmed'); + log.debug(`Raw output: ${JSON.stringify(contractUpdateResult.rawOutput, null, 2)}`); + log.debug(`Transaction hash: ${contractUpdateResult.txHash}`); + log.debug(`Block hash: ${contractUpdateResult.blockHash}`); + + contractUpdateBlockHash = contractUpdateResult.blockHash; + contractUpdateTransactionHash = contractUpdateResult.txHash; + }, CONTRACT_ACTION_TIMEOUT); /** * Once a contract update transaction has been submitted to node and confirmed, the indexer should report @@ -290,9 +306,29 @@ describe.sequential('contract actions', () => { * @when we query the indexer with a transaction query by hash, using the transaction hash reported by the toolkit * @then the transaction should be found and reported correctly */ - test.todo( + test( 'should be reported by the indexer through a transaction query by hash', - async () => {}, + async (context: TestContext) => { + context.task!.meta.custom = { + labels: ['Query', 'Transaction', 'ByHash', 'ContractUpdate'], + }; + + const transactionResponse = await getTransactionByHashWithRetry( + contractUpdateTransactionHash, + ); + + // Verify the transaction appears in the response + expect(transactionResponse?.data?.transactions).toBeDefined(); + expect(transactionResponse?.data?.transactions?.length).toBeGreaterThan(0); + + // Find our specific transaction by hash + const foundTransaction = transactionResponse.data?.transactions?.find( + (tx: Transaction) => tx.hash === contractUpdateTransactionHash, + ); + + expect(foundTransaction).toBeDefined(); + expect(foundTransaction?.hash).toBe(contractUpdateTransactionHash); + }, TEST_TIMEOUT, ); @@ -304,9 +340,20 @@ describe.sequential('contract actions', () => { * @when we query the indexer with a block query by hash, using the block hash reported by the toolkit * @then the block should contain the contract update transaction */ - test.todo( + test( 'should be reported by the indexer through a block query by hash', - async () => {}, + async (context: TestContext) => { + context.task!.meta.custom = { + labels: ['Query', 'Block', 'ByHash', 'ContractUpdate'], + }; + + const blockResponse = await getBlockByHashWithRetry(contractUpdateBlockHash); + + // Verify the block appears in the response + expect(blockResponse).toBeSuccess(); + expect(blockResponse.data?.block).toBeDefined(); + expect(blockResponse.data?.block?.hash).toBe(contractUpdateBlockHash); + }, TEST_TIMEOUT, ); @@ -318,9 +365,32 @@ describe.sequential('contract actions', () => { * @when we query the indexer with a contract action query by address * @then the contract action should be found with __typename 'ContractUpdate' */ - test.todo( + test( 'should be reported by the indexer through a contract action query by address', - async () => {}, + async (context: TestContext) => { + context.task!.meta.custom = { + labels: ['Query', 'ContractAction', 'ByAddress', 'ContractUpdate'], + }; + + // Query the contract action by address (using the contract address for GraphQL queries) + const contractActionResponse = await indexerHttpClient.getContractAction( + contractDeployResult['contract-address-untagged'], + ); + + // Verify the contract action appears in the response + expect(contractActionResponse?.data?.contractAction).toBeDefined(); + + const contractAction = contractActionResponse.data?.contractAction; + expect(contractAction?.__typename).toBe('ContractUpdate'); + + if (contractAction?.__typename === 'ContractUpdate') { + expect(contractAction.address).toBeDefined(); + expect(contractAction.address).toBe(contractDeployResult['contract-address-untagged']); + expect(contractAction.state).toBeDefined(); + expect(contractAction.transaction).toBeDefined(); + expect(contractAction.transaction?.hash).toBeDefined(); + } + }, TEST_TIMEOUT, ); }); diff --git a/qa/tests/utils/indexer/graphql/contract-queries.ts b/qa/tests/utils/indexer/graphql/contract-queries.ts index 4eab6a39..c3b0a216 100644 --- a/qa/tests/utils/indexer/graphql/contract-queries.ts +++ b/qa/tests/utils/indexer/graphql/contract-queries.ts @@ -32,6 +32,13 @@ export const CONTRACT_ACTION_LIGHT_BODY = ` } } ... on ContractUpdate { + transaction { + hash + block { + hash + height + } + } unshieldedBalances { tokenType amount diff --git a/qa/tests/utils/testdata-provider.ts b/qa/tests/utils/testdata-provider.ts index 0967786c..bfb7e44d 100644 --- a/qa/tests/utils/testdata-provider.ts +++ b/qa/tests/utils/testdata-provider.ts @@ -88,6 +88,41 @@ class TestDataProvider { return undeployedFundingSeed; } + /** + * Gets the RNG (Random Number Generator) seed for contract operations. + * The RNG seed is used to generate deterministic contract addresses and transaction randomness. + * First checks for an environment-specific variable (e.g., RNG_SEED_PREVIEW), + * then falls back to a default seed for undeployed environments. + * + * Note that for node-dev-01 the variable will have to be RNG_SEED_NODE_DEV_01 + * as "-" is not allowed in environment variable names. + * @returns The RNG seed as a string (64 hex characters). + */ + getRngSeed() { + // Build the environment-specific variable name (e.g., RNG_SEED_PREVIEW) + const envName = env.getCurrentEnvironmentName(); + const envNameUppercase = envName.toUpperCase().replace(/-/g, '_'); + const envVarName = `RNG_SEED_${envNameUppercase}`; + + // Try environment-specific variable first + const rngSeed = process.env[envVarName]; + + if (rngSeed) { + return rngSeed; + } + + if (envName !== 'undeployed') { + throw new Error( + `Please provide an RNG seed for ${envName} environment by setting up a variable named RNG_SEED_${envNameUppercase}`, + ); + } + + // Default fallback - using the same seed as deployContract for consistency + // This ensures deterministic contract addresses across test runs + const undeployedRngSeed = '00000000000000000000000000000000000000000000000000000000000000AB'; + return undeployedRngSeed; + } + /** * Retrieves an unshielded address from the test data by property name. * @param property - The property name of the unshielded address to retrieve. diff --git a/qa/tests/utils/toolkit/toolkit-wrapper.ts b/qa/tests/utils/toolkit/toolkit-wrapper.ts index c0fb6159..f20b6906 100644 --- a/qa/tests/utils/toolkit/toolkit-wrapper.ts +++ b/qa/tests/utils/toolkit/toolkit-wrapper.ts @@ -21,6 +21,7 @@ import { env } from '../../environment/model'; import { GenericContainer, StartedTestContainer } from 'testcontainers'; import { getContractDeploymentHashes } from '../../tests/e2e/test-utils'; import { z } from 'zod'; +import dataProvider from '../testdata-provider'; import { Coin, DustBalance, @@ -684,31 +685,28 @@ class ToolkitWrapper { } /** - * Call a smart contract function by generating and submitting a circuit transaction. - * This method retrieves the current contract state, generates a circuit intent for the specified - * contract call, converts it to a transaction, and submits it to the network. + * Validates that the container is started and the deployment result is valid. + * Extracts and returns the contract address from the deployment result. * - * @param callKey - The contract function to call (e.g., 'increment'). Defaults to 'increment'. - * @param deploymentResult - The deployment result object from deployContract. The contract-address-untagged will be extracted. - * @param rngSeed - The random number generator seed for the transaction. Defaults to a fixed seed. - * @returns A promise that resolves to the transaction result containing the transaction hash, - * optional block hash, and submission status. - * @throws Error if the container is not started or if any step in the contract call process fails. + * @param deploymentResult - The deployment result to validate + * @param operationName - The name of the operation (e.g., 'call', 'update') for error messages + * @returns The untagged contract address + * @throws Error if validation fails */ - async callContract( - callKey: string = 'increment', + private validateDeploymentResult( deploymentResult: DeployContractResult, - rngSeed: string = '0000000000000000000000000000000000000000000000000000000000000037', - ): Promise { + operationName: string, + ): string { if (!this.startedContainer) { throw new Error('Container is not started. Call start() first.'); } - // Validate deployment result if (!deploymentResult) { - log.error('No deployment result provided. Cannot call contract without a valid deployment.'); + log.error( + `No deployment result provided. Cannot ${operationName} contract without a valid deployment.`, + ); throw new Error( - 'Deployment result is required but was not provided. Ensure deployContract() succeeded before calling callContract().', + `Deployment result is required but was not provided. Ensure deployContract() succeeded before calling ${operationName}Contract().`, ); } @@ -723,23 +721,76 @@ class ToolkitWrapper { ); } - const txFile = `/out/${callKey}_tx.mn`; + return contractAddressUntagged; + } - log.info(`Generating ${callKey} contract call...`); - const result = await this.startedContainer.exec([ + /** + * Sends a transaction file to the network and returns the raw output. + * + * @param txFile - The transaction file name (e.g., 'deploy_tx.mn') + * @param operationName - The name of the operation for logging + * @returns The raw output from the send command + * @throws Error if sending fails + */ + private async sendTransactionFile(txFile: string, operationName: string): Promise { + log.info(`Submitting ${operationName} transaction to network...`); + const sendResult = await this.startedContainer!.exec([ + '/midnight-node-toolkit', + 'generate-txs', + '--src-file', + `/out/${txFile}`, + '--dest-url', + env.getNodeWebsocketBaseURL(), + 'send', + ]); + + if (sendResult.exitCode !== 0) { + const e = sendResult.stderr || sendResult.output || 'Unknown error'; + throw new Error(`generate-txs send failed: ${e}`); + } + + return sendResult.output.trim(); + } + + /** + * Call a smart contract function by generating and submitting a circuit transaction. + * This method retrieves the current contract state, generates a circuit intent for the specified + * contract call, converts it to a transaction, and submits it to the network. + * + * @param callKey - The contract function to call (e.g., 'increment'). Defaults to 'increment'. + * @param deploymentResult - The deployment result object from deployContract. The contract-address-untagged will be extracted. + * @param rngSeed - The random number generator seed for the transaction. Defaults to value from testdata-provider. + * @returns A promise that resolves to the transaction result containing the transaction hash, + * optional block hash, and submission status. + * @throws Error if the container is not started or if any step in the contract call process fails. + */ + async callContract( + callKey: string = 'increment', + deploymentResult: DeployContractResult, + rngSeed?: string, + ): Promise { + const contractAddressUntagged = this.validateDeploymentResult(deploymentResult, 'call'); + const fundingSeed = dataProvider.getFundingSeed(); + const rngSeedValue = rngSeed ?? dataProvider.getRngSeed(); + + log.info(`Generating and submitting ${callKey} contract call...`); + const result = await this.startedContainer!.exec([ '/midnight-node-toolkit', 'generate-txs', - '--dest-file', - txFile, - '--to-bytes', 'contract-simple', 'call', + '--contract-address', + contractAddressUntagged, '--call-key', callKey, + '--funding-seed', + fundingSeed, '--rng-seed', - rngSeed, - '--contract-address', - contractAddressUntagged, + rngSeedValue, + '--src-url', + env.getNodeWebsocketBaseURL(), + '--dest-url', + env.getNodeWebsocketBaseURL(), ]); if (result.exitCode !== 0) { @@ -747,23 +798,90 @@ class ToolkitWrapper { throw new Error(`Failed to generate contract call: ${errorMessage}`); } - log.info('Submitting transaction to network...'); - const sendResult = await this.startedContainer.exec([ + const rawOutput = result.output.trim(); + return this.parseTransactionOutput(rawOutput); + } + + /** + * Update (maintain) a smart contract on the network. + * This method generates a maintenance transaction, submits it to the network, + * and returns the transaction result. + * + * @param deploymentResult - The deployment result object from deployContract. The contract-address-untagged will be extracted. + * @param options - Optional parameters for maintenance: + * - counter: The counter value for the maintenance operation (default: 0) + * - rngSeed: The random number generator seed (default: value from testdata-provider) + * - entryPoints: Array of entrypoint verifier file paths (optional, for adding/updating entrypoints) + * - authoritySeed: Authority seed for maintenance (optional, only supported in latest-arm64) + * @returns A promise that resolves to the transaction result containing the transaction hash, + * optional block hash, and submission status. + * @throws Error if the container is not started or if any step in the maintenance process fails. + */ + async updateContract( + deploymentResult: DeployContractResult, + options: { + counter?: number; + rngSeed?: string; + entryPoints?: string[]; + authoritySeed?: string; + } = {}, + ): Promise { + const contractAddressUntagged = this.validateDeploymentResult(deploymentResult, 'update'); + + const { counter = 0, entryPoints = [], authoritySeed } = options; + + const fundingSeed = dataProvider.getFundingSeed(); + const rngSeed = options.rngSeed ?? dataProvider.getRngSeed(); + const maintenanceTx = 'maintenance_tx.mn'; + + // Generate maintenance transaction + log.info('Generating contract maintenance transaction...'); + const generateArgs = [ '/midnight-node-toolkit', 'generate-txs', - '--src-file', - txFile, - '--dest-url', - env.getNodeWebsocketBaseURL(), - 'send', - ]); + '--dest-file', + `/out/${maintenanceTx}`, + '--to-bytes', + 'contract-simple', + 'maintenance', + '--contract-address', + contractAddressUntagged, + '--funding-seed', + fundingSeed, + '--counter', + counter.toString(), + '--rng-seed', + rngSeed, + ]; - if (sendResult.exitCode !== 0) { - const errorMessage = sendResult.stderr || sendResult.output || 'Unknown error occurred'; - throw new Error(`Failed to submit transaction: ${errorMessage}`); + // Add authority-seed if provided (only supported in latest-arm64) + if (authoritySeed) { + generateArgs.push('--authority-seed', authoritySeed); + } + + // Add entrypoints if provided + for (const entryPoint of entryPoints) { + generateArgs.push('--upsert-entrypoint', entryPoint); + } + + const generateResult = await this.startedContainer!.exec(generateArgs); + + log.debug(`contract-simple maintenance generate command output:\n${generateResult.output}`); + log.debug(`contract-simple maintenance generate command stderr:\n${generateResult.stderr}`); + log.debug(`contract-simple maintenance generate exit code: ${generateResult.exitCode}`); + + if (generateResult.exitCode !== 0) { + const e = generateResult.stderr || generateResult.output || 'Unknown error'; + throw new Error(`contract-simple maintenance generate failed: ${e}`); + } + + const outMaintenanceTx = join(this.config.targetDir!, maintenanceTx); + if (!fs.existsSync(outMaintenanceTx)) { + throw new Error('contract-simple maintenance did not produce expected output file'); } - const rawOutput = sendResult.output.trim(); + // Send the maintenance transaction + const rawOutput = await this.sendTransactionFile(maintenanceTx, 'maintenance'); return this.parseTransactionOutput(rawOutput); } @@ -772,69 +890,58 @@ class ToolkitWrapper { * This method generates a deployment intent, converts it to a transaction, submits it to the network, * and retrieves both tagged and untagged contract addresses. * + * @param rngSeed - The random number generator seed for deployment. Defaults to value from testdata-provider. + * The RNG seed determines the contract address deterministically. * @returns A promise that resolves to the deployment result containing untagged address, tagged address, and coin public key. * @throws Error if the container is not started or if any step in the deployment process fails. */ - async deployContract(): Promise { + async deployContract(rngSeed?: string): Promise { if (!this.startedContainer) { throw new Error('Container is not started. Call start() first.'); } const outDir = this.config.targetDir!; - const deployTx = 'deploy_tx.mn'; - const outDeployTx = join(outDir, deployTx); - const coinPublicSeed = '0000000000000000000000000000000000000000000000000000000000000001'; + const rngSeedValue = rngSeed ?? dataProvider.getRngSeed(); + + // Get coin public key from the funding seed (same seed used for funding) + const coinPublicSeed = dataProvider.getFundingSeed(); const addressInfo = await this.showAddress(coinPublicSeed); const coinPublic = addressInfo.coinPublic; - { - const result = await this.startedContainer.exec([ - '/midnight-node-toolkit', - 'generate-txs', - '--dest-file', - `/out/${deployTx}`, - '--to-bytes', - 'contract-simple', - 'deploy', - '--rng-seed', - '0000000000000000000000000000000000000000000000000000000000000037', - ]); - - log.debug(`contract-simple deploy command output:\n${result.output}`); - log.debug(`contract-simple deploy command stderr:\n${result.stderr}`); - log.debug(`contract-simple deploy exit code: ${result.exitCode}`); - - if (result.exitCode !== 0) { - const e = result.stderr || result.output || 'Unknown error'; - throw new Error(`contract-simple deploy failed: ${e}`); - } + // Generate deployment transaction + const result = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'generate-txs', + '--dest-file', + `/out/${deployTx}`, + '--to-bytes', + 'contract-simple', + 'deploy', + '--rng-seed', + rngSeedValue, + ]); - log.debug(`Checking for output files:`); - log.debug(` ${outDeployTx} exists: ${fs.existsSync(outDeployTx)}`); + log.debug(`contract-simple deploy command output:\n${result.output}`); + log.debug(`contract-simple deploy command stderr:\n${result.stderr}`); + log.debug(`contract-simple deploy exit code: ${result.exitCode}`); - if (!fs.existsSync(outDeployTx)) { - throw new Error('contract-simple deploy did not produce expected output file'); - } + if (result.exitCode !== 0) { + const e = result.stderr || result.output || 'Unknown error'; + throw new Error(`contract-simple deploy failed: ${e}`); } - { - const result = await this.startedContainer.exec([ - '/midnight-node-toolkit', - 'generate-txs', - '--src-file', - `/out/${deployTx}`, - '--dest-url', - env.getNodeWebsocketBaseURL(), - 'send', - ]); - if (result.exitCode !== 0) { - const e = result.stderr || result.output || 'Unknown error'; - throw new Error(`generate-txs send failed: ${e}`); - } + log.debug(`Checking for output files:`); + log.debug(` ${outDeployTx} exists: ${fs.existsSync(outDeployTx)}`); + + if (!fs.existsSync(outDeployTx)) { + throw new Error('contract-simple deploy did not produce expected output file'); } + // Send the deployment transaction (output not needed for deploy, only for parsing transaction results) + await this.sendTransactionFile(deployTx, 'deployment'); + const contractAddressTagged = await this.getContractAddress(deployTx, 'tagged'); const contractAddressUntagged = await this.getContractAddress(deployTx, 'untagged'); const { txHash, blockHash } = await getContractDeploymentHashes(contractAddressUntagged);