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

Battleship contract

이 Battleship 튜토리얼은 다음 기능을 다루는 중급 수준의 예제입니다:

  • 상태 머신으로서의 Compact 컨트랙트
  • 명시적 상태 관리
  • 비공개 상태 데이터 (설정, 조회, 갱신, 검증, 악의적 조작)
  • List 연산
  • 중급 Witness 기능
  • MidnightJS를 활용한 프론트엔드 테스트

이 튜토리얼에는 두 가지 핵심 구성 요소가 있습니다:

  1. Compact 컨트랙트
  2. 테스트 스크립트

공개 데이터와 비공개 데이터가 혼합된 안전한 Compact 컨트랙트를 작성하고, MidnightJS로 로컬 devnet에서 테스트하는 과정을 다룹니다. 코드를 직접 한 줄씩 타이핑하며 따라가는 것을 권장합니다. 복사/붙여넣기보다 훨씬 효과적입니다.

Prerequisites

시작하기 전에 다음 사항을 확인하세요:

  • 툴체인 설치 완료
  • Node.js v22 이상
  • 초급 튜토리얼 완료 (선택사항)

Problem analysis

Battleship(배틀쉽)은 두 명이 즐기는 추측 게임입니다. 각 플레이어는 개별 격자에 자신의 함선을 배치하며, 상대방에게는 함선 위치가 보이지 않습니다. 플레이어는 번갈아 가며 상대 함선을 향해 "사격"하고, 상대 함선을 모두 "격침"하는 것이 목표입니다. 먼저 상대 함선을 모두 맞힌 플레이어가 승리합니다.

Battleship의 핵심은 비공개 데이터(함선)와 공개 데이터(사격, 명중)의 조합입니다. 보드게임 버전은 상당한 신뢰 가정에 의존하기 때문에 악의적 플레이어에 취약합니다. 플레이어가 HIT인데도 MISS라고 거짓 주장할 수 있고, 게임 도중 함선을 이동(비공개 상태 변경)하거나, 상대 함선 위치를 몰래 확인하여 승리를 보장할 수도 있습니다.

블록체인을 활용하면 이러한 신뢰 가정을 온체인으로 옮겨 규칙을 강제할 수 있습니다. Midnight에서는 비공개 데이터를 공개적으로 노출하지 않으면서도 해당 플레이어의 비공개 상태에서 유효성을 검증할 수 있습니다.

Program design

코드를 간결하게 유지하기 위해 이 튜토리얼에서는 단순화된 버전으로 구현합니다. 원래 게임과의 주요 차이점은 다음과 같습니다:

  • 격자 대신 단일 숫자 라인 사용
  • 라인 위의 숫자 하나가 함선의 위치를 나타냄
  • 승리에 필요한 HIT 횟수를 2회로 축소

Operational steps

먼저 컨트랙트가 수행할 작업을 순서대로 정리합니다.

  1. 컨트랙트를 배포하고 player 1이 보드를 설정
  2. Player 2가 게임에 참여하고 보드를 설정
  3. Player 1이 board 2를 향해 사격
  4. Player 2가 자신의 보드에서 HIT 또는 MISS 확인
  5. Player 2가 board 1을 향해 사격
  6. Player 1이 자신의 보드에서 HIT 또는 MISS 확인
  7. board1Hits == 2 || board2Hits == 2가 될 때까지 3~6단계 반복
  8. 승자 결정

Data: public vs private

Midnight DApp 개발에서 가장 까다로운 부분 중 하나는 공개 데이터와 비공개 데이터의 혼합입니다. 데이터가 의도대로 처리되고 필요한 경우에만 공개되려면 모범 사례를 따르는 것이 좋습니다. 이 게임에서 두 플레이어의 데이터 요구사항은 동일합니다:

공개 데이터비공개 데이터
DApp IDAddress
HitsShips
ShotsPassword
StatesHIT/MISS

게임 규칙을 강제하고 부정행위를 방지하기 위해, 각 플레이어는 함선 위치에 대한 commitment을 생성하고 해당 해시를 원장에 게시합니다. 모든 원장 데이터는 공개되므로, 나중에 변경 여부를 검증할 수 있도록 비공개 상태 데이터(함선)를 온체인에서 신중하게 관리해야 합니다.

Cheating assertions

