diff --git a/.config/nextest.toml b/.config/nextest.toml index 589f9f6f3..9fcd2c4ce 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -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" diff --git a/changes/toolkit/added/tic-tac-toe-contract-e2e.md b/changes/toolkit/added/tic-tac-toe-contract-e2e.md new file mode 100644 index 000000000..dc020c0c2 --- /dev/null +++ b/changes/toolkit/added/tic-tac-toe-contract-e2e.md @@ -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 diff --git a/changes/toolkit/added/welcome-contract-e2e.md b/changes/toolkit/added/welcome-contract-e2e.md new file mode 100644 index 000000000..22a14e51d --- /dev/null +++ b/changes/toolkit/added/welcome-contract-e2e.md @@ -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>>` 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 diff --git a/util/toolkit/tests/common/toolkit_helper.rs b/util/toolkit/tests/common/toolkit_helper.rs index bb410c9e2..48fd39d2e 100644 --- a/util/toolkit/tests/common/toolkit_helper.rs +++ b/util/toolkit/tests/common/toolkit_helper.rs @@ -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 )), ]; @@ -329,6 +329,16 @@ impl ToolkitTestHelper { &self, config_file: &Path, coin_public: &str, + ) -> Result> { + 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> { let intent = self.work_dir.path().join("deploy_intent.bin"); let private_state = self.work_dir.path().join("deploy_private_state.json"); @@ -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, }), diff --git a/util/toolkit/tests/contracts/tic-tac-toe/config.template.ts b/util/toolkit/tests/contracts/tic-tac-toe/config.template.ts new file mode 100644 index 000000000..4506022c9 --- /dev/null +++ b/util/toolkit/tests/contracts/tic-tac-toe/config.template.ts @@ -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; + +type TicTacToeContract = TicTacToeContract_; +const TicTacToeContract = TicTacToeContract_; + +const createInitialPrivateState: () => TicTacToePrivateState = () => ({}); + +export default { + contractExecutable: CompiledContract.make( + 'TicTacToeContract', + TicTacToeContract, + ).pipe( + CompiledContract.withVacantWitnesses, + CompiledContract.withCompiledFileAssets('./out'), + ContractExecutable.make, + ), + createInitialPrivateState, + config: { + keys: { + coinPublic: '{{COIN_PUBLIC}}', + }, + network: '{{NETWORK}}', + }, +}; diff --git a/util/toolkit/tests/contracts/tic-tac-toe/tic_tac_toe.compact b/util/toolkit/tests/contracts/tic-tac-toe/tic_tac_toe.compact new file mode 100644 index 000000000..4ed2fa346 --- /dev/null +++ b/util/toolkit/tests/contracts/tic-tac-toe/tic_tac_toe.compact @@ -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>>; + +// 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>; +} diff --git a/util/toolkit/tests/contracts/welcome/config.template.ts b/util/toolkit/tests/contracts/welcome/config.template.ts new file mode 100644 index 000000000..0c1d6e1ec --- /dev/null +++ b/util/toolkit/tests/contracts/welcome/config.template.ts @@ -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_; +const WelcomeContract = WelcomeContract_; + +const witnesses: Contract.Contract.Witnesses = { + // 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, + ).pipe( + CompiledContract.withWitnesses(witnesses), + CompiledContract.withCompiledFileAssets('./out'), + ContractExecutable.make, + ), + createInitialPrivateState, + config: { + keys: { + coinPublic: '{{COIN_PUBLIC}}', + }, + network: '{{NETWORK}}', + }, +}; diff --git a/util/toolkit/tests/contracts/welcome/welcome.compact b/util/toolkit/tests/contracts/welcome/welcome.compact new file mode 100644 index 000000000..a332ceccf --- /dev/null +++ b/util/toolkit/tests/contracts/welcome/welcome.compact @@ -0,0 +1,54 @@ +pragma language_version >= 0.14.0; + +import CompactStandardLibrary; +export { Maybe } + +// This contract is not intended to provide a production-grade user registration service. It is intended to be a simple +// demonstration of a Midnight decentralized application. + +// Stores the identity the participant used to check in +witness set_local_id(participant: Opaque<"string">): []; + +// Secret key of the current user (only defined for organizers) +witness local_sk(): Maybe>; + +export ledger organizer_pks: Set>; +export ledger eligible_participants: Set>; +export ledger checked_in_participants: Set>; + +// Simplified from upstream `Vector<5000, Maybe>>` (unexpressible via the +// toolkit's CLI args) to a small fixed vector of plain strings; seed via `add_participant`. +constructor(initial_eligible_participants: Vector<1, Opaque<"string">>) { + organizer_pks.insert(public_key(local_sk_or_error())); + for (const participant of disclose(initial_eligible_participants)) { + eligible_participants.insert(participant); + } +} + +export circuit add_participant(participant: Opaque<"string">): [] { + assert (organizer_pks.member(public_key(local_sk_or_error())), "Not an organizer"); + eligible_participants.insert(disclose(participant)); +} + +export circuit add_organizer(organizer_pk: Bytes<32>): [] { + assert (organizer_pks.member(public_key(local_sk_or_error())), "Not an organizer"); + organizer_pks.insert(disclose(organizer_pk)); +} + +// Yes, anyone can check in as any other eligible participant +export circuit check_in(participant: Opaque<"string">): [] { + const p = disclose(participant); + assert (eligible_participants.member(p), "Not eligible participant"); + checked_in_participants.insert(p); + set_local_id(p); +} + +circuit local_sk_or_error(): Bytes<32> { + const maybe_sk = disclose(local_sk()); + assert (maybe_sk.is_some, "No secret key found"); + return maybe_sk.value; +} + +export circuit public_key(sk: Bytes<32>): Bytes<32> { + return persistentHash>>([pad(32, "welcome:pk:"), sk]); +} diff --git a/util/toolkit/tests/toolkit_e2e.rs b/util/toolkit/tests/toolkit_e2e.rs index 36b5ec0eb..57d8a2761 100644 --- a/util/toolkit/tests/toolkit_e2e.rs +++ b/util/toolkit/tests/toolkit_e2e.rs @@ -556,3 +556,174 @@ async fn counter_increment_e2e() { helper.submit_tx(&increment_tx).await.expect("submit increment tx failed"); } + +/// Welcome contract E2E ported from `midnight-contracts`: deploy, `add_participant`, +/// `check_in` through the full compile -> prove -> submit -> verify pipeline. The +/// constructor is simplified from the upstream `Vector<5000, Maybe<..>>` to a small +/// fixed vector of plain strings (see `welcome.compact`). +#[tokio::test] +async fn welcome_e2e() { + let url = node_ws_url().await; + let helper = ToolkitTestHelper::new(url); + + if !helper.prerequisites_ready() { + return; + } + + // Arbitrary key; makes the deployer an organizer via the `local_sk` witness. + let organizer_sk = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + // `Opaque<"string">`; must be identical across add/check_in so membership matches. + let participant = "\"alice\""; + + let coin_public = helper.show_address_coin_public(FUNDING_SEED); + + let welcome_source = helper.load_contract_file("welcome/welcome.compact"); + let compiled_dir = helper + .compile_contract(&welcome_source, "welcome") + .await + .expect("contract compilation failed"); + + let config_content = helper.load_template( + "welcome/config.template.ts", + &[("ORGANIZER_SK", organizer_sk), ("COIN_PUBLIC", &coin_public), ("NETWORK", "undeployed")], + ); + let config_file = helper.write_config(&config_content, "welcome/contract.config.ts"); + + // Single-element seed vector for the `Vector<1, Opaque<"string">>` constructor. + let deploy = helper + .generate_intent_deploy_with_args(&config_file, &coin_public, &["[\"seed\"]"]) + .await + .expect("generate deploy intent failed"); + let deploy_tx = helper + .send_intent(&deploy.intent, &compiled_dir, FUNDING_SEED, None) + .await + .expect("send deploy intent failed"); + helper.submit_tx(&deploy_tx).await.expect("submit deploy tx failed"); + let welcome_addr = + helper.contract_address(&deploy_tx).expect("contract address extraction failed"); + + // Organizer adds an eligible participant. + let state_1 = helper.work_dir.path().join("welcome_state_1.mn"); + helper + .contract_state(&welcome_addr, &state_1) + .await + .expect("contract state fetch failed"); + let add = helper + .generate_intent_circuit( + &config_file, + &coin_public, + &state_1, + &deploy.private_state, + &welcome_addr, + CircuitCall { circuit_id: "add_participant", call_args: &[participant] }, + ) + .await + .expect("generate add_participant intent failed"); + let add_tx = helper + .send_intent(&add.intent, &compiled_dir, FUNDING_SEED, Some(&add.zswap_state)) + .await + .expect("send add_participant intent failed"); + helper.submit_tx(&add_tx).await.expect("submit add_participant tx failed"); + + // Check in the just-added participant. + let state_2 = helper.work_dir.path().join("welcome_state_2.mn"); + helper + .contract_state(&welcome_addr, &state_2) + .await + .expect("contract state fetch failed"); + let check_in = helper + .generate_intent_circuit( + &config_file, + &coin_public, + &state_2, + &add.private_state, + &welcome_addr, + CircuitCall { circuit_id: "check_in", call_args: &[participant] }, + ) + .await + .expect("generate check_in intent failed"); + let check_in_tx = helper + .send_intent(&check_in.intent, &compiled_dir, FUNDING_SEED, Some(&check_in.zswap_state)) + .await + .expect("send check_in intent failed"); + helper.submit_tx(&check_in_tx).await.expect("submit check_in tx failed"); +} + +/// Tic-tac-toe contract E2E ported from `midnight-contracts`: deploy a two-player game, +/// play a full game to a Player X win, and assert the final state via verifier circuits. +/// +/// `make_move` gates on `ownPublicKey().bytes == player_{x,o}_key`, so each move is +/// submitted with that player's `--coin-public` (X is FUNDING_SEED, O is a distinct +/// wallet); fees are always paid by FUNDING_SEED. All circuit args are primitives. +#[tokio::test] +async fn tic_tac_toe_e2e() { + let url = node_ws_url().await; + let helper = ToolkitTestHelper::new(url); + + if !helper.prerequisites_ready() { + return; + } + + // Player X is the funding wallet; player O is a distinct wallet (the constructor + // asserts the two keys differ). + const PLAYER_O_SEED: &str = + "0000000000000000000000000000000000000000000000000000000000000002"; + let player_x = helper.show_address_coin_public(FUNDING_SEED); + let player_o = helper.show_address_coin_public(PLAYER_O_SEED); + + let source = helper.load_contract_file("tic-tac-toe/tic_tac_toe.compact"); + let compiled_dir = + helper.compile_contract(&source, "tic-tac-toe").await.expect("contract compilation failed"); + + let config_content = helper.load_template( + "tic-tac-toe/config.template.ts", + &[("COIN_PUBLIC", &player_x), ("NETWORK", "undeployed")], + ); + let config_file = helper.write_config(&config_content, "tic-tac-toe/contract.config.ts"); + + let deploy = helper + .generate_intent_deploy_with_args(&config_file, &player_x, &[&player_x, &player_o]) + .await + .expect("generate deploy intent failed"); + let deploy_tx = helper + .send_intent(&deploy.intent, &compiled_dir, FUNDING_SEED, None) + .await + .expect("send deploy intent failed"); + helper.submit_tx(&deploy_tx).await.expect("submit deploy tx failed"); + let addr = helper.contract_address(&deploy_tx).expect("contract address extraction failed"); + + // X wins the middle row (3-4-5). Each entry is (circuit, args, is_player_o). + let calls: Vec<(&str, Vec<&str>, bool)> = vec![ + ("make_move", vec!["4"], false), // X center + ("make_move", vec!["0"], true), // O top-left + ("make_move", vec!["3"], false), // X mid-left + ("make_move", vec!["1"], true), // O top-middle + ("make_move", vec!["5"], false), // X mid-right -> X wins + ("verify_game_state", vec!["1", "1", "5"], false), // turn=X, status=x_wins, moves=5 + ("verify_winner", vec!["1"], false), // winner=X + ]; + + let mut prev_private = deploy.private_state.clone(); + for (i, &(circuit, ref args, is_o)) in calls.iter().enumerate() { + let state = helper.work_dir.path().join(format!("ttt_state_{i}.mn")); + helper.contract_state(&addr, &state).await.expect("contract state fetch failed"); + let coin_public: &str = if is_o { &player_o } else { &player_x }; + let out = helper + .generate_intent_circuit( + &config_file, + coin_public, + &state, + &prev_private, + &addr, + CircuitCall { circuit_id: circuit, call_args: args.as_slice() }, + ) + .await + .unwrap_or_else(|e| panic!("generate {circuit} intent failed: {e}")); + let tx = helper + .send_intent(&out.intent, &compiled_dir, FUNDING_SEED, Some(&out.zswap_state)) + .await + .unwrap_or_else(|e| panic!("send {circuit} intent failed: {e}")); + helper.submit_tx(&tx).await.unwrap_or_else(|e| panic!("submit {circuit} tx failed: {e}")); + prev_private = out.private_state; + } +}