Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ test-group = "toolkit-js"
max-threads = 1

[[profile.ci.overrides]]
filter = "test(bboard_private_witness_not_leaked) | test(counter_increment_e2e)"
filter = "test(bboard_private_witness_not_leaked) | test(counter_increment_e2e) | test(welcome_e2e) | test(tic_tac_toe_e2e)"
test-group = "contract-e2e"
15 changes: 15 additions & 0 deletions changes/toolkit/added/tic-tac-toe-contract-e2e.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#toolkit

# Add tic-tac-toe contract e2e test

Ports the tic-tac-toe contract from midnight-contracts and plays a full
two-player game through the compile/prove/submit/on-chain-verify pipeline:
deploy with the X and O player keys, alternate `make_move` between the two
players (each move proven with that player's `--coin-public` while fees are
paid by `FUNDING_SEED`), then assert the outcome via the `verify_game_state`
and `verify_winner` circuits.

Exercises Map- and Counter-backed on-chain state, `ownPublicKey()`-based turn
validation across two wallets, and a witness-free contract config.

PR: https://github.com/midnightntwrk/midnight-node/pull/1898
18 changes: 18 additions & 0 deletions changes/toolkit/added/welcome-contract-e2e.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#toolkit

# Add welcome contract e2e test

Ports the welcome contract from midnight-contracts and drives
deploy -> add_participant -> check_in through the full
compile/prove/submit/on-chain-verify pipeline. Adds a config template and a
`generate_intent_deploy_with_args` helper for passing constructor arguments.

Also fixes `prerequisites_ready` to match the compact variant directory by
major.minor.patch (previously major.minor), without which all contract e2e
tests silently skip.

The welcome constructor is simplified from the upstream
`Vector<5000, Maybe<Opaque<"string">>>` to a small fixed vector of plain
strings, which the toolkit's CLI arguments can express.

PR: https://github.com/midnightntwrk/midnight-node/pull/1898
16 changes: 13 additions & 3 deletions util/toolkit/tests/common/toolkit_helper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ impl ToolkitTestHelper {
let required_paths = [
self.toolkit_js_path.join("dist/bin.js"),
self.toolkit_js_path.join(format!(
"node_modules/@midnight-ntwrk/node-toolkit-compact-{}.{}",
compactc_version.major, compactc_version.minor
"node_modules/@midnight-ntwrk/node-toolkit-compact-{}.{}.{}",
compactc_version.major, compactc_version.minor, compactc_version.patch
)),
];

Expand Down Expand Up @@ -329,6 +329,16 @@ impl ToolkitTestHelper {
&self,
config_file: &Path,
coin_public: &str,
) -> Result<DeployOutput, Box<dyn std::error::Error + Send + Sync>> {
self.generate_intent_deploy_with_args(config_file, coin_public, &[]).await
}

/// [`Self::generate_intent_deploy`] with positional `constructor_args`.
pub async fn generate_intent_deploy_with_args(
&self,
config_file: &Path,
coin_public: &str,
constructor_args: &[&str],
) -> Result<DeployOutput, Box<dyn std::error::Error + Send + Sync>> {
let intent = self.work_dir.path().join("deploy_intent.bin");
let private_state = self.work_dir.path().join("deploy_private_state.json");
Expand All @@ -346,7 +356,7 @@ impl ToolkitTestHelper {
output_intent: RelativePath(intent.clone()),
output_private_state: RelativePath(private_state.clone()),
output_zswap_state: RelativePath(zswap_state.clone()),
constructor_args: vec![],
constructor_args: constructor_args.iter().map(|s| s.to_string()).collect(),
},
dry_run: false,
}),
Expand Down
28 changes: 28 additions & 0 deletions util/toolkit/tests/contracts/tic-tac-toe/config.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CompiledContract, ContractExecutable } from '@midnight-ntwrk/compact-js/effect';
import { Contract as TicTacToeContract_ } from './out/contract/index.js';

// The contract declares no witnesses and keeps all state on-chain, so the private state is empty.
type TicTacToePrivateState = Record<string, never>;

type TicTacToeContract = TicTacToeContract_<TicTacToePrivateState>;
const TicTacToeContract = TicTacToeContract_;

const createInitialPrivateState: () => TicTacToePrivateState = () => ({});

