Skip to main content
For the complete documentation index, see llms.txt

Battleship tests

스마트 컨트랙트 개발 생명주기의 다음 단계는 프론트엔드 테스트 스위트입니다. 이 단계에서 개발자는 컨트랙트의 다양한 인스턴스에 대해 수많은 테스트 반복을 실행합니다. 퍼블릭 블록체인의 비허가적 특성상 모든 컨트랙트 회로는 사실상 각 컨트랙트 인스턴스의 공개 API로 접근 가능합니다.

개발자는 컨트랙트와의 상호작용에 대해 가정하는 모든 것을 테스트해야 하며, 성공하는 호출과 실패하는 호출 모두를 검증해야 합니다. 실패 테스트는 통과 테스트만큼이나 중요한데, 컨트랙트의 취약점을 방어하는 역할을 하기 때문입니다.

좋은 테스트 스위트란 컨트랙트와의 모든 가능한 상호작용을 빠짐없이 다루는 것입니다.

Prerequisites

Setup

다음 설정 파일을 복사하여 테스트 환경을 구성합니다.

package.json

프로젝트 루트에서:

touch package.json

다음 내용으로 채웁니다:

{
"name": "battleship",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"compile": "compact compile contract/battleship.compact contract/managed/battleship",
"test": "NODE_OPTIONS='--experimental-vm-modules' vitest run",
"test:local": "MIDNIGHT_NETWORK=local yarn test",
"env:up": "docker compose up -d --wait",
"env:down": "docker compose down",
"validate": "yarn env:up && yarn test:local; yarn env:down"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0",
"@midnight-ntwrk/ledger-v8": "8.0.3",
"@midnight-ntwrk/midnight-js": "^4.0.4",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-level-private-state-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.0.4",
"@midnight-ntwrk/testkit-js": "4.0.4",
"@midnight-ntwrk/wallet-sdk-facade": "3.0.0",
"axios": "^1.13.6",
"pino": "^9.0.0",
"pino-pretty": "^13.0.0",
"rxjs": "^7.8.2",
"testcontainers": "^11.13.0",
"ws": "^8.14.2"
},
"devDependencies": {
"@midnight-ntwrk/midnight-js-compact": "4.0.4",
"@types/node": "^22.0.0",
"@types/ws": "^8.5.9",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"resolutions": {
"@midnight-ntwrk/ledger-v8": "8.0.3",
"@midnight-ntwrk/midnight-js": "^4.0.4",
"@midnight-ntwrk/compact-runtime": "0.16.0"
},
"packageManager": "yarn@1.22.22"
}

의존성을 설치합니다:

yarn install

vitest.config.ts

테스트 설정 파일을 생성합니다:

touch vitest.config.ts

다음 내용으로 채웁니다:

import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
globals: true,
testTimeout: 10 * 60_000,
hookTimeout: 15 * 60_000,
include: ['src/**/*.test.ts'],
reporters: ['default'],
sequence: { concurrent: false },
disableConsoleIntercept: true,
},
});

compose.yml

로컬 devnet 배포를 위한 Docker 설정을 추가합니다:

touch compose.yml

다음 내용으로 채웁니다:

services:
proof-server:
image: 'midnightntwrk/proof-server:8.0.3'
command: ['midnight-proof-server', '-v']
ports:
- '127.0.0.1:6300:6300'
environment:
RUST_BACKTRACE: 'full'
healthcheck:
test: ['CMD-SHELL', 'echo > /dev/tcp/127.0.0.1/6300']
interval: 10s
timeout: 5s
retries: 20
start_period: 10s

indexer:
image: 'midnightntwrk/indexer-standalone:4.0.0'
ports:
- '127.0.0.1:8088:8088'
environment:
RUST_LOG: 'indexer=info,chain_indexer=info,indexer_api=info,wallet_indexer=info,indexer_common=info,fastrace_opentelemetry=off,info'
APP__INFRA__NODE__URL: 'ws://node:9944'
APP__APPLICATION__NETWORK_ID: 'undeployed'
APP__INFRA__STORAGE__PASSWORD: 'indexer'
APP__INFRA__PUB_SUB__PASSWORD: 'indexer'
APP__INFRA__LEDGER_STATE_STORAGE__PASSWORD: 'indexer'
APP__INFRA__SECRET: '303132333435363738393031323334353637383930313233343536373839303132'
healthcheck:
test: ['CMD-SHELL', 'cat /var/run/indexer-standalone/running']
interval: 10s
timeout: 5s
retries: 20
start_period: 10s
depends_on:
node:
condition: service_healthy

