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
text/typescript
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 [];
}
};