export default {
contractExecutable: CompiledContract.make<TicTacToeContract>(
'TicTacToeContract',
TicTacToeContract,
).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets('./out'),
ContractExecutable.make,
),
createInitialPrivateState,
config: {
keys: {
coinPublic: '{{COIN_PUBLIC}}',
},
network: '{{NETWORK}}',
},
};
208 changes: 208 additions & 0 deletions util/toolkit/tests/contracts/tic-tac-toe/tic_tac_toe.compact
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Tic-Tac-Toe Game Contract
*
* Two-player turn-based game on a 3x3 board using Map for state.
*
* Features:
* - Multi-user gameplay (Player X vs Player O identified by public keys)
* - Move validation (position, turn, occupancy)
* - Win detection (3 in a row: horizontal, vertical, diagonal)
* - Draw detection (board full with no winner)
* - Game state verification for testing
*
* Test intent:
* - Exercise Map-backed board state, Counter-backed move count, ownPublicKey()
* - Check that invalid moves fail before ledger mutation
* - Expose small verifier circuits so tests can assert exact state after each move
*
* Board positions:
* 0 | 1 | 2
* -----------
* 3 | 4 | 5
* -----------
* 6 | 7 | 8
*/

import CompactStandardLibrary;

// Board: position -> value (0=empty, 1=X, 2=O)
// Map lookup returns 0 for non-existent keys (empty positions)
export ledger board: Map<Uint<8>, Uint<8>>;

// Player public keys
export ledger player_x_key: Bytes<32>;
export ledger player_o_key: Bytes<32>;

// Game state
export ledger current_turn: Uint<8>; // 1=X, 2=O
export ledger move_count: Counter;
export ledger game_status: Uint<8>; // 0=in_progress, 1=x_wins, 2=o_wins, 3=draw
export ledger winner: Uint<8>; // 0=none, 1=X, 2=O

constructor(player_x_public_key: Bytes<32>, player_o_public_key: Bytes<32>) {
// Validate players are different so turn checks cannot be satisfied by one wallet.
assert(disclose(player_x_public_key) != disclose(player_o_public_key), "Players must be different");

// Set players
player_x_key = disclose(player_x_public_key);
player_o_key = disclose(player_o_public_key);

// Initialize each playable square explicitly; later lookups assert occupancy by position.
board.insertDefault(0 as Uint<8>);
board.insertDefault(1 as Uint<8>);
board.insertDefault(2 as Uint<8>);
board.insertDefault(3 as Uint<8>);
board.insertDefault(4 as Uint<8>);
board.insertDefault(5 as Uint<8>);
board.insertDefault(6 as Uint<8>);
board.insertDefault(7 as Uint<8>);
board.insertDefault(8 as Uint<8>);

// X starts first
current_turn = 1 as Uint<8>;
game_status = 0 as Uint<8>; // In progress
winner = 0 as Uint<8>;
}

/**
* Make a move at the specified position (0-8)
* Validates turn, position, and checks for win/draw
*/
export circuit make_move(board_position: Uint<8>): [] {
// The public board position is disclosed because board indexes and test expectations are public.
const pos = disclose(board_position);

// Reject all state changes after a win or draw has been recorded.
assert(game_status == 0, "Game already finished");

// Validate the board index and occupancy before checking the caller's turn.
assert(pos < 9, "Invalid position (must be 0-8)");
assert(board.lookup(pos) == 0, "Position already occupied");

// Validate player turn against the wallet public key of the caller.
const caller_key = ownPublicKey().bytes;
if (current_turn == 1) {
assert(caller_key == player_x_key, "Wrong player: expected player X");
} else {
assert(caller_key == player_o_key, "Wrong player: expected player O");
}

// Store the current player marker in the board map and advance the move counter.
board.insert(pos, current_turn);
move_count.increment(1 as Uint<16>);

// Check for a completed row, column, or diagonal for the player who just moved.
const has_winner = check_winner(current_turn);

if (has_winner) {
game_status = current_turn; // 1=X wins, 2=O wins
winner = current_turn;
} else if (move_count.read() == 9) {
// Board full, no winner = draw
game_status = 3 as Uint<8>;
winner = 0 as Uint<8>;
} else {
// Switch turns
if (current_turn == 1) {
current_turn = 2 as Uint<8>;
} else {
current_turn = 1 as Uint<8>;
}
}
}

/**
* Check if the current player has won
* Returns true if player has 3 in a row
*/
circuit check_winner(player: Uint<8>): Boolean {
// Check rows: 0-2, 3-5, and 6-8.
const row0 = (board.lookup(0) == player && board.lookup(1) == player && board.lookup(2) == player);
const row1 = (board.lookup(3) == player && board.lookup(4) == player && board.lookup(5) == player);
const row2 = (board.lookup(6) == player && board.lookup(7) == player && board.lookup(8) == player);

// Check columns: left, middle, and right.
const col0 = (board.lookup(0) == player && board.lookup(3) == player && board.lookup(6) == player);
const col1 = (board.lookup(1) == player && board.lookup(4) == player && board.lookup(7) == player);
const col2 = (board.lookup(2) == player && board.lookup(5) == player && board.lookup(8) == player);

// Check diagonals: top-left to bottom-right, and top-right to bottom-left.
const diag1 = (board.lookup(0) == player && board.lookup(4) == player && board.lookup(8) == player);
const diag2 = (board.lookup(2) == player && board.lookup(4) == player && board.lookup(6) == player);

return row0 || row1 || row2 || col0 || col1 || col2 || diag1 || diag2;
}