node:
image: 'midnightntwrk/midnight-node:0.22.3'
ports:
- '127.0.0.1:9944:9944'
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9944/health']
interval: 2s
timeout: 5s
retries: 20
start_period: 5s
environment:
CFG_PRESET: 'dev'
SIDECHAIN_BLOCK_BENEFICIARY: '04bcf7ad3be7a5c790460be82a713af570f22e0f801f6659ab8e84a52be6969e'
note

이 설정은 로컬 devnet 전용입니다. 프로덕션 애플리케이션에 포함해서는 안 되는 환경 변수가 있습니다.

Midnight setup

Midnight 전용 설정을 구성합니다:

mkdir src && cd src

네트워크 설정 파일을 생성합니다:

touch config.ts

다음 내용으로 채웁니다:

export type NetworkConfig = {
networkId: string;
indexer: string;
indexerWS: string;
node: string;
nodeWS: string;
proofServer: string;
faucet: string;
};

// depends on docker config in compose.yml running
export const LOCAL_CONFIG: NetworkConfig = {
networkId: 'undeployed',
indexer: 'http://127.0.0.1:8088/api/v4/graphql',
indexerWS: 'ws://127.0.0.1:8088/api/v4/graphql/ws',
node: 'http://127.0.0.1:9944',
nodeWS: 'ws://127.0.0.1:9944',
proofServer: 'http://127.0.0.1:6300',
faucet: '',
};

export function getConfig(): NetworkConfig {
const network = process.env['MIDNIGHT_NETWORK'] ?? 'local';
if (network !== 'local') {
throw new Error(
`Unknown network: ${network}. This harness only supports 'local'.`,
);
}
return LOCAL_CONFIG;
}

이 설정 파일은 로컬 devnet만 지원하지만, 이 패턴을 확장하여 다른 네트워크 설정도 추가할 수 있습니다.

여러 지갑을 생성할 수 있도록 wallet 클래스를 만듭니다:

touch wallet.ts

다음 내용으로 채웁니다:

import {
type CoinPublicKey,
DustSecretKey,
type EncPublicKey,
type FinalizedTransaction,
LedgerParameters,
ZswapSecretKeys,
} from '@midnight-ntwrk/ledger-v8';
import { types, utils } from '@midnight-ntwrk/midnight-js';
import { type WalletFacade, type FacadeState } from '@midnight-ntwrk/wallet-sdk-facade';
import {
type DustWalletOptions,
type EnvironmentConfiguration,
FluentWalletBuilder,
} from '@midnight-ntwrk/testkit-js';
import * as Rx from 'rxjs';
import type { Logger } from 'pino';

export class MidnightWalletProvider implements types.MidnightProvider, types.WalletProvider {
readonly wallet: WalletFacade;

private constructor(
private readonly logger: Logger,
private readonly env: EnvironmentConfiguration,
wallet: WalletFacade,
private readonly zswapSecretKeys: ZswapSecretKeys,
private readonly dustSecretKey: DustSecretKey,
) {
this.wallet = wallet;
}

getCoinPublicKey(): CoinPublicKey {
return this.zswapSecretKeys.coinPublicKey;
}

getEncryptionPublicKey(): EncPublicKey {
return this.zswapSecretKeys.encryptionPublicKey;
}

async balanceTx(
tx: types.UnboundTransaction,
ttl: Date = utils.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);
}

async start(): Promise<void> {
this.logger.info('Starting wallet...');
await this.wallet.start(this.zswapSecretKeys, this.dustSecretKey);
}

async stop(): Promise<void> {
return this.wallet.stop();
}

