UNPKG

create-midnight-app

Version:

🌙 Scaffold for building Midnight smart contracts with dynamic type detection and automated CLI generation

445 lines (405 loc) • 16.1 kB
import { type ContractAddress } from '@midnight-ntwrk/compact-runtime'; import { contracts, witnesses } from '@midnight-ntwrk/contract'; import { type CoinInfo, nativeToken, Transaction, type TransactionId } from '@midnight-ntwrk/ledger'; import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts'; import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider'; import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider'; import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider'; import { assertIsContractAddress, toHex } from '@midnight-ntwrk/midnight-js-utils'; import { getLedgerNetworkId, getZswapNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; import { type BalancedTransaction, createBalancedTx, type FinalizedTxData, type MidnightProvider, type UnbalancedTransaction, type WalletProvider, } from '@midnight-ntwrk/midnight-js-types'; import { type Resource, WalletBuilder } from '@midnight-ntwrk/wallet'; import { type Wallet } from '@midnight-ntwrk/wallet-api'; import { Transaction as ZswapTransaction } from '@midnight-ntwrk/zswap'; import { webcrypto } from 'crypto'; import { type Logger } from 'pino'; import * as Rx from 'rxjs'; import { WebSocket } from 'ws'; import * as fsAsync from 'node:fs/promises'; import * as fs from 'node:fs'; import { ContractAnalyzer } from './contract-analyzer.js'; import { type CounterContract, type CounterPrivateState, type CounterPrivateStateId, type CounterProviders, type DeployedCounterContract, } from './common-types'; import { type Config, contractConfig } from './config'; // Get the dynamic contract module const getContractModule = () => { const contractNames = Object.keys(contracts); if (contractNames.length === 0) { throw new Error('No contract found in contracts object'); } return contracts[contractNames[0]]; }; const contractModule = getContractModule(); let logger: Logger; /** * Create an opaque string value from a plain string * This is needed for Opaque<"string"> parameters in Midnight contracts */ export const createOpaqueString = (value: string): any => { // In Midnight, opaque strings need to be properly encoded as Uint8Array // The contract expects the raw string bytes, not a complex object const encoder = new TextEncoder(); return encoder.encode(value); }; // Instead of setting globalThis.crypto which is read-only, we'll ensure crypto is available // but won't try to overwrite the global property // @ts-expect-error: It's needed to enable WebSocket usage through apollo globalThis.WebSocket = WebSocket; export const getCounterLedgerState = async ( providers: CounterProviders, contractAddress: ContractAddress, ): Promise<bigint | null> => { assertIsContractAddress(contractAddress); logger.info('Checking contract ledger state...'); const state = await providers.publicDataProvider .queryContractState(contractAddress) .then((contractState) => (contractState != null ? contractModule.ledger(contractState.data).round : null)) logger.info(`Ledger state: ${state}`); return state; }; export const counterContractInstance: CounterContract = new contractModule.Contract(witnesses); export const joinContract = async ( providers: CounterProviders, contractAddress: string, ): Promise<DeployedCounterContract> => { const counterContract = await findDeployedContract(providers, { contractAddress, contract: counterContractInstance, privateStateId: 'counterPrivateState', initialPrivateState: { privateCounter: 0 }, }); logger.info(`Joined contract at address: ${counterContract.deployTxData.public.contractAddress}`); return counterContract; }; export const deploy = async ( providers: CounterProviders, privateState: CounterPrivateState, ): Promise<DeployedCounterContract> => { // Get dynamic contract name const analyzer = new ContractAnalyzer(); const analysis = await analyzer.analyzeContract(); logger.info(`Deploying ${analysis.contractName.toLowerCase()}...`); const counterContract = await deployContract(providers, { contract: counterContractInstance, privateStateId: 'counterPrivateState', initialPrivateState: privateState, }); logger.info(`Deployed contract at address: ${counterContract.deployTxData.public.contractAddress}`); return counterContract; }; export const displayCounterValue = async ( providers: CounterProviders, counterContract: DeployedCounterContract, ): Promise<{ counterValue: bigint | null; contractAddress: string }> => { const contractAddress = counterContract.deployTxData.public.contractAddress; const counterValue = await getCounterLedgerState(providers, contractAddress); if (counterValue === null) { logger.info(`There is no counter contract deployed at ${contractAddress}.`); } else { logger.info(`Current counter value: ${Number(counterValue)}`); } return { contractAddress, counterValue }; }; export const createWalletAndMidnightProvider = async (wallet: Wallet): Promise<WalletProvider & MidnightProvider> => { const state = await Rx.firstValueFrom(wallet.state()); return { coinPublicKey: state.coinPublicKey, encryptionPublicKey: state.encryptionPublicKey, balanceTx(tx: UnbalancedTransaction, newCoins: CoinInfo[]): Promise<BalancedTransaction> { return wallet .balanceTransaction( ZswapTransaction.deserialize(tx.serialize(getLedgerNetworkId()), getZswapNetworkId()), newCoins, ) .then((tx) => wallet.proveTransaction(tx)) .then((zswapTx) => Transaction.deserialize(zswapTx.serialize(getZswapNetworkId()), getLedgerNetworkId())) .then(createBalancedTx); }, submitTx(tx: BalancedTransaction): Promise<TransactionId> { return wallet.submitTransaction(tx); }, }; }; export const waitForSync = (wallet: Wallet) => Rx.firstValueFrom( wallet.state().pipe( Rx.throttleTime(5_000), Rx.tap((state) => { const applyGap = state.syncProgress?.lag.applyGap ?? 0n; const sourceGap = state.syncProgress?.lag.sourceGap ?? 0n; logger.info( `Waiting for funds. Backend lag: ${sourceGap}, wallet lag: ${applyGap}, transactions=${state.transactionHistory.length}`, ); }), Rx.filter((state) => { // Let's allow progress only if wallet is synced fully return state.syncProgress !== undefined && state.syncProgress.synced; }), ), ); export const waitForSyncProgress = async (wallet: Wallet) => await Rx.firstValueFrom( wallet.state().pipe( Rx.throttleTime(5_000), Rx.tap((state) => { const applyGap = state.syncProgress?.lag.applyGap ?? 0n; const sourceGap = state.syncProgress?.lag.sourceGap ?? 0n; logger.info( `Waiting for funds. Backend lag: ${sourceGap}, wallet lag: ${applyGap}, transactions=${state.transactionHistory.length}`, ); }), Rx.filter((state) => { // Let's allow progress only if syncProgress is defined return state.syncProgress !== undefined; }), ), ); export const waitForFunds = (wallet: Wallet) => Rx.firstValueFrom( wallet.state().pipe( Rx.throttleTime(10_000), Rx.tap((state) => { const applyGap = state.syncProgress?.lag.applyGap ?? 0n; const sourceGap = state.syncProgress?.lag.sourceGap ?? 0n; logger.info( `Waiting for funds. Backend lag: ${sourceGap}, wallet lag: ${applyGap}, transactions=${state.transactionHistory.length}`, ); }), Rx.filter((state) => { // Let's allow progress only if wallet is synced return state.syncProgress?.synced === true; }), Rx.map((s) => s.balances[nativeToken()] ?? 0n), Rx.filter((balance) => balance > 0n), ), ); export const buildWalletAndWaitForFunds = async ( { indexer, indexerWS, node, proofServer }: Config, seed: string, filename: string, ): Promise<Wallet & Resource> => { const directoryPath = process.env.SYNC_CACHE; let wallet: Wallet & Resource; if (directoryPath !== undefined) { if (fs.existsSync(`${directoryPath}/${filename}`)) { logger.info(`Attempting to restore state from ${directoryPath}/${filename}`); try { const serializedStream = fs.createReadStream(`${directoryPath}/${filename}`, 'utf-8'); const serialized = await streamToString(serializedStream); serializedStream.on('finish', () => { serializedStream.close(); }); wallet = await WalletBuilder.restore(indexer, indexerWS, proofServer, node, seed, serialized, 'info'); wallet.start(); const stateObject = JSON.parse(serialized); if ((await isAnotherChain(wallet, Number(stateObject.offset))) === true) { logger.warn('The chain was reset, building wallet from scratch'); wallet = await WalletBuilder.buildFromSeed( indexer, indexerWS, proofServer, node, seed, getZswapNetworkId(), 'info', ); wallet.start(); } else { const newState = await waitForSync(wallet); // allow for situations when there's no new index in the network between runs if (newState.syncProgress?.synced) { logger.info('Wallet was able to sync from restored state'); } else { logger.info(`Offset: ${stateObject.offset}`); logger.info(`SyncProgress.lag.applyGap: ${newState.syncProgress?.lag.applyGap}`); logger.info(`SyncProgress.lag.sourceGap: ${newState.syncProgress?.lag.sourceGap}`); logger.warn('Wallet was not able to sync from restored state, building wallet from scratch'); wallet = await WalletBuilder.buildFromSeed( indexer, indexerWS, proofServer, node, seed, getZswapNetworkId(), 'info', ); wallet.start(); } } } catch (error: unknown) { if (typeof error === 'string') { logger.error(error); } else if (error instanceof Error) { logger.error(error.message); } else { logger.error(error); } logger.warn('Wallet was not able to restore using the stored state, building wallet from scratch'); wallet = await WalletBuilder.buildFromSeed( indexer, indexerWS, proofServer, node, seed, getZswapNetworkId(), 'info', ); wallet.start(); } } else { logger.info('Wallet save file not found, building wallet from scratch'); wallet = await WalletBuilder.buildFromSeed( indexer, indexerWS, proofServer, node, seed, getZswapNetworkId(), 'info', ); wallet.start(); } } else { logger.info('File path for save file not found, building wallet from scratch'); wallet = await WalletBuilder.buildFromSeed( indexer, indexerWS, proofServer, node, seed, getZswapNetworkId(), 'info', ); wallet.start(); } const state = await Rx.firstValueFrom(wallet.state()); logger.info(`Your wallet seed is: ${seed}`); logger.info(`Your wallet address is: ${state.address}`); let balance = state.balances[nativeToken()]; if (balance === undefined || balance === 0n) { logger.info(`Your wallet balance is: 0`); logger.info(`Waiting to receive tokens...`); balance = await waitForFunds(wallet); } logger.info(`Your wallet balance is: ${balance}`); return wallet; }; export const randomBytes = (length: number): Uint8Array => { const bytes = new Uint8Array(length); webcrypto.getRandomValues(bytes); return bytes; }; export const buildFreshWallet = async (config: Config): Promise<Wallet & Resource> => await buildWalletAndWaitForFunds(config, toHex(randomBytes(32)), ''); export const configureProviders = async (wallet: Wallet & Resource, config: Config) => { const walletAndMidnightProvider = await createWalletAndMidnightProvider(wallet); return { privateStateProvider: levelPrivateStateProvider<typeof CounterPrivateStateId>({ privateStateStoreName: contractConfig.privateStateStoreName, }), publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS), zkConfigProvider: new NodeZkConfigProvider<'increment'>(contractConfig.zkConfigPath), proofProvider: httpClientProofProvider(config.proofServer), walletProvider: walletAndMidnightProvider, midnightProvider: walletAndMidnightProvider, }; }; export function setLogger(_logger: Logger) { logger = _logger; } export const streamToString = async (stream: fs.ReadStream): Promise<string> => { const chunks: Buffer[] = []; return await new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk)); stream.on('error', (err) => { reject(err); }); stream.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')); }); }); }; export const isAnotherChain = async (wallet: Wallet, offset: number) => { await waitForSyncProgress(wallet); // Here wallet does not expose the offset block it is synced to, that is why this workaround const walletOffset = Number(JSON.parse(await wallet.serializeState()).offset); if (walletOffset < offset - 1) { logger.info(`Your offset offset is: ${walletOffset} restored offset: ${offset} so it is another chain`); return true; } else { logger.info(`Your offset offset is: ${walletOffset} restored offset: ${offset} ok`); return false; } }; export const saveState = async (wallet: Wallet, filename: string) => { const directoryPath = process.env.SYNC_CACHE; if (directoryPath !== undefined) { logger.info(`Saving state in ${directoryPath}/${filename}`); try { await fsAsync.mkdir(directoryPath, { recursive: true }); const serializedState = await wallet.serializeState(); const writer = fs.createWriteStream(`${directoryPath}/${filename}`); writer.write(serializedState); writer.on('finish', function () { logger.info(`File '${directoryPath}/${filename}' written successfully.`); }); writer.on('error', function (err) { logger.error(err); }); writer.end(); } catch (e) { if (typeof e === 'string') { logger.warn(e); } else if (e instanceof Error) { logger.warn(e.message); } } } else { logger.info('Not saving cache as sync cache was not defined'); } }; export const getItemsSet = async ( providers: CounterProviders, contractAddress: ContractAddress, ): Promise<string[]> => { assertIsContractAddress(contractAddress); logger.info('Checking items set...'); try { const contractState = await providers.publicDataProvider.queryContractState(contractAddress); if (contractState?.data) { const ledgerData = contractModule.ledger(contractState.data); if (ledgerData.items) { // Convert Set to Array and then to string representations const itemsArray = Array.from(ledgerData.items); logger.info(`Found ${itemsArray.length} items in set`); return itemsArray.map(item => { if (item instanceof Uint8Array) { // Convert bytes to hex string return '0x' + Array.from(item) .map(b => b.toString(16).padStart(2, '0')) .join(''); } return String(item); }); } } logger.info('Items set is empty or not accessible'); return []; } catch (error) { logger.warn(`Failed to extract items set: ${error}`); return []; } };