Battleship에서 가장 흔한 부정행위는 실제로 HIT인 "사격"을 MISS라고 주장하는 것입니다. 이 DApp에서는 플레이어가 비공개 상태 데이터를 악의적으로 조작하려는 시도에 해당합니다.

검증 로직으로 ShotState의 유효성을 확인하여 MISS가 실제로 MISS인지 검사할 수 있습니다. Alice나 Bob을 신뢰할 필요가 없으며, Compact 코드로 이를 강제합니다. 프론트엔드 테스트 스위트에서 이러한 방식으로 컨트랙트를 공격하는 테스트를 작성하고, 검증 로직이 이를 올바르게 거부하는지 확인합니다.

컨트랙트는 다음과 같은 부정행위를 처리합니다:

  • 이중 사격
  • 이전 HIT를 반복하여 hitCount 증가 시도
  • HIT인데 MISS라고 주장
  • 게임 중 함선 위치 변경

스마트 컨트랙트 개발자는 방어적 프로그래밍에 항상 주의를 기울여야 합니다. 블록체인의 공개적이고 비허가적인 특성상, 노드 커맨드라인만 있으면 누구든 컨트랙트에 접근할 수 있습니다. 접근 제어, 입력 검증, 명시적 상태 관리를 통해 회로를 의도적으로 보호하면 이러한 고려 없이 작성한 프로그램보다 훨씬 안전한 결과물을 만들 수 있습니다.

State machines

Compact 컨트랙트는 상태 머신으로 생각하는 것이 가장 적절합니다. 다음으로 고려할 것은 컨트랙트의 상태 설계입니다. 다음 항목에 대해 커스텀 상태가 필요합니다:

  • BoardState
  • ShotState
  • WinState
  • TurnState

이러한 상태를 명시적으로 관리하고 assert 문과 결합하면, 특정 시점에 사용 가능한 함수에만 접근을 제한할 수 있습니다. 예를 들어 TurnState를 활용하면 자신의 턴에만 사격할 수 있도록 보장합니다.

턴 순서:
PLAYER_1_SHOOTPLAYER_2_CHECKPLAYER_2_SHOOTPLAYER_1_CHECK → 반복

PLAYER_1_SHOOT 회로가 먼저 활성화되고, 나머지 회로는 차단됩니다. 상태 전이가 성공하면 PLAYER_2_CHECK가 "잠금 해제"되고 PLAYER_1_SHOOT는 비활성화됩니다.

Compact tutorial

Compact는 공개/비공개 데이터 관리를 적절히 조합하여 Battleship 구현을 깔끔하게 처리합니다.

Setup

Compact 코드 셋업부터 시작합니다:

mkdir example-battleship && cd example-battleship
mkdir contract && cd contract
touch battleship.compact

텍스트 에디터에서 프로젝트를 열고 battleship.compact 파일을 엽니다.

먼저 언어 버전과 import를 선언합니다:

pragma language_version >= 0.23;
import CompactStandardLibrary;

그런 다음 커스텀 상태를 선언합니다:

export enum BoardState { UNSET, SET }
export enum ShotState { MISS, HIT }
export enum TurnState {
PLAYER_1_SHOOT,
PLAYER_1_CHECK,
PLAYER_2_SHOOT,
PLAYER_2_CHECK,
}
export enum WinState {
CONTINUE_PLAY,
PLAYER_1_WINS,
PLAYER_2_WINS
}

다음으로 공개 원장 필드를 선언합니다:

export ledger player1: Bytes<32>;
export ledger player2: Bytes<32>;
export ledger turn: TurnState;
export ledger board1: Set<Bytes<32>>;// linear board shape
export ledger board2: Set<Bytes<32>>;// hashed storage of ship locations
export ledger board1State: BoardState;
export ledger board2State: BoardState;
export ledger player1Shot: List<Uint<8>>;// current shot
export ledger player2Shot: List<Uint<8>>;
export ledger board1Hits: Set<Uint<8>>;// previous hits stored for later assertions
export ledger board2Hits: Set<Uint<8>>;
export ledger winState: WinState;
export ledger board1HitCount: Counter;
export ledger board2HitCount: Counter;

모든 원장 필드는 공개적으로 볼 수 있습니다. 비공개 데이터를 이러한 필드에 숨기려면 Compact의 해싱 함수를 활용합니다. 구체적인 회로 구현은 나중에 다루겠지만, 우선 이 데이터를 숨기는 전략이 필요하다는 점을 알아두세요.