static async build(
logger: Logger,
env: EnvironmentConfiguration,
seed: string,
): Promise<MidnightWalletProvider> {
const dustOptions: DustWalletOptions = {
ledgerParams: LedgerParameters.initialParameters(),
additionalFeeOverhead: 1_000n,
feeBlocksMargin: 5,
};

const builder = FluentWalletBuilder.forEnvironment(env)
.withDustOptions(dustOptions);

const buildResult = await builder.withSeed(seed).buildWithoutStarting();
const { wallet, seeds } = buildResult as {
wallet: WalletFacade;
seeds: {
masterSeed: string;
shielded: Uint8Array;
dust: Uint8Array;
};
};

logger.info(`Wallet built from seed: ${seeds.masterSeed.slice(0, 8)}...`);

return new MidnightWalletProvider(
logger,
env,
wallet,
ZswapSecretKeys.fromSeed(seeds.shielded),
DustSecretKey.fromSeed(seeds.dust),
);
}
}

function isProgressStrictlyComplete(progress: unknown): boolean {
if (!progress || typeof progress !== 'object') {
return false;
}
const candidate = progress as { isStrictlyComplete?: unknown };
if (typeof candidate.isStrictlyComplete !== 'function') {
return false;
}
return (candidate.isStrictlyComplete as () => boolean)();
}

export async function syncWallet(
logger: Logger,
wallet: WalletFacade,
timeout = 300_000,
): Promise<FacadeState> {
logger.info('Syncing wallet...');
let emissionCount = 0;
return Rx.firstValueFrom(
wallet.state().pipe(
Rx.tap((state: FacadeState) => {
emissionCount++;
const shielded = isProgressStrictlyComplete(state.shielded.state.progress);
const unshielded = isProgressStrictlyComplete(state.unshielded.progress);
const dust = isProgressStrictlyComplete(state.dust.state.progress);
logger.info(
`Wallet sync [${emissionCount}]: shielded=${shielded}, unshielded=${unshielded}, dust=${dust}`,
);
if (!shielded) {
logger.debug(` shielded.progress: ${JSON.stringify(state.shielded.state.progress)}`);
}
if (!unshielded) {
logger.debug(` unshielded.progress: ${JSON.stringify(state.unshielded.progress)}`);
}
if (!dust) {
logger.debug(` dust.progress: ${JSON.stringify(state.dust.state.progress)}`);
}
}),
Rx.filter(
(state: FacadeState) =>
isProgressStrictlyComplete(state.shielded.state.progress) &&
isProgressStrictlyComplete(state.dust.state.progress) &&
isProgressStrictlyComplete(state.unshielded.progress),
),
Rx.tap(() => logger.info(`Wallet sync complete after ${emissionCount} emissions`)),
Rx.timeout({
each: timeout,
with: () =>
Rx.throwError(
() => new Error(`Wallet sync timeout after ${timeout}ms (${emissionCount} emissions received)`),
),
}),
Rx.catchError((err) => {
logger.error(`Wallet sync error: ${err}`);
return Rx.throwError(() => err);
}),
),
);
}

이 wallet 클래스는 다른 프로젝트에서도 재사용할 수 있으며, Compact 컨트랙트 구현이 달라져도 크게 변경되지 않습니다.

providers 파일을 생성합니다:

touch providers.ts

Provider는 MidnightJS의 핵심 구성 요소로, 비공개 상태, 공개 상태, 지갑 연산 등의 데이터를 설정하고 조회하는 인터페이스입니다. 실제 테스트 파일에서 이를 활용하는 방법을 보여드리겠습니다.

다음 내용으로 채웁니다:

// Returns the providers in an object which can be created for each users individual tests
import { type MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
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 { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { type MidnightWalletProvider } from './wallet.js';
import { type NetworkConfig } from './config.js';

export type BattleshipCircuits = 'acceptGame' | 'player1Shoot' | 'checkBoard1' | 'player2Shoot' | 'checkBoard2';

export type BattleshipProviders = MidnightProviders<any>;

export function buildProviders(
wallet: MidnightWalletProvider,
zkConfigPath: string,
config: NetworkConfig,
): BattleshipProviders {
const zkConfigProvider = new NodeZkConfigProvider<BattleshipCircuits>(zkConfigPath);
return {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: `battleship-${Date.now()}`,
// this password has requirements (capital/special chars >= 3)
privateStoragePasswordProvider: () => 'Battleship-Test-Password',
accountId: wallet.getCoinPublicKey(),
}),
publicDataProvider: indexerPublicDataProvider(
config.indexer,
config.indexerWS,
),
zkConfigProvider,
proofProvider: httpClientProofProvider(
config.proofServer,
zkConfigProvider,
),
walletProvider: wallet,
midnightProvider: wallet,
};
}

