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 examples/battleship/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
soroban-sdk = "25.1.0"
cougr-core = "1.0.0"
cougr-core = "1.1.0"

[dev-dependencies]
soroban-sdk = { version = "25.1.0", features = ["testutils"] }
Expand Down
62 changes: 59 additions & 3 deletions examples/battleship/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

A two-player Battleship game demonstrating **hidden information** using commit-reveal pattern and Merkle proofs on Stellar Soroban. Players commit their board layouts cryptographically, then prove hit/miss results without revealing unattacked positions.

This example is Cougr's canonical hidden-information reference. It intentionally leans on the stable privacy surface in `cougr_core::privacy::stable` instead of re-defining Merkle verification inside the example.
This example is Cougr's **canonical** hidden-information reference. It intentionally leans on the stable privacy surface in `cougr_core::privacy::stable` instead of re-defining Merkle verification inside the example.

## Status

**Canonical** β€” maintained reference implementation for commit-reveal + selective disclosure on Soroban. Uses `cougr-core = "1.1.0"`, `privacy::stable` Merkle primitives, and `impl_component!` macros for standardized serialization.

## The Hidden Information Problem

Expand Down Expand Up @@ -134,6 +138,55 @@ assert!(verifier.verify(&env, &proof, &merkle_root)?);

The leaf payload still binds `index || value`, but the inclusion proof format and verification rules come from Cougr's stable privacy API.

## When to Use Commit-Reveal vs ZK Circuits

| Pattern | Best For | Cost | Complexity |
|---------|----------|------|------------|
| **Commit-Reveal + Merkle** | Hidden boards, card hands, fog-of-war | O(log n) proof verification | Low β€” uses standard SHA256 |
| **ZK Circuits (Groth16/Poseidon)** | Private game logic evaluation, hidden card deals | Single on-chain verification | High β€” requires circuit compilation |

Use **commit-reveal** when you need to hide state but reveal it incrementally with verifiable proofs. Use **ZK circuits** when the game logic itself must remain private (e.g., proving a move is valid without revealing the move).

For a reference ZK implementation, see [hidden_hand](../hidden_hand/), which demonstrates circuit-based hidden card dealing with Groth16 proofs.

## Storage Model

### Committed State

Stored on-chain during setup:
- `commitment_a/b` β€” SHA256 hash of each player's board with random salt
- `merkle_root_a/b` β€” root of the Merkle tree built from cell hashes
- `has_commitment_a/b` β€” flags tracking which players have committed

Both commitments must be submitted before the game transitions to `Phase::Attack`.

### Revealed State

Updated during the attack phase:
- `attack_grid_a/b` β€” maps cell indices to `CellResult` (hit/miss)
- `ship_status` β€” tracks remaining ship cells per player (starts at 17)
- `turn_state` β€” tracks current player, phase, and pending reveals

### Proven State

The `reveal_cell` function verifies that disclosed cell values match the original commitment:
1. Constructs the expected leaf hash from `(index, value)` using the same SHA256 scheme as the commit phase
2. Validates the `OnChainMerkleProof` against the stored `merkle_root` using `Sha256MerkleProofVerifier`
3. Only if the proof verifies is the hit/miss recorded on-chain

## Component Serialization

`BoardCommitment` uses the `impl_component!` macro for standardized serialization:

```rust
impl_component!(BoardCommitment, "board", Table, {
commitment: bytes32,
merkle_root: bytes32
});
```

This replaces manual byte-level serialization with a type-safe macro that handles big-endian encoding/decoding automatically.

## Building & Testing