/**
* Verify current game state
* Used for testing and validation
*/
export circuit verify_game_state(
expected_current_turn: Uint<8>,
expected_game_status: Uint<8>,
expected_move_count: Uint<64>
): [] {
// Tests call this after moves and resets to assert the encoded turn, status, and count.
assert(current_turn == disclose(expected_current_turn), "Turn mismatch");
assert(game_status == disclose(expected_game_status), "Status mismatch");
assert(move_count.read() == disclose(expected_move_count), "Move count mismatch");
}

/**
* Verify winner
*/
export circuit verify_winner(expected_winner: Uint<8>): [] {
// Winner uses the same 0/1/2 encoding as board markers, with 0 meaning no winner.
assert(winner == disclose(expected_winner), "Winner mismatch");
}

/**
* Verify board position
*/
export circuit verify_board_position(board_position: Uint<8>, expected_value: Uint<8>): [] {
const pos = disclose(board_position);
// Bounds checking keeps verifier failures clear when a test passes a bad square.
assert(pos < 9, "Invalid position");
assert(board.lookup(pos) == disclose(expected_value), "Position value mismatch");
}

/**
* Verify player keys
*/
export circuit verify_players(
expected_player_x: Bytes<32>,
expected_player_o: Bytes<32>
): [] {
assert(player_x_key == disclose(expected_player_x), "Player X key mismatch");
assert(player_o_key == disclose(expected_player_o), "Player O key mismatch");
}

/**
* Reset game for new round
* Keeps the same players, resets board and state
*/
export circuit reset_game(force: Boolean): [] {
if (!disclose(force)) {
// Normal reset is only allowed after a completed game; force supports test setup.
assert(game_status > 0, "Game not finished");
}

// Reset board to empty
board.resetToDefault();

// Re-initialize all board positions to 0 (empty)
board.insertDefault(0 as Uint<8>);
board.insertDefault(1 as Uint<8>);
board.insertDefault(2 as Uint<8>);
board.insertDefault(3 as Uint<8>);
board.insertDefault(4 as Uint<8>);
board.insertDefault(5 as Uint<8>);
board.insertDefault(6 as Uint<8>);
board.insertDefault(7 as Uint<8>);
board.insertDefault(8 as Uint<8>);

current_turn = 1 as Uint<8>; // X starts again
move_count.resetToDefault();
game_status = 0 as Uint<8>; // In progress
winner = 0 as Uint<8>;
}
47 changes: 47 additions & 0 deletions util/toolkit/tests/contracts/welcome/config.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { CompiledContract, ContractExecutable, type Contract } from '@midnight-ntwrk/compact-js/effect';
import { Contract as WelcomeContract_ } from './out/contract/index.js';

// The deployer is the organizer; its key is stored as hex (Uint8Array does not round-trip
// through the JSON private-state file) and converted to bytes in the witness.
type WelcomePrivateState = {
readonly organizerSecretKey: string | null;
readonly participantId: string | null;
};

type WelcomeContract = WelcomeContract_<WelcomePrivateState>;
const WelcomeContract = WelcomeContract_;

const witnesses: Contract.Contract.Witnesses<WelcomeContract> = {
// Returns the caller's secret key when they are an organizer, otherwise `none`.
local_sk: ({ privateState }) => [
privateState,
privateState.organizerSecretKey
? { is_some: true, value: new Uint8Array(Buffer.from(privateState.organizerSecretKey, 'hex')) }
: { is_some: false, value: new Uint8Array(32) },
],
// Records the identity used to check in.
set_local_id: ({ privateState }, participantId) => [{ ...privateState, participantId }, []],
};

const createInitialPrivateState: () => WelcomePrivateState = () => ({
organizerSecretKey: '{{ORGANIZER_SK}}',
participantId: null,
});

export default {
contractExecutable: CompiledContract.make<WelcomeContract>(
'WelcomeContract',
WelcomeContract,
).pipe(
CompiledContract.withWitnesses(witnesses),
CompiledContract.withCompiledFileAssets('./out'),
ContractExecutable.make,
),
createInitialPrivateState,
config: {
keys: {
coinPublic: '{{COIN_PUBLIC}}',
},
network: '{{NETWORK}}',
},
};
Loading
Loading