Tests, tests, and more tests

테스트 디렉토리와 파일을 생성합니다:

mkdir test && cd test
touch battle.test.ts

Test imports

테스트 파일은 이 과정에서 매우 중요한 구성 요소이므로, 여기서 다루는 개념을 잘 이해해야 합니다. 먼저 필요한 패키지를 import합니다:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { randomBytes } from 'node:crypto';
import pino from 'pino';
import { submitCallTx, deployContract } from '@midnight-ntwrk/midnight-js/contracts';
import { setNetworkId } from '@midnight-ntwrk/midnight-js/network-id';
import type { ContractAddress } from '@midnight-ntwrk/compact-runtime';
import type { EnvironmentConfiguration } from '@midnight-ntwrk/testkit-js';
import { getConfig } from '../config.js';
import { MidnightWalletProvider, syncWallet } from '../wallet.js';
import { buildProviders, type BattleshipProviders } from '../providers.js';
import {
CompiledBattleshipContract,
ledger,
zkConfigPath,
} from '../../contract/index.js';
import {
BoardState,
ShotState,
WinState,
TurnState,
Contract
} from '../../contract/managed/battleship/contract/index.js';
import { createBattlePrivateState } from '../../contract/witnesses.js';
import type { DeployedContract,FinalizedCallTxData } from '@midnight-ntwrk/midnight-js/contracts';

Wallet preparation

플레이어의 식별자를 생성합니다:

const ALICE_SEED = '0000000000000000000000000000000000000000000000000000000000000001';
const BOB_SEED = '0000000000000000000000000000000000000000000000000000000000000002';
const ALICE_PRIVATE_ID = 'alicePrivateState';
const BOB_PRIVATE_ID = 'bobPrivateState';

const logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
transport: { target: 'pino-pretty' },
});

로컬 devnet에는 처음 3개 seed 계정에 대한 사전 충전 주소가 제공됩니다. Alice는 1번, Bob은 2번에 할당됩니다. 각 플레이어에게는 비공개 상태를 식별하는 문자열도 필요합니다.

다음으로 메인 테스트를 설정합니다:

describe('Battleship Smart Contract via midnight-js', async () => {
let aliceWallet: MidnightWalletProvider;
let bobWallet: MidnightWalletProvider;
let aliceProviders: BattleshipProviders;
let bobProviders: BattleshipProviders;
let contractAddress: ContractAddress;

const config = getConfig();
const board1x1 = BigInt(1);
const board1x2 = BigInt(2);
const board2x1 = BigInt(10);
const board2x2 = BigInt(11);
});// end of describe

MidnightWalletProviderwallet.ts에서 export한 wallet 클래스가 정의한 타입이고, BattleshipProvidersproviders.ts의 export에서 가져옵니다.

getConfig()config.ts에서 네트워크 설정을 반환하며 현재 undeployed 네트워크에서 동작하도록 구성되어 있습니다. 뒤따르는 const 할당은 각 플레이어의 함선 위치를 고유하게 지정합니다. 여기서는 프로그래밍 테스트 목적으로 값을 할당하지만, 프로덕션 버전에서는 사용자가 UI 컴포넌트를 통해 이 값을 입력합니다.

Ledger queries

원장 조회를 위한 헬퍼 함수를 작성합니다:

    async function queryLedger(providers: BattleshipProviders) {
const state = await providers.publicDataProvider.queryContractState(contractAddress);
expect(state).not.toBeNull();
return ledger(state!.data);
}
})// end of describe

이 함수에서 각 플레이어의 providers 활용을 볼 수 있습니다. 특정 플레이어의 provider 객체를 전달하여 publicDataProvider를 통해 블록체인에 현재 존재하는 컨트랙트 상태를 조회하고 반환합니다. 이 함수는 곧 사용됩니다.

beforeAll

테스트 시작 전 beforeAll 작업을 수행합니다:

    // setup before tests