Witness declaration

비공개 상태 데이터를 설정하고 접근하려면 witness 함수를 선언합니다:

witness localSk(): Bytes<32>;
witness localSetBoard(_x1: Uint<8>, _x2: Uint<8>): BoardState;
witness localCheckBoard(x: Uint<8>): ShotState;

Witness 함수는 Compact에서 선언하지만, 실제 구현은 TypeScript 프론트엔드에서 합니다. Witness 함수에서 오는 데이터를 절대 신뢰하지 마세요. 반드시 엄격하게 검증해야 합니다. 각 TypeScript 인스턴스에서 이 함수를 조작할 수 있으므로, Compact 컨트랙트는 witness 함수가 기대대로 구현되었다고 가정해서는 안 됩니다. assert 문을 통해 데이터를 철저히 검증하세요.

Constructor

다음으로 컨트랙트 배포 시 실행되는 constructor를 작성합니다. 편의상 player1(Alice)이 컨트랙트를 배포한다고 가정하여 몇 가지 작업을 묶어서 처리합니다.

먼저 입력값이 게임 범위 내에 있는지 검증합니다:

constructor(_x1: Uint<8>, _x2: Uint<8>) {
// input verification checks
assert(_x1 != _x2, "Cannot use the same number twice");
assert(_x1 > 0 && _x2 > 0, "No zero index, board starts at 1");
assert(_x1 <= 20 && _x2 <= 20, "Out of bounds, please keep ships on the board");

}// end of constructor

식별자에 밑줄 접두사를 사용하는 것(_x1)은 암호학에서 비공개 데이터 관리를 추적하는 좋은 습관입니다. 컴파일러에는 영향을 주지 않지만, 비공개로 유지해야 하는 정보를 개발자가 파악하는 데 도움이 됩니다.

그런 다음 player1에게 DApp 전용 공개 키를 할당합니다:

    // user id and assignment
const _sk = localSk();
const pubKey = getDappPubKey(_sk);
player1 = disclose(pubKey);

}// end of constructor

이 패턴은 플레이어의 비공개 상태에서 비밀 키를 가져와 도메인 구분자와 함께 해싱하여, 이 DApp 내에서는 사용자 상호작용을 추적하되 다른 DApp에서는 추적할 수 없도록 합니다. getDappPublicKey 회로는 나중에 작성합니다.

비공개 상태 데이터(_x1, _x2)를 공개적으로 저장하려면 해싱이 필요합니다:

    // hash the inputs to verify them later, user needs to provide the same value and _sk
const hash1 = commitBoardSpace(_x1 as Bytes<32>, _sk);
board1.insert(hash1);
const hash2 = commitBoardSpace(_x2 as Bytes<32>, _sk);
board1.insert(hash2);

}// end of constructor

이렇게 저장된 해시로는 공격자가 플레이어의 비공개 상태에 접근하지 않는 한 원본 비공개 데이터를 도출할 수 없습니다. 단방향 결정적 함수이므로 역추적이 불가능합니다. 함선 위치가 변경되지 않았는지 검증하려면 프로그램 후반에 같은 데이터를 다시 제출하도록 요청하고, 다시 해싱하여 해시값을 비교합니다. 해시가 일치하지 않으면 플레이어가 다른 데이터를 제공한 것입니다.

이제 플레이어에게 로컬 보드에 함선 위치를 설정하도록 요청합니다:

    // disclose only what you need (localBoardState);
const localBoardState = localSetBoard(_x1, _x2);
assert(localBoardState == BoardState.SET, "Please update the state of board1 to SET");
board1State = disclose(localBoardState);

}// end of constructor

localSetBoardState는 witness 함수이므로 반환값을 신뢰하면 안 되고, assert로 강제해야 합니다. 이 오프체인 함수가 완료되면 보드가 설정되었다고 가정하므로, 이를 명시적으로 assert합니다. 함선 위치는 플레이어가 보드를 확인할 때 원본과 대조하여 검증합니다.

Constructor의 마지막 작업은 공개 상태 할당입니다:

    // setting initial states
board2State = BoardState.UNSET;
winState = WinState.CONTINUE_PLAY;
}// end of constructor

이러한 공개 상태 값을 통해 특정 함수에 대한 접근을 허용하기 전 assert로 상태가 기대한 대로인지 검증할 수 있습니다.

