For the complete documentation index, see llms.txt
Deploying and operating a contract
오프체인 테스트를 통과한 컨트랙트는 네트워크에 올릴 준비가 된 것입니다. 컨트랙트를 네트워크에 올리고 이후에도 계속 동작하게 유지하는 일은 네 부분으로 이루어진 하나의 워크플로입니다. Midnight.js가 네트워크에 접근할 때 사용하는 provider를 연결하고, 컨트랙트를 배포한 뒤 그 주소를 확보하고, 온체인 상태를 관찰하고, 이후 circuit을 업데이트할 수 있는 maintenance authority를 운영하는 것입니다.
이 가이드는 그 워크플로를 순서대로 따라갑니다. 예제는 Networks and environments의 로컬 네트워크에 bulletin board 컨트랙트를 배포하므로, 모든 단계를 아무 위험 없이 실행할 수 있습니다. 동일한 코드는 endpoint를 바꾸고 실제 지갑에 자금을 넣으면 그대로 Preprod로 옮겨갑니다.
Prerequisites
이 가이드의 모든 절차에 공통으로 적용됩니다:
- 완전히 컴파일된 컨트랙트: skip 플래그 없이
compact compile을 실행해, 출력에contract/와 함께keys/·zkir/가 포함되어야 합니다. 컴파일 과정과 생성된 모듈은 Using Compact contracts from JavaScript에서 다룹니다. - 실행 중인 네트워크. 절차는 Running a local network의 로컬 스택을 사용합니다. 이 클론은 검증 테스트가 import하는 지갑 헬퍼도 함께 제공합니다.
- NIGHT을 보유하고 DUST 생성을 위해 등록된 지갑. 모든 트랜잭션에는 DUST가 들기 때문입니다. 로컬 네트워크에서는 genesis 지갑에 미리 자금이 들어 있습니다. Preprod에서는 Funding a wallet을 따르세요.
- Node.js 22 이상, 그리고 support matrix가 사용 중인 컴파일러와 짝지어 제시하는 버전의
@midnight-ntwrk/midnight-js-*패키지.
The transaction pipeline and its providers
circuit 호출을 제출하는 과정은 하나의 파이프라인이며, 각 단계에는 교체 가능한 컴포넌트가 있습니다. DApp이 배포된 컨트랙트를 호출하면, Midnight.js는 컨트랙트 로직을 로컬에서 실행하고, 그 실행에 대한 영지식 증명을 생성하고, 수수료를 충당하기 위해 DUST로 트랜잭션의 잔액을 맞추고, 그 결과를 네트워크에 제출합니다. 쓰기가 아닌 읽기도 같은 형태를 따릅니다. 컨트랙트 상태는 indexer에서 오고, private state는 절대 머신을 벗어나지 않습니다.
MidnightProviders 객체는 각 기능마다 provider를 하나씩 지정하며, 모든 배포와 호출은 이 객체 전체를 인자로 받습니다:
privateStateProvider는 컨트랙트의 private state와 SDK가 생성하는 signing key를 로컬 디바이스에 저장합니다.publicDataProvider는 indexer의 GraphQL API를 통해 온체인 컨트랙트 상태를 조회하고 구독합니다.zkConfigProvider는 컴파일 결과물에서 prover key, verifier key, ZKIR 아티팩트를 공급합니다.proofProvider는 proof server에 증명 요청을 보냅니다.walletProvider는 지갑의 수신 키를 노출하고 트랜잭션의 잔액을 맞춥니다.midnightProvider는 확정된 트랜잭션을 제출합니다.
선택적인 일곱 번째 슬롯인 loggerProvider는 logger를 받습니다. 위 여섯 개는 필수입니다.
이 타입은 컨트랙트에 대해 제네릭이므로, 일치하지 않는 circuit 이름이나 private state 형태를 컴파일러가 잡아냅니다:
import { type MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
export type BBoardPrivateState = { readonly secretKey: Uint8Array };
export type BBoardProviders = MidnightProviders<'post' | 'takeDown', 'bboardPrivateState', BBoardPrivateState>;
API 패키지와 UI 패키지가 같은 컨트랙트 타입을 사용한다면, 이 별칭들을 common-types.ts 같은 공유 모듈에 두세요.
각 슬롯을 어떤 구현이 채우는지는 코드가 어디서 실행되는지에 따라 다릅니다. 선택지는 Provider implementations 레퍼런스에 정리되어 있습니다. 아래 절차는 Node.js 구현을 사용합니다.
Configuring providers for a contract
여섯 개의 provider를 만들어, 모든 배포와 호출이 인자로 받는 객체로 조립합니다. 아래 코드는 로컬 네트워크를 대상으로 합니다. public 네트워크의 경우 environment reference의 endpoint로 바꾸세요.
Procedure
-
provider 패키지를 설치합니다. 버전은 support matrix에 맡기세요:
npm install @midnight-ntwrk/midnight-js-types @midnight-ntwrk/midnight-js-contracts \@midnight-ntwrk/midnight-js-network-id @midnight-ntwrk/midnight-js-level-private-state-provider \@midnight-ntwrk/midnight-js-indexer-public-data-provider @midnight-ntwrk/midnight-js-node-zk-config-provider \@midnight-ntwrk/midnight-js-http-client-proof-provider @midnight-ntwrk/midnight-js-utils -
provider를 다루기 전에 network ID를 먼저 설정하고, Wallet SDK의 indexer 연결이 동작하도록 Node.js에서
WebSocket을 폴리필하세요. public data provider는 자체 구현을 함께 제공합니다. Midnight.js는 주소를 정규화하고 트랜잭션을 만들 때 network ID를 읽습니다:import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';import { WebSocket } from 'ws';setNetworkId('undeployed'); // local network; 'preprod', 'preview', or 'mainnet' for public networksglobalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket; -
private state provider를 만듭니다. 이 provider는 private state와 signing key를 로컬 디바이스의 암호화된 LevelDB 저장소에 보관합니다. 두 옵션은 런타임에 강제됩니다.
accountId는 필수이며, 이 덕분에 저장된 상태가 하나의 지갑으로 범위가 한정되어 계정 간에 새어 나가지 않습니다. 그리고 암호화 password는 최소 16자여야 하고 네 가지 문자 종류(대문자, 소문자, 숫자, 특수문자) 중 최소 세 가지를 포함해야 합니다:import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';const privateStateProvider = levelPrivateStateProvider({privateStateStoreName: 'bboard-private-state',signingKeyStoreName: 'bboard-signing-keys',privateStoragePasswordProvider: () => 'Docs-Verify-2026',accountId: walletAddress, // the wallet's Bech32m address, or another per-account identifier});Derive the password, do not hardcode it이 password는 저장 시점의 private state와 signing key를 암호화합니다. 프로덕션에서는 소스에 리터럴을 넣지 말고, 지갑 자격 증명이나 키 관리 시스템에서 password를 도출하세요.
-
indexer의 query·subscription endpoint로 public data provider를 만듭니다:
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';const publicDataProvider = indexerPublicDataProvider('http://127.0.0.1:8088/api/v4/graphql','ws://127.0.0.1:8088/api/v4/graphql/ws',); -
keys/와zkir/를 담고 있는 컴파일 출력 디렉터리를 가리키도록 ZK config provider를 만듭니다. 타입 파라미터는 circuit 이름들의 union입니다:import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>('/path/to/managed/bboard'); -
proof server URL과 ZK config provider로 proof provider를 만듭니다. ZK config provider는 증명에 필요한 아티팩트를 공급합니다:
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';const proofProvider = httpClientProofProvider('http://127.0.0.1:6300', zkConfigProvider); -
wallet provider와 submission provider를 구현합니다. 동기화된
WalletFacade를 기반으로, 하나의 클래스가 두 슬롯을 모두 담당할 수 있습니다. ledger 타입은 ledger 패키지에서 직접 가져오지 말고@midnight-ntwrk/midnight-js-protocol/ledger에서 가져오세요. protocol 패키지는 Midnight.js가 컴파일 대상으로 삼는 바로 그 복사본을 다시 export하며, ledger 패키지의 두 번째 복사본을 끌어들이면 타입 검사에 실패합니다:import {type CoinPublicKey,type EncPublicKey,type FinalizedTransaction,ZswapSecretKeys,DustSecretKey,} from '@midnight-ntwrk/midnight-js-protocol/ledger';import {type WalletProvider,type MidnightProvider,type UnboundTransaction,} from '@midnight-ntwrk/midnight-js-types';import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';import { type WalletFacade } from '@midnight-ntwrk/wallet-sdk';class BBoardWalletProvider implements WalletProvider, MidnightProvider {constructor(private readonly wallet: WalletFacade,private readonly zswapSecretKeys: ZswapSecretKeys,private readonly dustSecretKey: DustSecretKey,) {}getCoinPublicKey(): CoinPublicKey {return this.zswapSecretKeys.coinPublicKey;}getEncryptionPublicKey(): EncPublicKey {return this.zswapSecretKeys.encryptionPublicKey;}async balanceTx(tx: UnboundTransaction, ttl: Date = ttlOneHour()): Promise<FinalizedTransaction> {const recipe = await this.wallet.balanceUnboundTransaction(tx,{ shieldedSecretKeys: this.zswapSecretKeys, dustSecretKey: this.dustSecretKey },{ ttl },);return await this.wallet.finalizeRecipe(recipe);}submitTx(tx: FinalizedTransaction): Promise<string> {return this.wallet.submitTransaction(tx);}}getCoinPublicKey와getEncryptionPublicKey는 shielded 출력을 수신하고 복호화하는 키를 노출하고,balanceTx는 수수료를 충당할 DUST를 선택해 트랜잭션을 확정하며,submitTx는 그것을 네트워크로 보냅니다.WalletFacade를 만들고 동기화하는 과정과 secret key를 도출하는 과정은 Funding a wallet에서 다룹니다. -
walletProvider와midnightProvider에 같은 인스턴스를 전달해 객체를 조립합니다:const walletProvider = new BBoardWalletProvider(wallet, zswapSecretKeys, dustSecretKey);const providers: BBoardProviders = {privateStateProvider,publicDataProvider,zkConfigProvider,proofProvider,walletProvider,midnightProvider: walletProvider,};
Verification
provider들은 실제 컴파일 아티팩트와 endpoint를 대상으로 조립되며, private state provider는 accountId 요구 사항을 강제합니다. 이 파일은 이 가이드의 모든 테스트와 마찬가지로, 로컬 네트워크 설정에서 만든 midnight-local-dev 클론 안에서 실행되며 그 지갑 헬퍼를 import합니다. 클론의 docs-tests/ 아래에 저장하고 다음을 실행하세요:
npx vitest run docs-tests --testTimeout=600000 --hookTimeout=600000
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import { StandaloneConfig } from '../src/config.js';
import { buildWalletFromHexSeed, closeWallet, type WalletContext } from '../src/wallet.js';
const BBOARD = new URL('../managed-bboard', import.meta.url).pathname;
const GENESIS_SEED = '0'.repeat(63) + '1';
describe('configuring providers', () => {
const config = new StandaloneConfig();
let ctx: WalletContext;
beforeAll(async () => {
ctx = await buildWalletFromHexSeed(config, GENESIS_SEED);
});
afterAll(async () => {
await closeWallet(ctx);
});
it('rejects a private-state store configured without an accountId', () => {
expect(() =>
(levelPrivateStateProvider as any)({
privateStateStoreName: 'docs-no-account-state',
signingKeyStoreName: 'docs-no-account-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
}),
).toThrow('accountId is required');
});
it('assembles all six providers against real artifacts and endpoints', async () => {
const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(BBOARD);
const walletAndMidnightProvider = {
getCoinPublicKey: () => ctx.shieldedSecretKeys.coinPublicKey,
getEncryptionPublicKey: () => ctx.shieldedSecretKeys.encryptionPublicKey,
balanceTx: async (tx: any, ttl: Date = ttlOneHour()) => {
const recipe = await ctx.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl },
);
return await ctx.wallet.finalizeRecipe(recipe);
},
submitTx: (tx: any) => ctx.wallet.submitTransaction(tx),
};
const providers = {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: 'docs-bboard-private-state',
signingKeyStoreName: 'docs-bboard-signing-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
accountId: ctx.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};
expect(Object.keys(providers).sort()).toEqual([
'midnightProvider',
'privateStateProvider',
'proofProvider',
'publicDataProvider',
'walletProvider',
'zkConfigProvider',
]);
const verifierKey = await zkConfigProvider.getVerifierKey('post');
expect(verifierKey.length).toBeGreaterThan(0);
});
});
✓ docs-tests/providers.test.ts > configuring providers > rejects a private-state store configured without an accountId 1ms
✓ docs-tests/providers.test.ts > configuring providers > assembles all six providers against real artifacts and endpoints 5ms
Test Files 1 passed (1)
Tests 2 passed (2)
Deploying a contract
생성된 컨트랙트 모듈을 그 컴파일 아티팩트와 짝지은 뒤 deployContract를 호출해 배포합니다. 돌려받는 컨트랙트 주소는 이후 모든 것이 컨트랙트에 도달하는 통로이므로, 반드시 확보해 두세요.
Prerequisites
- Configuring providers for a contract에서 만든 providers 객체.
Procedure
-
생성된 모듈에서
CompiledContract를 만듭니다.make는 컨트랙트에 이름을 붙이고 생성된Contract클래스를 받고,withWitnesses는 witness 구현을 붙이며,withCompiledFileAssets는 컴파일 출력 디렉터리를 가리킵니다:import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';import { Contract } from './managed/bboard/contract/index.js';const witnesses = {localSecretKey: ({ privateState }) => [privateState, privateState.secretKey],};const compiled = CompiledContract.withCompiledFileAssets(CompiledContract.withWitnesses(CompiledContract.make('bboard', Contract), witnesses),'/path/to/managed/bboard',); -
private state 식별자와 초기 private state를 전달해 배포합니다. 이 호출은 컨트랙트의 생성자를 실행하고, 증명을 생성하고, 수수료 잔액을 맞추고, 제출한 뒤, 트랜잭션이 확정되면 완료됩니다:
import { deployContract } from '@midnight-ntwrk/midnight-js-contracts';const deployed = await deployContract(providers, {compiledContract: compiled,privateStateId: 'bboardPrivateState',initialPrivateState: { secretKey },});const contractAddress = deployed.deployTxData.public.contractAddress;signingKey를 전달하지 않으면,deployContract는 새 키를 하나 뽑아 컨트랙트의 maintenance authority로 설정하고, 컨트랙트 주소 아래에 private state provider에 저장합니다. 직접 키를 공급하는 방법은 Operating a maintenance authority에서 다루며, 모든 옵션은DeployContractOptions에 나와 있습니다. -
애플리케이션에 필요한 곳이면 어디든
contractAddress를 저장하세요. CLI, 서버, 프런트엔드 등 어디서든 컨트랙트에 다시 연결할 때는 주소로 연결합니다:import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';const found = await findDeployedContract(providers, {contractAddress,compiledContract: compiled,privateStateId: 'bboardPrivateState',initialPrivateState: { secretKey },}); -
callTx를 통해 circuit을 호출합니다. 각 호출은 트랜잭션을 만들고, 증명하고, 잔액을 맞추고, 제출한 뒤, 해당 식별자와 함께 완료됩니다:const call = await found.callTx.post('Hello from the network!');// call.public.txId, call.public.blockHeight
Verification
로컬 네트워크에 실제로 배포하면 주소가 반환되고, 다시 연결한 컨트랙트가 circuit 호출을 받아들이며, indexer가 그 결과 상태를 제공합니다:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import { Contract, State, ledger } from '../managed-bboard/contract/index.js';
import { StandaloneConfig } from '../src/config.js';
import { buildWalletFromHexSeed, registerNightForDust, closeWallet, type WalletContext } from '../src/wallet.js';
const BBOARD = new URL('../managed-bboard', import.meta.url).pathname;
const GENESIS_SEED = '0'.repeat(63) + '1';
const secretKey = new Uint8Array(32);
const witnesses = {
localSecretKey: ({ privateState }: any) => [privateState, privateState.secretKey],
};
describe('deploying a contract', () => {
const config = new StandaloneConfig();
let ctx: WalletContext;
let providers: any;
let compiled: any;
let contractAddress: string;
beforeAll(async () => {
ctx = await buildWalletFromHexSeed(config, GENESIS_SEED);
await registerNightForDust(ctx);
const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(BBOARD);
const walletAndMidnightProvider = {
getCoinPublicKey: () => ctx.shieldedSecretKeys.coinPublicKey,
getEncryptionPublicKey: () => ctx.shieldedSecretKeys.encryptionPublicKey,
balanceTx: async (tx: any, ttl: Date = ttlOneHour()) => {
const recipe = await ctx.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl },
);
return await ctx.wallet.finalizeRecipe(recipe);
},
submitTx: (tx: any) => ctx.wallet.submitTransaction(tx),
};
providers = {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: 'docs-bboard-private-state',
signingKeyStoreName: 'docs-bboard-signing-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
accountId: ctx.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};
compiled = CompiledContract.withCompiledFileAssets(
CompiledContract.withWitnesses(CompiledContract.make('bboard', Contract), witnesses),
BBOARD,
);
});
afterAll(async () => {
await closeWallet(ctx);
});
it('deploys and returns the contract address', async () => {
const deployed = await deployContract(providers, {
compiledContract: compiled,
privateStateId: 'bboardPrivateState',
initialPrivateState: { secretKey },
});
contractAddress = deployed.deployTxData.public.contractAddress;
expect(contractAddress).toMatch(/^[0-9a-f]{64,}$/);
});
it('reconnects with findDeployedContract and calls a circuit', async () => {
const found = await findDeployedContract(providers, {
contractAddress,
compiledContract: compiled,
privateStateId: 'bboardPrivateState',
initialPrivateState: { secretKey },
});
const call = await found.callTx.post('Deployed from the guide verification');
expect(call.public.txId).toBeDefined();
expect(call.public.blockHeight).toBeGreaterThan(0);
});
it('reads the deployed state through the indexer', async () => {
const state = await providers.publicDataProvider.queryContractState(contractAddress);
const board = ledger(state.data);
expect(board.state).toBe(State.OCCUPIED);
expect(board.message.value).toBe('Deployed from the guide verification');
});
});
✓ docs-tests/deploy.test.ts > deploying a contract > deploys and returns the contract address 20427ms
✓ docs-tests/deploy.test.ts > deploying a contract > reconnects with findDeployedContract and calls a circuit 18675ms
✓ docs-tests/deploy.test.ts > deploying a contract > reads the deployed state through the indexer 8ms
Test Files 1 passed (1)
Tests 3 passed (3)
Promoting a deployment to Preprod
Deploying a contract에서 실행한 배포를, 다른 개발자와 explorer에게 컨트랙트가 보이는 public 테스트넷인 Preprod로 승격합니다. 코드는 바뀌지 않고 설정만 바뀝니다. 대신 hello world tutorial에서 시작했다면, 그 저장소가 이 전체 흐름을 yarn test:preprod로 패키징해 두었고, 스크립트 뒤에는 동일한 단계가 있습니다.
Prerequisites
- Deploying a contract에서 만든, 로컬 네트워크를 대상으로 동작하는 배포.
Procedure
-
Preprod 지갑에 자금을 넣습니다. Funding a wallet에 따라 faucet에서 tNIGHT을 요청한 뒤 DUST 생성을 위해 등록하세요. DUST가 없으면 제출이
Wallet.InsufficientFunds로 실패합니다. -
지갑 secret은 소스 밖에 두세요. 버전 관리가 무시하는 환경 파일에서 seed나 mnemonic을 불러오세요:
# .env.preprod (listed in .gitignore; never commit or share it)MIDNIGHT_PREPROD_SEED=... # 64 hex characters, or a 24-word mnemonic variable -
설정을 Preprod로 향하게 합니다.
setNetworkId('preprod'), environment reference의 Preprod indexer·node endpoint, 그리고 Run a proof server에 따라 직접 실행하는 proof server를 사용하세요. proof server는 witness 데이터를 평문으로 보므로, 네트워크가 public이더라도 로컬에 둡니다. -
Deploying a contract의 동일한 배포 코드를 새 설정으로 실행하세요. 새 Preprod 지갑의 첫 동기화는 체인의 히스토리를 훑기 때문에 로컬 네트워크보다 상당히 오래 걸립니다.
Verification
출력된 컨트랙트 주소를 midnightexplorer.com이나 subscan.io 같은 Preprod explorer에서 조회하세요. explorer는 해당 주소의 배포 트랜잭션과 그 뒤의 각 호출 트랜잭션을 보여줍니다.
Observing contract and chain state
배포된 컨트랙트가 하는 일을 외부에서 지켜봅니다. indexer를 통해 public state를 읽고, 체인 수준의 관점이 필요할 때는 node에서 블록과 이벤트를 따라가세요.
Procedure
-
public data provider를 통해 컨트랙트 상태를 읽고, 생성된
ledger()함수로 디코딩하세요. 상태의data필드를 전달하세요. 이를 감싼ContractState자체는 받아들여지지 않습니다:const state = await providers.publicDataProvider.queryContractState(contractAddress);const board = ledger(state.data);// board.state, board.message.value, board.sequence: typed getters, no manual decoding -
체인 수준의 관점을 얻으려면, Midnight을 포함해 모든 Substrate 기반 체인과 통신하는
@polkadot/api로 node의 RPC endpoint에 연결하세요:import { ApiPromise, WsProvider } from '@polkadot/api';const api = await ApiPromise.create({provider: new WsProvider('ws://127.0.0.1:9944'),}); -
새 블록을 구독합니다. 콜백은 블록마다 한 번씩 그 헤더와 함께 호출됩니다:
api.rpc.chain.subscribeNewHeads((lastHeader) => {console.log(`\nBlock #${lastHeader.number} has been added`);}); -
블록에서 트랜잭션과 이벤트를 추출합니다. Substrate 체인에서 트랜잭션은 extrinsic입니다. 즉 체인 바깥에서 도착해 체인에 동작을 요청하는 데이터로, 사용자가 서명한 것일 수도, 서명되지 않은 것일 수도, 블록 타임스탬프 같은 내재적 시스템 데이터일 수도 있습니다:
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);const signedBlock = await api.rpc.chain.getBlock(blockHash);signedBlock.block.extrinsics.forEach((extrinsic, index) => {console.log(`\nExtrinsic ${index}: ${extrinsic.method.section}.${extrinsic.method.method}`);});const apiAt = await api.at(blockHash);const events = await apiAt.query.system.events();events.forEach(({ event, phase }) => {console.log(`\nEvent: ${event.section}.${event.method}, phase: ${phase.toString()}`);console.log(`Data: ${event.data.toString()}`);});
Verification
실행 중인 node는 구독으로 블록 헤더를 전달하고, 블록은 자신의 extrinsic과 이벤트를 내어 줍니다:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ApiPromise, WsProvider } from '@polkadot/api';
describe('observing the chain', () => {
let api: ApiPromise;
beforeAll(async () => {
api = await ApiPromise.create({
provider: new WsProvider('ws://127.0.0.1:9944'),
noInitWarn: true,
});
});
afterAll(async () => {
await api.disconnect();
});
it('delivers new block headers to a subscription', async () => {
const header = await new Promise<any>((resolve) => {
const unsubscribe = api.rpc.chain.subscribeNewHeads((lastHeader) => {
unsubscribe.then((stop) => stop());
resolve(lastHeader);
});
});
expect(header.number.toNumber()).toBeGreaterThan(0);
});
it('extracts extrinsics and events from a block', async () => {
const header = await api.rpc.chain.getHeader();
const blockHash = await api.rpc.chain.getBlockHash(header.number.toNumber());
const signedBlock = await api.rpc.chain.getBlock(blockHash);
expect(signedBlock.block.extrinsics.length).toBeGreaterThan(0);
const apiAt = await api.at(blockHash);
const events = await apiAt.query.system.events();
expect(events.length).toBeGreaterThan(0);
expect(events[0].event.section).toBeDefined();
});
});
✓ docs-tests/observe-chain.test.ts > observing the chain > delivers new block headers to a subscription 50ms
✓ docs-tests/observe-chain.test.ts > observing the chain > extracts extrinsics and events from a block 58ms
Test Files 1 passed (1)
Tests 2 passed (2)
Contract updatability and the maintenance authority
배포된 컨트랙트의 circuit은 그것을 컴파일한 proof system에 묶여 있습니다. Midnight의 proving 스택이 발전함에 따라 proving 스킴, circuit 중간 표현, 또는 verifier key 형식이 바뀔 수 있고, 예전 규칙으로 배포된 circuit은 그 증명을 더 이상 생성하거나 검증할 수 없는 지점에 이를 수 있습니다. updatability는 컨트랙트가 그 상황을 견뎌내는 방법이며, 이후가 아니라 배포 시점에 내리는 결정입니다.
그 메커니즘은 verifier key 관리입니다. 각 circuit은 proof system 버전마다 하나의 verifier key로 온체인에 표현되며, 컨트랙트가 지정한 authority가 배포 이후 이를 바꿀 수 있습니다. 그 authority가 바로 contract maintenance authority로, 서명 임계값을 가진 공개 키들의 위원회이며, 정확히 세 가지 일을 할 수 있습니다:
- verifier key 삽입 — 특정 proof system 버전의 circuit에 verifier key를 넣어 기능을 복원하거나 추가합니다. 해당 버전에 이미 키가 있으면 안 되므로, 기존 키를 먼저 제거하세요.
- verifier key 제거 — 제거하면 네트워크는 해당 버전의 그 circuit을 사용하는 트랜잭션을 거부합니다.
- authority 자체 교체 — 제어권을 새 위원회에 넘기거나, 빈 위원회를 지정해 제어권을 완전히 내려놓습니다.
ledger 수준에서, authority를 설정하지 않고 배포한 컨트랙트는 영구적으로 업그레이드할 수 없습니다. 기본값이 임계값 1의 빈 위원회인데, 이는 어떤 서명 집합으로도 충족할 수 없는 조건이기 때문입니다. Midnight.js를 통해 배포하면 이 그림이 달라집니다. deployContract는 직접 키를 공급하지 않는 한 새 signing key를 하나 뽑아 private state provider에 저장하면서, 단일 서명 authority를 대신 설치해 줍니다. 어느 경로를 택하든 신중하게 결정하세요. vesting 일정이나 신원 registry처럼 현실적으로 마이그레이션할 수 없는 장기 상태를 가진 컨트랙트에는 유지되는 authority가 필요합니다. 반대로 불변이어야 하는 컨트랙트에는, 어떤 키도 컨트랙트를 바꿀 수 없도록 authority를 내려놓아야 합니다.
authority의 키를 쥔 사람은 컨트랙트가 무엇을 받아들일지 다시 쓸 수 있으므로, 키 보관은 보안 결정입니다. 제어권을 독립적인 여러 당사자에게 분산하고, 보안 가이드의 key custody guidance를 따르세요. support matrix로 proof system 변경을 추적하고, 비호환이 닥치기 전에 circuit 업데이트를 계획하세요. updatability를 내려놓은 경우에는 대신 컨트랙트 마이그레이션을 계획하세요.
Operating a maintenance authority
배포된 컨트랙트를 대상으로 세 가지 maintenance 연산을 실행합니다. verifier key를 제거해 circuit을 비활성화하고, 하나를 삽입해 다시 활성화하고, 제어권을 새 authority에 넘깁니다.
Prerequisites
- Deploying a contract에서 만든, 배포된 컨트랙트와 그 provider들.
- private state provider가 보유하거나 배포 시점에 공급한 maintenance authority의 signing key.
Procedure
-
authority를 명시적으로 제어하려면, signing key를 생성해 배포 시점에 전달하세요. 여러 배포에서 하나의 키를 재사용하면 여러 컨트랙트가 같은 authority를 갖게 됩니다:
import { sampleSigningKey } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';const signingKey = sampleSigningKey();const deployed = await deployContract(providers, {compiledContract: compiled,privateStateId: 'bboardPrivateState',initialPrivateState: { secretKey },signingKey,}); -
circuit별 maintenance 인터페이스를 통해 circuit의 verifier key를 제거합니다.
DeployedContract와FoundContract는 모두circuitMaintenanceTx를 노출하며, circuit마다CircuitMaintenanceTxInterface가 하나씩 있습니다. 트랜잭션이 반영되면, 네트워크는 그 circuit에 대한 호출을 거부합니다:await deployed.circuitMaintenanceTx.takeDown.removeVerifierKey(); -
verifier key를 삽입해 circuit을 다시 활성화합니다. ZK config provider가 컴파일 출력에서 이 키를 읽습니다. 컴파일러를 업그레이드한 뒤에는, 이렇게 새로 컴파일한 키가 발이 묶인 기존 키를 대체합니다:
const verifierKey = await zkConfigProvider.getVerifierKey('takeDown');await deployed.circuitMaintenanceTx.takeDown.insertVerifierKey(verifierKey); -
제어권을 옮겨야 할 때는 새 위원회의 signing key를 전달해 authority를 교체합니다:
await deployed.contractMaintenanceTx.replaceAuthority(sampleSigningKey());
Verification
verifier key를 제거하면 네트워크가 circuit을 거부하고, 삽입하면 circuit이 복원되며, authority 이전이 성공합니다. 모두 실제 배포를 대상으로 확인합니다:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { deployContract } from '@midnight-ntwrk/midnight-js-contracts';
import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
import { sampleSigningKey } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import { Contract } from '../managed-bboard/contract/index.js';
import { StandaloneConfig } from '../src/config.js';
import { buildWalletFromHexSeed, registerNightForDust, closeWallet, type WalletContext } from '../src/wallet.js';
const BBOARD = new URL('../managed-bboard', import.meta.url).pathname;
const GENESIS_SEED = '0'.repeat(63) + '1';
const secretKey = new Uint8Array(32);
const witnesses = {
localSecretKey: ({ privateState }: any) => [privateState, privateState.secretKey],
};
describe('operating a maintenance authority', () => {
const config = new StandaloneConfig();
let ctx: WalletContext;
let deployed: any;
beforeAll(async () => {
ctx = await buildWalletFromHexSeed(config, GENESIS_SEED);
await registerNightForDust(ctx);
const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(BBOARD);
const walletAndMidnightProvider = {
getCoinPublicKey: () => ctx.shieldedSecretKeys.coinPublicKey,
getEncryptionPublicKey: () => ctx.shieldedSecretKeys.encryptionPublicKey,
balanceTx: async (tx: any, ttl: Date = ttlOneHour()) => {
const recipe = await ctx.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl },
);
return await ctx.wallet.finalizeRecipe(recipe);
},
submitTx: (tx: any) => ctx.wallet.submitTransaction(tx),
};
const providers = {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: 'docs-bboard-private-state',
signingKeyStoreName: 'docs-bboard-signing-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
accountId: ctx.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};
const compiled = CompiledContract.withCompiledFileAssets(
CompiledContract.withWitnesses(CompiledContract.make('bboard', Contract), witnesses),
BBOARD,
);
deployed = await deployContract(providers, {
compiledContract: compiled,
privateStateId: 'bboardPrivateState',
initialPrivateState: { secretKey },
signingKey: sampleSigningKey(),
});
await deployed.callTx.post('Maintenance drill');
});
afterAll(async () => {
await closeWallet(ctx);
});
it('removing a verifier key disables the circuit on-chain', async () => {
await deployed.circuitMaintenanceTx.takeDown.removeVerifierKey();
await expect(deployed.callTx.takeDown()).rejects.toThrow(
"Operation 'takeDown' is undefined",
);
});
it('inserting the verifier key re-enables the circuit', async () => {
const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(BBOARD);
const verifierKey = await zkConfigProvider.getVerifierKey('takeDown');
await deployed.circuitMaintenanceTx.takeDown.insertVerifierKey(verifierKey);
const call = await deployed.callTx.takeDown();
expect(call.public.txId).toBeDefined();
});
it('replaces the contract maintenance authority', async () => {
await expect(
deployed.contractMaintenanceTx.replaceAuthority(sampleSigningKey()),
).resolves.not.toThrow();
});
});
✓ docs-tests/maintenance.test.ts > operating a maintenance authority > removing a verifier key disables the circuit on-chain 17403ms
✓ docs-tests/maintenance.test.ts > operating a maintenance authority > inserting the verifier key re-enables the circuit 36159ms
✓ docs-tests/maintenance.test.ts > operating a maintenance authority > replaces the contract maintenance authority 18699ms
Test Files 1 passed (1)
Tests 3 passed (3)
Provider implementations
각 provider 슬롯을 환경별로 어떤 구현이 채우는지 정리합니다. 패키지는 @midnight-ntwrk/ scope 아래에 있으며, 버전은 support matrix에 나와 있습니다.
| Slot | Implementation | Package | Notes |
|---|---|---|---|
privateStateProvider | levelPrivateStateProvider | midnight-js-level-private-state-provider | 로컬 디바이스의 암호화된 LevelDB. accountId가 필요하며, password는 16자 이상에 네 가지 문자 종류 중 세 가지가 필요합니다 |
publicDataProvider | indexerPublicDataProvider | midnight-js-indexer-public-data-provider | indexer의 HTTP·WebSocket URL을 받습니다. query는 Node.js와 브라우저에서 동작합니다. 브라우저 구독의 경우 네이티브 WebSocket을 세 번째 인자로 전달하세요 |
zkConfigProvider | NodeZkConfigProvider | midnight-js-node-zk-config-provider | 로컬 파일시스템에서 keys/와 zkir/를 읽습니다 |
zkConfigProvider | FetchZkConfigProvider | midnight-js-fetch-zk-config-provider | 같은 아티팩트를 HTTP로 가져옵니다. 브라우저와 호스팅 환경용입니다 |
proofProvider | httpClientProofProvider | midnight-js-http-client-proof-provider | proof server URL과 zkConfigProvider를 받습니다 |
walletProvider, midnightProvider | WalletFacade를 감싼 사용자 클래스 | wallet-sdk | Configuring providers for a contract에서처럼 하나의 인스턴스가 두 슬롯을 모두 채울 수 있습니다 |
Deployment troubleshooting
이 가이드의 경로에서 마주치기 쉬운 실패와 그 해결책입니다. 인용된 메시지는 모두 실제 실행에서 나온 것입니다.
| Error | Cause | Fix |
|---|---|---|
accountId is required. Provide an account identifier | accountId 없이 levelPrivateStateProvider를 호출함 | 지갑의 Bech32m 주소 같은 계정별 식별자를 전달하세요 |
PasswordValidationError: Password must contain at least 3 of: uppercase letters, lowercase letters, digits, special characters | private state 암호화 password가 너무 약함 | 네 가지 문자 종류 중 최소 세 가지를 포함하는 password를 제공하세요 |
Password is shorter than 16 characters | private state 암호화 password가 길이 규칙을 통과하지 못함 | 16자 이상을 사용하세요 |
Wallet.InsufficientFunds | 지갑에 NIGHT은 있으나 사용 가능한 DUST가 없음 | DUST 생성을 위해 NIGHT을 등록하고 사용 가능한 코인이 생길 때까지 기다리세요. Funding a wallet을 참고하세요 |
tsc: Types have separate declarations of a private property 'type_' | ledger 타입을 ledger 패키지에서 직접 import해, Midnight.js가 컴파일 대상으로 삼는 복사본과 충돌함 | ZswapSecretKeys, DustSecretKey, 트랜잭션 타입을 @midnight-ntwrk/midnight-js-protocol/ledger에서 import하세요 |
expected instance of ContractMaintenanceAuthority (또는 배포 시 다른 expected instance of 오류) | 생성된 컨트랙트 모듈과 Midnight.js가 서로 다른 런타임 복사본을 해석해, 트리 간 클래스 검사가 실패함 | 컨트랙트의 managed 출력과 Midnight.js를 하나의 node_modules를 쓰는 같은 패키지 트리에 두세요 |
Operation 'takeDown' is undefined for contract state | circuit의 verifier key가 제거되었거나, 현재 proof system 버전에 존재하지 않음 | circuit에 verifier key를 삽입하세요. Operating a maintenance authority를 참고하세요 |
expected instance of ChargedState | indexer에서 온 ContractState 전체를 ledger()에 전달함 | 그 data 필드를 전달하세요: ledger(state.data) |
| 새 로컬 체인의 첫 시작에서 indexer가 종료됨 | 로컬 스택에서 문서화된 시작 시 경쟁 상태 | Local network troubleshooting을 참고하세요 |
Additional resources
- Using Compact contracts from JavaScript: 생성된 모듈, witness, 그리고 DUST를 들이기 전에 컨트랙트 로직을 검증하는 오프체인 테스트 스위트.
- Funding a wallet: faucet tNIGHT과 DUST 등록을, Lace와 wallet SDK로.
- Networks and environments: 로컬 스택, 모든 endpoint, 그리고 mainnet 준비 체크리스트.
- Security and best practices: 업그레이드 키 보관을 포함한 배포 전 체크리스트.
- Midnight.js API reference:
deployContract,findDeployedContract, 그리고 provider 인터페이스 전체. - Support matrix: 어떤 컴파일러·런타임·SDK 버전이 짝을 이루는지.