beforeAll(async () => {
setNetworkId(config.networkId);

const envConfig: EnvironmentConfiguration = {
walletNetworkId: config.networkId,
networkId: config.networkId,
indexer: config.indexer,
indexerWS: config.indexerWS,
node: config.node,
nodeWS: config.nodeWS,
faucet: config.faucet,
proofServer: config.proofServer,
};

aliceWallet = await MidnightWalletProvider.build(logger, envConfig, ALICE_SEED);
await aliceWallet.start();
await syncWallet(logger, aliceWallet.wallet, 600_000);

bobWallet = await MidnightWalletProvider.build(logger, envConfig, BOB_SEED);
await bobWallet.start();
await syncWallet(logger, bobWallet.wallet, 600_000);

aliceProviders = buildProviders(aliceWallet, zkConfigPath, config);
bobProviders = buildProviders(bobWallet, zkConfigPath, config);

logger.info('Providers initialized, ready to test.');
});// end of beforeAll
})// end of describe

setNetworkId()는 MidnightJS에서 주소 형식 등을 위해 어떤 네트워크와 상호작용할지 식별하는 데 필요한 호출입니다.

각 사용자는 wallet 클래스의 build()를 호출하여 지갑을 준비합니다. 그런 다음 지갑을 시작하고 wallet 클래스의 다른 함수를 호출하여 네트워크와 동기화합니다.

마지막으로 각 사용자는 providers.ts에서 export한 buildProviders()를 호출하여 provider(공개, 비공개, 지갑)를 구성합니다.

afterAll

테스트가 완료되면 지갑을 중지해야 하므로 afterAll()을 작성합니다:

    // tear down after tests
afterAll(async () => {
if(aliceWallet) {
logger.info('Stopping aliceWallet...');
await aliceWallet.stop();
}
if(bobWallet) {
logger.info('Stopping bobWallet...');
await bobWallet.stop();
}
});
})// end of describe

이제 첫 번째 테스트를 작성할 준비가 되었습니다!

각 개별 테스트는 Vitest 패키지의 it()으로 구성합니다:

    it('deploys the contract', async () => {

});
});// end of describe

Deploying the contract

컨트랙트를 배포하는 것이 논리적으로 첫 번째 작업입니다. 컨트랙트 코드를 네트워크에 전송하고 MidnightJS 호출을 통해 컨트랙트 회로에 접근할 수 있게 합니다. MidnightJS의 deployContract 함수를 호출하려면 배포자 Alice를 위한 초기 비공개 상태를 생성해야 합니다:

    it('deploys the contract', async () => {
const aliceSk = randomBytes(32);
const alicePrivateState = createBattlePrivateState(
board1x1,// x1 ship location
board1x2,// x2
BoardState.UNSET,
ShotState.MISS,
aliceSk,
);
});
});// end of describe

aliceSk는 DApp 전용 비밀 키를 나타내는 랜덤 바이트입니다. 이 키를 해싱하여 각 플레이어의 DApp 전용 공개 키를 게시합니다. 이 패턴으로 이 DApp 내에서는 플레이어를 추적할 수 있지만, 다른 DApp에서는 추적할 수 없습니다.

alicePrivateStatewitnesses.ts에서 export한 createBattlePrivateState 함수의 반환값입니다. MidnightJS를 통해 접근하는 컴파일된 컨트랙트의 타입 기대값과 일치하는 객체를 생성합니다.

블록체인에 컨트랙트를 배포합니다:

        const deployed: DeployedContract<Contract> = 
await (deployContract<Contract>)(aliceProviders, {
compiledContract: CompiledBattleshipContract,
privateStateId: ALICE_PRIVATE_ID,
initialPrivateState: alicePrivateState,
args: [alicePrivateState.x1, alicePrivateState.x2]
});
});// end of it('deploys the contract')
});// end of describe

deployContract는 블록체인에 컨트랙트를 배포하는 MidnightJS 함수입니다. 다음 인자를 받습니다:

  • aliceProviders -- 호출하는 사용자의 providers 객체
  • compiledContract -- index.ts 파일에서 가져온 컴파일된 Battleship 컨트랙트
  • privateStateId -- 호출자의 비공개 상태에 대한 고유 식별자
  • initialPrivateState -- 호출자의 초기 비공개 상태, createBattlePrivateState 함수의 반환값
  • args -- 컨트랙트에서 요구하는 constructor 인자

