diff --git a/qa/tests/tests/e2e/contract-actions-sequential.test.ts b/qa/tests/tests/e2e/contract-actions-sequential.test.ts index 849c3698..4754fd16 100644 --- a/qa/tests/tests/e2e/contract-actions-sequential.test.ts +++ b/qa/tests/tests/e2e/contract-actions-sequential.test.ts @@ -25,9 +25,11 @@ import { } from '@utils/toolkit/toolkit-wrapper'; 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 TEST_TIMEOUT = 10_000; // 10 seconds +const TOOLKIT_WRAPPER_TIMEOUT = 60_000; +const DEPLOY_TIMEOUT = 300_000; +const CALL_TIMEOUT = 180_000; +const MAINTENANCE_TIMEOUT = 300_000; +const TEST_TIMEOUT = 10_000; describe.sequential('contract actions', () => { let indexerHttpClient: IndexerHttpClient; @@ -38,6 +40,9 @@ describe.sequential('contract actions', () => { beforeAll(async () => { indexerHttpClient = new IndexerHttpClient(); + // Use default toolkit version from environment (NODE_TOOLKIT_TAG env var or NODE_VERSION file) + // Note: Contract maintenance features require toolkit version that supports maintenance commands + // Override with NODE_TOOLKIT_TAG environment variable if needed toolkit = new ToolkitWrapper({}); await toolkit.start(); }, TOOLKIT_WRAPPER_TIMEOUT); @@ -49,7 +54,7 @@ describe.sequential('contract actions', () => { describe('a transaction to deploy a smart contract', () => { beforeAll(async () => { contractDeployResult = await toolkit.deployContract(); - }, CONTRACT_ACTION_TIMEOUT); + }, DEPLOY_TIMEOUT); /** * Once a contract deployment transaction has been submitted to node and confirmed, the indexer should report @@ -136,7 +141,7 @@ describe.sequential('contract actions', () => { labels: ['Query', 'ContractAction', 'ByAddress', 'ContractDeploy'], }; - // Query the contract action by address (using the contract address for GraphQL queries) + // Query the contract action by address const contractActionResponse = await indexerHttpClient.getContractAction( contractDeployResult['contract-address-untagged'], ); @@ -168,16 +173,13 @@ describe.sequential('contract actions', () => { let contractCallTransactionHash: string; beforeAll(async () => { - contractCallResult = await toolkit.callContract('store', contractDeployResult); + contractCallResult = await toolkit.callContract('increment', contractDeployResult); expect(contractCallResult.status).toBe('confirmed'); - log.debug(`Raw output: ${JSON.stringify(contractCallResult.rawOutput, null, 2)}`); - log.debug(`Transaction hash: ${contractCallResult.txHash}`); - log.debug(`Block hash: ${contractCallResult.blockHash}`); contractCallBlockHash = contractCallResult.blockHash; contractCallTransactionHash = contractCallResult.txHash; - }, CONTRACT_ACTION_TIMEOUT); + }, CALL_TIMEOUT); /** * Once a contract call transaction has been submitted to node and confirmed, the indexer should report @@ -263,7 +265,6 @@ describe.sequential('contract actions', () => { const contractAction = contractActionResponse.data?.contractAction; expect(contractAction?.__typename).toBe('ContractCall'); - if (contractAction?.__typename === 'ContractCall') { expect(contractAction.address).toBeDefined(); expect(contractAction.address).toBe(contractDeployResult['contract-address-untagged']); @@ -277,10 +278,44 @@ describe.sequential('contract actions', () => { }); describe('a transaction to update a smart contract', () => { + let contractUpdateResult: ToolkitTransactionResult; + 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 - }); + if (!contractDeployResult) { + throw new Error( + 'Contract deployment result is required. Ensure deployContract() completed successfully.', + ); + } + + const contractAddress = contractDeployResult['contract-address-untagged']; + + contractUpdateResult = await toolkit.performContractMaintenance(contractAddress, { + authoritySeed: '0000000000000000000000000000000000000000000000000000000000000001', + newAuthoritySeed: '1000000000000000000000000000000000000000000000000000000000000001', + counter: 0, + rngSeed: '0000000000000000000000000000000000000000000000000000000000000001', + removeEntrypoints: ['decrement'], + upsertEntrypoints: [ + '/toolkit-js/contract/managed/counter/keys/increment.verifier', + '/toolkit-js/contract/managed/counter/keys/increment2.verifier', + ], + prepareVerifiers: [ + { + source: 'managed/counter/keys/increment.verifier', + target: 'managed/counter/keys/increment2.verifier', + }, + ], + }); + + await new Promise((resolve) => setTimeout(resolve, 10_000)); + + expect(contractUpdateResult.status).toBe('confirmed'); + + contractUpdateBlockHash = contractUpdateResult.blockHash; + contractUpdateTransactionHash = contractUpdateResult.txHash; + }, MAINTENANCE_TIMEOUT); /** * Once a contract update transaction has been submitted to node and confirmed, the indexer should report @@ -290,9 +325,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 +359,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 +384,29 @@ 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']); + } + }, TEST_TIMEOUT, ); }); diff --git a/qa/tests/utils/toolkit/toolkit-wrapper.ts b/qa/tests/utils/toolkit/toolkit-wrapper.ts index 925bd125..a3ae1bc9 100644 --- a/qa/tests/utils/toolkit/toolkit-wrapper.ts +++ b/qa/tests/utils/toolkit/toolkit-wrapper.ts @@ -15,6 +15,7 @@ import fs from 'fs'; import { join, resolve } from 'path'; +import { execSync } from 'child_process'; import { retry } from '../retry-helper'; import log from '@utils/logging/logger'; import { env } from '../../environment/model'; @@ -77,6 +78,7 @@ class ToolkitWrapper { private container: GenericContainer; private startedContainer?: StartedTestContainer; private config: ToolkitConfig; + private contractDir?: string; private parseTransactionOutput(output: string): ToolkitTransactionResult { const lines = output.trim().split('\n'); @@ -135,7 +137,6 @@ class ToolkitWrapper { // Ensure the target directory exists if (!fs.existsSync(this.config.targetDir)) { fs.mkdirSync(this.config.targetDir, { recursive: true }); - console.debug(`[SETUP]Created target directory: ${this.config.targetDir}`); } // This block is making sure that if a golden cache directory is available, we use it. @@ -177,22 +178,36 @@ class ToolkitWrapper { log.debug(`Toolkit container name : ${this.config.containerName}`); log.debug(`Toolkit sync cache dir : ${this.config.syncCacheDir}`); + // Set up contract directory path + this.contractDir = join(this.config.targetDir!, 'contract'); + + // Prepare bind mounts + const bindMounts = [ + { + source: this.config.targetDir, + target: '/out', + }, + { + source: this.config.syncCacheDir, + target: `/.cache/sync`, + }, + ]; + + // Add contract directory mount if it exists (will be created in start()) + if (this.contractDir) { + bindMounts.push({ + source: this.contractDir, + target: '/toolkit-js/contract', + }); + } + this.container = new GenericContainer( `ghcr.io/midnight-ntwrk/midnight-node-toolkit:${this.config.nodeToolkitTag}`, ) .withName(this.config.containerName) .withNetworkMode('host') // equivalent to --network host .withEntrypoint([]) // equivalent to --entrypoint "" - .withBindMounts([ - { - source: this.config.targetDir, - target: '/out', - }, - { - source: this.config.syncCacheDir, - target: `/.cache/sync`, - }, - ]) + .withBindMounts(bindMounts) .withCommand(['sleep', 'infinity']); // equivalent to sleep infinity } @@ -217,6 +232,35 @@ class ToolkitWrapper { log.debug(`Cleaned output directory: ${this.config.targetDir}`); } + // Copy contract directory from toolkit image + if (this.contractDir) { + log.debug('Copying contract directory from toolkit image...'); + try { + const toolkitImage = `ghcr.io/midnight-ntwrk/midnight-node-toolkit:${this.config.nodeToolkitTag}`; + + // Create temporary container to copy from + const tmpContainerId = execSync(`docker create ${toolkitImage}`, { + encoding: 'utf-8', + }).trim(); + + try { + // Copy contract directory + execSync(`docker cp ${tmpContainerId}:/toolkit-js/test/contract ${this.contractDir}`, { + encoding: 'utf-8', + stdio: 'inherit', + }); + log.debug(`Contract directory copied to: ${this.contractDir}`); + } finally { + // Clean up temporary container + execSync(`docker rm -v ${tmpContainerId}`, { encoding: 'utf-8', stdio: 'ignore' }); + } + } catch (error) { + log.warn( + `Failed to copy contract directory: ${error}. Intent-based deployment may not work.`, + ); + } + } + this.startedContainer = await retry(async () => this.container.start(), { maxRetries: 2, delayMs: 2_000, @@ -270,12 +314,12 @@ class ToolkitWrapper { (await this.showAddress('0'.repeat(63) + '9')).unshielded, 1, ); - console.debug(`[SETUP] Warmup cache output:\n${JSON.stringify(output, null, 2)}`); + log.debug(`Warmup cache output:\n${JSON.stringify(output, null, 2)}`); } catch (_error) { log.debug( 'Heads up, we are expecting an error here, the following log message is only reported for debugging purposes', ); - console.debug(`${_error}`); + log.debug(`Warmup cache error: ${_error}`); } } @@ -377,8 +421,6 @@ class ToolkitWrapper { amount.toString(), ]); - log.debug(`Generate single transaction output:\n${result.output}`); - if (result.exitCode !== 0) { const errorMessage = result.stderr || result.output || 'Unknown error occurred'; throw new Error(`Toolkit command failed with exit code ${result.exitCode}: ${errorMessage}`); @@ -411,7 +453,6 @@ class ToolkitWrapper { '--src-file', `/out/${contractFile}`, ]); - log.debug(`contract-address taggedAddress:\n${JSON.stringify(addressResult, null, 2)}`); if (addressResult.exitCode !== 0) { const e = addressResult.stderr || addressResult.output || 'Unknown error'; throw new Error(`contract-address failed: ${e}`); @@ -421,12 +462,15 @@ 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. + * Call a smart contract function using the intent-based approach. + * This method: + * 1. Gets the current contract state from the chain + * 2. Generates a circuit intent for the specified contract call + * 3. Converts the intent to a transaction + * 4. 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 deploymentResult - The deployment result object from deployContract. The contract-address-untagged and coin-public 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. @@ -450,54 +494,118 @@ class ToolkitWrapper { } const contractAddressUntagged = deploymentResult['contract-address-untagged']; + const coinPublic = deploymentResult['coin-public']; if (!contractAddressUntagged) { - log.error('Deployment result is missing contract address. Deployment may have failed.'); - log.debug(`Deployment result received: ${JSON.stringify(deploymentResult, null, 2)}`); throw new Error( 'Contract address is missing in deployment result. The contract deployment may have failed. ' + 'Please check deployment logs and ensure deployContract() completed successfully.', ); } - const txFile = `/out/${callKey}_tx.mn`; + if (!coinPublic) { + throw new Error('Coin public key is missing in deployment result.'); + } - log.info(`Generating ${callKey} contract call...`); - const result = await this.startedContainer.exec([ + const callTx = `${callKey}_tx.mn`; + const callIntent = `${callKey}_intent.bin`; + const contractStateFile = 'contract_state.mn'; + const callPrivateState = `${callKey}_ps_state.json`; + const callZswapState = `${callKey}_zswap_state.json`; + + // Step 1: Get contract state from chain + log.info('Getting contract state from chain...'); + const stateResult = await this.startedContainer.exec([ '/midnight-node-toolkit', - 'generate-txs', + 'contract-state', + '--src-url', + env.getNodeWebsocketBaseURL(), + '--contract-address', + contractAddressUntagged, '--dest-file', - txFile, - '--to-bytes', - 'contract-simple', - 'call', - '--call-key', - callKey, - '--rng-seed', - rngSeed, + `/out/${contractStateFile}`, + ]); + + if (stateResult.exitCode !== 0) { + const e = stateResult.stderr || stateResult.output || 'Unknown error'; + throw new Error(`contract-state failed: ${e}`); + } + + // Step 2: Generate call intent + log.info(`Generating call intent for ${callKey} entrypoint...`); + const intentResult = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'generate-intent', + 'circuit', + '-c', + '/toolkit-js/contract/contract.config.ts', + '--coin-public', + coinPublic, + '--input-onchain-state', + `/out/${contractStateFile}`, + '--input-private-state', + '/out/initial_state.json', '--contract-address', contractAddressUntagged, + '--output-intent', + `/out/${callIntent}`, + '--output-private-state', + `/out/${callPrivateState}`, + '--output-zswap-state', + `/out/${callZswapState}`, + callKey, ]); - if (result.exitCode !== 0) { - const errorMessage = result.stderr || result.output || 'Unknown error occurred'; - throw new Error(`Failed to generate contract call: ${errorMessage}`); + if (intentResult.exitCode !== 0) { + const e = intentResult.stderr || intentResult.output || 'Unknown error'; + throw new Error(`generate-intent circuit failed: ${e}`); + } + + // Verify intent file was created + const checkIntentResult = await this.startedContainer.exec([ + 'sh', + '-c', + `test -f /out/${callIntent} && echo "EXISTS" || echo "MISSING"`, + ]); + if (!checkIntentResult.output.includes('EXISTS')) { + throw new Error(`Intent file not found at /out/${callIntent}`); + } + + // Step 3: Convert intent to transaction + log.info('Converting call intent to transaction...'); + const sendIntentResult = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'send-intent', + '--intent-file', + `/out/${callIntent}`, + '--compiled-contract-dir', + '/toolkit-js/contract/managed/counter', + '--to-bytes', + '--dest-file', + `/out/${callTx}`, + ]); + + if (sendIntentResult.exitCode !== 0) { + const e = sendIntentResult.stderr || sendIntentResult.output || 'Unknown error'; + throw new Error(`send-intent failed: ${e}`); } - log.info('Submitting transaction to network...'); + log.info('Sending call transaction to node...'); const sendResult = await this.startedContainer.exec([ '/midnight-node-toolkit', 'generate-txs', '--src-file', - txFile, + `/out/${callTx}`, '--dest-url', env.getNodeWebsocketBaseURL(), + '-r', + '1', 'send', ]); if (sendResult.exitCode !== 0) { - const errorMessage = sendResult.stderr || sendResult.output || 'Unknown error occurred'; - throw new Error(`Failed to submit transaction: ${errorMessage}`); + const e = sendResult.stderr || sendResult.output || 'Unknown error'; + throw new Error(`generate-txs send failed: ${e}`); } const rawOutput = sendResult.output.trim(); @@ -505,11 +613,11 @@ class ToolkitWrapper { } /** - * Deploy a smart contract to the network. + * Deploy a smart contract to the network using the intent-based approach. * This method generates a deployment intent, converts it to a transaction, submits it to the network, * and retrieves both tagged and untagged contract addresses. * - * @returns A promise that resolves to the deployment result containing untagged address, tagged address, and coin public key. + * @returns A promise that resolves to the deployment result containing untagged address, tagged address, coin public key, and transaction hashes. * @throws Error if the container is not started or if any step in the deployment process fails. */ async deployContract(): Promise { @@ -518,75 +626,431 @@ class ToolkitWrapper { } const outDir = this.config.targetDir!; - const deployTx = 'deploy_tx.mn'; + const deployIntent = 'deploy.bin'; - const outDeployTx = join(outDir, deployTx); const coinPublicSeed = '0000000000000000000000000000000000000000000000000000000000000001'; 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}`); - } + // Use intent-based deployment approach + log.info('Generating deploy intent...'); + const intentResult = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'generate-intent', + 'deploy', + '-c', + '/toolkit-js/contract/contract.config.ts', + '--coin-public', + coinPublic, + '--authority-seed', + coinPublicSeed, + '--output-intent', + `/out/${deployIntent}`, + '--output-private-state', + '/out/initial_state.json', + '--output-zswap-state', + '/out/temp.json', + '20', + ]); - log.debug(`Checking for output files:`); - log.debug(` ${outDeployTx} exists: ${fs.existsSync(outDeployTx)}`); + if (intentResult.exitCode !== 0) { + const e = intentResult.stderr || intentResult.output || 'Unknown error'; + throw new Error(`generate-intent deploy failed: ${e}`); + } - if (!fs.existsSync(outDeployTx)) { - throw new Error('contract-simple deploy did not produce expected output file'); - } + // Verify intent file was created + const checkIntentResult = await this.startedContainer.exec([ + 'sh', + '-c', + `test -f /out/${deployIntent} && echo "EXISTS" || echo "MISSING"`, + ]); + if (!checkIntentResult.output.includes('EXISTS')) { + throw new Error(`Intent file not found at /out/${deployIntent}`); } - { - 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}`); - } + // Convert intent to transaction + log.info('Converting intent to transaction...'); + const sendIntentResult = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'send-intent', + '--intent-file', + `/out/${deployIntent}`, + '--compiled-contract-dir', + 'contract/managed/counter', + '--to-bytes', + '--dest-file', + `/out/${deployTx}`, + ]); + + if (sendIntentResult.exitCode !== 0) { + const e = sendIntentResult.stderr || sendIntentResult.output || 'Unknown error'; + throw new Error(`send-intent failed: ${e}`); + } + + const outDeployTx = join(outDir, deployTx); + if (!fs.existsSync(outDeployTx)) { + throw new Error('send-intent did not produce expected output file'); + } + + log.info('Sending deployment transaction to node...'); + const sendResult = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'generate-txs', + '--src-file', + `/out/${deployTx}`, + '--dest-url', + env.getNodeWebsocketBaseURL(), + '-r', + '1', + 'send', + ]); + + if (sendResult.exitCode !== 0) { + const e = sendResult.stderr || sendResult.output || 'Unknown error'; + throw new Error(`generate-txs send failed: ${e}`); } + // Get contract address first (needed for result) const contractAddressTagged = await this.getContractAddress(deployTx, 'tagged'); const contractAddressUntagged = await this.getContractAddress(deployTx, 'untagged'); - const { txHash, blockHash } = await getContractDeploymentHashes(contractAddressUntagged); - const deploymentResult = { + // Extract transaction hash and block hash from output + const sendOutput = sendResult.output.trim(); + const txHashMatch = sendOutput.match(/"midnight_tx_hash":"(0x[^"]+)"/); + const blockHashMatch = sendOutput.match(/"block_hash":"(0x[^"]+)"/); + + let txHash = ''; + let blockHash = ''; + + if (txHashMatch) { + txHash = txHashMatch[1].replace(/^0x/, ''); // Remove 0x prefix to match indexer format + } + if (blockHashMatch) { + blockHash = blockHashMatch[1].replace(/^0x/, ''); // Remove 0x prefix to match indexer format + } + + if (!txHash || !blockHash) { + log.warn( + `Could not extract transaction/block hash from send output. They will be available from indexer queries later.`, + ); + } + + return { 'contract-address-untagged': contractAddressUntagged, 'contract-address-tagged': contractAddressTagged, 'coin-public': coinPublic, 'deploy-tx-hash': txHash, 'deploy-block-hash': blockHash, }; + } + + /** + * Switch the maintenance authority for a contract. + * This is an optional step that can fail if the contract is not ready or authority seeds are incorrect. + * + * @param contractAddress - The untagged contract address + * @param authoritySeed - The current authority seed + * @param newAuthoritySeed - The new authority seed to switch to + * @param fundingSeed - The seed to use for funding the transaction (defaults to authoritySeed) + * @param counter - The counter value for the transaction (default: 0) + * @param rngSeed - The random number generator seed (default: fixed seed) + * @returns A promise that resolves to the transaction result, or null if the switch failed + * @throws Error if the container is not started + */ + async switchMaintenanceAuthority( + contractAddress: string, + authoritySeed: string = '0000000000000000000000000000000000000000000000000000000000000001', + newAuthoritySeed: string = '1000000000000000000000000000000000000000000000000000000000000001', + fundingSeed?: string, + counter: number = 0, + rngSeed: string = '0000000000000000000000000000000000000000000000000000000000000001', + ): Promise { + if (!this.startedContainer) { + throw new Error('Container is not started. Call start() first.'); + } + + const fundingSeedToUse = fundingSeed ?? authoritySeed; + const txFile = '/out/authority_switch_tx.mn'; + + log.info('Generating maintenance authority switch transaction...'); + const result = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'generate-txs', + '--src-url', + env.getNodeWebsocketBaseURL(), + 'contract-simple', + 'maintenance', + '--funding-seed', + fundingSeedToUse, + '--authority-seed', + authoritySeed, + '--new-authority-seed', + newAuthoritySeed, + '--counter', + counter.toString(), + '--rng-seed', + rngSeed, + '--contract-address', + contractAddress, + '--to-bytes', + '--dest-file', + txFile, + ]); + + if (result.exitCode !== 0) { + log.warn( + `Failed to generate authority switch transaction: ${result.stderr || result.output}`, + ); + log.warn('Continuing without authority switch - will use default authority for maintenance'); + return null; + } + + log.info('Submitting authority switch transaction to network...'); + const sendResult = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'generate-txs', + '--src-file', + txFile, + '--dest-url', + env.getNodeWebsocketBaseURL(), + 'send', + ]); + + if (sendResult.exitCode !== 0) { + log.warn( + `Failed to submit authority switch transaction: ${sendResult.stderr || sendResult.output}`, + ); + return null; + } + + const rawOutput = sendResult.output.trim(); + return this.parseTransactionOutput(rawOutput); + } + + /** + * Perform contract maintenance by removing and/or upserting entrypoints. + * This method generates a maintenance transaction and submits it to the network. + * + * @param contractAddress - The untagged contract address + * @param options - Maintenance options + * @param options.authoritySeed - The authority seed to use (default: default seed) + * @param options.fundingSeed - The seed to use for funding (defaults to authoritySeed) + * @param options.counter - The counter value for the transaction (default: 0) + * @param options.rngSeed - The random number generator seed (default: fixed seed) + * @param options.removeEntrypoints - Array of entrypoint names to remove (e.g., ['decrement']) + * @param options.upsertEntrypoints - Array of paths to verifier files to upsert (e.g., ['/toolkit-js/contract/managed/counter/keys/increment.verifier']) + * @returns A promise that resolves to the transaction result + * @throws Error if the container is not started or if maintenance transaction generation fails + */ + async maintainContract( + contractAddress: string, + options: { + authoritySeed?: string; + fundingSeed?: string; + counter?: number; + rngSeed?: string; + removeEntrypoints?: string[]; + upsertEntrypoints?: string[]; + } = {}, + ): Promise { + if (!this.startedContainer) { + throw new Error('Container is not started. Call start() first.'); + } + + const { + authoritySeed = '0000000000000000000000000000000000000000000000000000000000000001', + fundingSeed, + counter = 0, + rngSeed = '0000000000000000000000000000000000000000000000000000000000000001', + removeEntrypoints = [], + upsertEntrypoints = [], + } = options; + + const fundingSeedToUse = fundingSeed ?? authoritySeed; + const txFile = '/out/maintenance_tx.mn'; + + // Build the command arguments + const commandArgs: string[] = [ + '/midnight-node-toolkit', + 'generate-txs', + '--src-url', + env.getNodeWebsocketBaseURL(), + 'contract-simple', + 'maintenance', + '--funding-seed', + fundingSeedToUse, + '--authority-seed', + authoritySeed, + '--counter', + counter.toString(), + '--rng-seed', + rngSeed, + '--contract-address', + contractAddress, + ]; + + // Add remove-entrypoint flags + for (const entrypoint of removeEntrypoints) { + commandArgs.push('--remove-entrypoint', entrypoint); + } + + // Add upsert-entrypoint flags + for (const verifierPath of upsertEntrypoints) { + commandArgs.push('--upsert-entrypoint', verifierPath); + } + + commandArgs.push('--to-bytes', '--dest-file', txFile); + + log.info('Generating contract maintenance transaction...'); + const result = await this.startedContainer.exec(commandArgs); + + if (result.exitCode !== 0) { + const errorMessage = result.stderr || result.output || 'Unknown error occurred'; + throw new Error(`Failed to generate maintenance transaction: ${errorMessage}`); + } + + log.info('Submitting maintenance transaction to network...'); + const sendResult = await this.startedContainer.exec([ + '/midnight-node-toolkit', + 'generate-txs', + '--src-file', + txFile, + '--dest-url', + env.getNodeWebsocketBaseURL(), + 'send', + ]); + + if (sendResult.exitCode !== 0) { + const errorMessage = sendResult.stderr || sendResult.output || 'Unknown error occurred'; + throw new Error(`Failed to submit maintenance transaction: ${errorMessage}`); + } + + const rawOutput = sendResult.output.trim(); + return this.parseTransactionOutput(rawOutput); + } + + /** + * Prepare contract maintenance by copying verifier files. + * This is a helper method to prepare the contract directory for maintenance operations. + * + * @param sourceVerifier - The source verifier file path (relative to contract directory) + * @param targetVerifier - The target verifier file path (relative to contract directory) + * @throws Error if the source verifier file doesn't exist + */ + private prepareVerifierFile(sourceVerifier: string, targetVerifier: string): void { + if (!this.contractDir || !fs.existsSync(this.contractDir)) { + throw new Error('Contract directory not available'); + } + + const sourcePath = join(this.contractDir, sourceVerifier); + const targetPath = join(this.contractDir, targetVerifier); - log.debug(`Contract address info:\n${JSON.stringify(deploymentResult, null, 2)}`); + if (!fs.existsSync(sourcePath)) { + throw new Error(`Source verifier file not found: ${sourcePath}`); + } + + // Ensure target directory exists + const targetDir = join(targetPath, '..'); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + fs.copyFileSync(sourcePath, targetPath); + log.debug(`Copied ${sourceVerifier} to ${targetVerifier}`); + } + + /** + * Perform contract maintenance with optional authority switch and verifier preparation. + * This is a high-level method that handles the complete maintenance flow including: + * - Optional authority switch + * - Verifier file preparation + * - Maintenance transaction submission + * + * @param contractAddress - The untagged contract address + * @param options - Maintenance options + * @param options.authoritySeed - The authority seed to use (default: default seed) + * @param options.newAuthoritySeed - Optional new authority seed to switch to before maintenance + * @param options.fundingSeed - The seed to use for funding (defaults to authoritySeed) + * @param options.counter - The counter value for the transaction (default: 0) + * @param options.rngSeed - The random number generator seed (default: fixed seed) + * @param options.removeEntrypoints - Array of entrypoint names to remove (e.g., ['decrement']) + * @param options.upsertEntrypoints - Array of paths to verifier files to upsert (e.g., ['/toolkit-js/contract/managed/counter/keys/increment.verifier']) + * @param options.prepareVerifiers - Optional array of {source, target} verifier file pairs to copy before maintenance + * @param options.waitAfterCall - Wait time in ms after contract call before maintenance (default: 15000) + * @param options.waitAfterAuthoritySwitch - Wait time in ms after authority switch (default: 10000) + * @returns A promise that resolves to the transaction result + * @throws Error if the container is not started or if maintenance transaction generation fails + */ + async performContractMaintenance( + contractAddress: string, + options: { + authoritySeed?: string; + newAuthoritySeed?: string; + fundingSeed?: string; + counter?: number; + rngSeed?: string; + removeEntrypoints?: string[]; + upsertEntrypoints?: string[]; + prepareVerifiers?: Array<{ source: string; target: string }>; + waitAfterCall?: number; + waitAfterAuthoritySwitch?: number; + } = {}, + ): Promise { + if (!this.startedContainer) { + throw new Error('Container is not started. Call start() first.'); + } - return deploymentResult; + const { + authoritySeed = '0000000000000000000000000000000000000000000000000000000000000001', + newAuthoritySeed, + fundingSeed, + counter = 0, + rngSeed = '0000000000000000000000000000000000000000000000000000000000000001', + removeEntrypoints = [], + upsertEntrypoints = [], + prepareVerifiers = [], + waitAfterCall = 15_000, + waitAfterAuthoritySwitch = 10_000, + } = options; + + // Wait for previous operations to finalize + if (waitAfterCall > 0) { + await new Promise((resolve) => setTimeout(resolve, waitAfterCall)); + } + + // Step 1: Try to switch maintenance authority if requested + let maintenanceAuthoritySeed = authoritySeed; + if (newAuthoritySeed) { + try { + const authoritySwitchResult = await this.switchMaintenanceAuthority( + contractAddress, + authoritySeed, + newAuthoritySeed, + ); + if (authoritySwitchResult) { + await new Promise((resolve) => setTimeout(resolve, waitAfterAuthoritySwitch)); + maintenanceAuthoritySeed = newAuthoritySeed; + } + } catch (error) { + log.debug(`Authority switch failed, continuing with default authority: ${error}`); + } + } + + // Step 2: Prepare verifier files if needed + for (const { source, target } of prepareVerifiers) { + this.prepareVerifierFile(source, target); + } + + // Step 3: Perform maintenance + return this.maintainContract(contractAddress, { + authoritySeed: maintenanceAuthoritySeed, + fundingSeed: fundingSeed ?? maintenanceAuthoritySeed, + counter, + rngSeed, + removeEntrypoints, + upsertEntrypoints, + }); } }