Bob joins the game

프로그램 설계에 따르면, 다음으로 player2(Bob)가 게임을 수락하고 함선 위치를 설정해야 합니다. acceptGame 회로는 두 함선을 인자(비공개)로 받아 Bob에게 이 DApp 전용 ID를 할당합니다. player1이 자신과 대전하려는 것은 아닌지도 검증합니다:

export circuit acceptGame(_x1: Uint<8>, _x2: Uint<8>): [] {
// caller verification checks
const _sk = localSk();
const pubKey = getDappPubKey(_sk);
assert(player1 != disclose(pubKey), "You cannot play against yourself");

}// end of acceptGame

명시적 상태 관리를 위해 게임 상태가 기대한 대로인지 확인하는 검사를 추가합니다:

    // state verification check
assert(board2State == BoardState.UNSET, "There is already a player2");

}// end of acceptGame

이 상태는 회로 끝에서 갱신되므로, 컨트랙트 생명주기 동안 이 회로에 다시 접근할 수 없게 됩니다.

입력값이 범위 내에 있는지 검증합니다:

    // input verification checks
assert(_x1 != _x2, "Cannot use the same number twice");
assert(_x1 > 0 && _x2 > 0, "No zero index, please keep ships on the board");
assert(_x1 <= 20 && _x2 <= 20, "Out of bounds, please keep ships on the board");

}// end of acceptGame

모든 검사를 통과했으므로 호출자를 player2로 할당합니다:

    // user assignment
player2 = disclose(pubKey);

}// end of acceptGame

player2도 비공개 데이터를 공개적으로 숨겨야 합니다:

    // hash inputs and store them to the ledger for comparison later
const hash1 = commitBoardSpace(_x1 as Bytes<32>, _sk);
board2.insert(hash1);
const hash2 = commitBoardSpace(_x2 as Bytes<32>, _sk);
board2.insert(hash2);

}// end of acceptGame

값을 공개적으로 commit했으므로, 플레이어에게 로컬 보드를 설정하도록 요청합니다:

    // setting the state locally and verifying
const localBoardState = localSetBoard(_x1, _x2);
assert(localBoardState == BoardState.SET,
"Please update the state of your board to SET");

}// end of acceptGame

마지막으로 온체인 상태를 갱신하여 다음 게임 상태로 전이합니다:

    // setting on-chain state
board2State = disclose(localBoardState);

// updating on-chain state
turn = TurnState.PLAYER_1_SHOOT;

}// end of acceptGame

이제 두 플레이어 모두 비공개 데이터에 대한 공개 commitment을 갖추었습니다. 이후 checkBoard 회로에서 함선 위치에 대해 다른 데이터를 제시하면 프로그램이 이를 감지합니다.

Hashing circuits

게임 흐름을 더 진행하기 전에 필요한 해싱 함수를 구현합니다:

// hashing a commitment to a board space
circuit commitBoardSpace(_x: Bytes<32>, _sk: Bytes<32>): Bytes<32> {
const hash = persistentHash<Vector<2, Bytes<32>>>([_x, _sk]);
return disclose(hash);
}

// hashing a DApp specific public key to identify the user (only in this contract)
circuit getDappPubKey(_sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "battleship:pk:"), _sk]);
}

commitBoardSpace는 단일 함선 위치 _x를 받아 persistentHash_sk로 해싱합니다. 이 패턴은 _x의 데이터를 추측할 수 없도록 보장합니다. 보드 크기가 작으므로(20칸) 악의적 행위자가 가능한 모든 숫자를 브루트포스 해싱하여 온체인 함선 위치와 비교하기가 비교적 쉽습니다. _sk와 같은 복잡한 바이너리 데이터와 함께 해싱하면 브루트포스 공격이 사실상 불가능해집니다.

getDappPubKey도 비슷한 패턴이지만, _sk를 도메인 구분자("battleship:pk:")와만 결합하여 유사한 패턴을 사용하는 다른 DApp과의 해시 충돌을 줄입니다. persistentCommit도 입력 요구사항은 다르지만 유사한 기능을 제공합니다.

Shoot circuits

게임 운영으로 돌아가면, 이제 player1의 사격 차례입니다:

export circuit player1Shoot (x: Uint<8>): [] {

// caller verification check
const _sk = localSk();
const pubKey = getDappPubKey(_sk);
assert(player1 == disclose(pubKey), "You are not player1");

}// end of player1Shoot