호출이 확정되면 DeployedContract 타입의 값을 반환하며, 이를 직접 검사하고 검증할 수 있습니다.

컨트랙트가 배포되었으므로 전역 contractAddress와 Alice의 비공개 상태를 설정합니다:

        contractAddress = deployed.deployTxData.public.contractAddress;      
aliceProviders.privateStateProvider.setContractAddress(contractAddress);
await aliceProviders.privateStateProvider.set(ALICE_PRIVATE_ID, alicePrivateState);

logger.info(`Contract deployed at: ${contractAddress}`);
expect(contractAddress).toBeDefined();
expect(contractAddress.length).toBeGreaterThan(0);
});// end of it('deploys the contract')
});// end of describe

aliceProviders.privateStateProvider에 접근하기 전에 setContractAddress()를 호출하지 않으면 오류가 발생합니다. 컨트랙트 주소가 확보되는 즉시 반드시 할당하세요.

createBattlePrivateState로 Alice의 비공개 상태 객체를 단순히 생성하는 것만으로는 충분하지 않다는 점에 주의하세요. privateStateProvider.set()에 전달하여 MidnightJS의 비공개 상태 provider를 초기화해야 합니다. 이후 테스트에서 비공개 상태에 접근할 때 이 provider를 사용합니다.

컨트랙트가 배포되었으므로 constructor가 성공적으로 실행된 결과가 블록체인에 초기 상태로 반영되어야 합니다. queryLedger() 함수로 상태가 기대한 대로인지 확인합니다:

        const state = await queryLedger(aliceProviders);
expect(state.board1State).toEqual(BoardState.SET);
expect(state.board2State).toEqual(BoardState.UNSET);
expect(state.winState).toEqual(WinState.CONTINUE_PLAY);
});// end of it('deploys the contract')
});// end of describe

.compact 파일의 constructor를 다시 살펴보면 예상 상태와 테스트 항목을 파악하는 데 도움이 됩니다.

첫 번째 테스트가 완성되었으므로 실행하여 통과하는지 확인합니다. Docker 엔진이 실행 중인지 확인하고 로컬 devnet을 시작하세요:

yarn env:up

로컬 devnet에는 Node, Indexer, Proof server가 로컬에서 실행되어야 하므로, 테스트가 진행되는 동안 이 서비스를 계속 실행해 두고 다음 단계로 진행하기 전에 컨테이너 상태를 확인하세요.

별도의 터미널에서 로컬 devnet 테스트 스크립트를 실행합니다:

yarn test:local

테스트는 플레이어 지갑을 동기화하고 provider를 초기화하는 것으로 시작됩니다. 그 후 컨트랙트가 성공적으로 배포되었다고 표시됩니다!

[20:32:05.816] INFO (11363): Wallet sync complete after 104 emissions
[20:32:05.824] INFO (11363): Providers initialized, ready to test.
✓ src/test/battleship.test.ts (1 test) 29035ms
✓ Battleship Smart Contract via midnight-js > deploys the contract 21282ms

Test Files 1 passed (1)
Tests 1 passed (1)

테스트를 작성할 때마다 바로 실행하여 다음 단계로 넘어가기 전에 정상 동작을 확인하는 것이 좋습니다.

Bob accepts the game

Bob이 게임에 참여할 준비가 되었습니다:

    });// end of it('deploys the contract')
it('Allows Bob to acceptGame', async () => {

const bobSk = randomBytes(32);
const bobInitialPrivateState = createBattlePrivateState(
board2x1,
board2x2,
BoardState.UNSET,
ShotState.MISS,
bobSk
);

bobProviders.privateStateProvider.setContractAddress(contractAddress);
await bobProviders.privateStateProvider.set(BOB_PRIVATE_ID, bobInitialPrivateState);
const bobPrivateState = await bobProviders.privateStateProvider.get(BOB_PRIVATE_ID);

logger.info(`Bob is accepting the game...`);
const txData: FinalizedCallTxData<Contract, 'acceptGame'> =
await (submitCallTx<Contract, 'acceptGame'>)(bobProviders, {
compiledContract: CompiledBattleshipContract,
contractAddress,
privateStateId: BOB_PRIVATE_ID,
circuitId: 'acceptGame',
args: [bobPrivateState.x1, bobPrivateState.x2]
});
logger.info(`Bob successfully joined the game!`);

const state = await queryLedger(bobProviders);
expect(state.board2State).toEqual(BoardState.SET);
expect(state.board2.size()).toEqual(2n);
expect(state.turn).toEqual(TurnState.PLAYER_1_SHOOT);
});
});// end of describe