### Prerequisites
Expand All @@ -151,7 +204,7 @@ cargo build --release --target wasm32v1-none
cargo test
```

**Test Coverage (10 tests):**
**Test Coverage (12 tests):**
- βœ… Game initialization
- βœ… Board commitment
- βœ… Attack and reveal (miss)
Expand All @@ -160,8 +213,10 @@ cargo test
- βœ… Cannot attack same cell twice
- βœ… Turn enforcement
- βœ… Win condition
- βœ… Component trait serialization
- βœ… Component trait serialization (via `impl_component!`)
- βœ… Turn switching
- βœ… Reveal without pending attack
- βœ… Attack before commit phase

## Example Usage

Expand Down Expand Up @@ -266,6 +321,7 @@ stellar contract deploy \
- [Cougr Repository](https://github.com/salazarsebas/Cougr)
- [Merkle Trees](https://en.wikipedia.org/wiki/Merkle_tree)
- [Commitment Schemes](https://en.wikipedia.org/wiki/Commitment_scheme)
- [hidden_hand β€” ZK circuit example](../hidden_hand/)
- [Soroban Documentation](https://developers.stellar.org/docs/build/smart-contracts)

## License
Expand Down
32 changes: 10 additions & 22 deletions examples/battleship/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![no_std]

use cougr_core::component::ComponentTrait;
use cougr_core::impl_component;
use cougr_core::privacy::stable::{
MerkleProofVerifier, OnChainMerkleProof, Sha256MerkleProofVerifier,
};
Expand All @@ -24,33 +24,21 @@ pub enum Phase {
Finished,
}

/// Commitment to a player's board layout.
///
/// Both fields are 32-byte hashes: `commitment` binds the board to a salt,
/// and `merkle_root` enables selective cell-level proof verification.
#[contracttype]
#[derive(Clone, Debug)]
pub struct BoardCommitment {
pub commitment: BytesN<32>,
pub merkle_root: BytesN<32>,
}

impl ComponentTrait for BoardCommitment {
fn component_type() -> Symbol {
symbol_short!("board")
}

fn serialize(&self, env: &Env) -> Bytes {
let mut bytes = Bytes::new(env);
for i in 0..32 {
bytes.push_back(self.commitment.get(i).unwrap());
}
for i in 0..32 {
bytes.push_back(self.merkle_root.get(i).unwrap());
}
bytes
}

fn deserialize(_env: &Env, _data: &Bytes) -> Option<Self> {
None
}
}
impl_component!(BoardCommitment, "board", Table, {
commitment: bytes32,
merkle_root: bytes32
});

#[contracttype]
#[derive(Clone, Debug)]
Expand Down Expand Up @@ -258,7 +246,7 @@ impl BattleshipContract {
panic!("Not a player");
};

// Verify Merkle proof
// Verify Merkle proof using privacy::stable primitives
let coord = Self::coord_to_index(x, y);
if proof.leaf_index != coord {
panic!("Invalid proof");
Expand Down
55 changes: 53 additions & 2 deletions examples/battleship/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::*;
use cougr_core::privacy::stable::{to_on_chain_proof, MerkleTree, OnChainMerkleProof};
use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env};
use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env, Vec};

fn setup_game() -> (Env, BattleshipContractClient<'static>, Address, Address) {
let env = Env::default();
Expand Down Expand Up @@ -293,7 +293,7 @@ fn test_win_condition() {
}

#[test]
fn test_component_trait() {
fn test_component_trait_via_macro() {
let env = Env::default();

let commitment = BoardCommitment {
Expand All @@ -304,6 +304,11 @@ fn test_component_trait() {
let serialized = commitment.serialize(&env);
assert_eq!(serialized.len(), 64);
assert_eq!(BoardCommitment::component_type(), symbol_short!("board"));

// Test round-trip deserialize
let deserialized = BoardCommitment::deserialize(&env, &serialized).unwrap();
assert_eq!(deserialized.commitment, commitment.commitment);
assert_eq!(deserialized.merkle_root, commitment.merkle_root);
}

#[test]
Expand Down Expand Up @@ -347,3 +352,49 @@ fn test_turn_switching() {
let state = client.get_state();
assert_eq!(state.turn_state.current_player, player_a);
}

#[test]
#[should_panic(expected = "No pending attack")]
fn test_reveal_without_pending_attack() {
let (env, client, player_a, player_b) = setup_game();
env.mock_all_auths();

client.new_game(&player_a, &player_b);

let board_a = [0u32; 100];
let board_b = [0u32; 100];

let salt_a = BytesN::from_array(&env, &[1u8; 32]);
let salt_b = BytesN::from_array(&env, &[2u8; 32]);

let commitment_a = make_commitment(&env, &board_a, &salt_a);
let commitment_b = make_commitment(&env, &board_b, &salt_b);

let (root_a, _) = build_merkle_tree(&env, &board_a);
let (root_b, _) = build_merkle_tree(&env, &board_b);

client.commit_board(&player_a, &commitment_a, &root_a);
client.commit_board(&player_b, &commitment_b, &root_b);

// Try to reveal without attacking first
let fake_proof = OnChainMerkleProof {
siblings: Vec::new(&env),
path_bits: 0,
leaf: BytesN::from_array(&env, &[0u8; 32]),
leaf_index: 0,
depth: 0,
};
client.reveal_cell(&player_b, &0, &0, &0, &fake_proof);
}

#[test]
#[should_panic(expected = "Not in attack phase")]
fn test_attack_before_commit() {
let (env, client, player_a, _player_b) = setup_game();
env.mock_all_auths();

client.new_game(&player_a, &Address::generate(&env));

// Try to attack before boards are committed
client.attack(&player_a, &0, &0);
}
2 changes: 1 addition & 1 deletion examples/proof_of_hunt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0"
crate-type = ["cdylib", "rlib"]

[dependencies]
cougr-core = "1.0.0"
cougr-core = "1.1.0"
soroban-sdk = "25.1.0"

[dev-dependencies]
Expand Down
Loading
Loading