_skgetDappPubKey로 해싱하여 호출자가 player1으로 할당된 사용자와 동일한지 확인하는 같은 패턴을 사용합니다. 해시가 다르면 다른 플레이어가 이 회로를 호출한 것입니다.

호출자가 player1임을 확인한 후 상태와 입력값을 검증합니다:


// state verification checks
assert(board2State == BoardState.SET, "Player 2 has not yet set their board");
assert(turn == TurnState.PLAYER_1_SHOOT, "It is not player1 turn to shoot");
assert(winState == WinState.CONTINUE_PLAY, "A winner has already been declared");

// input validation
assert(x > 0 && x <= 20, "Shot out of bounds, please shoot on the board");

}// end of player1Shoot
warning

assert 검사를 통해 민감한 상태 데이터가 유출되지 않도록 주의하세요. 표시되는 메시지는 해당 assertion 실패 시 블록체인에서 반환하는 내용입니다. 이 튜토리얼에서는 학습 목적으로 명시적인 메시지를 사용하지만, 악의적 행위자가 이러한 메시지를 통해 민감한 정보를 파악할 수 있습니다.

입력값 검증이 완료되었으므로 사격을 disclose하여 공개 저장을 준비하고, 이전에 HIT된 위치가 아닌지 확인합니다:


// shots are public knowledge
const currentShot = disclose(x);
assert(!board2Hits.member(currentShot),
"Cheat Detected: Player1: Attempt to repeat a previous HIT");

}// end of player1Shoot

disclose 키워드 자체가 값을 공개하는 것은 아닙니다. 현재 비공개인 데이터를 공개할 의도가 있음을 프로그래머가 컴파일러에 명시적으로 알리는 수동 요구사항입니다. 일반적으로 원장 값 할당을 통해 공개됩니다.

온체인 상태와 데이터를 갱신합니다:


// on-chain state updates
player1Shot.pushFront(currentShot);
turn = TurnState.PLAYER_2_CHECK;

}// end of player1Shoot

player1ShotList로, 요소에 순서대로 접근할 수 있습니다. 이 프로그램에서는 List의 앞쪽 요소를 현재 사격으로 사용합니다.

player2player1과 같은 기능이 필요하므로, 해당 플레이어에 맞는 식별자만 변경하여 동일한 회로를 구현합니다:

export circuit player2Shoot(x: Uint<8>): [] {
// caller verification checks
const _sk = localSk();
const pubKey = getDappPubKey(_sk);
assert(player2 == disclose(pubKey), "You are not player2");

// state verification checks
assert(turn == TurnState.PLAYER_2_SHOOT, "It is not player2 turn to shoot");
assert(winState == WinState.CONTINUE_PLAY, "A winner has already been declared");

// input validation
assert(x > 0 && x <= 20, "Shot out of bounds, please shoot on the board");

// shots are public knowledge
const currentShot = disclose(x);
assert(!board1Hits.member(currentShot),
"Cheat Detected: Player2: Attempt to repeat a previous HIT");

// on-chain state updates
player2Shot.pushFront(currentShot);
turn = TurnState.PLAYER_1_CHECK;

}// end of player2Shoot

코드가 완성된 상태에서 이 회로를 살펴보세요. "shoot" 회로에서 가장 눈에 띄는 패턴이 무엇인가요? assert 문이 코드 연산만큼이나 많습니다! 이것이 안전한 스마트 컨트랙트의 징표입니다.

특정 데이터, 상태, 신원에 대해 가정하는 모든 것을 항상 assert하세요. 가장 일반적으로는 상태 검증, 권한 검사, 입력 검증 assertion이 이에 해당합니다. 데이터가 기대한 대로인지 철저히 검증한 후에야 해당 데이터를 의도한 함수에서 안심하고 사용할 수 있습니다.

Check boards locally

컨트랙트가 수행해야 할 마지막 핵심 작업은 플레이어가 자신의 보드에서 HIT 또는 MISS를 확인하는 것입니다. 로컬(비공개)에서 확인하고 원하는 응답을 공개적으로 반환하는데, 컨트랙트가 올바른 응답을 강제해야 합니다.

데이터를 처리하기 전에 상세한 assert부터 시작합니다:

export circuit checkBoard1(): [] {

// caller verification check
const _sk = localSk();
const pubKey = getDappPubKey(_sk);
assert(player1 == disclose(pubKey), "You are not player1");

// state verification checks
assert(winState == WinState.CONTINUE_PLAY, "A winner has already been declared");
assert(turn == TurnState.PLAYER_1_CHECK, "It is not Player 1 turn to CHECK");
assert(!player2Shot.isEmpty(), "No shot to check");

// shot processing
const currentShot = player2Shot.head().value;
assert(!board1Hits.member(currentShot),
"Cheat Detected: Player2: Attempt to repeat a previous HIT");
player2Shot.popFront();

}// end of checkBoard1

먼저 호출자가 player2인지 검증하고, 각종 상태 기대값을 확인한 후 currentShot을 저장하여 처리하며 popFront()player1Shot을 정리합니다.

이제 정직성 검사를 수행합니다. 실제로 HIT인 사격을 MISS라고 주장하는 것이 가장 흔한 부정행위라는 점을 기억하세요. honestyCheckHash를 생성하고 상태가 유효한지 검증합니다:


// hash for comparision with on-chain hash
const honestyCheckHash = commitBoardSpace(currentShot as Bytes<32>, _sk);

// currentShot has already been exposed, but we need to satisfy the compiler here too
const shotState = disclose(localCheckBoard(currentShot));
assert(shotState == ShotState.HIT || shotState == ShotState.MISS,
"Please provide a valid state");

}// end of checkBoard1

shotState가 두 가지 유효한 상태 중 하나임을 확인했으므로 각 경우를 조건부로 처리합니다. 먼저 MISS가 주장된 경우 -- 신뢰하지 말고 검증합니다:


// conditional handling
if(shotState == ShotState.MISS){

// don't trust, verify
assert(!board1.member(honestyCheckHash),
"Cheat Detected: Player 1: claimed a MISS, when it was in fact a HIT");
turn = TurnState.PLAYER_1_SHOOT;

} else {

}// end of checkBoard1

assert가 플레이어의 정직성을 보장하는 핵심입니다. 이 데이터는 변경하거나 위조할 수 없습니다. player1이 부정행위를 시도하면 컨트랙트가 이를 감지하고 상호작용을 거부합니다.

HIT인 경우를 처리합니다:


// don't trust, verify
assert(board1.member(honestyCheckHash),
"Cheat Detected: Player 1: claimed a HIT, when is was in fact a MISS.
Why would they do that?");

board1HitCount.increment(1);
board1Hits.insert(currentShot);
turn = TurnState.PLAYER_1_SHOOT;

// did someone win?
winState = board1HitCount == 2 ? WinState.PLAYER_2_WINS : WinState.CONTINUE_PLAY;

}// end of if...else

}// end of checkBoard1

입력이 기대한 대로인지 항상 검증하세요. player1이 MISS인데 HIT라고 주장할 수도 있지만, 왜 그러겠습니까?

board1HitCount를 증가시키고 성공한 사격을 hits에 추가합니다. 그런 다음 turn 상태를 갱신하고 이번 HIT가 게임을 종료하는 것인지 확인합니다.

player2도 동일한 기능이 필요하므로, 해당 플레이어에 맞는 식별자로 동일한 회로를 구현합니다:

export circuit checkBoard2(): [] {
// caller verification
const _sk = localSk();
const pubKey = getDappPubKey(_sk);
assert(player2 == disclose(pubKey), "You are not player2");

// state verification
assert(board2State == BoardState.SET, "Player 2 has not set the board yet");
assert(winState == WinState.CONTINUE_PLAY, "A winner has already been declared");
assert(turn == TurnState.PLAYER_2_CHECK, "It is not Player 2 turn to CHECK");
assert(!player1Shot.isEmpty(), "No shot to check");

// shot processing
const currentShot = player1Shot.head().value;
assert(!board2Hits.member(currentShot),
"Cheat Detected: Player 1: Attempt to repeat a previous HIT");
player1Shot.popFront();

// on-chain board comparison hash
const honestyCheckHash = commitBoardSpace(currentShot as Bytes<32>, _sk);

// state return verification
const shotState = disclose(localCheckBoard(currentShot));
assert(shotState == ShotState.HIT || shotState == ShotState.MISS,
"Please provide a valid state");

// conditional handling
if(shotState == ShotState.MISS){
// don't trust, verify
assert(!board2.member(honestyCheckHash),
"Cheat Detected: Player 2: claimed a MISS, when it was in fact a HIT");
turn = TurnState.PLAYER_2_SHOOT;

} else {

// dont trust, verify
assert(board2.member(honestyCheckHash),
"Cheat Detected: Player 2: claimed a HIT, when it was in fact a MISS.
Why would they do that?");
board2HitCount.increment(1);
board2Hits.insert(currentShot);
turn = TurnState.PLAYER_2_SHOOT;

// did someone win?
winState = board2HitCount == 2 ? WinState.PLAYER_1_WINS : WinState.CONTINUE_PLAY;
}
}// end of checkBoard2

안전하고 보안이 갖춰진 Battleship 게임에 필요한 Compact 코드가 모두 완성되었습니다!

Compact compile

이 코드를 컴파일하려면 Compact 컴파일러를 실행하세요. /contract 폴더에서:

compact compile battleship.compact managed/battleship

성공 시 출력:

Compiling 5 circuits:
circuit "acceptGame" (k=14, rows=12767)
circuit "checkBoard1" (k=14, rows=8649)
circuit "checkBoard2" (k=14, rows=8650)
circuit "player1Shoot" (k=13, rows=4226)
circuit "player2Shoot" (k=13, rows=4222)
Overall progress [====================] 5/5

회로 데이터를 더 자세히 검사하려면 zkir linter를 실행하세요(선택사항):

npx compact-zkir-lint -r managed/battleship/zkir

성공 시 출력:

zkir-lint: scanned 5 file(s)

acceptGame (v2, k=12): clean
instructions: 159 inputs: 2 constrain_bits: 4 cond_select: 6
guarded regions: 0 (max depth 0) proof payload: ~192KB

checkBoard1 (v2, k=12): clean
instructions: 400 inputs: 0 constrain_bits: 2 cond_select: 62
guarded regions: 0 (max depth 1) proof payload: ~192KB

checkBoard2 (v2, k=12): clean
instructions: 416 inputs: 0 constrain_bits: 2 cond_select: 61
guarded regions: 0 (max depth 1) proof payload: ~192KB

player1Shoot (v2, k=11): clean
instructions: 185 inputs: 1 constrain_bits: 3 cond_select: 4
guarded regions: 0 (max depth 0) proof payload: ~96KB

player2Shoot (v2, k=11): clean
instructions: 168 inputs: 1 constrain_bits: 3 cond_select: 4
guarded regions: 0 (max depth 0) proof payload: ~96KB

0 error(s), 0 warning(s), 0 info(s) | 5/5 clean

Witnesses

Compact 섹션에서 언급했듯이 witness는 Compact에서 선언만 하고, 실제 구현은 TypeScript 프론트엔드에서 합니다. 이를 통해 비용이 많이 드는 연산을 오프체인으로 위임하고 비공개 상태 함수를 격리할 수 있습니다.

TypeScript를 작성하기 전에 설정 파일을 생성합니다:

cd ..
touch tsconfig.json

다음 내용으로 채웁니다:

{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "contract/**/*.ts"]
}

컨트랙트를 테스트하기 전에 필요한 witness 기능을 구현해야 합니다. /contract 디렉토리 안에서:

cd contract
touch witnesses.ts

import부터 시작합니다:

import { type Ledger, BoardState, ShotState } from './managed/battleship/contract/index.js';
import { type WitnessContext } from '@midnight-ntwrk/compact-runtime';

프로그램에 필요한 비공개 상태 데이터의 커스텀 타입을 정의합니다:

export type BattlePrivateState = {
x1: bigint,
x2: bigint,
boardState: number,
shotState: number,
sk: Uint8Array,
};

x1x2는 플레이어 함선의 비공개 위치입니다. boardState는 처음에 비공개 데이터로 시작하여 게임 시작을 위해 공개됩니다. shotState는 각 플레이어가 매 사격마다 올바른 응답을 제출할지 부정행위를 할지 결정하는 값인데, Compact 코드가 이를 방어하며 프론트엔드 테스트 스크립트에서 이 동작을 검증합니다.

BattlePrivateState 타입의 객체를 생성하는 헬퍼 함수를 작성합니다:

export const createBattlePrivateState = (
x1: bigint,
x2: bigint,
boardState: number,
shotState: number,
sk: Uint8Array,
) => ({
x1,
x2,
boardState,
shotState,
sk
});

이 함수는 테스트에서 각 플레이어의 비공개 상태를 생성하거나 비공개 상태 조작을 시뮬레이션하는 데 사용됩니다.

이제 Compact 코드에서 선언한 witnesses를 구현합니다. 먼저 비공개 상태 데이터를 가져오는 간단한 "getter"부터:

export const witnesses = {
localSk: ({
privateState
}: WitnessContext<Ledger, BattlePrivateState>): [
BattlePrivateState,
Uint8Array
] => {
return [privateState, privateState.sk];
},// end of localSk
};// end of witnesses

Witness 함수의 시그니처는 대응하는 Compact 선언과 정확히 일치해야 하며, 항상 privateState 객체를 인자로 받고, <Ledger, PrivateState> 타입의 WitnessContext가 뒤따릅니다.

다음 두 줄은 함수의 반환 타입을 나타냅니다. Witness 함수는 항상 첫 번째 반환값으로 privateState를 전달해야 하고, 그 뒤에 함수 시그니처에 명시된 반환값이 옵니다.

비공개 상태 데이터를 설정하는 "setter"를 구현합니다:

    localSetBoard: ({
privateState
}: WitnessContext<Ledger, BattlePrivateState>, x1: bigint, x2: bigint): [
BattlePrivateState,
BoardState
] => {
privateState.x1 = x1;
privateState.x2 = x2;
privateState.boardState = BoardState.SET;
return [privateState, privateState.boardState];
},// end of localSetBoard
};// end of witnesses

이 함수의 형태는 localSk()와 요구사항이 비슷하지만, x1x2가 입력으로 추가되어 privateState 객체에 설정됩니다. 마지막으로 privateState.boardState를 갱신한 후 컨트랙트에 반환합니다.

마지막 witness 함수는 플레이어의 비공개 상태 데이터를 확인하는 역할입니다:

    localCheckBoard: ({
privateState
}: WitnessContext<Ledger, BattlePrivateState>, x: bigint): [
BattlePrivateState,
ShotState
] => {
let currentShot = ShotState.MISS;// reset to default -- MISS
if(x == privateState.x1 || x == privateState.x2){
currentShot = ShotState.HIT;// only HIT if it is in fact a HIT
}
privateState.shotState = currentShot;
return [privateState, privateState.shotState];
},// end of localCheckBoard
};//end of witnesses

Witness 함수 내의 비공개 상태 데이터 연산은 어떤 방식으로도 검증되지 않는다는 점이 중요합니다. DApp 개발자는 witness 함수의 연산이 기대대로 수행되었는지 반드시 assert 문으로 검증해야 합니다. .compact 코드에 엄격한 검사가 내장되어 있으며, 프론트엔드 테스트 스위트에서 정상 동작을 확인합니다.

오프체인 witness 구현이 모두 완료되었습니다. 이제 컴파일된 컨트랙트를 export 할 수 있습니다.

Exports

/contract 디렉토리에서:

touch index.ts

index.ts 파일로 컴파일된 컨트랙트와 필요한 타입을 export합니다:

import { CompiledContract } from '@midnight-ntwrk/compact-js';
import path from 'node:path';

export {
Contract,
ledger,
pureCircuits,
type Witnesses,
type Ledger,
type ImpureCircuits,
type PureCircuits
} from './managed/battleship/contract/index.js';
import { Contract } from './managed/battleship/contract/index.js';
import { witnesses } from './witnesses.js';

const currentDir = path.resolve(new URL(import.meta.url).pathname, '..');
export const zkConfigPath = path.resolve(currentDir, 'managed', 'battleship');

export const CompiledBattleshipContract = CompiledContract.make(
'BattleshipContract',
Contract,
).pipe(
CompiledContract.withWitnesses(witnesses),
CompiledContract.withCompiledFileAssets(zkConfigPath),
)

Compact와 관련 파일에 필요한 코드가 모두 완성되었습니다. 지금까지의 디렉토리 구조는 다음과 같습니다:

contract/
├── managed/
| └── battleship/
| ├── compiler/
| ├── contract/
| ├── keys/
| └── zkir/
├── battleship.compact
├── index.ts
└── witnesses.ts

Next steps

다음 단계는 테스트 스위트에서 다룹니다.