Bob의 비공개 상태는 Alice와 같은 방식으로 설정하지만, MidnightJS 함수의 형태가 약간 다릅니다. submitCallTx는 기본 컨트랙트 호출에 사용하는 함수이며 deployContract와 비슷한 요구사항이 있습니다. 차이점을 주의 깊게 살펴보세요. submitCallTx는 배포된 컨트랙트와 상호작용할 때 사용합니다.

note

submitCallTx의 반환값을 txData에 캡처하여 보여드립니다. 여기서는 직접 사용하지 않지만, 회로 반환 데이터(status, txId, txHash, 블록 정보, 수수료, unshielded 출력 등)를 검사하는 데 유용합니다.

Alice takes the first shot

컨트랙트가 배포되고 Bob이 게임에 참여했으므로, 원장에 기록된 turn 값에 따라 Alice의 사격 차례입니다. 해당 테스트를 작성합니다:

    it('Allows Alice to take the first shot(MISS)', async () => {
const shot = BigInt(5);// miss

logger.info(`Alice shoots (MISS) at Bobs board...`);
const txData: FinalizedCallTxData<Contract, 'player1Shoot'> =
await (submitCallTx<Contract, 'player1Shoot'>)(aliceProviders, {
compiledContract: CompiledBattleshipContract,
contractAddress,
privateStateId: ALICE_PRIVATE_ID,
circuitId: 'player1Shoot',
args: [shot]
});
logger.info(`Alice shot successfully!`);

const state = await queryLedger(aliceProviders);
expect(state.board2HitCount).toEqual(0n);
expect(state.player1Shot.head().is_some).toBeTruthy();
expect(state.player1Shot.head().value).toEqual(shot);
expect(state.turn).toEqual(TurnState.PLAYER_2_CHECK);
});
});// end of describe

이 패턴이 이제 익숙해지기 시작할 것이며, 나머지 대부분의 테스트에서도 계속 사용됩니다. 하지만 실패하는 트랜잭션 호출의 패턴은 어떨까요? 결국 예상되는 실패를 테스트하는 것은 성공하는 호출을 테스트하는 것만큼 중요합니다. Bob이 순서를 무시하고 사격을 시도하는 부정행위를 이 테스트에 추가합니다:

    it('Allows Alice to take the first shot(MISS)', async () => {
const shot = BigInt(5);// miss

// new
logger.info(`Bob tries to shoot out of turn...`);
await expect(async () => {
await (submitCallTx<Contract, 'player2Shoot'>)(bobProviders, {
compiledContract: CompiledBattleshipContract,
contractAddress,
privateStateId: BOB_PRIVATE_ID,
circuitId: 'player2Shoot',
args: [BigInt(1)]// arbitrary
});
}).rejects.toThrow();// .rejects.toThrow() for calls expecting to fail
logger.info(`Bobs shot (out of turn) was rejected!`);
// end new

logger.info(`Alice shoots (MISS) at Bobs board...`);
const txData: FinalizedCallTxData<Contract, 'player1Shoot'> =
await (submitCallTx<Contract, 'player1Shoot'>)(aliceProviders, {
compiledContract: CompiledBattleshipContract,
contractAddress,
privateStateId: ALICE_PRIVATE_ID,
circuitId: 'player1Shoot',
args: [shot]
});
logger.info(`Alice shot successfully!`);

const state = await queryLedger(aliceProviders);
expect(state.board2HitCount).toEqual(0n);
expect(state.player1Shot.head().is_some).toBeTruthy();
expect(state.player1Shot.head().value).toEqual(shot);
expect(state.turn).toEqual(TurnState.PLAYER_2_CHECK);
});
});// end of describe

