Build the counter CLI
This tutorial explains how to build a command-line interface that interacts with the counter smart contract created in the build the counter contract tutorial. This CLI demonstrates wallet management, contract deployment, and transaction submission on the Midnight Preprod network.
By the end of this tutorial, you will:
- Set up a CLI project with Midnight.js dependencies.
- Implement wallet creation using Hierarchical Deterministic (HD) key derivation.
- Configure providers for contract interaction.
- Deploy the counter contract to Preprod.
- Submit increment transactions with Zero Knowledge (ZK) proofs.
- Query contract state from the blockchain.
- Handle DUST token generation for transaction fees.
The CLI uses the WalletFacade from the Midnight wallet SDK, which manages three wallet types:
- Shielded (ZSwap) for privacy-preserving transactions
- Unshielded for transparent operations
- Dust for transaction fees
Prerequisites
Before you begin, ensure that you have:
- Completed the build the counter contract tutorial with the contract compiled in
contract/src/managed/counter/ - Docker Desktop installed to run the proof server
- Node.js version 22 or higher
- Basic TypeScript knowledge including async/await and Promises
Project structure
The complete example-counter project uses a monorepo structure with npm workspaces:
example-counter/
├── package.json # Root package with workspaces
├── contract/ # Compact contract
│ ├── src/
│ │ ├── counter.compact
│ │ ├── managed/
│ │ └── index.ts
│ └── package.json
└── counter-cli/ # Counter CLI
├── src/
│ ├── config.ts # Network configuration
│ ├── common-types.ts # Type definitions
│ ├── api.ts # Contract interaction
│ ├── cli.ts # User interface
│ ├── logger-utils.ts # Logging setup
│ ├── preprod.ts # Entry point
│ └── index.ts # Re-exports
└── package.json
The monorepo structure enables code sharing between packages through workspace references. The counter-cli depends on the contract package without requiring separate publication to npm.
Set up the root package
This section explains the process of setting up the root package.
Create the root configuration
From the example-counter root directory, create or update package.json:
{
"name": "example-counter",
"version": "2.0.2",
"private": true,
"type": "module",
"workspaces": [
"counter-cli",
"contract"
],
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.2.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"testcontainers": "^11.11.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.14.0",
"@midnight-ntwrk/ledger": "^4.0.0",
"@midnight-ntwrk/midnight-js-contracts": "3.0.0",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "3.0.0",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "3.0.0",
"@midnight-ntwrk/midnight-js-level-private-state-provider": "3.0.0",
"@midnight-ntwrk/midnight-js-network-id": "3.0.0",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "3.0.0",
"@midnight-ntwrk/midnight-js-types": "3.0.0",
"@midnight-ntwrk/wallet-sdk-facade": "1.0.0",
"@midnight-ntwrk/wallet-sdk-hd": "3.0.0",
"@midnight-ntwrk/wallet-sdk-shielded": "1.0.0",
"@midnight-ntwrk/wallet-sdk-dust-wallet": "1.0.0",
"@midnight-ntwrk/wallet-sdk-address-format": "3.0.0",
"@midnight-ntwrk/wallet-sdk-unshielded-wallet": "1.0.0",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"ws": "^8.19.0"
}
}
The workspaces configuration tells npm to manage both the contract and counter-cli as linked packages. Dependencies defined at the root level are shared across all workspaces, reducing duplication and ensuring version consistency.
Install root dependencies
Install all dependencies from the root:
npm install
This command installs dependencies for the root package and all workspace packages, creating symlinks between them for local development.
Set up the CLI package
This section explains the process of setting up the CLI DApp.
Create the CLI directory
From the root, create the counter-cli structure:
mkdir -p counter-cli/src
cd counter-cli
Configure the CLI package
Create counter-cli/package.json:
{
"name": "@midnight-ntwrk/counter-cli",
"version": "0.1.0",
"license": "Apache-2.0",
"private": true,
"type": "module",
"scripts": {
"preprod": "node --experimental-specifier-resolution=node --loader ts-node/esm src/preprod.ts",
"build": "rm -rf dist && tsc --project tsconfig.build.json",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@midnight-ntwrk/counter-contract": "*"
}
}
The package depends on @midnight-ntwrk/counter-contract using the wildcard version *, which resolves to the local workspace package. This enables importing the compiled contract code without publishing to npm.
Configure TypeScript
Create counter-cli/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
The TypeScript configuration enables ES modules and strict type checking. The ts-node section allows direct execution of TypeScript files during development without a separate build step.
Implement configuration
The configuration file defines network endpoints and contract settings.
Create counter-cli/src/config.ts:
import path from 'node:path';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
export const currentDir = path.resolve(new URL(import.meta.url).pathname, '..');
export const contractConfig = {
privateStateStoreName: 'counter-private-state',
zkConfigPath: path.resolve(currentDir, '..', '..', 'contract', 'src', 'managed', 'counter'),
};
export interface Config {
readonly logDir: string;
readonly indexer: string;
readonly indexerWS: string;
readonly node: string;
readonly proofServer: string;
}
export class PreprodConfig implements Config {
logDir = path.resolve(currentDir, '..', 'logs', 'preprod', `${new Date().toISOString()}.log`);
indexer = 'https://indexer.preprod.midnight.network/api/v3/graphql';
indexerWS = 'wss://indexer.preprod.midnight.network/api/v3/graphql/ws';
node = 'https://rpc.preprod.midnight.network';
proofServer = 'http://127.0.0.1:6300';
constructor() {
setNetworkId('preprod');
}
}
The configuration separates network-specific settings from application logic.
The PreprodConfig class provides endpoints for Midnight-hosted infrastructure.
The contractConfig object specifies where the compiled contract artifacts are located and where to store private state locally.
The setNetworkId call in the constructor establishes a global network context that Midnight.js libraries use automatically.
This ensures all SDK components operate on the correct network without requiring the network ID in every API call.
Define common types
Type definitions provide type safety and improve code readability.
Create counter-cli/src/common-types.ts:
import { Counter, type CounterPrivateState } from '@midnight-ntwrk/counter-contract';
import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
import type { DeployedContract, FoundContract } from '@midnight-ntwrk/midnight-js-contracts';
import type { ImpureCircuitId } from '@midnight-ntwrk/compact-js';
export type CounterCircuits = ImpureCircuitId<Counter.Contract<CounterPrivateState>>;
export const CounterPrivateStateId = 'counterPrivateState';
export type CounterProviders = MidnightProviders<CounterCircuits, typeof CounterPrivateStateId, CounterPrivateState>;
export type CounterContract = Counter.Contract<CounterPrivateState>;
export type DeployedCounterContract = DeployedContract<CounterContract> | FoundContract<CounterContract>;
These type definitions create aliases for complex generic types. The CounterCircuits type extracts the circuit identifiers from the contract. The CounterProviders type specifies the provider interface with appropriate type parameters. The DeployedCounterContract type union handles both newly deployed contracts and contracts found through joining.
Implement logging utilities
Logging utilities provide structured logging to both console and file.
Create counter-cli/src/logger-utils.ts:
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import pinoPretty from 'pino-pretty';
import pino from 'pino';
import { createWriteStream } from 'node:fs';
export const createLogger = async (logPath: string): Promise<pino.Logger> => {
await fs.mkdir(path.dirname(logPath), { recursive: true });
const pretty: pinoPretty.PrettyStream = pinoPretty({
colorize: true,
sync: true,
});
const level =
process.env.DEBUG_LEVEL !== undefined && process.env.DEBUG_LEVEL !== null && process.env.DEBUG_LEVEL !== ''
? process.env.DEBUG_LEVEL
: 'info';
return pino(
{
level,
depthLimit: 20,
},
pino.multistream([
{ stream: pretty, level },
{ stream: createWriteStream(logPath), level },
]),
);
};
The logger configuration creates two output streams: a pretty-printed console stream for development and a file stream for persistent logs.
The log level can be controlled through the DEBUG_LEVEL environment variable, defaulting to 'info'.
The multistream approach ensures logs are both visible during execution and preserved for debugging.
Implement the API layer
The API layer handles all interactions with the Midnight network. This file includes wallet creation, provider configuration, contract deployment, and transaction submission functionality.
Create the API file
From the counter-cli directory, create the src/api.ts file and add the following code:
import { type ContractAddress } from '@midnight-ntwrk/compact-runtime';
import { Counter, type CounterPrivateState, witnesses } from '@midnight-ntwrk/counter-contract';
import * as ledger from '@midnight-ntwrk/ledger-v7';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v7';
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 { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { type FinalizedTxData, type MidnightProvider, type WalletProvider } from '@midnight-ntwrk/midnight-js-types';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import { HDWallet, Roles, generateRandomSeed } from '@midnight-ntwrk/wallet-sdk-hd';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import {
createKeystore,
InMemoryTransactionHistoryStorage,
PublicKey,
UnshieldedWallet,
type UnshieldedKeystore,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { type Logger } from 'pino';
import * as Rx from 'rxjs';
import { WebSocket } from 'ws';
import {
type CounterCircuits,
type CounterContract,
type CounterPrivateStateId,
type CounterProviders,
type DeployedCounterContract,
} from './common-types';
import { type Config, contractConfig } from './config';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { assertIsContractAddress, toHex } from '@midnight-ntwrk/midnight-js-utils';
import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { CompiledContract } from '@midnight-ntwrk/compact-js';
import { Buffer } from 'buffer';
import {
MidnightBech32m,
ShieldedAddress,
ShieldedCoinPublicKey,
ShieldedEncryptionPublicKey,
} from '@midnight-ntwrk/wallet-sdk-address-format';
let logger: Logger;
// Required for GraphQL subscriptions (wallet sync) to work in Node.js
globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;
// Pre-compile the counter contract with ZK circuit assets
const counterCompiledContract = CompiledContract.make('counter', Counter.Contract).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets(contractConfig.zkConfigPath),
);
export interface WalletContext {
wallet: WalletFacade;
shieldedSecretKeys: ledger.ZswapSecretKeys;
dustSecretKey: ledger.DustSecretKey;
unshieldedKeystore: UnshieldedKeystore;
}
This code sets up the necessary imports and configuration for the Counter DApp:
- Imports: The file imports functionality from multiple Midnight packages, including wallet management (
@midnight-ntwrk/wallet-sdk-*), network providers (@midnight-ntwrk/midnight-js-*), and the compiled Counter contract. - WebSocket configuration: The
globalThis.WebSocket = WebSocket;assignment makes the WebSocket constructor available globally. This is required for GraphQL subscriptions to work in Node.js, as the Apollo client (used by the wallet for synchronization) expects to findWebSocketin the global scope. - Contract pre-compilation: The
counterCompiledContractconstant pre-compiles the contract definition with its Zero Knowledge (ZK) circuit assets. This optimization improves performance by loading the circuit files once at startup instead of recompiling them on every contract interaction.
Derive wallet key pairs
Below the counterCompiledContract constant, create the deriveKeysFromSeed function to derive the wallet key pairs:
/**
* Derive HD wallet keys for all three roles (Zswap, NightExternal, Dust)
* from a hex-encoded seed using BIP-44 style derivation at account 0, index 0.
*/
const deriveKeysFromSeed = (seed: string) => {
const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
if (hdWallet.type !== 'seedOk') {
throw new Error('Failed to initialize HDWallet from seed');
}
const derivationResult = hdWallet.hdWallet
.selectAccount(0)
.selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
.deriveKeysAt(0);
if (derivationResult.type !== 'keysDerived') {
throw new Error('Failed to derive keys');
}
hdWallet.hdWallet.clear();
return derivationResult.keys;
};
The hierarchical deterministic wallet derives multiple key pairs from a single seed using BIP-44 style derivation paths.
The three roles correspond to different wallet functionalities: Zswap for shielded transactions, NightExternal for unshielded transactions, and Dust for fee management.
The clear method securely erases sensitive key material from memory after derivation.
Utility functions for formatting and status display
Below the deriveKeysFromSeed function, create the formatBalance function to format the token balance for display:
/**
* Formats a token balance for display (for example, 1000000000 -> "1,000,000,000").
*/
const formatBalance = (balance: bigint): string => balance.toLocaleString();
/**
* Runs an async operation with an animated spinner on the console.
* Shows ⠋⠙⠹... while running, then ✓ on success or ✗ on failure.
*/
export const withStatus = async <T>(message: string, fn: () => Promise<T>): Promise<T> => {
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r ${frames[i++ % frames.length]} ${message}`);
}, 80);
try {
const result = await fn();
clearInterval(interval);
process.stdout.write(`\r ✓ ${message}\n`);
return result;
} catch (e) {
clearInterval(interval);
process.stdout.write(`\r ✗ ${message}\n`);
throw e;
}
};
The withStatus function provides visual feedback for long-running operations.
The spinner animation uses Unicode braille patterns that create a smooth rotation effect.
The function clears the interval and displays a checkmark on success or an X on failure, maintaining a clean console output.
Wallet configuration builders
Add wallet configuration builders:
const buildShieldedConfig = ({ indexer, indexerWS, node, proofServer }: Config) => ({
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: indexer,
indexerWsUrl: indexerWS,
},
provingServerUrl: new URL(proofServer),
relayURL: new URL(node.replace(/^http/, 'ws')),
});
const buildUnshieldedConfig = ({ indexer, indexerWS }: Config) => ({
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: indexer,
indexerWsUrl: indexerWS,
},
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
});
const buildDustConfig = ({ indexer, indexerWS, node, proofServer }: Config) => ({
networkId: getNetworkId(),
costParameters: {
additionalFeeOverhead: 300_000_000_000_000n,
feeBlocksMargin: 5,
},
indexerClientConnection: {
indexerHttpUrl: indexer,
indexerWsUrl: indexerWS,
},
provingServerUrl: new URL(proofServer),
relayURL: new URL(node.replace(/^http/, 'ws')),
});
Each wallet type requires its own configuration object. The shielded wallet needs proof server access for generating ZK proofs. The unshielded wallet uses in-memory transaction history storage since it doesn't require proofs. The dust wallet includes cost parameters that configure fee estimation with an additional overhead buffer and block margin for safety.
Wallet synchronization and funding functions
Add wallet synchronization and funding functions:
/** Wait until the wallet has fully synced with the network. Returns the synced state. */
export const waitForSync = (wallet: WalletFacade) =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((state) => state.isSynced),
),
);
/** Wait until the wallet has a non-zero unshielded balance. Returns the balance. */
export const waitForFunds = (wallet: WalletFacade): Promise<bigint> =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(10_000),
Rx.filter((state) => state.isSynced),
Rx.map((s) => s.unshielded.balances[unshieldedToken().raw] ?? 0n),
Rx.filter((balance) => balance > 0n),
),
);
These functions use RxJS operators to observe wallet state changes reactively.
The throttleTime operator prevents excessive processing by limiting updates to once per interval.
The filter operator selects only states meeting specific conditions. The firstValueFrom function converts the observable to a promise that resolves with the first matching value.
DUST generation registration
Below the waitForFunds function, create the registerForDustGeneration function to register the unshielded NIGHT UTXOs for DUST generation:
/**
* Register unshielded NIGHT UTXOs for dust generation.
*
* On Preprod/Preview, NIGHT tokens generate DUST over time, but only after
* the UTXOs have been explicitly designated for dust generation via an on-chain
* transaction. DUST is the non-transferable network resource used by the Midnight network to process transactions.
*/
const registerForDustGeneration = async (
wallet: WalletFacade,
unshieldedKeystore: UnshieldedKeystore,
): Promise<void> => {
const state = await Rx.firstValueFrom(wallet.state().pipe(Rx.filter((s) => s.isSynced)));
// Check if dust is already available (for example, from a previous designation)
if (state.dust.availableCoins.length > 0) {
const dustBal = state.dust.walletBalance(new Date());
console.log(` ✓ Dust tokens already available (${formatBalance(dustBal)} DUST)`);
return;
}
// Only register coins that haven't been designated yet
const nightUtxos = state.unshielded.availableCoins.filter(
(coin: any) => coin.meta?.registeredForDustGeneration !== true,
);
if (nightUtxos.length === 0) {
// All coins already registered — just wait for dust to generate
await withStatus('Waiting for dust tokens to generate', () =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s) => s.isSynced),
Rx.filter((s) => s.dust.walletBalance(new Date()) > 0n),
),
),
);
return;
}
await withStatus(`Registering ${nightUtxos.length} NIGHT UTXO(s) for dust generation`, async () => {
const recipe = await wallet.registerNightUtxosForDustGeneration(
nightUtxos,
unshieldedKeystore.getPublicKey(),
(payload) => unshieldedKeystore.signData(payload),
);
const finalized = await wallet.finalizeRecipe(recipe);
await wallet.submitTransaction(finalized);
});
// Wait for dust to actually generate (balance > 0), not just for coins to appear
await withStatus('Waiting for dust tokens to generate', () =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s) => s.isSynced),
Rx.filter((s) => s.dust.walletBalance(new Date()) > 0n),
),
),
);
};
DUST generation designates tNIGHT tokens to automatically generate DUST tokens over time after registration. The registration process creates an on-chain transaction that designates specific UTXOs for DUST generation.
This two-step process (registration + generation) ensures users have DUST for transaction fees without requiring a separate faucet for DUST.
Wallet summary display
Below the registerForDustGeneration function, create the printWalletSummary function to display the wallet summary:
/**
* Prints a formatted wallet summary to the console, showing all three
* wallet types (Shielded, Unshielded, Dust) with their addresses and balances.
*/
const printWalletSummary = (seed: string, state: any, unshieldedKeystore: UnshieldedKeystore) => {
const networkId = getNetworkId();
const unshieldedBalance = state.unshielded.balances[unshieldedToken().raw] ?? 0n;
// Build the bech32m shielded address from coin + encryption public keys
const coinPubKey = ShieldedCoinPublicKey.fromHexString(state.shielded.coinPublicKey.toHexString());
const encPubKey = ShieldedEncryptionPublicKey.fromHexString(state.shielded.encryptionPublicKey.toHexString());
const shieldedAddress = MidnightBech32m.encode(networkId, new ShieldedAddress(coinPubKey, encPubKey)).toString();
const DIV = '──────────────────────────────────────────────────────────────';
console.log(`
${DIV}
Wallet Overview Network: ${networkId}
${DIV}
Seed: ${seed}
${DIV}
Shielded (ZSwap)
└─ Address: ${shieldedAddress}
Unshielded
├─ Address: ${unshieldedKeystore.getBech32Address()}
└─ Balance: ${formatBalance(unshieldedBalance)} tNight
Dust
└─ Address: ${state.dust.dustAddress}
${DIV}`);
};
The wallet summary displays all three address types with their current balances:
- Shielded address: Constructed from two public keys (coin and encryption) and encoded in Bech32m format. This address enables private transactions on the Midnight network.
- Unshielded address: Derived from the keystore and represents a transparent Midnight address. This address is used for public transactions and receiving funds from faucets.
- Dust address: Automatically generated and used for fee management. This address holds DUST tokens required for transaction fees.
Main wallet building function
Add the main wallet building function:
/**
* Build (or restore) a wallet from a hex seed, then wait for the wallet
* to sync and receive funds before returning.
*/
export const buildWalletAndWaitForFunds = async (config: Config, seed: string): Promise<WalletContext> => {
console.log('');
// Derive HD keys and initialize the three sub-wallets
const { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore } = await withStatus(
'Building wallet',
async () => {
const keys = deriveKeysFromSeed(seed);
const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], getNetworkId());
const shieldedWallet = ShieldedWallet(buildShieldedConfig(config)).startWithSecretKeys(shieldedSecretKeys);
const unshieldedWallet = UnshieldedWallet(buildUnshieldedConfig(config)).startWithPublicKey(
PublicKey.fromKeyStore(unshieldedKeystore),
);
const dustWallet = DustWallet(buildDustConfig(config)).startWithSecretKey(
dustSecretKey,
ledger.LedgerParameters.initialParameters().dust,
);
const wallet = new WalletFacade(shieldedWallet, unshieldedWallet, dustWallet);
await wallet.start(shieldedSecretKeys, dustSecretKey);
return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore };
},
);
// Show seed and unshielded address immediately so user can fund via faucet while syncing
const networkId = getNetworkId();
const DIV = '──────────────────────────────────────────────────────────────';
console.log(`
${DIV}
Wallet Overview Network: ${networkId}
${DIV}
Seed: ${seed}
Unshielded Address (send tNight here):
${unshieldedKeystore.getBech32Address()}
Fund your wallet with tNight from the Preprod faucet:
https://faucet.preprod.midnight.network/
${DIV}
`);
// Wait for the wallet to sync with the network
const syncedState = await withStatus('Syncing with network', () => waitForSync(wallet));
// Display the full wallet summary with all addresses and balances
printWalletSummary(seed, syncedState, unshieldedKeystore);
// Check if wallet has funds; if not, wait for incoming tokens
const balance = syncedState.unshielded.balances[unshieldedToken().raw] ?? 0n;
if (balance === 0n) {
const fundedBalance = await withStatus('Waiting for incoming tokens', () => waitForFunds(wallet));
console.log(` Balance: ${formatBalance(fundedBalance)} tNight\n`);
}
// Register NIGHT UTXOs for dust generation (required for tx fees on Preprod/Preview)
await registerForDustGeneration(wallet, unshieldedKeystore);
return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore };
};
export const buildFreshWallet = async (config: Config): Promise<WalletContext> =>
await buildWalletAndWaitForFunds(config, toHex(Buffer.from(generateRandomSeed())));
The wallet building process follows these key steps:
- Display unshielded address: The function immediately prints the unshielded address so users can request faucet tokens while the wallet syncs in the background.
- Sync with network: The
waitForSyncfunction monitors the wallet's synchronization progress until it catches up with the blockchain. This ensures the wallet has accurate balance information. - Display wallet summary: After syncing,
printWalletSummaryshows all three address types (shielded, unshielded, and dust) along with their current balances. - Check and wait for funds: The function checks if the unshielded balance is non-zero. If the balance is zero, it calls
waitForFundsto monitor the wallet state until tokens arrive from the faucet. - Register for dust generation: This step registers the wallet's NIGHT UTXOs with the dust generation service, which is required for transaction fees on Preprod and Preview networks.
The function waits for both synchronization and funding before proceeding, ensuring the wallet is ready for transactions.
Transaction signing helper
Below the buildWalletAndWaitForFunds function, create the signTransactionIntents function to sign the transaction intents:
/**
* Sign all unshielded offers in a transaction's intents, using the correct
* proof marker for Intent.deserialize. This works around a bug in the wallet
* SDK where signRecipe hardcodes 'pre-proof', which fails for proven
* (UnboundTransaction) intents that contain 'proof' data.
*/
const signTransactionIntents = (
tx: { intents?: Map<number, any> },
signFn: (payload: Uint8Array) => ledger.Signature,
proofMarker: 'proof' | 'pre-proof',
): void => {
if (!tx.intents || tx.intents.size === 0) return;
for (const segment of tx.intents.keys()) {
const intent = tx.intents.get(segment);
if (!intent) continue;
// Clone the intent with the correct proof marker
const cloned = ledger.Intent.deserialize<ledger.SignatureEnabled, ledger.Proofish, ledger.PreBinding>(
'signature',
proofMarker,
'pre-binding',
intent.serialize(),
);
const sigData = cloned.signatureData(segment);
const signature = signFn(sigData);
if (cloned.fallibleUnshieldedOffer) {
const sigs = cloned.fallibleUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) => cloned.fallibleUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.fallibleUnshieldedOffer = cloned.fallibleUnshieldedOffer.addSignatures(sigs);
}
if (cloned.guaranteedUnshieldedOffer) {
const sigs = cloned.guaranteedUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) => cloned.guaranteedUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.guaranteedUnshieldedOffer = cloned.guaranteedUnshieldedOffer.addSignatures(sigs);
}
tx.intents.set(segment, cloned);
}
};
This function addresses a wallet SDK limitation in the signRecipe method, which hardcodes the proof marker to 'pre-proof'.
The problem: When signing a transaction that has already been proven (an UnboundTransaction), the intents contain 'proof' data, but signRecipe attempts to deserialize them with 'pre-proof', causing deserialization failures.
The workaround: The function performs the following steps:
- Deserializes the intent with the correct proof marker. Using
'proof'for proven transactions and'pre-proof'for unproven transactions. - Generates signatures for the intent's signature data.
- Adds signatures to both fallible and guaranteed unshielded offers within the intent.
- Updates the transaction's intent map with the correctly signed intent.
This ensures that signatures are valid regardless of whether the transaction is in a pre-proof or post-proof state.