이것은 Compact의 명시적 상태 관리를 테스트합니다. assert 문이 커스텀 TurnState와 함께 올바르게 구현되어 있다면 Bob의 부정행위를 허용하지 않습니다. 테스트를 실행하여 확인해 봅니다:

yarn test:local
Bobs shot (out of turn) was rejected!

부정행위는 통하지 않습니다, Bob.

Test yourself

성공하는 호출과 실패하는 호출의 패턴을 확인했으니, 이제 직접 테스트를 작성하여 컨트랙트 상호작용을 진행해 보세요. .compact 코드를 다시 살펴보며 특정 회로 실행 후 상태를 확인하고, 각 테스트 끝에서 queryLedger()를 통해 검증하세요. (힌트: 인자가 없는 회로에 대한 submitCallTx 호출은 args 필드를 생략해야 합니다.)

도전 과제로, submitCallTx에서 반환되는 txData를 활용하여 반환값을 검사하고 활용해 보세요.

전체 테스트 스위트를 참고하려면 example-battleshipbattleship.test.ts를 확인하세요. 이 튜토리얼의 순차적 진행은 여기까지입니다. 위 링크의 테스트 파일을 참고하면 나머지 테스트를 완성하는 데 필요한 코드를 확인할 수 있습니다.

Further cheating attempts

다음으로 흥미로운 것은 나머지 부정행위 방어 기능입니다. Compact 컨트랙트에 구현된 보호 메커니즘을 강조했는데, 이를 명시적으로 테스트해야 합니다.

Bob(또는 Alice)이 비공개 상태를 악의적으로 변경하려는 시도를 시뮬레이션하려면, 새로운 비공개 상태 객체를 생성하고 비공개 상태 provider에 설정합니다:

        logger.info(`Bob realizes it is going to be a HIT and tries to cheat...`);
const bobPrivateState = await bobProviders.privateStateProvider.get(BOB_PRIVATE_ID);
const cheatBobPrivateState = createBattlePrivateState(
BigInt(15),
BigInt(16),
BoardState.SET,
ShotState.MISS,
bobPrivateState.sk,
);
await bobProviders.privateStateProvider.set(BOB_PRIVATE_ID, cheatBobPrivateState);
await expect(async () => {
await (submitCallTx<Contract, 'checkBoard2'>)(bobProviders, {
compiledContract: CompiledBattleshipContract,
contractAddress,
privateStateId: BOB_PRIVATE_ID,
circuitId: 'checkBoard2',
});
}).rejects.toThrow();
logger.info(`Bobs cheating attempt was rejected!`);

logger.info(`Bob is resetting his board to the original private state...`);
await bobProviders.privateStateProvider.set(BOB_PRIVATE_ID, bobPrivateState);
logger.info(`Bob successfully reverted his private state to the original!`);
  • bobProviders.privateStateProvider.get(BOB_PRIVATE_ID) -- 현재 비공개 상태 조회
  • createBattlePrivateState -- Bob의 함선 위치가 다른 새로운 비공개 상태 객체 생성
  • bobProviders.privateStateProvider.set() -- Bob의 비공개 상태를 새 객체로 설정
  • 그런 다음 submitCallTx 호출이 .rejects.toThrow()를 기대하도록 실행하면 Bob의 시도가 거부됩니다!
  • 이후 호출이 예상대로 성공하려면 Bob의 비공개 상태를 원래 함선 위치로 반드시 되돌려야 합니다

Conclusion

Battleship 튜토리얼이 마무리되었습니다. 다음과 같은 핵심 내용을 다루었습니다:

  • MidnightJS에서의 비공개 상태 관리 (설정, 조회, 변경)
  • Compact 코드에서의 명시적 상태 관리
  • MidnightJS를 통한 컨트랙트 호출
  • 성공/실패 컨트랙트 호출과 테스트
  • 방어적 프로그래밍

따라와 주셔서 감사합니다. Battleship 마스터가 되셨다면 Discord dev-chat에서 알려주시거나, 이 튜토리얼에 대한 피드백이나 질문을 공유해 주세요. 전체 Battleship 리포지토리는 example-battleship에서 확인하실 수 있습니다.