diff --git a/.gitignore b/.gitignore index c378931..bfe1388 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,12 @@ debug/ test_real_zk.py # Large simulator state files simulator_data/state.json + +# audit reports +.audit/ + +# local dev notes +CLAUDE.md +IMPLEMENTATION_STATUS.md +OFFERS_STATUS.md +run_examples.sh \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 70f49dc..99e282e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "backends/sp1", # sp1 backend implementation "backends/sp1/program", # sp1 guest "backends/sp1/program_recursive", # sp1 recursive aggregation guest + "backends/sp1/program_settlement", # sp1 settlement guest "backends/mock" # mock backend for testing ] @@ -42,6 +43,9 @@ mock = ["dep:clvm-zk-mock", "clvm-zk-core/sha2-hasher"] # mock backend for test # testing utilities (enabled by default for tests) testing = ["clvm-zk-core/sha2-hasher"] +# optional debug logging (disabled by default for production) +debug-logging = [] + [dependencies] # Core dependencies serde = { version = "1.0.219", features = ["derive"] } @@ -56,9 +60,9 @@ rand = "0.8" bip32 = "0.5" hmac = "0.12" k256 = { version = "0.13", features = ["ecdsa", "sha256"] } # For ECDSA signature verification -rs_merkle = "1.5" # For merkle tree proof generation on host x25519-dalek = { version = "2.0", features = ["static_secrets"] } # For ECDH key exchange in encrypted notes chacha20poly1305 = "0.10" # For authenticated encryption of payment notes +once_cell = "1.18" # For lazy static initialization # Backend implementations clvm-zk-risc0 = { path = "backends/risc0", optional = true } diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..37214dc --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,557 @@ +# veil documentation + +comprehensive technical documentation for veil's privacy-preserving chialisp zkvm. + +**quick links:** +- [nullifier protocol](#nullifier-protocol) - double-spend prevention +- [stealth addresses](#stealth-addresses) - unlinkable payments +- [CAT protocol](#cat-protocol) - colored asset tokens +- [simulator](#simulator) - local testing environment +- [clvm opcodes](#clvm-opcodes) - chia-standard opcode reference + +--- + +## nullifier protocol + +the nullifier protocol prevents double-spending in veil's privacy-preserving transactions. + +### overview + +each coin has a unique serial number that generates a deterministic nullifier when spent. the nullifier is public and stored on-chain - if someone tries to spend the same coin twice, the same nullifier would be revealed, and the blockchain rejects the second spend. + +### key concepts + +| term | definition | +|------|------------| +| **serial_number** | 32-byte secret tied to a specific coin | +| **serial_randomness** | 32-byte random value for hiding serial in commitment | +| **serial_commitment** | `hash("clvm_zk_serial_v1.0" \|\| serial_number \|\| serial_randomness)` | +| **coin_commitment** | `hash("clvm_zk_coin_v2.0" \|\| tail_hash \|\| amount \|\| puzzle_hash \|\| serial_commitment)` | +| **nullifier** | `hash(serial_number \|\| program_hash \|\| amount)` - revealed when spending | + +### protocol flow + +**1. coin creation:** +``` +1. generate random serial_number (32 bytes) +2. generate random serial_randomness (32 bytes) +3. compute serial_commitment = hash("clvm_zk_serial_v1.0" || serial_number || serial_randomness) +4. compute coin_commitment = hash("clvm_zk_coin_v2.0" || tail_hash || amount || puzzle_hash || serial_commitment) +5. add coin_commitment to merkle tree (public) +6. store serial_number, serial_randomness privately (CRITICAL: losing these = losing funds) +``` + +**2. coin spending:** +``` +1. prove knowledge of (serial_number, serial_randomness) that matches serial_commitment +2. prove coin_commitment is in merkle tree (membership proof) +3. prove program_hash matches the coin's puzzle_hash +4. compute nullifier = hash(serial_number || program_hash || amount) +5. reveal nullifier publicly (checked by blockchain) +6. execute puzzle program and reveal conditions +``` + +**3. blockchain validation:** +- checks nullifier hasn't been used before +- verifies zk proof is valid +- adds nullifier to spent set +- applies output conditions (CREATE_COIN, etc.) + +### security guarantees + +| guarantee | mechanism | +|-----------|-----------| +| **double-spend prevention** | each coin has exactly one valid nullifier | +| **program binding** | program_hash in nullifier prevents puzzle swaps | +| **amount binding** | amount in nullifier prevents amount-hiding attacks | +| **unlinkability** | serial_randomness excluded from nullifier | +| **hiding** | serial_number hidden inside ZK proof | + +### domain separation + +| operation | domain prefix | +|-----------|---------------| +| serial_commitment | `"clvm_zk_serial_v1.0"` | +| coin_commitment | `"clvm_zk_coin_v2.0"` | +| nullifier | (no prefix, direct concatenation) | + +### code references + +``` +clvm_zk_core/src/lib.rs: + - compute_serial_commitment() + - compute_coin_commitment() + - compute_nullifier() + +clvm_zk_core/src/coin_commitment.rs: + - SerialCommitment + - CoinCommitment + - CoinSecrets + +backends/risc0/guest/src/main.rs: + - nullifier verification in guest +``` + +### wallet requirements + +**CRITICAL: losing serial_number or serial_randomness means permanent coin loss.** + +wallets must: +1. derive secrets deterministically from seed (for recovery) +2. backup seed phrase securely +3. track which indices have been used + +for stealth addresses, secrets are derived from `shared_secret`: +``` +coin_secret = hash("veil_stealth_nullifier_v1" || shared_secret) +serial_number = hash(coin_secret || "serial") +serial_randomness = hash(coin_secret || "rand") +``` + +--- + +## stealth addresses + +dual-key stealth address protocol using hash-based derivation for zkVM efficiency. + +### security warning + +**in nullifier mode (the only implemented mode), anyone with your view key CAN SPEND YOUR COINS.** + +this is a fundamental design tradeoff for ~200x faster zkVM proving: +- serial secrets derive from shared_secret +- shared_secret derives from view_privkey + nonce +- view key holder can compute shared_secret → derive serial secrets → spend + +**implications:** +- DO NOT give view key to auditors if you want audit-only access +- DO NOT share view key with anyone you wouldn't trust with your funds + +### overview + +stealth addresses allow a sender to create a payment that only the intended receiver can find and spend, without requiring any interaction. the receiver publishes a single stealth address and can receive unlimited unlinkable payments to it. + +**key design choice:** uses hash-based derivation instead of ECDH for ~200x faster proving in zkVM (no elliptic curve math). + +### key structure + +each wallet has two keypairs (hash-based, not EC): + +| keypair | private | public | purpose | +|---------|---------|--------|---------| +| view | `v` (32 bytes) | `V = sha256("stealth_pubkey_v1" \|\| v)` | scan for incoming payments | +| spend | `s` (32 bytes) | `S = sha256("stealth_pubkey_v1" \|\| s)` | derive spending authorization | + +**stealth address** (published): `(V, S)` - 64 bytes total + +### protocol + +**sender creates payment:** +``` +INPUT: + - receiver's stealth address: (V, S) + - amount, tail_hash, nonce_index + +DERIVE: + 1. nonce = sha256("stealth_nonce_v1" || sender_privkey || nonce_index) + 2. shared_secret = sha256("stealth_v1" || V || nonce) + 3. coin_secret = sha256("veil_stealth_nullifier_v1" || shared_secret) + 4. serial_number = sha256(coin_secret || "serial") + 5. serial_randomness = sha256(coin_secret || "rand") + +OUTPUT: + - coin with puzzle_hash = STEALTH_NULLIFIER_PUZZLE_HASH + - nonce (32 bytes, stored on-chain or transmitted to receiver) +``` + +**receiver scans for payments:** +``` +FOR EACH (coin, nonce): + 1. shared_secret = sha256("stealth_v1" || V || nonce) + 2. check if coin.puzzle_hash == STEALTH_NULLIFIER_PUZZLE_HASH + 3. IF match: save (coin, shared_secret, nonce) to wallet +``` + +**receiver spends coin:** +``` +1. shared_secret = sha256("stealth_v1" || V || nonce) +2. derive serial_number, serial_randomness from shared_secret +3. create ZK proof with nullifier protocol +4. reveal nullifier = hash(serial_number || program_hash || amount) +``` + +### hash-based vs ECDH + +| aspect | ECDH (old) | hash-based (current) | +|--------|------------|----------------------| +| shared_secret derivation | `ephemeral * V` (EC math) | `sha256(V \|\| nonce)` | +| on-chain data | ephemeral_pubkey (33 bytes) | nonce (32 bytes) | +| proving cost in zkVM | ~2M cycles | ~10K cycles | +| receiver derives from pubkey | yes | needs nonce | + +### privacy properties + +| property | guarantee | +|----------|-----------| +| receiver unlinkability | different nonce per payment | +| sender anonymity | nonce doesn't reveal sender identity | +| amount hiding | hidden in coin_commitment | +| forward secrecy | compromise one shared_secret reveals only that payment | +| scanning privacy | only view_key holder can compute shared_secret | + +### implementation + +```rust +// create payment with HD-derived nonce +let payment = create_stealth_payment_hd(&sender_keys, nonce_index, &recipient_address); + +// scan for payments +let scanned = view_key.try_scan_with_nonce(&puzzle_hash, &nonce); + +// derive spending authorization +let spend_auth = receiver_keys.get_spend_auth(&shared_secret); +``` + +--- + +## CAT protocol + +chia asset tokens (CATs) in veil's privacy-preserving system. + +### asset identification + +| asset type | tail_hash | +|------------|-----------| +| XCH (native) | `[0u8; 32]` (all zeros) | +| CAT | `hash(TAIL_program)` | + +coins with different `tail_hash` values cannot be mixed in the same ring spend. + +### commitment scheme (v2) + +``` +coin_commitment = hash( + "clvm_zk_coin_v2.0" || + tail_hash || // 32 bytes - asset identifier + amount || // 8 bytes + puzzle_hash || // 32 bytes + serial_commitment // 32 bytes +) +``` + +### ring spends + +multiple coins can be spent atomically in a single proof: +- all coins must share the same `tail_hash` +- each coin produces its own nullifier +- announcements are verified across all coins in the ring + +### announcement handling + +**chia vs veil coin_id:** +- chia: `coin_id = hash(parent_coin_id || puzzle_hash || amount)` - creates public transaction graph +- veil: `coin_commitment` as coin identifier - preserves privacy + +| opcode | condition | hash formula | +|--------|-----------|--------------| +| 60 | CREATE_COIN_ANNOUNCEMENT | `hash(coin_commitment \|\| message)` | +| 61 | ASSERT_COIN_ANNOUNCEMENT | verifies hash exists in set | +| 62 | CREATE_PUZZLE_ANNOUNCEMENT | `hash(puzzle_hash \|\| message)` | +| 63 | ASSERT_PUZZLE_ANNOUNCEMENT | verifies hash exists in set | + +### privacy properties + +| property | how achieved | +|----------|--------------| +| hidden amounts | commitment hides amount | +| hidden asset type | commitment hides tail_hash | +| hidden transaction graph | no parent_coin_id reference | +| unlinkable spends | nullifier doesn't link to commitment | +| ring anonymity | can't determine coin boundaries in proof | + +### differences from chia CAT2 + +| aspect | chia CAT2 | veil | +|--------|-----------|------| +| announcements | public on-chain | verified in zkVM, hidden | +| coin_id | `hash(parent \|\| puzzle \|\| amount)` | `coin_commitment` | +| ring accounting | public delta verification | private delta verification | +| CREATE_COIN output | public (puzzle, amount) | commitment only | +| transaction graph | fully visible | broken by nullifiers | + +### data flow + +``` +Host constructs Input: + - primary coin (puzzle, solution, secrets, merkle proof) + - additional_coins[] for ring spends + - tail_hash (None = XCH) + +Guest processes: + 1. Compile and execute each coin's puzzle + 2. Verify merkle membership for each coin + 3. Compute nullifier for each coin + 4. Collect and verify announcements across ring + 5. Transform CREATE_COIN → commitments + 6. Filter announcement conditions + +Output: + - program_hash (primary coin) + - nullifiers[] (one per coin) + - transformed conditions (commitments only) +``` + +--- + +## simulator + +local privacy-preserving blockchain simulator that generates real zero-knowledge proofs. + +### quick start + +```bash +./sim_demo.sh # sp1 backend (default) +./sim_demo.sh risc0 # risc0 backend +``` + +**what it does:** +1. builds backend if needed +2. resets simulator state +3. creates wallets with stealth addresses +4. funds wallets from faucet +5. sends via stealth payment +6. receiver scans and discovers payments +7. shows timing and final balances + +### manual commands + +```bash +# initialize +cargo run-sp1 -- sim init + +# create wallet +cargo run-sp1 -- sim wallet alice create + +# fund from faucet +cargo run-sp1 -- sim faucet alice --amount 5000 + +# check balance +cargo run-sp1 -- sim wallet alice show +``` + +### program hash mechanism + +**locking coins:** +```bash +cargo run -- sim spend-to-puzzle alice 1000 "(> secret_amount 500)" +``` + +1. simulator compiles chialisp in TEMPLATE mode +2. strips parameter names, keeps logic structure only +3. generates deterministic program hash = sha256(template_bytecode) +4. coin gets locked to this SPECIFIC program hash + +**spending coins:** +1. compile same program in INSTANCE mode with actual secret values +2. generate zk proof: "i executed program X with hidden inputs" +3. proof links to original program hash but hides the actual values +4. verifier confirms proof matches the program hash without seeing logic + +**privacy guarantee:** verifier only sees "valid proof for program ABC123..." but never sees: +- the actual spending condition logic +- the secret parameters used +- how the computation worked + +### examples + +**basic setup:** +```bash +cargo run-sp1 -- sim init +cargo run-sp1 -- sim wallet alice create +cargo run-sp1 -- sim wallet bob create +cargo run-sp1 -- sim faucet alice --amount 5000 --count 3 +cargo run-sp1 -- sim wallet alice show +cargo run-sp1 -- sim status +``` + +**private transactions:** +```bash +# generate password puzzle +cargo run-sp1 -- hash-password mysecret +# Output: (= (sha256 password) 0x652c7dc...) + +# alice locks coins with password +cargo run-sp1 -- sim spend-to-puzzle alice 3000 \ + "(= (sha256 password) 0x652c7dc...)" --coins "0" + +# bob unlocks with password +cargo run-sp1 -- sim spend-to-wallet \ + "(= (sha256 password) 0x652c7dc...)" bob 3000 --params "mysecret" +``` + +**stealth payments:** +```bash +cargo run-mock -- sim init --reset +cargo run-mock -- sim wallet alice create +cargo run-mock -- sim wallet bob create +cargo run-mock -- sim faucet alice +cargo run-mock -- sim send alice bob 5000 --coins 0 +cargo run-mock -- sim scan bob # bob finds the payment +``` + +**more commands:** +```bash +cargo run-sp1 -- sim send alice bob 2000 --coins "0,1" +cargo run-sp1 -- sim wallets # list all +cargo run-sp1 -- sim wallet alice coins # all coins +cargo run-sp1 -- sim wallet alice unspent # unspent only +cargo run-sp1 -- sim wallet alice balance # balance only +``` + +### choosing backend + +```bash +cargo run-sp1 -- sim init # sp1 backend +cargo run-risc0 -- sim init # risc0 backend +cargo run-mock -- sim init # mock (fast, no real proofs) +``` + +note: both `sim_demo.sh` and cargo default to sp1. + +### features + +- real ZK proof generation using RISC0/SP1 backends +- custom chialisp programs compiled inside zkvm guests +- HD wallets with cryptographic seeds +- nullifier protocol prevents double-spending +- observer mode for auditing without spending access +- multi-coin transactions with batch ZK proof generation + +### troubleshooting + +- use mock backend for fast testing: `cargo run-mock -- sim init` +- reset corrupted state: `cargo run-sp1 -- sim init --reset` +- `run-risc0` and `run-sp1` aliases include `--release` automatically + +### use cases + +- privacy application development +- custom puzzle program development +- observer functionality prototyping +- compliance and auditing +- escrow and multi-sig services +- research and education +- backend testing + +--- + +## security considerations + +### critical warnings + +1. **losing serial_number or serial_randomness = permanent coin loss** + - wallets derive keys deterministically from seed + - backup your seed phrase + +2. **view key = spend key in nullifier mode** + - do not share view key with anyone you wouldn't trust with funds + - no true view/spend separation currently + +3. **nonce uniqueness** + - reuse breaks unlinkability + - HD derivation via `derive_nonce(index)` ensures uniqueness + +4. **merkle tree attacks** + - tree depth bounded (max 64) to prevent DoS + +5. **front-running** + - nullifier revealed in mempool could be front-run + - use commit-reveal or encrypted mempool + +### comparison to other systems + +| system | nullifier scheme | hiding | +|--------|------------------|--------| +| zcash | `hash(note_commitment)` | yes | +| tornado cash | `hash(secret \|\| nullifier_secret)` | yes | +| veil | `hash(serial_number \|\| program_hash \|\| amount)` | yes | + +veil's scheme includes program_hash to bind nullifier to specific puzzle logic, enabling programmable spending conditions. + +--- + +## clvm opcodes + +veil uses chia-standard clvm opcodes for compatibility with `clvmr` (the reference rust clvm implementation). + +### core operators + +| operator | symbol | opcode | description | +|----------|--------|--------|-------------| +| quote | q | 1 | return argument unevaluated | +| apply | a | 2 | apply function to arguments | +| if | i | 3 | conditional branch | +| cons | c | 4 | construct pair | +| first | f | 5 | get first element of pair | +| rest | r | 6 | get rest of pair | +| listp | l | 7 | check if value is a pair | + +### comparison operators + +| operator | symbol | opcode | description | +|----------|--------|--------|-------------| +| equal | = | 9 | equality comparison | +| greater | > | 21 | greater than comparison | + +### arithmetic operators + +| operator | symbol | opcode | description | +|----------|--------|--------|-------------| +| add | + | 16 | addition | +| subtract | - | 17 | subtraction | +| multiply | * | 18 | multiplication | +| divide | / | 19 | division | +| divmod | divmod | 20 | division with modulo | +| modulo | % | 61 | modulo operation | + +### cryptographic operators + +| operator | opcode | description | +|----------|--------|-------------| +| sha256 | 11 | SHA-256 hash | +| bls_verify | 59 | BLS signature verification | +| ecdsa_verify | 200 | ECDSA signature verification | + +### condition opcodes + +these are chia condition opcodes used in puzzle outputs: + +| condition | opcode | description | +|-----------|--------|-------------| +| AGG_SIG_UNSAFE | 49 | aggregate signature (unsafe) | +| AGG_SIG_ME | 50 | aggregate signature (with coin ID) | +| CREATE_COIN | 51 | create new coin | +| RESERVE_FEE | 52 | reserve transaction fee | +| CREATE_COIN_ANNOUNCEMENT | 60 | create coin announcement | +| ASSERT_COIN_ANNOUNCEMENT | 61 | assert coin announcement | +| CREATE_PUZZLE_ANNOUNCEMENT | 62 | create puzzle announcement | +| ASSERT_PUZZLE_ANNOUNCEMENT | 63 | assert puzzle announcement | + +### bytecode format + +clvm bytecode encoding: +- `0x00-0x7F`: small atoms (literal values 0-127) +- `0x80`: nil (empty atom) +- `0x81-0xBF`: atom with 1-64 byte length prefix +- `0xFF`: cons pair marker + +example bytecode for `(+ 5 3)`: +``` +[255, 16, 255, 255, 1, 5, 255, 255, 1, 3, 128] +[cons, +, cons, cons, q, 5, cons, cons, q, 3, nil] +``` + +### references + +- [chia clvm reference](https://chialisp.com/docs/ref/clvm) +- [clvmr repository](https://github.com/Chia-Network/clvm_rs) diff --git a/README.md b/README.md index 9ca92de..02f26f7 100644 --- a/README.md +++ b/README.md @@ -1,367 +1,158 @@ # Veil -**Work in progress research project** +Zero-knowledge proof system for Chialisp. Compiles and executes arbitrary Chialisp programs inside zkVM (SP1/RISC0), generating proofs of correct execution without revealing inputs or program logic. -Zero-knowledge proof system for running Chialisp (CLVM) programs privately. Generates proofs of correct execution without revealing inputs or program logic. - -Supports arbitrary Chialisp programs instead of hardcoded circuits. - -## What it does - -- Run Chialisp programs in zkVM (SP1 by default, RISC0 also supported) -- Generate proofs that hide inputs and program logic -- Verify proofs without revealing private data -- BLS and ECDSA signature verification - - -## Getting started - -### Simulator demo - -Run encrypted payment notes demo: +## Quick start ```bash -# install dependencies ./install-deps.sh - -# run demo -./sim_demo.sh # RISC0 backend (default) -./sim_demo.sh sp1 # SP1 backend +./sim_demo.sh # sp1 (default) +./sim_demo.sh risc0 # risc0 ``` -See **[SIMULATOR.md](SIMULATOR.md)** for details. +## Build -### Install dependencies +Backend-specific builds to separate target directories: ```bash -# install dependencies -./install-deps.sh +cargo risc0 # build to target/risc0/ +cargo sp1 # build to target/sp1/ +cargo mock # build to target/mock/ -# manual installation -rustup target add riscv32im-unknown-none-elf -curl -L https://risczero.com/install | bash && rzup -``` - -### Build and test - -Use backend-specific cargo aliases to avoid rebuilding when switching backends: - -```bash -# build (release) -cargo risc0 # RISC0 backend to target/risc0/ -cargo sp1 # SP1 backend to target/sp1/ -cargo mock # mock backend to target/mock/ - -# test -cargo test-risc0 # run all tests with RISC0 -cargo test-sp1 # run all tests with SP1 -cargo test-mock # fast tests without zkVM +cargo test-risc0 # test with risc0 +cargo test-sp1 # test with sp1 +cargo test-mock # fast tests, no zkvm -# run cargo run-risc0 -- demo cargo run-sp1 -- prove --expression "(mod (a b) (+ a b))" --variables "5,3" - -# examples -cargo run-risc0 --example alice_bob_lock -cargo run-sp1 --example backend_benchmark - -# development -cargo check-risc0 # fast compile check -cargo clippy-risc0 # lints ``` -Backend-specific builds prevent clobbering. Each backend uses ~1GB disk space. Aliases defined in `.cargo/config.toml`. - -For simulator usage, see **[SIMULATOR.md](SIMULATOR.md)**. - - - -## Supported Chialisp - -**Arithmetic**: `+`, `-`, `*`, `divmod`, `modpow` -**Comparison**: `=`, `>` (use `(> b a)` for less-than) -**Control flow**: `i` (if-then-else), `if` -**Lists**: `c` (cons), `f` (first), `r` (rest), `l` (length) -**Functions**: Helper functions with recursion support -**Cryptography**: `sha256`, `ecdsa_verify`, `bls_verify` -**Blockchain**: `CREATE_COIN`, `AGG_SIG_ME`, `RESERVE_FEE`, `REMARK`, announcements, assertions, etc -**Modules**: `mod` wrapper syntax for named parameters - -BLS signature verification (`bls_verify`) works on SP1 and RISC0 backends. - -### Program structure - -```chialisp -;; Simple expression -(+ 1 2) - -;; Named parameters with mod wrapper -(mod (amount fee) - (+ amount fee)) - -;; Helper functions -(mod (x) - (defun double (n) (* n 2)) - (double x)) - -;; Recursion -(mod (n) - (defun factorial (x) - (if (= x 0) - 1 - (* x (factorial (- x 1))))) - (factorial n)) - -;; Nested expressions -(mod (threshold values) - (if (> (length values) threshold) - (sha256 (c threshold values)) - 0)) -``` - -See `tests/` for examples. - - +Aliases in `.cargo/config.toml`. Each backend ~1GB disk. ## Architecture -### Project structure - ``` -clvm-zk/ -├── src/ # main api and host functionality -│ ├── lib.rs # primary api entry point -│ ├── cli.rs # command line interface -│ ├── protocol/ # protocol layer (spender, structures, puzzles) -│ ├── wallet/ # wallet functionality (hd_wallet, types) -│ └── backends.rs # zkvm backend abstraction +veil/ +├── clvm_zk_core/ # no_std chialisp compiler + clvm executor +│ └── src/ +│ ├── lib.rs # main exports, VeilEvaluator wrapper +│ ├── chialisp/mod.rs # clvm_tools_rs wrapper + with_standard_conditions() +│ ├── types.rs # Input, ProofOutput, ProgramParameter, Condition +│ ├── coin_commitment.rs # commitment schemes +│ └── merkle.rs # merkle tree │ -├── clvm_zk_core/ # chialisp compilation and clvm execution (no_std) -│ ├── src/ -│ │ ├── lib.rs # ClvmEvaluator and all opcode handler methods -│ │ ├── types.rs # core types (ProgramParameter, etc) -│ │ ├── backend_utils.rs # shared backend utilities -│ │ ├── chialisp/ # chialisp source parser and compiler -│ │ │ ├── parser.rs # chialisp source → s-expressions -│ │ │ ├── frontend.rs # s-expressions → ast -│ │ │ └── compiler_utils.rs # compilation helpers -│ │ ├── operators.rs # CLVM operator definitions -│ │ └── clvm_parser.rs # CLVM binary bytecode → ClvmValue -│ └── Cargo.toml # no zkvm dependencies, unconditionally no_std +├── backends/ +│ ├── risc0/guest/ # risc0 guest program +│ ├── sp1/program/ # sp1 guest program +│ └── mock/ # no-zkvm testing │ -├── backends/ # zkvm backend implementations -│ ├── risc0/ # risc0 backend -│ │ ├── src/ -│ │ │ ├── lib.rs # implementation + methods re-exports -│ │ │ └── methods.rs # generated elf/id constants wrapper -│ │ ├── guest/src/main.rs # risc0 guest program -│ │ └── Cargo.toml -│ ├── sp1/ # sp1 backend (default) -│ │ ├── src/ -│ │ │ ├── lib.rs # implementation + methods re-exports -│ │ │ └── methods.rs # elf loading wrapper -│ │ ├── program/src/main.rs # sp1 guest program -│ │ └── Cargo.toml -│ └── mock/ # mock backend for testing -│ ├── src/ -│ │ └── backend.rs # no-zkvm test implementation -│ └── Cargo.toml +├── src/ +│ ├── protocol/ # spender, puzzles, structures +│ ├── wallet/ # hd_wallet, stealth addresses +│ └── simulator.rs # blockchain simulator │ -├── examples/ # working code examples -│ ├── alice_bob_lock.rs # ecdsa signature verification -│ ├── performance_profiling.rs # performance benchmarks -│ └── backend_benchmark.rs # backend comparison benchmarks -│ -├── tests/ # test suite -│ ├── fuzz_tests.rs # fuzzing tests (500+ cases) -│ ├── simulator_tests.rs # simulator tests (10/10 tests pass) -│ ├── bls_signature_tests.rs # BLS12-381 signature verification -│ ├── signature_tests.rs # signature verification tests -│ ├── proof_validation_tests.rs # security validation tests -│ ├── test_create_coin_privacy.rs # CREATE_COIN output privacy tests -│ ├── test_condition_transformation.rs # 4-arg→1-arg transformation tests -│ ├── test_simulator_create_coin.rs # simulator integration tests -│ └── ... # additional test files -│ -└── Cargo.toml # workspace configuration +└── tests/ ``` +### Core flow -### Core components - -#### `clvm_zk_core/` - Backend-agnostic compilation and execution -no_std Chialisp compiler and CLVM executor with dependency injection for zkVM-optimized crypto: - -**Compilation:** -- **`compile_chialisp_to_bytecode_with_table()`**: Compiles Chialisp source to CLVM bytecode with function table -- **`compile_chialisp_template_hash()`**: Generates deterministic program hashes for verification -- **Compilation pipeline**: Chialisp source → s-expressions → AST → CLVM bytecode → ClvmValue - -**Execution:** -- **`ClvmEvaluator`**: Main evaluation struct with injected backend crypto - - `hasher: fn(&[u8]) -> [u8; 32]` - Hash function (zkVM-optimized) - - `bls_verifier: fn(&[u8], &[u8], &[u8]) -> Result` - BLS signature verification - - `ecdsa_verifier: fn(&[u8], &[u8], &[u8]) -> Result` - ECDSA signature verification -- **`evaluate_clvm_program()`**: Executes bytecode with parameter substitution -- **All CLVM opcodes**: Arithmetic, comparison, list operations, conditionals, crypto, blockchain conditions - -#### `backends/` - zkVM implementations -Each backend provides host integration and guest program: -- **RISC0**: Mature backend with optimized precompiles for BLS/ECDSA -- **SP1**: Default backend, potentially faster proving times -- **mock**: No-zkVM testing backend for fast iteration - -#### `examples/` - Working code examples -- `alice_bob_lock.rs` - ECDSA signature verification with ZK proofs -- `performance_profiling.rs` - Performance benchmarking suite -- `backend_benchmark.rs` - Backend comparison tool - -## Development - -### Basic usage +1. Host sends `Input` (chialisp source + parameters + optional coin data) to guest +2. Guest compiles chialisp via `clvm_tools_rs` → bytecode + program_hash +3. Guest executes bytecode via `VeilEvaluator` with injected crypto (sha256, bls, ecdsa) +4. Guest transforms CREATE_COIN conditions (4-arg → commitment) +5. Guest verifies coin commitments and merkle proofs if spending +6. Guest outputs `ProofOutput` (program_hash, nullifiers, clvm_res, public_values) -`ClvmZkProver::prove(expression)` generates proofs. `ClvmZkProver::verify_proof()` verifies them. Expressions support named variables and `mod` wrapper syntax. -**Flow**: Host sends chialisp source to guest → guest compiles and executes → returns proof with program hash. +### clvm_tools_rs integration -See `examples/` for complete working code including `alice_bob_lock.rs` for ECDSA signatures. +Uses Chia's official compiler (`clvm_tools_rs` no_std branch) for full chialisp support including recursion. The `VeilEvaluator` wraps clvmr with injectable crypto handlers. +**zkvm limitation**: no filesystem, so `(include condition_codes.clib)` doesn't work. Use the helper: -**Flow**: Host sends Chialisp source to guest → guest compiles and executes → returns proof with program hash. - +```rust +use clvm_zk_core::with_standard_conditions; +let program = with_standard_conditions( + "(mod (recipient amount) + (list (list CREATE_COIN recipient amount)))" +); +// prepends defconstant for CREATE_COIN, AGG_SIG_ME, etc. +``` ## Privacy protocols -### Nullifier protocol - -Coins use serial commitment scheme to prevent double-spending while hiding which coin was spent: - -**Coin creation:** -- Generate random `serial_number` and `serial_randomness` -- `serial_commitment = hash("clvm_zk_serial_v1.0" || serial_number || serial_randomness)` -- `coin_commitment = hash("clvm_zk_coin_v1.0" || amount || puzzle_hash || serial_commitment)` -- Coin commitment added to merkle tree +### Nullifiers -**Spending:** -- Guest verifies: `puzzle_hash == program_hash` (proves running correct puzzle) -- Guest verifies: `hash(serial_number || serial_randomness) == serial_commitment` -- Guest verifies: `hash(amount || puzzle_hash || serial_commitment) == coin_commitment` -- Guest verifies: Merkle membership of coin_commitment -- Guest computes: `nullifier = hash(serial_number || program_hash || amount)` -- Proof reveals nullifier, hides which coin was spent +Prevents double-spend without revealing which coin: -**Security properties:** -- Each coin has exactly one valid nullifier -- Nullifier set tracks spent coins -- Double-spending cryptographically impossible -- Merkle proof shows coin exists without revealing which one -- serial_randomness prevents linking nullifier to coin_commitment - -### Output privacy (CREATE_COIN transformation) - -Chialisp programs create coins with 4 arguments, but zkVM transforms to 1 argument before outputting: - -**Inside zkVM (private):** -```chialisp -;; program outputs detailed CREATE_COIN condition -(list CREATE_COIN puzzle_hash amount serial_number serial_randomness) +``` +coin creation: + serial_commitment = hash("clvm_zk_serial_v1.0" || serial_number || serial_randomness) + coin_commitment = hash("clvm_zk_coin_v2.0" || tail_hash || amount || puzzle_hash || serial_commitment) + +spending: + guest verifies: program_hash == puzzle_hash + guest verifies: serial_commitment, coin_commitment, merkle proof + guest computes: nullifier = hash(serial_number || program_hash || amount) + proof reveals: nullifier (not which coin) ``` -**Guest transformation (still private):** -- Computes `serial_commitment = hash(serial_number || serial_randomness)` -- Computes `coin_commitment = hash(amount || puzzle_hash || serial_commitment)` -- Replaces 4-arg condition with 1-arg: `CREATE_COIN(coin_commitment)` +### CREATE_COIN transformation -**Proof output (public):** +inside the zkVM, chialisp outputs 4-arg CREATE_COIN: ``` -CREATE_COIN(coin_commitment) // single 32-byte commitment +(CREATE_COIN puzzle_hash amount serial_number serial_randomness) ``` -**Privacy guarantee:** -- Proof verifier sees only `coin_commitment` -- Cannot determine `puzzle_hash` (address), `amount`, or `serial_number` -- zkVM proves commitment was computed correctly without revealing preimage -- Only coin owner (with serial secrets) can later spend - -See `tests/test_create_coin_privacy.rs` and `tests/test_condition_transformation.rs`. - -### Recovery protocol - -Encrypted payment notes enable offline receiving and backup recovery: - -**Sending:** -- Alice generates coin with random serial_number and serial_randomness -- Alice encrypts `(serial_number, serial_randomness)` to Bob's x25519 viewing key -- Alice publishes encrypted note on-chain alongside transaction - -**Receiving:** -- Bob scans blockchain with x25519 decryption key -- Bob decrypts notes to discover coins sent to him -- Bob stores coin secrets locally - -**Recovery:** -- Bob re-scans blockchain with viewing key -- Recovers all coins from encrypted notes -- Works offline - no interaction with sender needed +Guest transforms to 1-arg before output: +``` +CREATE_COIN(coin_commitment) // hides puzzle_hash, amount, serial +``` -**Privacy:** -- Alice cannot track Bob's spending after sending -- Encrypted notes unlinkable to coin commitments -- Viewing key enables read-only access +### Stealth addresses -See **[ENCRYPTED_NOTES.md](ENCRYPTED_NOTES.md)** and **[nullifier.md](nullifier.md)** for detailed specifications. +Hash-based unlinkable receiving. Sender derives shared_secret from receiver's pubkey + random nonce. Receiver scans with view key. ~200x faster than ECDH in zkVM. -## Blockchain simulator +## Simulator -Local privacy-preserving blockchain simulator with encrypted payment notes, HD wallets, persistent state, and real ZK proofs. +Local blockchain with HD wallets, stealth addresses, merkle trees, real ZK proofs. ```bash -# run the full demo -./sim_demo.sh # RISC0 backend (default) -./sim_demo.sh sp1 # SP1 backend +./sim_demo.sh ``` -See **[SIMULATOR.md](SIMULATOR.md)** for detailed documentation. +## Documentation -## Recursive proof aggregation +See [DOCUMENTATION.md](DOCUMENTATION.md) for detailed technical docs on: +- Nullifier protocol (double-spend prevention) +- Stealth addresses (unlinkable payments) +- CAT protocol (colored asset tokens) +- Simulator usage -**Production-ready** recursive aggregation compresses N transaction proofs into 1 aggregated proof. +## Adding backends -**Features:** -- N base proofs → 1 aggregated proof (flat aggregation) -- duplicate nullifier detection (security) -- merkle tree commitments for proof inclusion -- constant proof size (~252KB regardless of child count) -- works on both risc0 and sp1 backends +1. Create `backends/your_zkvm/` with guest program using `clvm_zk_core` +2. Inject crypto via `create_veil_evaluator(hasher, bls_verifier, ecdsa_verifier)` +3. Add feature flag to workspace `Cargo.toml` +4. Implement `ZKCLVMBackend` trait in `src/backends.rs` -**Performance (risc0):** -- 10→1 aggregation: ~22 seconds -- proof size: ~252KB (constant) +See `backends/risc0/guest/src/main.rs` for reference. -See `examples/recursive_aggregation.rs` +## Examples +```bash +./run_examples.sh risc0 # run all examples with risc0 +./run_examples.sh sp1 # run all examples with sp1 +./run_examples.sh mock # run all examples with mock -## Adding new zkVM backends - -To add a new zkVM backend: - -1. Create `backends/your_zkvm/src/lib.rs` implementing the backend -2. Create guest program using `clvm_zk_core` (no_std compatible) -3. Inject zkVM-optimized crypto functions into `ClvmEvaluator` -4. Add feature flag to workspace `Cargo.toml` -5. Implement `ZKCLVMBackend` trait in `src/backends.rs` - -See `backends/risc0/` or `backends/sp1/` as reference implementations. - -## Contributing +cargo run-risc0 --example alice_bob_lock # run single example +``` -Contributions welcome in these areas: -- Performance optimizations -- Chialisp operations -- Error messages -- Documentation -- Test cases -- zkVM backends +## Tests -See test suite in `tests/` for implementation patterns. +```bash +cargo test-mock # fast +cargo test-risc0 --test simulator_tests # simulator +cargo test-risc0 --test bls_signature_tests # bls +``` diff --git a/SIMULATOR.md b/SIMULATOR.md deleted file mode 100644 index d5a0787..0000000 --- a/SIMULATOR.md +++ /dev/null @@ -1,226 +0,0 @@ -# CLVM-ZK Blockchain Simulator - -Local privacy-preserving blockchain simulator that generates real zero-knowledge proofs. Create wallets, send private transactions, and test ZK applications without setting up a real blockchain. - - -### program hash mechanism - -how programmable spending conditions work with privacy: - -**locking coins**: when you create a puzzle-locked coin -```bash -cargo run -- sim spend-to-puzzle alice 1000 "(> secret_amount 500)" -``` - -1. simulator compiles chialisp in TEMPLATE mode -2. strips parameter names, keeps logic structure only -3. generates deterministic program hash = sha256(template_bytecode) -4. coin gets locked to this SPECIFIC program hash - -**spending coins**: to unlock later -1. compile same program in INSTANCE mode with actual secret values -2. generate zk proof: "i executed program X with hidden inputs" -3. proof links to original program hash but hides the actual values -4. verifier confirms proof matches the program hash without seeing logic - -**privacy guarantee**: verifier only sees "valid proof for program ABC123..." but never sees: -- the actual spending condition logic -- the secret parameters used -- how the computation worked - -**nullifier protocol**: prevents double-spending using serial number commitments - -when creating a coin: -``` -serial_number = random(32 bytes) -serial_randomness = random(32 bytes) -serial_commitment = hash("clvm_zk_serial_v1.0" || serial_number || serial_randomness) -coin_commitment = hash("clvm_zk_coin_v1.0" || amount || puzzle_hash || serial_commitment) -``` - -when spending a coin: -``` -// Guest verifies: -verify: hash(serial_number || serial_randomness) == serial_commitment -verify: coin_commitment exists in merkle tree - -// Guest computes and reveals: -nullifier = hash(serial_number || program_hash) -``` - -**security properties:** -- each coin has exactly one valid nullifier for its (serial_number, program_hash) pair -- nullifier = hash(serial_number || program_hash) - uniquely identifies spent coin -- serial_randomness excluded from nullifier to prevent linkability with coin_commitment -- double-spend impossible: same nullifier can only be revealed once -- merkle membership proves coin exists without revealing which coin - -**critical:** losing serial_number or serial_randomness = permanent coin loss. see [ENCRYPTED_NOTES.md](ENCRYPTED_NOTES.md) for backup procedures. - -## Quick start - -### Demo script - -Run the full encrypted payment notes demo: - -```bash -./sim_demo.sh # RISC0 backend (default) -./sim_demo.sh sp1 # SP1 backend -``` - -**What it does:** -1. Builds backend if needed (`target/risc0/` or `target/sp1/`) -2. Resets simulator state -3. Creates alice and bob wallets with HD keys -4. Funds alice from faucet -5. Alice sends to bob (bob offline, doesn't receive yet) -6. Bob scans blockchain and discovers payments via encrypted notes -7. Bob sends back to alice -8. Shows timing and final balances - - -- Scan operations: instant (decryption only, no zkVM) - -**Output:** Persistent state in `simulator_data/state.json` with all ZK proofs. - -### manual commands - -```bash -# Initialize the simulator -cargo run-sp1 -- sim init - -# Create wallet -cargo run-sp1 -- sim wallet alice create - -# Fund it from the faucet -cargo run-sp1 -- sim faucet alice --amount 5000 - -# Check your balance -cargo run-sp1 -- sim wallet alice show -``` - -Run tests with: -```bash -cargo test-sp1 --test simulator_tests -``` - -## How it works - -Everything gets saved to `./simulator_data/state.json`: -- HD wallets with real cryptographic keys -- Observer wallets for monitoring -- Coins and transaction history -- Puzzle-locked coins - -**Full wallets** can spend and view. **Observer wallets** can only view. - -**Privacy**: spend secrets, program parameters, execution traces stay private. Program hashes, nullifiers, outputs, proofs are public. - -## Examples - -### Basic setup -```bash -# Initialize simulator -cargo run-sp1 -- sim init - -# Create wallets -cargo run-sp1 -- sim wallet alice create -cargo run-sp1 -- sim wallet bob create - -# Fund wallet from faucet -cargo run-sp1 -- sim faucet alice --amount 5000 --count 3 - -# Check wallet status -cargo run-sp1 -- sim wallet alice show -cargo run-sp1 -- sim status -``` - -### Private transactions -```bash -# Generate password puzzle program -cargo run-sp1 -- hash-password mysecret -# Output: (= (sha256 password) 0x652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0) - -# Alice locks coins with password -cargo run-sp1 -- sim spend-to-puzzle alice 3000 \ - "(= (sha256 password) 0x652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0)" \ - --coins "0" - -# Bob unlocks with password -cargo run-sp1 -- sim spend-to-wallet \ - "(= (sha256 password) 0x652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0)" \ - bob 3000 --params "mysecret" -``` - -### More examples -```bash -# Send between wallets -cargo run-sp1 -- sim send alice bob 2000 --coins "0,1" - -# Custom puzzle programs -cargo run-sp1 -- sim spend-to-puzzle alice 1000 "(> minimum_amount 100)" --coins "auto" -cargo run-sp1 -- sim spend-to-wallet "(> minimum_amount 100)" bob 1000 --params "150" - -# Using mod wrapper syntax for named parameters -cargo run-sp1 -- sim spend-to-puzzle alice 1000 "(mod (threshold) (> threshold 100))" --coins "auto" -cargo run-sp1 -- sim spend-to-wallet "(mod (threshold) (> threshold 100))" bob 1000 --params "150" - -# Wallet management -cargo run-sp1 -- sim wallets # List all wallets -cargo run-sp1 -- sim wallet alice coins # Show all coins -cargo run-sp1 -- sim wallet alice unspent # Show unspent coins -cargo run-sp1 -- sim wallet alice balance # Show balance only -``` - -## encrypted payment notes - -- alice sends to bob → creates encrypted note (bob doesn't receive coin yet) -- bob runs `sim scan bob` → decrypts notes, discovers payments -- uses x25519 ecdh + chacha20-poly1305 authenticated encryption -- supports memos (e.g., "payment from alice") - -see [ENCRYPTED_NOTES.md](ENCRYPTED_NOTES.md) for full documentation. - -**quick test:** -```bash -./TEST_ENCRYPTED_NOTES.sh -``` - -## Features - -- Real ZK proof generation using RISC0/SP1 backends -- Custom chialisp programs compiled inside zkvm guests -- HD wallets with cryptographic seeds -- Nullifier protocol prevents double-spending -- Observer mode for auditing without spending access -- Multi-coin transactions with batch ZK proof generation - -### Choose zk backend -```bash -# Default: SP1 backend (--release included in alias) -cargo run-sp1 -- sim init - -# Use RISC0 backend (--release included in alias) -cargo run-risc0 -- sim init - -# Use mock backend for fast testing (no real proofs) -cargo run-mock -- sim init -``` - -## Troubleshooting - -**Use the correct backend alias** - `run-risc0` and `run-sp1` include `--release` automatically. -Use mock backend for fast testing without real proofs: `cargo run-mock -- sim init` -Reset corrupted state with `cargo run-sp1 -- sim init --reset` -Switch backends using cargo aliases: `cargo run-risc0`, `cargo run-sp1`, `cargo run-mock` - -## Use cases - -Perfect for: -- Privacy application development - test guest-side compilation locally before mainnet -- Custom puzzle program development - rapid iteration with real guest-compiled ZK proofs -- Observer functionality prototyping - monitoring without spending access -- Compliance and auditing - transparent monitoring with privacy preservation -- Escrow and multi-sig services - complex spending conditions with deterministic program hashes -- Research and education - understand zero-knowledge privacy protocols and guest compilation -- Backend testing - verify risc0 and sp1 compilation consistency \ No newline at end of file diff --git a/backends/mock/src/backend.rs b/backends/mock/src/backend.rs index d815a97..1603e0c 100644 --- a/backends/mock/src/backend.rs +++ b/backends/mock/src/backend.rs @@ -1,7 +1,9 @@ use clvm_zk_core::verify_ecdsa_signature_with_hasher; use clvm_zk_core::{ - compile_chialisp_to_bytecode, create_veil_evaluator, run_clvm_with_conditions, - serialize_params_to_clvm, ClvmResult, ClvmZkError, Condition, ProgramParameter, ProofOutput, + compile_chialisp_to_bytecode, compute_coin_commitment, compute_nullifier, + compute_serial_commitment, create_veil_evaluator, enforce_ring_balance, + parse_variable_length_amount, run_clvm_with_conditions, serialize_params_to_clvm, + verify_merkle_proof, ClvmResult, ClvmZkError, Condition, ProgramParameter, ProofOutput, ZKClvmResult, BLS_DST, }; use sha2::{Digest, Sha256}; @@ -84,6 +86,7 @@ fn validate_signature_conditions(conditions: &[Condition]) -> Result<(), ClvmZkE fn transform_create_coin_conditions( conditions: &mut [Condition], output_bytes: Vec, + tail_hash: [u8; 32], ) -> Result, ClvmZkError> { let mut has_transformations = false; @@ -94,55 +97,36 @@ fn transform_create_coin_conditions( // Transparent mode: leave as-is } 4 => { - let puzzle_hash = &condition.args[0]; - let amount_bytes = &condition.args[1]; - let serial_number = &condition.args[2]; - let serial_randomness = &condition.args[3]; - - // Validate sizes - if puzzle_hash.len() != 32 { - return Err(ClvmZkError::ProofGenerationFailed( - "puzzle_hash must be 32 bytes".to_string(), - )); - } - if amount_bytes.len() > 8 { - return Err(ClvmZkError::ProofGenerationFailed( - "amount too large (max 8 bytes)".to_string(), - )); - } - if serial_number.len() != 32 { - return Err(ClvmZkError::ProofGenerationFailed( - "serial_number must be 32 bytes".to_string(), - )); - } - if serial_randomness.len() != 32 { - return Err(ClvmZkError::ProofGenerationFailed( - "serial_randomness must be 32 bytes".to_string(), - )); - } - - // Parse amount from variable-length big-endian bytes - let mut amount = 0u64; - for &byte in amount_bytes { - amount = (amount << 8) | (byte as u64); - } - - // Compute serial_commitment - let serial_domain = b"clvm_zk_serial_v1.0"; - let mut serial_data = [0u8; 83]; - serial_data[..19].copy_from_slice(serial_domain); - serial_data[19..51].copy_from_slice(serial_number); - serial_data[51..83].copy_from_slice(serial_randomness); - let serial_commitment = hash_data(&serial_data); - - // Compute coin_commitment - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 89]; - coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(puzzle_hash); - coin_data[57..89].copy_from_slice(&serial_commitment); - let coin_commitment = hash_data(&coin_data); + let puzzle_hash: &[u8; 32] = + condition.args[0].as_slice().try_into().map_err(|_| { + ClvmZkError::ProofGenerationFailed( + "puzzle_hash must be 32 bytes".to_string(), + ) + })?; + let amount = parse_variable_length_amount(&condition.args[1]) + .map_err(|e| ClvmZkError::ProofGenerationFailed(e.to_string()))?; + let serial_number: &[u8; 32] = + condition.args[2].as_slice().try_into().map_err(|_| { + ClvmZkError::ProofGenerationFailed( + "serial_number must be 32 bytes".to_string(), + ) + })?; + let serial_randomness: &[u8; 32] = + condition.args[3].as_slice().try_into().map_err(|_| { + ClvmZkError::ProofGenerationFailed( + "serial_randomness must be 32 bytes".to_string(), + ) + })?; + + let serial_commitment = + compute_serial_commitment(hash_data, serial_number, serial_randomness); + let coin_commitment = compute_coin_commitment( + hash_data, + tail_hash, + amount, + puzzle_hash, + &serial_commitment, + ); condition.args = vec![coin_commitment.to_vec()]; has_transformations = true; @@ -189,7 +173,9 @@ impl MockBackend { )?; validate_signature_conditions(&conditions)?; - let final_output = transform_create_coin_conditions(&mut conditions, output_bytes)?; + let tail_hash = [0u8; 32]; // default XCH for simple proving API + let final_output = + transform_create_coin_conditions(&mut conditions, output_bytes, tail_hash)?; let clvm_output = ClvmResult { output: final_output, @@ -198,7 +184,7 @@ impl MockBackend { let proof_output = ProofOutput { program_hash, - nullifier: None, + nullifiers: vec![], clvm_res: clvm_output, proof_type: 0, public_values: vec![], @@ -242,8 +228,17 @@ impl MockBackend { |e| ClvmZkError::ProofGenerationFailed(format!("clvm execution failed: {:?}", e)), )?; + // BALANCE ENFORCEMENT (critical security check) + // verify sum(inputs) == sum(outputs) and tail_hash consistency + // MUST run BEFORE CREATE_COIN transformation + enforce_ring_balance(&inputs, &conditions).map_err(|e| { + ClvmZkError::ProofGenerationFailed(format!("balance enforcement failed: {}", e)) + })?; + validate_signature_conditions(&conditions)?; - let final_output = transform_create_coin_conditions(&mut conditions, output_bytes)?; + let tail_hash = inputs.tail_hash.unwrap_or([0u8; 32]); + let final_output = + transform_create_coin_conditions(&mut conditions, output_bytes, tail_hash)?; let clvm_output = ClvmResult { output: final_output, @@ -252,84 +247,89 @@ impl MockBackend { let nullifier = match inputs.serial_commitment_data { Some(commitment_data) => { - let serial_randomness = commitment_data.serial_randomness; - let serial_number = commitment_data.serial_number; - let puzzle_hash = commitment_data.program_hash; - - if program_hash != puzzle_hash { + if program_hash != commitment_data.program_hash { return Err(ClvmZkError::ProofGenerationFailed( "program_hash mismatch: cannot spend coin with different puzzle" .to_string(), )); } - let domain = b"clvm_zk_serial_v1.0"; - let mut serial_commit_data = Vec::with_capacity(19 + 64); - serial_commit_data.extend_from_slice(domain); - serial_commit_data.extend_from_slice(&serial_number); - serial_commit_data.extend_from_slice(&serial_randomness); - let computed_serial_commitment = hash_data(&serial_commit_data); - - let serial_commitment_expected = commitment_data.serial_commitment; - if computed_serial_commitment != serial_commitment_expected { + let computed_serial_commitment = compute_serial_commitment( + hash_data, + &commitment_data.serial_number, + &commitment_data.serial_randomness, + ); + if computed_serial_commitment != commitment_data.serial_commitment { return Err(ClvmZkError::ProofGenerationFailed( "serial commitment verification failed".to_string(), )); } - let coin_domain = b"clvm_zk_coin_v1.0"; - let amount = commitment_data.amount; - let mut coin_data = [0u8; 17 + 8 + 32 + 32]; - coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(&program_hash); - coin_data[57..89].copy_from_slice(&computed_serial_commitment); - let computed_coin_commitment = hash_data(&coin_data); - - let coin_commitment_provided = commitment_data.coin_commitment; - if computed_coin_commitment != coin_commitment_provided { + let tail_hash = inputs.tail_hash.unwrap_or([0u8; 32]); + let computed_coin_commitment = compute_coin_commitment( + hash_data, + tail_hash, + commitment_data.amount, + &program_hash, + &computed_serial_commitment, + ); + if computed_coin_commitment != commitment_data.coin_commitment { return Err(ClvmZkError::ProofGenerationFailed( "coin commitment verification failed".to_string(), )); } - let merkle_path = commitment_data.merkle_path; - let expected_root = commitment_data.merkle_root; - let leaf_index = commitment_data.leaf_index; - let mut current_hash = computed_coin_commitment; - let mut current_index = leaf_index; - for sibling in merkle_path.iter() { - let mut combined = [0u8; 64]; - if current_index % 2 == 0 { - combined[..32].copy_from_slice(¤t_hash); - combined[32..].copy_from_slice(sibling); - } else { - combined[..32].copy_from_slice(sibling); - combined[32..].copy_from_slice(¤t_hash); - } - current_hash = hash_data(&combined); - current_index /= 2; - } - - let computed_root = current_hash; - if computed_root != expected_root { - return Err(ClvmZkError::ProofGenerationFailed( - "merkle root mismatch: coin not in current tree state".to_string(), - )); - } - - let mut nullifier_data = Vec::with_capacity(72); - nullifier_data.extend_from_slice(&serial_number); - nullifier_data.extend_from_slice(&program_hash); - nullifier_data.extend_from_slice(&amount.to_be_bytes()); - Some(hash_data(&nullifier_data)) + verify_merkle_proof( + hash_data, + computed_coin_commitment, + &commitment_data.merkle_path, + commitment_data.leaf_index, + commitment_data.merkle_root, + ) + .map_err(|e| { + ClvmZkError::ProofGenerationFailed(format!("merkle verification failed: {}", e)) + })?; + + Some(compute_nullifier( + hash_data, + &commitment_data.serial_number, + &program_hash, + commitment_data.amount, + )) } None => None, }; + // collect nullifiers: primary coin + additional coins + let mut nullifiers = nullifier.map(|n| vec![n]).unwrap_or_default(); + + // process additional coins for ring spends + if let Some(additional_coins) = &inputs.additional_coins { + for coin in additional_coins { + let coin_data = &coin.serial_commitment_data; + + let (_, coin_program_hash) = + compile_chialisp_to_bytecode(hash_data, &coin.chialisp_source).map_err( + |e| { + ClvmZkError::ProofGenerationFailed(format!( + "additional coin compilation failed: {:?}", + e + )) + }, + )?; + + nullifiers.push(compute_nullifier( + hash_data, + &coin_data.serial_number, + &coin_program_hash, + coin_data.amount, + )); + } + } + let proof_output = ProofOutput { program_hash, - nullifier, + nullifiers, clvm_res: clvm_output, proof_type: 0, public_values: vec![], diff --git a/backends/risc0/guest/src/main.rs b/backends/risc0/guest/src/main.rs index a7050f0..1be857b 100644 --- a/backends/risc0/guest/src/main.rs +++ b/backends/risc0/guest/src/main.rs @@ -7,8 +7,10 @@ use risc0_zkvm::guest::env; use risc0_zkvm::sha::{Impl, Sha256 as RiscSha256}; use clvm_zk_core::{ - compile_chialisp_to_bytecode, create_veil_evaluator, run_clvm_with_conditions, - serialize_params_to_clvm, ClvmResult, Input, ProofOutput, BLS_DST, + compile_chialisp_to_bytecode, compute_coin_commitment, compute_nullifier, + compute_serial_commitment, create_veil_evaluator, parse_variable_length_amount, + run_clvm_with_conditions, serialize_params_to_clvm, verify_merkle_proof, ClvmResult, Input, + ProofOutput, BLS_DST, }; use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve}; @@ -17,6 +19,30 @@ use sha2::Sha256; risc0_zkvm::guest::entry!(main); +// precompiled standard puzzles for performance optimization +// bypasses guest-side compilation for known puzzles (580s -> ~10s improvement) + +const DELEGATED_PUZZLE_SOURCE: &str = r#"(mod (offered requested maker_pubkey change_amount change_puzzle change_serial change_rand) + (c + (c 51 (c change_puzzle (c change_amount (c change_serial (c change_rand ()))))) + (c offered (c requested (c maker_pubkey ()))) + ) +)"#; + +const DELEGATED_PUZZLE_BYTECODE: &[u8] = &[ + 0xff, 0x02, 0xff, 0xff, 0x01, 0xff, 0x04, 0xff, 0xff, 0x04, 0xff, 0xff, 0x01, 0x33, 0xff, 0xff, + 0x04, 0xff, 0x5f, 0xff, 0xff, 0x04, 0xff, 0x2f, 0xff, 0xff, 0x04, 0xff, 0x82, 0x00, 0xbf, 0xff, + 0xff, 0x04, 0xff, 0x82, 0x01, 0x7f, 0xff, 0xff, 0x01, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xff, + 0xff, 0x04, 0xff, 0x05, 0xff, 0xff, 0x04, 0xff, 0x0b, 0xff, 0xff, 0x04, 0xff, 0x17, 0xff, 0xff, + 0x01, 0x80, 0x80, 0x80, 0x80, 0x80, 0xff, 0xff, 0x04, 0xff, 0xff, 0x01, 0x80, 0xff, 0x01, 0x80, + 0x80, +]; + +const DELEGATED_PUZZLE_HASH: [u8; 32] = [ + 0x26, 0x24, 0x38, 0x09, 0xc2, 0x14, 0xb0, 0x80, 0x00, 0x4c, 0x48, 0x36, 0x05, 0x75, 0x7a, 0xa7, + 0xb3, 0xfc, 0xd5, 0x24, 0x34, 0xad, 0xd2, 0x4f, 0xe8, 0x20, 0x69, 0x7b, 0xfd, 0x8a, 0x63, 0x81, +]; + fn risc0_hasher(data: &[u8]) -> [u8; 32] { let digest = Impl::hash_bytes(data); digest @@ -89,10 +115,137 @@ fn main() { let private_inputs: Input = env::read(); - // Compile chialisp to bytecode using the new VeilEvaluator-compatible compiler + // ============================================================================ + // MINT MODE: Create new CAT supply with TAIL verification + // ============================================================================ + if let Some(ref mint_data) = private_inputs.mint_data { + // 1. Compile TAIL program to get tail_hash + let (tail_bytecode, tail_hash) = + compile_chialisp_to_bytecode(risc0_hasher, &mint_data.tail_source) + .expect("TAIL compilation failed"); + + // 2. If genesis coin present, verify it and compute nullifier + let mut mint_nullifiers = vec![]; + let mut tail_params = mint_data.tail_params.clone(); + + if let Some(ref genesis) = mint_data.genesis_coin { + // verify genesis serial commitment + let genesis_serial_commitment = compute_serial_commitment( + risc0_hasher, + &genesis.serial_number, + &genesis.serial_randomness, + ); + assert_eq!( + genesis_serial_commitment, genesis.serial_commitment, + "genesis: serial commitment verification failed" + ); + + // verify genesis coin commitment + let genesis_coin_commitment = compute_coin_commitment( + risc0_hasher, + genesis.tail_hash, + genesis.amount, + &genesis.puzzle_hash, + &genesis_serial_commitment, + ); + assert_eq!( + genesis_coin_commitment, genesis.coin_commitment, + "genesis: coin commitment verification failed" + ); + + // verify genesis exists in merkle tree + verify_merkle_proof( + risc0_hasher, + genesis_coin_commitment, + &genesis.merkle_path, + genesis.leaf_index, + genesis.merkle_root, + ) + .expect("genesis: merkle root mismatch - coin not in tree"); + + // compute genesis nullifier (prevents re-minting) + let genesis_nullifier = compute_nullifier( + risc0_hasher, + &genesis.serial_number, + &genesis.puzzle_hash, + genesis.amount, + ); + + // prepend genesis_nullifier to TAIL params so TAIL can verify it + tail_params.insert( + 0, + clvm_zk_core::ProgramParameter::Bytes(genesis_nullifier.to_vec()), + ); + mint_nullifiers.push(genesis_nullifier); + } + + // 3. Create evaluator and serialize TAIL params + let evaluator = create_veil_evaluator(risc0_hasher, risc0_verify_bls, risc0_verify_ecdsa); + let tail_args = serialize_params_to_clvm(&tail_params); + + // 4. Execute TAIL program + let max_cost = 1_000_000_000; + let (tail_output, _conditions) = + run_clvm_with_conditions(&evaluator, &tail_bytecode, &tail_args, max_cost) + .expect("TAIL execution failed"); + + // 5. Verify TAIL returns truthy (non-nil, non-zero) + let is_truthy = !tail_output.is_empty() && tail_output != vec![0x80]; // 0x80 = nil in CLVM + assert!(is_truthy, "TAIL program did not authorize mint"); + + // 6. Compute serial commitment for the new coin + let serial_commitment = compute_serial_commitment( + risc0_hasher, + &mint_data.output_serial, + &mint_data.output_rand, + ); + + // 7. Compute coin commitment with the tail_hash + let coin_commitment = compute_coin_commitment( + risc0_hasher, + tail_hash, + mint_data.output_amount, + &mint_data.output_puzzle_hash, + &serial_commitment, + ); + + // 8. Output mint proof + let end_cycles = env::cycle_count(); + let total_cycles = end_cycles.saturating_sub(start_cycles); + + env::commit(&ProofOutput { + program_hash: tail_hash, + nullifiers: mint_nullifiers, // genesis nullifier if present (prevents re-mint) + clvm_res: ClvmResult { + output: coin_commitment.to_vec(), + cost: total_cycles, + }, + proof_type: 3, // Mint type + public_values: vec![ + tail_hash.to_vec(), // public: which CAT was minted + coin_commitment.to_vec(), // public: the new coin commitment + ], + }); + + return; // mint complete, don't run normal spend logic + } + + // // PROFILING: measure compilation cycles + // let compile_start = env::cycle_count(); + + // optimize: check if this is a known precompiled puzzle + // avoids expensive guest-side compilation for standard puzzles let (instance_bytecode, program_hash) = - compile_chialisp_to_bytecode(risc0_hasher, &private_inputs.chialisp_source) - .expect("Chialisp compilation failed"); + if private_inputs.chialisp_source == DELEGATED_PUZZLE_SOURCE { + // use precompiled bytecode - saves ~500-570s of compilation time + (DELEGATED_PUZZLE_BYTECODE.to_vec(), DELEGATED_PUZZLE_HASH) + } else { + // compile chialisp to bytecode for custom puzzles + compile_chialisp_to_bytecode(risc0_hasher, &private_inputs.chialisp_source) + .expect("Chialisp compilation failed") + }; + + // let compile_cycles = env::cycle_count().saturating_sub(compile_start); // Create VeilEvaluator with RISC-0 crypto functions let evaluator = create_veil_evaluator(risc0_hasher, risc0_verify_bls, risc0_verify_ecdsa); @@ -100,12 +253,70 @@ fn main() { // Serialize parameters to CLVM args format let args = serialize_params_to_clvm(&private_inputs.program_parameters); + // // PROFILING: measure execution cycles + // let exec_start = env::cycle_count(); + // Run CLVM bytecode and parse conditions from output let max_cost = 1_000_000_000; // 1 billion cost units let (output_bytes, mut conditions) = run_clvm_with_conditions(&evaluator, &instance_bytecode, &args, max_cost) .expect("CLVM execution failed"); + // let exec_cycles = env::cycle_count().saturating_sub(exec_start); + + // ============================================================================ + // BALANCE ENFORCEMENT (critical security check) + // ============================================================================ + // verify sum(inputs) == sum(outputs) and tail_hash consistency + // MUST run BEFORE CREATE_COIN transformation (which replaces args) + let (total_input, total_output) = + clvm_zk_core::enforce_ring_balance(&private_inputs, &conditions) + .expect("balance enforcement failed"); + + // ============================================================================ + // TAIL-ON-DELTA: CAT2-style TAIL authorization for supply changes + // ============================================================================ + // if delta != 0 and tail_source is provided, TAIL must authorize the change + // delta > 0 is already blocked by enforce_ring_balance (inflation) + // delta < 0 (melt/burn) requires TAIL authorization when tail_source present + if total_input != total_output { + let tail_hash = private_inputs.tail_hash.unwrap_or([0u8; 32]); + if tail_hash != [0u8; 32] { + // CAT: TAIL authorization required for supply change + if let Some(ref tail_source) = private_inputs.tail_source { + let delta = total_input.saturating_sub(total_output); // always >= 0 here + + let (tail_bytecode, compiled_tail_hash) = + compile_chialisp_to_bytecode(risc0_hasher, tail_source) + .expect("spend-path TAIL compilation failed"); + + // verify TAIL source matches the coin's tail_hash (prevents substitution attack) + assert_eq!( + compiled_tail_hash, tail_hash, + "TAIL source does not match coin's tail_hash" + ); + + // TAIL receives: (delta total_input total_output ...extra_params) + let tail_params = vec![ + clvm_zk_core::ProgramParameter::Int(delta), + clvm_zk_core::ProgramParameter::Int(total_input), + clvm_zk_core::ProgramParameter::Int(total_output), + ]; + let tail_args = serialize_params_to_clvm(&tail_params); + + let (tail_output, _) = + run_clvm_with_conditions(&evaluator, &tail_bytecode, &tail_args, max_cost) + .expect("spend-path TAIL execution failed"); + + let is_truthy = !tail_output.is_empty() && tail_output != vec![0x80]; + assert!(is_truthy, "TAIL did not authorize melt (delta != 0)"); + } else { + panic!("CAT supply change requires tail_source"); + } + } + // XCH (tail_hash == [0;32]): no TAIL needed, burn is implicitly allowed + } + // Transform CREATE_COIN conditions for output privacy let mut has_transformations = false; for condition in conditions.iter_mut() { @@ -118,42 +329,33 @@ fn main() { } 4 => { // Private mode: CREATE_COIN(puzzle_hash, amount, serial_num, serial_rand) - let puzzle_hash = &condition.args[0]; - let amount_bytes = &condition.args[1]; - let serial_number = &condition.args[2]; - let serial_randomness = &condition.args[3]; - - // Validate sizes - assert_eq!(puzzle_hash.len(), 32, "puzzle_hash must be 32 bytes"); - assert_eq!(amount_bytes.len(), 8, "amount must be 8 bytes"); - assert_eq!(serial_number.len(), 32, "serial_number must be 32 bytes"); - assert_eq!( - serial_randomness.len(), - 32, - "serial_randomness must be 32 bytes" + let puzzle_hash: &[u8; 32] = condition.args[0] + .as_slice() + .try_into() + .expect("puzzle_hash must be 32 bytes"); + let amount = parse_variable_length_amount(&condition.args[1]) + .expect("invalid amount encoding"); + let serial_number: &[u8; 32] = condition.args[2] + .as_slice() + .try_into() + .expect("serial_number must be 32 bytes"); + let serial_randomness: &[u8; 32] = condition.args[3] + .as_slice() + .try_into() + .expect("serial_randomness must be 32 bytes"); + + let serial_commitment = + compute_serial_commitment(risc0_hasher, serial_number, serial_randomness); + + let tail_hash = private_inputs.tail_hash.unwrap_or([0u8; 32]); + let coin_commitment = compute_coin_commitment( + risc0_hasher, + tail_hash, + amount, + puzzle_hash, + &serial_commitment, ); - // Parse amount - let amount = u64::from_be_bytes(amount_bytes.as_slice().try_into().unwrap()); - - // Compute serial_commitment - let serial_domain = b"clvm_zk_serial_v1.0"; - let mut serial_data = [0u8; 83]; - serial_data[..19].copy_from_slice(serial_domain); - serial_data[19..51].copy_from_slice(serial_number); - serial_data[51..83].copy_from_slice(serial_randomness); - let serial_commitment = risc0_hasher(&serial_data); - - // Compute coin_commitment - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 89]; - coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(puzzle_hash); - coin_data[57..89].copy_from_slice(&serial_commitment); - let coin_commitment = risc0_hasher(&coin_data); - - // Replace args: [puzzle, amount, serial, rand] → [commitment] condition.args = vec![coin_commitment.to_vec()]; has_transformations = true; } @@ -172,77 +374,116 @@ fn main() { output_bytes }; - let nullifier = match private_inputs.serial_commitment_data { + let nullifier = match &private_inputs.serial_commitment_data { Some(commitment_data) => { - let expected_program_hash = commitment_data.program_hash; assert_eq!( - program_hash, expected_program_hash, + program_hash, commitment_data.program_hash, "program_hash mismatch: cannot spend coin with different program" ); - let serial_number = commitment_data.serial_number; - let serial_randomness = commitment_data.serial_randomness; - let domain = b"clvm_zk_serial_v1.0"; - let mut serial_commit_data = [0u8; 83]; - serial_commit_data[..19].copy_from_slice(domain); - serial_commit_data[19..51].copy_from_slice(&serial_number); - serial_commit_data[51..83].copy_from_slice(&serial_randomness); - let computed_serial_commitment = risc0_hasher(&serial_commit_data); - - let serial_commitment_expected = commitment_data.serial_commitment; + let computed_serial_commitment = compute_serial_commitment( + risc0_hasher, + &commitment_data.serial_number, + &commitment_data.serial_randomness, + ); assert_eq!( - computed_serial_commitment, serial_commitment_expected, + computed_serial_commitment, commitment_data.serial_commitment, "serial commitment verification failed" ); - let amount = commitment_data.amount; - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 17 + 8 + 32 + 32]; - coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(&program_hash); - coin_data[57..89].copy_from_slice(&computed_serial_commitment); - let computed_coin_commitment = risc0_hasher(&coin_data); - - let coin_commitment_provided = commitment_data.coin_commitment; + let tail_hash = private_inputs.tail_hash.unwrap_or([0u8; 32]); + let computed_coin_commitment = compute_coin_commitment( + risc0_hasher, + tail_hash, + commitment_data.amount, + &program_hash, + &computed_serial_commitment, + ); assert_eq!( - computed_coin_commitment, coin_commitment_provided, + computed_coin_commitment, commitment_data.coin_commitment, "coin commitment verification failed" ); - let merkle_path = commitment_data.merkle_path; - let expected_root = commitment_data.merkle_root; - let leaf_index = commitment_data.leaf_index; - - let mut current_hash = computed_coin_commitment; - let mut current_index = leaf_index; - for sibling in merkle_path.iter() { - let mut combined = [0u8; 64]; - if current_index % 2 == 0 { - combined[..32].copy_from_slice(¤t_hash); - combined[32..].copy_from_slice(sibling); - } else { - combined[..32].copy_from_slice(sibling); - combined[32..].copy_from_slice(¤t_hash); - } - current_hash = risc0_hasher(&combined); - current_index /= 2; - } + verify_merkle_proof( + risc0_hasher, + computed_coin_commitment, + &commitment_data.merkle_path, + commitment_data.leaf_index, + commitment_data.merkle_root, + ) + .expect("merkle root mismatch: coin not in current tree state"); + + Some(compute_nullifier( + risc0_hasher, + &commitment_data.serial_number, + &program_hash, + commitment_data.amount, + )) + } + None => None, + }; + + // collect nullifiers: primary coin + additional coins for ring spends + let mut nullifiers = nullifier.map(|n| vec![n]).unwrap_or_default(); + + // process additional coins for ring spends + if let Some(additional_coins) = &private_inputs.additional_coins { + for coin in additional_coins { + let coin_data = &coin.serial_commitment_data; + + // optimize: check if this coin uses a precompiled puzzle + let coin_program_hash = if coin.chialisp_source == DELEGATED_PUZZLE_SOURCE { + DELEGATED_PUZZLE_HASH + } else { + let (_, hash) = compile_chialisp_to_bytecode(risc0_hasher, &coin.chialisp_source) + .expect("additional coin chialisp compilation failed"); + hash + }; - let computed_root = current_hash; assert_eq!( - computed_root, expected_root, - "merkle root mismatch: coin not in current tree state" + coin_program_hash, coin_data.program_hash, + "additional coin: program_hash mismatch" ); - let mut nullifier_data = Vec::with_capacity(72); - nullifier_data.extend_from_slice(&serial_number); - nullifier_data.extend_from_slice(&program_hash); - nullifier_data.extend_from_slice(&amount.to_be_bytes()); - Some(risc0_hasher(&nullifier_data)) + let computed_serial_commitment = compute_serial_commitment( + risc0_hasher, + &coin_data.serial_number, + &coin_data.serial_randomness, + ); + assert_eq!( + computed_serial_commitment, coin_data.serial_commitment, + "additional coin: serial commitment verification failed" + ); + + let computed_coin_commitment = compute_coin_commitment( + risc0_hasher, + coin.tail_hash, + coin_data.amount, + &coin_program_hash, + &computed_serial_commitment, + ); + assert_eq!( + computed_coin_commitment, coin_data.coin_commitment, + "additional coin: coin commitment verification failed" + ); + + verify_merkle_proof( + risc0_hasher, + computed_coin_commitment, + &coin_data.merkle_path, + coin_data.leaf_index, + coin_data.merkle_root, + ) + .expect("additional coin: merkle root mismatch"); + + nullifiers.push(compute_nullifier( + risc0_hasher, + &coin_data.serial_number, + &coin_program_hash, + coin_data.amount, + )); } - None => None, - }; + } let end_cycles = env::cycle_count(); let total_cycles = end_cycles.saturating_sub(start_cycles); @@ -251,9 +492,16 @@ fn main() { cost: total_cycles, }; + // // PROFILING: encode cycle counts in public_values for analysis + // // format: single vec containing [compile_cycles (8 bytes), exec_cycles (8 bytes), total_cycles (8 bytes)] + // let mut profiling_data = Vec::new(); + // profiling_data.extend_from_slice(&compile_cycles.to_le_bytes()); + // profiling_data.extend_from_slice(&exec_cycles.to_le_bytes()); + // profiling_data.extend_from_slice(&total_cycles.to_le_bytes()); + env::commit(&ProofOutput { program_hash, - nullifier, + nullifiers, clvm_res: clvm_output, proof_type: 0, // Transaction type (default) public_values: vec![], diff --git a/backends/risc0/guest_recursive/src/main.rs b/backends/risc0/guest_recursive/src/main.rs index 079a587..0c5b3b2 100644 --- a/backends/risc0/guest_recursive/src/main.rs +++ b/backends/risc0/guest_recursive/src/main.rs @@ -23,9 +23,10 @@ fn main() { let input: RecursiveInput = env::read(); // verify we have at least 1 proof - if input.child_journal_bytes.is_empty() { - panic!("need at least 1 proof to aggregate"); - } + assert!( + !input.child_journal_bytes.is_empty(), + "need at least 1 proof to aggregate" + ); // Use IMAGE_ID from input (passed by host, stays in sync with compiled guest) let standard_guest_image_id = input.standard_guest_image_id; @@ -35,34 +36,33 @@ fn main() { let mut proof_commitments = Vec::new(); // verify and aggregate all child proofs - for (i, journal_bytes) in input.child_journal_bytes.iter().enumerate() { + for (_i, journal_bytes) in input.child_journal_bytes.iter().enumerate() { // VERIFY the child proof using risc0 composition pattern risc0_zkvm::guest::env::verify(standard_guest_image_id, journal_bytes) - .expect(&alloc::format!("child proof {} verification failed", i)); + .expect("child proof verification failed"); // deserialize journal to extract ProofOutput (uses same bincode format as env::commit) let proof_output: clvm_zk_core::ProofOutput = risc0_zkvm::serde::from_slice(journal_bytes) - .expect(&alloc::format!("failed to deserialize journal {}", i)); - - // collect nullifier if present - if let Some(n) = proof_output.nullifier { - if all_nullifiers.contains(&n) { - panic!("duplicate nullifier detected: {:?}", n); - } - all_nullifiers.push(n); + .expect("failed to deserialize child journal"); + + // collect nullifiers (check for duplicates) + for nullifier in &proof_output.nullifiers { + assert!( + !all_nullifiers.contains(nullifier), + "duplicate nullifier detected" + ); + all_nullifiers.push(*nullifier); } // collect conditions all_conditions.push(proof_output.clvm_res.output.clone()); // build commitment for base proof - // commitment = hash(program_hash || nullifier || output) + // commitment = hash(program_hash || [nullifiers...] || output) let mut commitment_data = Vec::new(); commitment_data.extend_from_slice(&proof_output.program_hash); - if let Some(n) = proof_output.nullifier { - commitment_data.extend_from_slice(&n); - } else { - commitment_data.extend_from_slice(&[0u8; 32]); + for nullifier in &proof_output.nullifiers { + commitment_data.extend_from_slice(nullifier); } commitment_data.extend_from_slice(&proof_output.clvm_res.output); diff --git a/backends/risc0/guest_settlement/Cargo.toml b/backends/risc0/guest_settlement/Cargo.toml index 1423ae0..42ae639 100644 --- a/backends/risc0/guest_settlement/Cargo.toml +++ b/backends/risc0/guest_settlement/Cargo.toml @@ -8,7 +8,7 @@ risc0-zkvm = "3.0.3" clvm-zk-core = { path = "../../../clvm_zk_core", features = ["risc0"] } serde = { version = "1.0", features = ["derive"] } borsh = { version = "1.5", features = ["derive"] } -x25519-dalek = { version = "2.0", features = ["static_secrets"] } +# removed x25519-dalek - using hash-based stealth instead of ECDH [[bin]] name = "settlement" diff --git a/backends/risc0/guest_settlement/src/main.rs b/backends/risc0/guest_settlement/src/main.rs index c69e2af..259e927 100644 --- a/backends/risc0/guest_settlement/src/main.rs +++ b/backends/risc0/guest_settlement/src/main.rs @@ -5,12 +5,21 @@ risc0_zkvm::guest::entry!(main); extern crate alloc; use alloc::vec::Vec; -use clvm_zk_core::types::ClvmValue; +use clvm_zk_core::{ + compute_coin_commitment, compute_nullifier, compute_serial_commitment, verify_merkle_proof, +}; use risc0_zkvm::guest::env; use risc0_zkvm::sha::{Impl, Sha256}; -use x25519_dalek::{PublicKey, StaticSecret}; + +fn risc0_hasher(data: &[u8]) -> [u8; 32] { + Impl::hash_bytes(data) + .as_bytes() + .try_into() + .expect("sha256 digest must be 32 bytes") +} /// settlement output committed by taker's proof +/// maker_pubkey is PUBLIC so validator can check it matches the offer #[derive(serde::Serialize)] struct SettlementOutput { maker_nullifier: [u8; 32], @@ -19,6 +28,8 @@ struct SettlementOutput { payment_commitment: [u8; 32], // taker → maker (asset B) taker_goods_commitment: [u8; 32], // maker → taker (asset A) taker_change_commitment: [u8; 32], // taker's change (asset B) + // PUBLIC: validator checks this matches offer's maker_pubkey + maker_pubkey: [u8; 32], } /// taker's private coin data @@ -36,16 +47,20 @@ struct TakerCoinData { /// settlement parameters #[derive(serde::Deserialize)] struct SettlementInput { - /// IMAGE_ID of the standard guest (passed from host to avoid hardcoding) - standard_guest_image_id: [u8; 32], - - // maker's journal bytes for env::verify() (risc0 composition pattern) - maker_journal_bytes: Vec, + // maker's proof outputs (PUBLIC, extracted by host from verified journal) + maker_nullifier: [u8; 32], + maker_change_commitment: [u8; 32], + offered: u64, + requested: u64, + maker_pubkey: [u8; 32], // PRIVATE taker_coin: TakerCoinData, merkle_root: [u8; 32], - taker_ephemeral_privkey: [u8; 32], + // hash-based stealth: nonce instead of ephemeral privkey + // payment_puzzle = hash("stealth_v1" || maker_pubkey || nonce) + // host encrypts nonce to maker_pubkey, includes in tx for maker to decrypt + payment_nonce: [u8; 32], taker_goods_puzzle: [u8; 32], // for receiving offered goods (asset A) taker_change_puzzle: [u8; 32], // for receiving change (asset B) payment_serial: [u8; 32], // for payment to maker @@ -54,86 +69,112 @@ struct SettlementInput { goods_rand: [u8; 32], change_serial: [u8; 32], // for taker's change change_rand: [u8; 32], + // v2.0 coin commitment format: tail_hash identifies asset type + taker_tail_hash: [u8; 32], // taker's coin asset (XCH = zeros) + goods_tail_hash: [u8; 32], // offered goods asset (maker's asset) } fn main() { - let input: SettlementInput = env::read(); - - // risc0 proof composition pattern: call env::verify() with journal - // Use IMAGE_ID from input (passed by host, stays in sync with compiled guest) - risc0_zkvm::guest::env::verify(input.standard_guest_image_id, &input.maker_journal_bytes) - .expect("maker's proof verification failed"); - - // deserialize maker's journal to extract data (uses risc0's bincode format, not borsh) - let maker_output: clvm_zk_core::ProofOutput = - risc0_zkvm::serde::from_slice(&input.maker_journal_bytes) - .expect("failed to deserialize maker's journal"); - - let maker_nullifier = maker_output.nullifier.expect("maker must have nullifier"); - - // parse maker's clvm output: [CREATE_COIN, settlement_terms] - // expected format: ((51 change_puzzle change_amount change_serial change_rand) (offered requested maker_pubkey)) - let (maker_change_commitment, offered, requested, maker_pubkey) = - parse_maker_output(&maker_output.clvm_res.output); + // let start_cycles = env::cycle_count(); - // 5. verify taker's coin ownership - verify_taker_coin(&input.taker_coin, input.merkle_root); - - // 6. assert taker has enough funds + let input: SettlementInput = env::read(); + // let read_cycles = env::cycle_count(); + + // V2 optimization: maker's proof outputs extracted by HOST (not in guest) + // Host deserializes maker's journal and extracts these values + // Guest receives them as simple public inputs, no deserialization overhead + // Validator verifies maker's proof separately to ensure these values are correct + let maker_nullifier = input.maker_nullifier; + let maker_change_commitment = input.maker_change_commitment; + let offered = input.offered; + let requested = input.requested; + let maker_pubkey = input.maker_pubkey; + + // verify taker's coin ownership (v2.0 format with tail_hash) + verify_taker_coin(&input.taker_coin, input.merkle_root, &input.taker_tail_hash); + // let verify_cycles = env::cycle_count(); + + // assert taker has enough funds assert!( input.taker_coin.amount >= requested, "taker has insufficient funds" ); - // 7. compute ECDH for payment address - let taker_secret = StaticSecret::from(input.taker_ephemeral_privkey); - let maker_public = PublicKey::from(maker_pubkey); - let shared_secret = taker_secret.diffie_hellman(&maker_public); - - // 8. derive payment puzzle from ECDH - let mut payment_puzzle_data = Vec::new(); - payment_puzzle_data.extend_from_slice(b"ecdh_payment_v1"); - payment_puzzle_data.extend_from_slice(shared_secret.as_bytes()); + // HASH-BASED STEALTH ADDRESS (replaces ECDH - ~10K cycles vs ~2M cycles) + // payment_puzzle = sha256("stealth_v1" || maker_pubkey || nonce) + // - maker_pubkey is from the offer (public, validated by blockchain) + // - nonce is random, encrypted to maker_pubkey by host (outside zkVM) + // - maker decrypts nonce from tx metadata to derive same puzzle + let mut payment_puzzle_data = [0u8; 74]; // 10 + 32 + 32 + payment_puzzle_data[..10].copy_from_slice(b"stealth_v1"); + payment_puzzle_data[10..42].copy_from_slice(&maker_pubkey); + payment_puzzle_data[42..74].copy_from_slice(&input.payment_nonce); let payment_puzzle = Impl::hash_bytes(&payment_puzzle_data); let payment_puzzle_bytes: [u8; 32] = payment_puzzle .as_bytes() .try_into() .expect("sha256 digest must be 32 bytes"); + // let stealth_cycles = env::cycle_count(); - // 9. create payment commitment (taker → maker, asset B) + // create payment commitment (taker → maker, asset B = taker's asset) let payment_commitment = create_coin_commitment( requested, &payment_puzzle_bytes, &input.payment_serial, &input.payment_rand, + &input.taker_tail_hash, ); - // 10. create taker goods commitment (maker → taker, asset A) + // create taker goods commitment (maker → taker, asset A = goods asset) let taker_goods_commitment = create_coin_commitment( offered, &input.taker_goods_puzzle, &input.goods_serial, &input.goods_rand, + &input.goods_tail_hash, ); - // 11. create taker's change commitment (taker's leftover, asset B) + // create taker's change commitment (taker's leftover, asset B = taker's asset) let taker_change_amount = input.taker_coin.amount - requested; let taker_change_commitment = create_coin_commitment( taker_change_amount, &input.taker_change_puzzle, &input.change_serial, &input.change_rand, + &input.taker_tail_hash, ); + // let commitments_cycles = env::cycle_count(); - // 12. compute taker's nullifier - // use taker's coin puzzle_hash (not settlement IMAGE_ID) + // compute taker's nullifier let taker_nullifier = compute_nullifier( + risc0_hasher, &input.taker_coin.serial_number, &input.taker_coin.puzzle_hash, input.taker_coin.amount, ); - // 13. commit settlement output + // let end_cycles = env::cycle_count(); + + // // PROFILING: log cycle breakdown + // let total_cycles = end_cycles.saturating_sub(start_cycles); + // let read_delta = read_cycles.saturating_sub(start_cycles); + // let verify_delta = verify_cycles.saturating_sub(read_cycles); + // let stealth_delta = stealth_cycles.saturating_sub(verify_cycles); + // let commitments_delta = commitments_cycles.saturating_sub(stealth_cycles); + // let nullifier_delta = end_cycles.saturating_sub(commitments_cycles); + // + // risc0_zkvm::guest::env::log(&alloc::format!( + // "SETTLEMENT_PROFILING: total={}K read={}K verify={}K stealth={}K commits={}K nullifier={}K", + // total_cycles / 1_000, + // read_delta / 1_000, + // verify_delta / 1_000, + // stealth_delta / 1_000, + // commitments_delta / 1_000, + // nullifier_delta / 1_000, + // )); + + // commit settlement output + // maker_pubkey is PUBLIC so validator can assert it matches offer let output = SettlementOutput { maker_nullifier, taker_nullifier, @@ -141,247 +182,79 @@ fn main() { payment_commitment, taker_goods_commitment, taker_change_commitment, + maker_pubkey, // echoed for validation }; env::commit(&output); } -/// parse maker's clvm output to extract change CREATE_COIN and settlement terms -fn parse_maker_output(clvm_output: &[u8]) -> ([u8; 32], u64, u64, [u8; 32]) { - use clvm_zk_core::clvm_parser::ClvmParser; - - // parse CLVM bytecode - let mut parser = ClvmParser::new(clvm_output); - let value = parser.parse().expect("failed to parse CLVM output"); - - // expected: ((51 change_puzzle change_amount change_serial change_rand) (offered requested maker_pubkey)) - match value { - ClvmValue::Cons(create_coin_box, settlement_terms_box) => { - // extract maker_change_commitment from CREATE_COIN - let maker_change_commitment = extract_create_coin_commitment(&create_coin_box); - - // extract settlement terms - let (offered, requested, maker_pubkey) = - extract_settlement_terms(&settlement_terms_box); +/// verify taker's coin ownership via merkle membership (v2.0 format with tail_hash) +fn verify_taker_coin(coin: &TakerCoinData, merkle_root: [u8; 32], tail_hash: &[u8; 32]) { + let start = env::cycle_count(); - (maker_change_commitment, offered, requested, maker_pubkey) - } - _ => panic!("invalid maker output structure - expected cons pair"), - } -} - -/// extract CREATE_COIN commitment from (51 change_puzzle change_amount change_serial change_rand) -fn extract_create_coin_commitment(create_coin: &ClvmValue) -> [u8; 32] { - // parse (51 change_puzzle change_amount change_serial change_rand) - match create_coin { - ClvmValue::Cons(opcode_box, args_box) => { - // verify opcode is 51 (CREATE_COIN) - match opcode_box.as_ref() { - ClvmValue::Atom(opcode) if opcode.as_slice() == &[51u8] => { - // extract (change_puzzle change_amount change_serial change_rand) - match args_box.as_ref() { - ClvmValue::Cons(puzzle_box, rest1) => { - let change_puzzle = extract_bytes_32(puzzle_box.as_ref()); - - match rest1.as_ref() { - ClvmValue::Cons(amount_box, rest2) => { - let change_amount = extract_u64(amount_box.as_ref()); - - match rest2.as_ref() { - ClvmValue::Cons(serial_box, rest3) => { - let change_serial = - extract_bytes_32(serial_box.as_ref()); - - match rest3.as_ref() { - ClvmValue::Cons(rand_box, _) => { - let change_rand = - extract_bytes_32(rand_box.as_ref()); - - // compute commitment - create_coin_commitment( - change_amount, - &change_puzzle, - &change_serial, - &change_rand, - ) - } - _ => panic!("invalid CREATE_COIN: missing rand"), - } - } - _ => panic!("invalid CREATE_COIN: missing serial"), - } - } - _ => panic!("invalid CREATE_COIN: missing amount"), - } - } - _ => panic!("invalid CREATE_COIN: missing puzzle"), - } - } - _ => panic!("invalid CREATE_COIN opcode"), - } - } - _ => panic!("invalid CREATE_COIN structure"), - } -} - -/// extract settlement terms from (offered requested maker_pubkey) -fn extract_settlement_terms(terms: &ClvmValue) -> (u64, u64, [u8; 32]) { - match terms { - ClvmValue::Cons(offered_box, rest1) => { - let offered = extract_u64(offered_box.as_ref()); - - match rest1.as_ref() { - ClvmValue::Cons(requested_box, rest2) => { - let requested = extract_u64(requested_box.as_ref()); - - match rest2.as_ref() { - ClvmValue::Cons(pubkey_box, _) => { - let maker_pubkey = extract_bytes_32(pubkey_box.as_ref()); - (offered, requested, maker_pubkey) - } - _ => panic!("invalid settlement terms: missing maker_pubkey"), - } - } - _ => panic!("invalid settlement terms: missing requested"), - } - } - _ => panic!("invalid settlement terms structure"), - } -} - -/// extract [u8; 32] from ClvmValue::Atom -fn extract_bytes_32(value: &ClvmValue) -> [u8; 32] { - match value { - ClvmValue::Atom(bytes) => { - if bytes.len() != 32 { - panic!("expected 32 bytes, got {}", bytes.len()); - } - let mut arr = [0u8; 32]; - arr.copy_from_slice(bytes); - arr - } - _ => panic!("expected atom for bytes"), - } -} - -/// extract u64 from ClvmValue::Atom (big-endian encoding) -fn extract_u64(value: &ClvmValue) -> u64 { - match value { - ClvmValue::Atom(bytes) => { - if bytes.is_empty() { - return 0; - } - if bytes.len() > 8 { - panic!("u64 value too large: {} bytes", bytes.len()); - } - - // CLVM uses big-endian encoding - let mut result: u64 = 0; - for &byte in bytes { - result = (result << 8) | (byte as u64); - } - result - } - _ => panic!("expected atom for u64"), - } -} - -/// verify taker's coin ownership via merkle membership -fn verify_taker_coin(coin: &TakerCoinData, merkle_root: [u8; 32]) { - // 1. verify serial commitment - let mut serial_commit_data = Vec::new(); - serial_commit_data.extend_from_slice(b"clvm_zk_serial_v1.0"); - serial_commit_data.extend_from_slice(&coin.serial_number); - serial_commit_data.extend_from_slice(&coin.serial_randomness); - let computed_serial_commitment = Impl::hash_bytes(&serial_commit_data); - let computed_serial_bytes: [u8; 32] = computed_serial_commitment - .as_bytes() - .try_into() - .expect("sha256 digest must be 32 bytes"); + // 1. verify serial commitment using clvm_zk_core (optimized fixed-size arrays) + let computed_serial = + compute_serial_commitment(risc0_hasher, &coin.serial_number, &coin.serial_randomness); + let serial_cycles = env::cycle_count(); assert_eq!( - computed_serial_bytes, coin.serial_commitment, + computed_serial, coin.serial_commitment, "invalid serial commitment" ); - // 2. compute coin_commitment using the VERIFIED serial_commitment - // (don't recompute it - use coin.serial_commitment which we just verified) - let mut coin_commit_data = Vec::new(); - coin_commit_data.extend_from_slice(b"clvm_zk_coin_v1.0"); - coin_commit_data.extend_from_slice(&coin.amount.to_be_bytes()); // BIG-ENDIAN (matches core lib) - coin_commit_data.extend_from_slice(&coin.puzzle_hash); - coin_commit_data.extend_from_slice(&coin.serial_commitment); // use verified commitment! - let coin_commitment_hash = Impl::hash_bytes(&coin_commit_data); - let coin_commitment: [u8; 32] = coin_commitment_hash - .as_bytes() - .try_into() - .expect("sha256 digest must be 32 bytes"); - - // 3. verify merkle membership - let mut current_hash = coin_commitment; - let mut index = coin.leaf_index; - - for sibling in &coin.merkle_path { - let mut concat = Vec::new(); - if index % 2 == 0 { - concat.extend_from_slice(¤t_hash); - concat.extend_from_slice(sibling); - } else { - concat.extend_from_slice(sibling); - concat.extend_from_slice(¤t_hash); - } - let hash_result = Impl::hash_bytes(&concat); - current_hash = hash_result - .as_bytes() - .try_into() - .expect("sha256 digest must be 32 bytes"); - index /= 2; - } - - assert_eq!( - current_hash, merkle_root, - "merkle proof verification failed" + // 2. compute coin_commitment using clvm_zk_core (optimized fixed-size arrays) + let coin_commitment = compute_coin_commitment( + risc0_hasher, + *tail_hash, + coin.amount, + &coin.puzzle_hash, + &computed_serial, ); + let coin_commit_cycles = env::cycle_count(); + + // 3. verify merkle membership using clvm_zk_core (optimized fixed-size arrays) + verify_merkle_proof( + risc0_hasher, + coin_commitment, + &coin.merkle_path, + coin.leaf_index, + merkle_root, + ) + .expect("merkle proof verification failed"); + + let merkle_cycles = env::cycle_count(); + + risc0_zkvm::guest::env::log(&alloc::format!( + "verify_taker_coin: serial={}K coin_commit={}K merkle={}K total={}K", + (serial_cycles - start) / 1000, + (coin_commit_cycles - serial_cycles) / 1000, + (merkle_cycles - coin_commit_cycles) / 1000, + (merkle_cycles - start) / 1000, + )); } -/// create coin commitment: hash("clvm_zk_coin_v1.0" || amount || puzzle || serial_commitment) +/// create coin commitment v2.0: hash(domain || tail_hash || amount || puzzle || serial_commitment) fn create_coin_commitment( amount: u64, puzzle: &[u8; 32], serial: &[u8; 32], rand: &[u8; 32], + tail_hash: &[u8; 32], ) -> [u8; 32] { - // first create serial commitment - let mut serial_commit_data = Vec::new(); - serial_commit_data.extend_from_slice(b"clvm_zk_serial_v1.0"); - serial_commit_data.extend_from_slice(serial); - serial_commit_data.extend_from_slice(rand); - let serial_commitment = Impl::hash_bytes(&serial_commit_data); - - // then create coin commitment - let mut coin_commit_data = Vec::new(); - coin_commit_data.extend_from_slice(b"clvm_zk_coin_v1.0"); - coin_commit_data.extend_from_slice(&amount.to_be_bytes()); // BIG-ENDIAN (matches core lib) - coin_commit_data.extend_from_slice(puzzle); - coin_commit_data.extend_from_slice(serial_commitment.as_bytes()); - - let coin_commitment = Impl::hash_bytes(&coin_commit_data); - coin_commitment - .as_bytes() - .try_into() - .expect("sha256 digest must be 32 bytes") -} - -/// compute nullifier: hash(serial_number || program_hash || amount) -/// matches standard guest nullifier format -fn compute_nullifier(serial_number: &[u8; 32], program_hash: &[u8; 32], amount: u64) -> [u8; 32] { - let mut nullifier_data = Vec::new(); - nullifier_data.extend_from_slice(serial_number); - nullifier_data.extend_from_slice(program_hash); - nullifier_data.extend_from_slice(&amount.to_be_bytes()); - let nullifier = Impl::hash_bytes(&nullifier_data); - nullifier - .as_bytes() - .try_into() - .expect("sha256 digest must be 32 bytes") + let start = env::cycle_count(); + // use clvm_zk_core optimized implementations (fixed-size arrays, no Vec allocations) + let serial_commitment = compute_serial_commitment(risc0_hasher, serial, rand); + let serial_cycles = env::cycle_count(); + let result = + compute_coin_commitment(risc0_hasher, *tail_hash, amount, puzzle, &serial_commitment); + let coin_cycles = env::cycle_count(); + + risc0_zkvm::guest::env::log(&alloc::format!( + "create_coin_commitment: serial={}K coin={}K total={}K", + (serial_cycles - start) / 1000, + (coin_cycles - serial_cycles) / 1000, + (coin_cycles - start) / 1000, + )); + + result } diff --git a/backends/risc0/src/lib.rs b/backends/risc0/src/lib.rs index 11dcb74..0b503ef 100644 --- a/backends/risc0/src/lib.rs +++ b/backends/risc0/src/lib.rs @@ -4,7 +4,6 @@ pub mod recursive; pub use methods::*; pub use recursive::RecursiveAggregator; -use borsh; use clvm_zk_core::backend_utils::{ convert_proving_error, validate_nullifier_proof_output, validate_proof_output, }; @@ -25,6 +24,7 @@ impl Risc0Backend { Ok(Self {}) } + #[allow(clippy::const_is_empty)] fn is_risc0_available() -> bool { !CLVM_RISC0_GUEST_ELF.is_empty() } @@ -40,6 +40,10 @@ impl Risc0Backend { chialisp_source: chialisp_source.to_string(), program_parameters: program_parameters.to_vec(), serial_commitment_data: None, + tail_hash: None, // XCH by default + additional_coins: None, // single-coin spend + mint_data: None, + tail_source: None, }; let env = ExecutorEnv::builder() .write(&inputs) @@ -65,6 +69,25 @@ impl Risc0Backend { ClvmZkError::InvalidProofFormat(format!("failed to decode journal: {e}")) })?; + // // PROFILING: decode and print cycle counts if present + // if !result.public_values.is_empty() && result.public_values[0].len() == 24 { + // let data = &result.public_values[0]; + // let compile_cycles = u64::from_le_bytes(data[0..8].try_into().unwrap()); + // let exec_cycles = u64::from_le_bytes(data[8..16].try_into().unwrap()); + // let total_cycles = u64::from_le_bytes(data[16..24].try_into().unwrap()); + // + // // convert cycles to approximate seconds (risc0 ~2.4M cycles/sec on modern hardware) + // let cycles_per_sec = 2_400_000.0; + // eprintln!(" 📊 PROFILING: compile={:.1}M cycles ({:.1}s) | exec={:.1}M cycles ({:.1}s) | total={:.1}M cycles ({:.1}s)", + // compile_cycles as f64 / 1_000_000.0, + // compile_cycles as f64 / cycles_per_sec, + // exec_cycles as f64 / 1_000_000.0, + // exec_cycles as f64 / cycles_per_sec, + // total_cycles as f64 / 1_000_000.0, + // total_cycles as f64 / cycles_per_sec, + // ); + // } + validate_proof_output(&result, "RISC0")?; let proof_bytes = borsh::to_vec(&receipt_obj).map_err(|e| { @@ -109,6 +132,25 @@ impl Risc0Backend { ClvmZkError::InvalidProofFormat(format!("failed to decode journal: {e}")) })?; + // // PROFILING: decode and print cycle counts if present + // if !result.public_values.is_empty() && result.public_values[0].len() == 24 { + // let data = &result.public_values[0]; + // let compile_cycles = u64::from_le_bytes(data[0..8].try_into().unwrap()); + // let exec_cycles = u64::from_le_bytes(data[8..16].try_into().unwrap()); + // let total_cycles = u64::from_le_bytes(data[16..24].try_into().unwrap()); + // + // // convert cycles to approximate seconds (risc0 ~2.4M cycles/sec on modern hardware) + // let cycles_per_sec = 2_400_000.0; + // eprintln!(" 📊 PROFILING: compile={:.1}M cycles ({:.1}s) | exec={:.1}M cycles ({:.1}s) | total={:.1}M cycles ({:.1}s)", + // compile_cycles as f64 / 1_000_000.0, + // compile_cycles as f64 / cycles_per_sec, + // exec_cycles as f64 / 1_000_000.0, + // exec_cycles as f64 / cycles_per_sec, + // total_cycles as f64 / 1_000_000.0, + // total_cycles as f64 / cycles_per_sec, + // ); + // } + validate_nullifier_proof_output(&result, "RISC0")?; let proof_bytes: Vec = borsh::to_vec(&receipt_obj).map_err(|e| { diff --git a/backends/risc0/src/recursive.rs b/backends/risc0/src/recursive.rs index 9f769a2..109b336 100644 --- a/backends/risc0/src/recursive.rs +++ b/backends/risc0/src/recursive.rs @@ -106,8 +106,8 @@ fn image_id_to_bytes(id: [u32; 8]) -> [u8; 32] { mod tests { use super::*; use crate::{Risc0Backend, RECURSIVE_ID}; - use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets}; use clvm_zk_core::merkle::SparseMerkleTree; + use clvm_zk_core::{CoinCommitment, CoinSecrets, XCH_TAIL}; use clvm_zk_core::{Input, ProgramParameter, SerialCommitmentData}; use sha2::{Digest, Sha256}; @@ -134,8 +134,13 @@ mod tests { // compute commitments let amount = 100; let serial_commitment = secrets.serial_commitment(hash_data); - let coin_commitment = - CoinCommitment::compute(amount, &program_hash, &serial_commitment, hash_data); + let coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &program_hash, + &serial_commitment, + hash_data, + ); // create merkle tree let mut merkle_tree = SparseMerkleTree::new(20, hash_data); @@ -157,6 +162,10 @@ mod tests { program_hash, amount, }), + tail_hash: None, // XCH by default + additional_coins: None, + mint_data: None, + tail_source: None, }; backend diff --git a/backends/sp1/build.rs b/backends/sp1/build.rs index d5ac92d..2bc6fad 100644 --- a/backends/sp1/build.rs +++ b/backends/sp1/build.rs @@ -3,4 +3,5 @@ use sp1_build::build_program_with_args; fn main() { build_program_with_args("program", Default::default()); build_program_with_args("program_recursive", Default::default()); + build_program_with_args("program_settlement", Default::default()); } diff --git a/backends/sp1/program/src/main.rs b/backends/sp1/program/src/main.rs index 3788fcf..4ad7291 100644 --- a/backends/sp1/program/src/main.rs +++ b/backends/sp1/program/src/main.rs @@ -6,8 +6,10 @@ extern crate alloc; use alloc::vec; use clvm_zk_core::{ - compile_chialisp_to_bytecode, create_veil_evaluator, run_clvm_with_conditions, - serialize_params_to_clvm, ClvmResult, Input, ProofOutput, BLS_DST, + compile_chialisp_to_bytecode, compute_coin_commitment, compute_nullifier, + compute_serial_commitment, create_veil_evaluator, parse_variable_length_amount, + run_clvm_with_conditions, serialize_params_to_clvm, verify_merkle_proof, ClvmResult, Input, + ProofOutput, BLS_DST, }; use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve}; @@ -16,6 +18,30 @@ use sha2_v09::Sha256 as BLSSha256; sp1_zkvm::entrypoint!(main); +// precompiled standard puzzles for performance optimization +// bypasses guest-side compilation for known puzzles (580s -> ~10s improvement) + +const DELEGATED_PUZZLE_SOURCE: &str = r#"(mod (offered requested maker_pubkey change_amount change_puzzle change_serial change_rand) + (c + (c 51 (c change_puzzle (c change_amount (c change_serial (c change_rand ()))))) + (c offered (c requested (c maker_pubkey ()))) + ) +)"#; + +const DELEGATED_PUZZLE_BYTECODE: &[u8] = &[ + 0xff, 0x02, 0xff, 0xff, 0x01, 0xff, 0x04, 0xff, 0xff, 0x04, 0xff, 0xff, 0x01, 0x33, 0xff, 0xff, + 0x04, 0xff, 0x5f, 0xff, 0xff, 0x04, 0xff, 0x2f, 0xff, 0xff, 0x04, 0xff, 0x82, 0x00, 0xbf, 0xff, + 0xff, 0x04, 0xff, 0x82, 0x01, 0x7f, 0xff, 0xff, 0x01, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xff, + 0xff, 0x04, 0xff, 0x05, 0xff, 0xff, 0x04, 0xff, 0x0b, 0xff, 0xff, 0x04, 0xff, 0x17, 0xff, 0xff, + 0x01, 0x80, 0x80, 0x80, 0x80, 0x80, 0xff, 0xff, 0x04, 0xff, 0xff, 0x01, 0x80, 0xff, 0x01, 0x80, + 0x80, +]; + +const DELEGATED_PUZZLE_HASH: [u8; 32] = [ + 0x26, 0x24, 0x38, 0x09, 0xc2, 0x14, 0xb0, 0x80, 0x00, 0x4c, 0x48, 0x36, 0x05, 0x75, 0x7a, 0xa7, + 0xb3, 0xfc, 0xd5, 0x24, 0x34, 0xad, 0xd2, 0x4f, 0xe8, 0x20, 0x69, 0x7b, 0xfd, 0x8a, 0x63, 0x81, +]; + fn sp1_hasher(data: &[u8]) -> [u8; 32] { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); @@ -84,10 +110,129 @@ fn sp1_verify_ecdsa( fn main() { let private_inputs: Input = io::read(); - // Compile chialisp to bytecode using the new VeilEvaluator-compatible compiler + // ============================================================================ + // MINT MODE: Create new CAT supply with TAIL verification + // ============================================================================ + if let Some(ref mint_data) = private_inputs.mint_data { + // 1. Compile TAIL program to get tail_hash + let (tail_bytecode, tail_hash) = + compile_chialisp_to_bytecode(sp1_hasher, &mint_data.tail_source) + .expect("TAIL compilation failed"); + + // 2. If genesis coin present, verify it and compute nullifier + let mut mint_nullifiers = vec![]; + let mut tail_params = mint_data.tail_params.clone(); + + if let Some(ref genesis) = mint_data.genesis_coin { + // verify genesis serial commitment + let genesis_serial_commitment = compute_serial_commitment( + sp1_hasher, + &genesis.serial_number, + &genesis.serial_randomness, + ); + assert_eq!( + genesis_serial_commitment, genesis.serial_commitment, + "genesis: serial commitment verification failed" + ); + + // verify genesis coin commitment + let genesis_coin_commitment = compute_coin_commitment( + sp1_hasher, + genesis.tail_hash, + genesis.amount, + &genesis.puzzle_hash, + &genesis_serial_commitment, + ); + assert_eq!( + genesis_coin_commitment, genesis.coin_commitment, + "genesis: coin commitment verification failed" + ); + + // verify genesis exists in merkle tree + verify_merkle_proof( + sp1_hasher, + genesis_coin_commitment, + &genesis.merkle_path, + genesis.leaf_index, + genesis.merkle_root, + ) + .expect("genesis: merkle root mismatch - coin not in tree"); + + // compute genesis nullifier (prevents re-minting) + let genesis_nullifier = compute_nullifier( + sp1_hasher, + &genesis.serial_number, + &genesis.puzzle_hash, + genesis.amount, + ); + + // prepend genesis_nullifier to TAIL params so TAIL can verify it + tail_params.insert( + 0, + clvm_zk_core::ProgramParameter::Bytes(genesis_nullifier.to_vec()), + ); + mint_nullifiers.push(genesis_nullifier); + } + + // 3. Create evaluator and serialize TAIL params + let evaluator = create_veil_evaluator(sp1_hasher, sp1_verify_bls, sp1_verify_ecdsa); + let tail_args = serialize_params_to_clvm(&tail_params); + + // 4. Execute TAIL program + let max_cost = 1_000_000_000; + let (tail_output, _conditions) = + run_clvm_with_conditions(&evaluator, &tail_bytecode, &tail_args, max_cost) + .expect("TAIL execution failed"); + + // 5. Verify TAIL returns truthy (non-nil, non-zero) + let is_truthy = !tail_output.is_empty() && tail_output != vec![0x80]; // 0x80 = nil in CLVM + assert!(is_truthy, "TAIL program did not authorize mint"); + + // 6. Compute serial commitment for the new coin + let serial_commitment = compute_serial_commitment( + sp1_hasher, + &mint_data.output_serial, + &mint_data.output_rand, + ); + + // 7. Compute coin commitment with the tail_hash + let coin_commitment = compute_coin_commitment( + sp1_hasher, + tail_hash, + mint_data.output_amount, + &mint_data.output_puzzle_hash, + &serial_commitment, + ); + + // 8. Output mint proof + io::commit(&ProofOutput { + program_hash: tail_hash, + nullifiers: mint_nullifiers, // genesis nullifier if present (prevents re-mint) + clvm_res: ClvmResult { + output: coin_commitment.to_vec(), + cost: 0, + }, + proof_type: 3, // Mint type + public_values: vec![ + tail_hash.to_vec(), // public: which CAT was minted + coin_commitment.to_vec(), // public: the new coin commitment + ], + }); + + return; // mint complete, don't run normal spend logic + } + + // optimize: check if this is a known precompiled puzzle + // avoids expensive guest-side compilation for standard puzzles let (instance_bytecode, program_hash) = - compile_chialisp_to_bytecode(sp1_hasher, &private_inputs.chialisp_source) - .expect("Chialisp compilation failed"); + if private_inputs.chialisp_source == DELEGATED_PUZZLE_SOURCE { + // use precompiled bytecode - saves ~500-570s of compilation time + (DELEGATED_PUZZLE_BYTECODE.to_vec(), DELEGATED_PUZZLE_HASH) + } else { + // compile chialisp to bytecode for custom puzzles + compile_chialisp_to_bytecode(sp1_hasher, &private_inputs.chialisp_source) + .expect("Chialisp compilation failed") + }; // Create VeilEvaluator with SP1 crypto functions let evaluator = create_veil_evaluator(sp1_hasher, sp1_verify_bls, sp1_verify_ecdsa); @@ -101,6 +246,55 @@ fn main() { run_clvm_with_conditions(&evaluator, &instance_bytecode, &args, max_cost) .expect("CLVM execution failed"); + // ============================================================================ + // BALANCE ENFORCEMENT (critical security check) + // ============================================================================ + // verify sum(inputs) == sum(outputs) and tail_hash consistency + // MUST run BEFORE CREATE_COIN transformation (which replaces args) + let (total_input, total_output) = + clvm_zk_core::enforce_ring_balance(&private_inputs, &conditions) + .expect("balance enforcement failed"); + + // ============================================================================ + // TAIL-ON-DELTA: CAT2-style TAIL authorization for supply changes + // ============================================================================ + if total_input != total_output { + let tail_hash = private_inputs.tail_hash.unwrap_or([0u8; 32]); + if tail_hash != [0u8; 32] { + // CAT: TAIL authorization required for supply change + if let Some(ref tail_source) = private_inputs.tail_source { + let delta = total_input.saturating_sub(total_output); + + let (tail_bytecode, compiled_tail_hash) = + compile_chialisp_to_bytecode(sp1_hasher, tail_source) + .expect("spend-path TAIL compilation failed"); + + // verify TAIL source matches the coin's tail_hash (prevents substitution attack) + assert_eq!( + compiled_tail_hash, tail_hash, + "TAIL source does not match coin's tail_hash" + ); + + let tail_params = vec![ + clvm_zk_core::ProgramParameter::Int(delta), + clvm_zk_core::ProgramParameter::Int(total_input), + clvm_zk_core::ProgramParameter::Int(total_output), + ]; + let tail_args = serialize_params_to_clvm(&tail_params); + + let (tail_output, _) = + run_clvm_with_conditions(&evaluator, &tail_bytecode, &tail_args, max_cost) + .expect("spend-path TAIL execution failed"); + + let is_truthy = !tail_output.is_empty() && tail_output != vec![0x80]; + assert!(is_truthy, "TAIL did not authorize melt (delta != 0)"); + } else { + panic!("CAT supply change requires tail_source"); + } + } + // XCH (tail_hash == [0;32]): no TAIL needed, burn is implicitly allowed + } + // Transform CREATE_COIN conditions for output privacy let mut has_transformations = false; for condition in conditions.iter_mut() { @@ -113,42 +307,33 @@ fn main() { } 4 => { // Private mode: CREATE_COIN(puzzle_hash, amount, serial_num, serial_rand) - let puzzle_hash = &condition.args[0]; - let amount_bytes = &condition.args[1]; - let serial_number = &condition.args[2]; - let serial_randomness = &condition.args[3]; - - // Validate sizes - assert_eq!(puzzle_hash.len(), 32, "puzzle_hash must be 32 bytes"); - assert_eq!(amount_bytes.len(), 8, "amount must be 8 bytes"); - assert_eq!(serial_number.len(), 32, "serial_number must be 32 bytes"); - assert_eq!( - serial_randomness.len(), - 32, - "serial_randomness must be 32 bytes" + let puzzle_hash: &[u8; 32] = condition.args[0] + .as_slice() + .try_into() + .expect("puzzle_hash must be 32 bytes"); + let amount = parse_variable_length_amount(&condition.args[1]) + .expect("invalid amount encoding"); + let serial_number: &[u8; 32] = condition.args[2] + .as_slice() + .try_into() + .expect("serial_number must be 32 bytes"); + let serial_randomness: &[u8; 32] = condition.args[3] + .as_slice() + .try_into() + .expect("serial_randomness must be 32 bytes"); + + let serial_commitment = + compute_serial_commitment(sp1_hasher, serial_number, serial_randomness); + + let tail_hash = private_inputs.tail_hash.unwrap_or([0u8; 32]); + let coin_commitment = compute_coin_commitment( + sp1_hasher, + tail_hash, + amount, + puzzle_hash, + &serial_commitment, ); - // Parse amount - let amount = u64::from_be_bytes(amount_bytes.as_slice().try_into().unwrap()); - - // Compute serial_commitment - let serial_domain = b"clvm_zk_serial_v1.0"; - let mut serial_data = [0u8; 83]; - serial_data[..19].copy_from_slice(serial_domain); - serial_data[19..51].copy_from_slice(serial_number); - serial_data[51..83].copy_from_slice(serial_randomness); - let serial_commitment = sp1_hasher(&serial_data); - - // Compute coin_commitment - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 89]; - coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(puzzle_hash); - coin_data[57..89].copy_from_slice(&serial_commitment); - let coin_commitment = sp1_hasher(&coin_data); - - // Replace args: [puzzle, amount, serial, rand] → [commitment] condition.args = vec![coin_commitment.to_vec()]; has_transformations = true; } @@ -167,77 +352,116 @@ fn main() { output_bytes }; - let nullifier = match private_inputs.serial_commitment_data { + let nullifier = match &private_inputs.serial_commitment_data { Some(commitment_data) => { - let expected_program_hash = commitment_data.program_hash; assert_eq!( - program_hash, expected_program_hash, + program_hash, commitment_data.program_hash, "program_hash mismatch: cannot spend coin with different program" ); - let serial_randomness = commitment_data.serial_randomness; - let serial_number = commitment_data.serial_number; - let domain = b"clvm_zk_serial_v1.0"; - let mut serial_commit_data = [0u8; 83]; - serial_commit_data[..19].copy_from_slice(domain); - serial_commit_data[19..51].copy_from_slice(&serial_number); - serial_commit_data[51..83].copy_from_slice(&serial_randomness); - let computed_serial_commitment = sp1_hasher(&serial_commit_data); - - let serial_commitment_expected = commitment_data.serial_commitment; + let computed_serial_commitment = compute_serial_commitment( + sp1_hasher, + &commitment_data.serial_number, + &commitment_data.serial_randomness, + ); assert_eq!( - computed_serial_commitment, serial_commitment_expected, + computed_serial_commitment, commitment_data.serial_commitment, "serial commitment verification failed" ); - let amount = commitment_data.amount; - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 17 + 8 + 32 + 32]; - coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(&program_hash); - coin_data[57..89].copy_from_slice(&computed_serial_commitment); - let computed_coin_commitment = sp1_hasher(&coin_data); - - let coin_commitment = commitment_data.coin_commitment; + let tail_hash = private_inputs.tail_hash.unwrap_or([0u8; 32]); + let computed_coin_commitment = compute_coin_commitment( + sp1_hasher, + tail_hash, + commitment_data.amount, + &program_hash, + &computed_serial_commitment, + ); assert_eq!( - computed_coin_commitment, coin_commitment, + computed_coin_commitment, commitment_data.coin_commitment, "coin commitment verification failed" ); - let merkle_path = commitment_data.merkle_path; - let expected_root = commitment_data.merkle_root; - let leaf_index = commitment_data.leaf_index; - - let mut current_hash = coin_commitment; - let mut current_index = leaf_index; - for sibling in merkle_path.iter() { - let mut combined = [0u8; 64]; - if current_index % 2 == 0 { - combined[..32].copy_from_slice(¤t_hash); - combined[32..].copy_from_slice(sibling); - } else { - combined[..32].copy_from_slice(sibling); - combined[32..].copy_from_slice(¤t_hash); - } - current_hash = sp1_hasher(&combined); - current_index /= 2; - } + verify_merkle_proof( + sp1_hasher, + computed_coin_commitment, + &commitment_data.merkle_path, + commitment_data.leaf_index, + commitment_data.merkle_root, + ) + .expect("merkle root mismatch: coin not in current tree state"); + + Some(compute_nullifier( + sp1_hasher, + &commitment_data.serial_number, + &program_hash, + commitment_data.amount, + )) + } + None => None, + }; + + // collect nullifiers: primary coin + additional coins for ring spends + let mut nullifiers = nullifier.map(|n| vec![n]).unwrap_or_default(); + + // process additional coins for ring spends + if let Some(additional_coins) = &private_inputs.additional_coins { + for coin in additional_coins { + let coin_data = &coin.serial_commitment_data; + + // optimize: check if this coin uses a precompiled puzzle + let coin_program_hash = if coin.chialisp_source == DELEGATED_PUZZLE_SOURCE { + DELEGATED_PUZZLE_HASH + } else { + let (_, hash) = compile_chialisp_to_bytecode(sp1_hasher, &coin.chialisp_source) + .expect("additional coin chialisp compilation failed"); + hash + }; - let computed_root = current_hash; assert_eq!( - computed_root, expected_root, - "merkle root mismatch: coin not in current tree state" + coin_program_hash, coin_data.program_hash, + "additional coin: program_hash mismatch" ); - let mut nullifier_data = Vec::with_capacity(72); - nullifier_data.extend_from_slice(&serial_number); - nullifier_data.extend_from_slice(&program_hash); - nullifier_data.extend_from_slice(&amount.to_be_bytes()); - Some(sp1_hasher(&nullifier_data)) + let computed_serial_commitment = compute_serial_commitment( + sp1_hasher, + &coin_data.serial_number, + &coin_data.serial_randomness, + ); + assert_eq!( + computed_serial_commitment, coin_data.serial_commitment, + "additional coin: serial commitment verification failed" + ); + + let computed_coin_commitment = compute_coin_commitment( + sp1_hasher, + coin.tail_hash, + coin_data.amount, + &coin_program_hash, + &computed_serial_commitment, + ); + assert_eq!( + computed_coin_commitment, coin_data.coin_commitment, + "additional coin: coin commitment verification failed" + ); + + verify_merkle_proof( + sp1_hasher, + computed_coin_commitment, + &coin_data.merkle_path, + coin_data.leaf_index, + coin_data.merkle_root, + ) + .expect("additional coin: merkle root mismatch"); + + nullifiers.push(compute_nullifier( + sp1_hasher, + &coin_data.serial_number, + &coin_program_hash, + coin_data.amount, + )); } - None => None, - }; + } let clvm_output = ClvmResult { output: final_output, @@ -246,7 +470,7 @@ fn main() { io::commit(&ProofOutput { program_hash, - nullifier, + nullifiers, clvm_res: clvm_output, proof_type: 0, // Transaction type (default) public_values: vec![], diff --git a/backends/sp1/program_recursive/src/main.rs b/backends/sp1/program_recursive/src/main.rs index 14ec005..4dd0160 100644 --- a/backends/sp1/program_recursive/src/main.rs +++ b/backends/sp1/program_recursive/src/main.rs @@ -17,7 +17,7 @@ struct RecursiveInput { #[derive(serde::Deserialize)] struct BaseProofData { program_hash: [u8; 32], - nullifier: Option<[u8; 32]>, + nullifiers: Vec<[u8; 32]>, output: Vec, } @@ -39,23 +39,24 @@ pub fn main() { // aggregate all child proofs (base proofs only) for expected_data in input.expected_outputs.iter() { - // collect nullifier if present - if let Some(n) = expected_data.nullifier { - assert!(!all_nullifiers.contains(&n), "duplicate nullifier detected"); - all_nullifiers.push(n); + // collect nullifiers (check for duplicates) + for nullifier in &expected_data.nullifiers { + assert!( + !all_nullifiers.contains(nullifier), + "duplicate nullifier detected" + ); + all_nullifiers.push(*nullifier); } // collect conditions all_conditions.push(expected_data.output.clone()); // build commitment for base proof - // commitment = hash(program_hash || nullifier || output) + // commitment = hash(program_hash || [nullifiers...] || output) let mut commitment_data = Vec::new(); commitment_data.extend_from_slice(&expected_data.program_hash); - if let Some(n) = expected_data.nullifier { - commitment_data.extend_from_slice(&n); - } else { - commitment_data.extend_from_slice(&[0u8; 32]); + for nullifier in &expected_data.nullifiers { + commitment_data.extend_from_slice(nullifier); } commitment_data.extend_from_slice(&expected_data.output); diff --git a/backends/sp1/program_settlement/Cargo.toml b/backends/sp1/program_settlement/Cargo.toml new file mode 100644 index 0000000..32eaa2e --- /dev/null +++ b/backends/sp1/program_settlement/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "clvm-zk-sp1-program-settlement" +version = "0.1.0" +edition = "2021" + +[dependencies] +sp1-zkvm = "5.2.2" +sp1-lib = "5.2.2" +clvm-zk-core = { path = "../../../clvm_zk_core", default-features = false, features = ["sp1"] } +# use sp1's patched sha2 for syscall optimization +sha2 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha2", branch = "patch-sha2-v0.10.8", default-features = false } +serde = { version = "1.0", features = ["derive"] } + +[[bin]] +name = "settlement" +path = "src/main.rs" diff --git a/backends/sp1/program_settlement/src/main.rs b/backends/sp1/program_settlement/src/main.rs new file mode 100644 index 0000000..88b8e67 --- /dev/null +++ b/backends/sp1/program_settlement/src/main.rs @@ -0,0 +1,200 @@ +#![no_main] +sp1_zkvm::entrypoint!(main); + +extern crate alloc; +use alloc::vec::Vec; + +use clvm_zk_core::{ + compute_coin_commitment, compute_nullifier, compute_serial_commitment, verify_merkle_proof, +}; +use sha2::{Digest, Sha256}; + +fn sp1_hasher(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +/// settlement output committed by taker's proof +/// maker_pubkey is PUBLIC so validator can check it matches the offer +#[derive(serde::Serialize)] +struct SettlementOutput { + maker_nullifier: [u8; 32], + taker_nullifier: [u8; 32], + maker_change_commitment: [u8; 32], // maker's change (asset A) + payment_commitment: [u8; 32], // taker → maker (asset B) + taker_goods_commitment: [u8; 32], // maker → taker (asset A) + taker_change_commitment: [u8; 32], // taker's change (asset B) + // PUBLIC: validator checks this matches offer's maker_pubkey + maker_pubkey: [u8; 32], +} + +/// taker's private coin data +#[derive(serde::Deserialize)] +struct TakerCoinData { + amount: u64, + puzzle_hash: [u8; 32], + serial_commitment: [u8; 32], + serial_number: [u8; 32], + serial_randomness: [u8; 32], + merkle_path: Vec<[u8; 32]>, + leaf_index: usize, +} + +/// settlement parameters +#[derive(serde::Deserialize)] +struct SettlementInput { + // maker's proof outputs (PUBLIC, extracted by host from verified journal) + maker_nullifier: [u8; 32], + maker_change_commitment: [u8; 32], + offered: u64, + requested: u64, + maker_pubkey: [u8; 32], + + // PRIVATE + taker_coin: TakerCoinData, + merkle_root: [u8; 32], + // hash-based stealth: nonce instead of ephemeral privkey + // payment_puzzle = hash("stealth_v1" || maker_pubkey || nonce) + // host encrypts nonce to maker_pubkey, includes in tx for maker to decrypt + payment_nonce: [u8; 32], + taker_goods_puzzle: [u8; 32], // for receiving offered goods (asset A) + taker_change_puzzle: [u8; 32], // for receiving change (asset B) + payment_serial: [u8; 32], // for payment to maker + payment_rand: [u8; 32], + goods_serial: [u8; 32], // for taker receiving goods + goods_rand: [u8; 32], + change_serial: [u8; 32], // for taker's change + change_rand: [u8; 32], + // v2.0 coin commitment format: tail_hash identifies asset type + taker_tail_hash: [u8; 32], // taker's coin asset (XCH = zeros) + goods_tail_hash: [u8; 32], // offered goods asset (maker's asset) +} + +fn main() { + let input: SettlementInput = sp1_zkvm::io::read(); + + // V2 optimization: maker's proof outputs extracted by HOST (not in guest) + // Host deserializes maker's journal and extracts these values + // Guest receives them as simple public inputs, no deserialization overhead + // Validator verifies maker's proof separately to ensure these values are correct + let maker_nullifier = input.maker_nullifier; + let maker_change_commitment = input.maker_change_commitment; + let offered = input.offered; + let requested = input.requested; + let maker_pubkey = input.maker_pubkey; + + // verify taker's coin ownership (v2.0 format with tail_hash) + verify_taker_coin(&input.taker_coin, input.merkle_root, &input.taker_tail_hash); + + // assert taker has enough funds + assert!( + input.taker_coin.amount >= requested, + "taker has insufficient funds" + ); + + // HASH-BASED STEALTH ADDRESS (replaces ECDH - ~10K cycles vs ~2M cycles) + // payment_puzzle = sha256("stealth_v1" || maker_pubkey || nonce) + // - maker_pubkey is from the offer (public, validated by blockchain) + // - nonce is random, encrypted to maker_pubkey by host (outside zkVM) + // - maker decrypts nonce from tx metadata to derive same puzzle + let mut payment_puzzle_hasher = Sha256::new(); + payment_puzzle_hasher.update(b"stealth_v1"); + payment_puzzle_hasher.update(&maker_pubkey); + payment_puzzle_hasher.update(&input.payment_nonce); + let payment_puzzle_bytes: [u8; 32] = payment_puzzle_hasher.finalize().into(); + + // create payment commitment (taker → maker, asset B = taker's asset) + let payment_commitment = create_coin_commitment( + requested, + &payment_puzzle_bytes, + &input.payment_serial, + &input.payment_rand, + &input.taker_tail_hash, + ); + + // create taker goods commitment (maker → taker, asset A = goods asset) + let taker_goods_commitment = create_coin_commitment( + offered, + &input.taker_goods_puzzle, + &input.goods_serial, + &input.goods_rand, + &input.goods_tail_hash, + ); + + // create taker's change commitment (taker's leftover, asset B = taker's asset) + let taker_change_amount = input.taker_coin.amount - requested; + let taker_change_commitment = create_coin_commitment( + taker_change_amount, + &input.taker_change_puzzle, + &input.change_serial, + &input.change_rand, + &input.taker_tail_hash, + ); + + // compute taker's nullifier + let taker_nullifier = compute_nullifier( + sp1_hasher, + &input.taker_coin.serial_number, + &input.taker_coin.puzzle_hash, + input.taker_coin.amount, + ); + + // commit settlement output + // maker_pubkey is PUBLIC so validator can assert it matches offer + let output = SettlementOutput { + maker_nullifier, + taker_nullifier, + maker_change_commitment, + payment_commitment, + taker_goods_commitment, + taker_change_commitment, + maker_pubkey, // echoed for validation + }; + + sp1_zkvm::io::commit(&output); +} + +/// verify taker's coin ownership via merkle membership (v2.0 format with tail_hash) +fn verify_taker_coin(coin: &TakerCoinData, merkle_root: [u8; 32], tail_hash: &[u8; 32]) { + // 1. verify serial commitment using clvm_zk_core (optimized fixed-size arrays) + let computed_serial = + compute_serial_commitment(sp1_hasher, &coin.serial_number, &coin.serial_randomness); + + assert_eq!( + computed_serial, coin.serial_commitment, + "invalid serial commitment" + ); + + // 2. compute coin_commitment using clvm_zk_core (optimized fixed-size arrays) + let coin_commitment = compute_coin_commitment( + sp1_hasher, + *tail_hash, + coin.amount, + &coin.puzzle_hash, + &computed_serial, + ); + + // 3. verify merkle membership using clvm_zk_core (optimized fixed-size arrays) + verify_merkle_proof( + sp1_hasher, + coin_commitment, + &coin.merkle_path, + coin.leaf_index, + merkle_root, + ) + .expect("merkle proof verification failed"); +} + +/// create coin commitment v2.0: hash(domain || tail_hash || amount || puzzle || serial_commitment) +fn create_coin_commitment( + amount: u64, + puzzle: &[u8; 32], + serial: &[u8; 32], + rand: &[u8; 32], + tail_hash: &[u8; 32], +) -> [u8; 32] { + // use clvm_zk_core optimized implementations (fixed-size arrays, no Vec allocations) + let serial_commitment = compute_serial_commitment(sp1_hasher, serial, rand); + compute_coin_commitment(sp1_hasher, *tail_hash, amount, puzzle, &serial_commitment) +} diff --git a/backends/sp1/src/lib.rs b/backends/sp1/src/lib.rs index ebce104..dc17654 100644 --- a/backends/sp1/src/lib.rs +++ b/backends/sp1/src/lib.rs @@ -4,6 +4,10 @@ pub mod recursive; pub use methods::*; pub use recursive::RecursiveAggregator; +// re-export for settlement.rs +pub use bincode; +pub use sp1_sdk; + pub use clvm_zk_core::{ ClvmResult, ClvmZkError, Input, ProgramParameter, ProofOutput, ZKClvmResult, }; @@ -35,6 +39,7 @@ impl Sp1Backend { }) } + #[allow(clippy::const_is_empty)] fn is_sp1_available() -> bool { !CLVM_ZK_SP1_ELF.is_empty() } @@ -66,6 +71,10 @@ impl Sp1Backend { chialisp_source: chialisp_source.to_string(), program_parameters: program_parameters.to_vec(), serial_commitment_data: None, + tail_hash: None, // XCH by default + additional_coins: None, // single-coin spend + mint_data: None, + tail_source: None, }; let mut stdin = SP1Stdin::new(); diff --git a/backends/sp1/src/methods.rs b/backends/sp1/src/methods.rs index 83e8397..208518f 100644 --- a/backends/sp1/src/methods.rs +++ b/backends/sp1/src/methods.rs @@ -7,3 +7,6 @@ pub const CLVM_ZK_SP1_ELF: &[u8] = include_elf!("clvm-zk-sp1-program"); /// The ELF for the recursive aggregation program pub const RECURSIVE_SP1_ELF: &[u8] = include_elf!("clvm-zk-sp1-recursive"); + +/// The ELF for the settlement program +pub const SETTLEMENT_SP1_ELF: &[u8] = include_elf!("settlement"); diff --git a/backends/sp1/src/recursive.rs b/backends/sp1/src/recursive.rs index 176bbef..0e46b39 100644 --- a/backends/sp1/src/recursive.rs +++ b/backends/sp1/src/recursive.rs @@ -17,7 +17,7 @@ impl RecursiveAggregator { /// aggregate N base proofs into 1 (flat aggregation: N→1) /// - /// all input proofs must be base proofs from prove_chialisp_with_nullifier() + /// all input proofs must be base proofs from prove_with_input() pub fn aggregate_proofs(&self, proofs: &[&[u8]]) -> Result, ClvmZkError> { if proofs.is_empty() { return Err(ClvmZkError::ConfigurationError( @@ -44,7 +44,7 @@ impl RecursiveAggregator { child_data.push(BaseProofData { program_hash: output.program_hash, - nullifier: output.nullifier, + nullifiers: output.nullifiers.clone(), output: output.clvm_res.output, }); } @@ -94,7 +94,7 @@ struct RecursiveInputData { #[derive(serde::Serialize, serde::Deserialize)] struct BaseProofData { program_hash: [u8; 32], - nullifier: Option<[u8; 32]>, + nullifiers: alloc::vec::Vec<[u8; 32]>, output: alloc::vec::Vec, } @@ -102,31 +102,92 @@ struct BaseProofData { mod tests { use super::*; use crate::Sp1Backend; - use clvm_zk_core::ProgramParameter; + use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets, XCH_TAIL}; + use clvm_zk_core::merkle::SparseMerkleTree; + use clvm_zk_core::{ + hash_data, AggregatedOutput, Input, ProgramParameter, SerialCommitmentData, ZKClvmResult, + }; + + fn compile_program_hash(program: &str) -> [u8; 32] { + clvm_zk_core::compile_chialisp_template_hash(hash_data, program) + .expect("program compilation failed") + } + + /// helper to generate a proof with proper nullifier protocol + fn generate_test_proof( + backend: &Sp1Backend, + program: &str, + params: &[ProgramParameter], + serial_seed: u8, + ) -> ZKClvmResult { + let program_hash = compile_program_hash(program); + + // create coin with serial commitment + let serial_number = [serial_seed; 32]; + let serial_randomness = [serial_seed.wrapping_add(100); 32]; + let coin_secrets = CoinSecrets::new(serial_number, serial_randomness); + let amount = 1000; + + // compute commitments + let serial_commitment = coin_secrets.serial_commitment(hash_data); + let coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &program_hash, + &serial_commitment, + hash_data, + ); + + // create merkle tree with single coin + let mut merkle_tree = SparseMerkleTree::new(20, hash_data); + let leaf_index = merkle_tree.insert(*coin_commitment.as_bytes(), hash_data); + let merkle_root = merkle_tree.root(); + let merkle_proof = merkle_tree.generate_proof(leaf_index, hash_data).unwrap(); + + // generate proof with serial commitment + let input = Input { + chialisp_source: program.to_string(), + program_parameters: params.to_vec(), + serial_commitment_data: Some(SerialCommitmentData { + serial_number, + serial_randomness, + merkle_path: merkle_proof.path, + coin_commitment: *coin_commitment.as_bytes(), + serial_commitment: *serial_commitment.as_bytes(), + merkle_root, + leaf_index, + program_hash, + amount, + }), + tail_hash: None, // XCH by default + additional_coins: None, + mint_data: None, + tail_source: None, + }; + + backend + .prove_with_input(input) + .expect("proof generation should succeed") + } #[test] fn test_aggregate_two_proofs() { // generate 2 transaction proofs with nullifiers let backend = Sp1Backend::new().unwrap(); - let spend_secret1 = [1u8; 32]; - let spend_secret2 = [2u8; 32]; - - let proof1 = backend - .prove_chialisp_with_nullifier( - "(mod (x) (* x 2))", - &[ProgramParameter::Int(5)], - spend_secret1, - ) - .unwrap(); + let proof1 = generate_test_proof( + &backend, + "(mod (x) (* x 2))", + &[ProgramParameter::Int(5)], + 1, + ); - let proof2 = backend - .prove_chialisp_with_nullifier( - "(mod (y) (+ y 10))", - &[ProgramParameter::Int(3)], - spend_secret2, - ) - .unwrap(); + let proof2 = generate_test_proof( + &backend, + "(mod (y) (+ y 10))", + &[ProgramParameter::Int(3)], + 2, + ); // aggregate them let aggregator = RecursiveAggregator::new().unwrap(); @@ -165,14 +226,12 @@ mod tests { let mut proofs = Vec::new(); for i in 0..5 { - let spend_secret = [i as u8; 32]; - let proof = backend - .prove_chialisp_with_nullifier( - "(mod (x) (* x 2))", - &[ProgramParameter::Int(i as u64)], - spend_secret, - ) - .unwrap(); + let proof = generate_test_proof( + &backend, + "(mod (x) (* x 2))", + &[ProgramParameter::Int(i as u64)], + i as u8, + ); proofs.push(proof); } diff --git a/cat_offer_demo.sh b/cat_offer_demo.sh new file mode 100755 index 0000000..16c1ae4 --- /dev/null +++ b/cat_offer_demo.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# CAT offer demo - demonstrates minting a CAT and creating an offer to trade it +# +# usage: ./cat_offer_demo.sh [risc0|sp1] + +set -e + +# backend selection (default: sp1) +BACKEND="${1:-sp1}" + +if [[ "$BACKEND" != "risc0" && "$BACKEND" != "sp1" ]]; then + echo "❌ invalid backend: $BACKEND" + echo "usage: $0 [risc0|sp1]" + exit 1 +fi + +# backend-specific paths +BUILD_DIR="./target/$BACKEND" +BINARY="$BUILD_DIR/release/clvm-zk" + +# build if needed +if [ ! -f "$BINARY" ]; then + echo "🔨 building $BACKEND backend..." + cargo build --release --target-dir "$BUILD_DIR" --no-default-features --features "$BACKEND,testing" + echo "✅ build complete" + echo "" +fi + +echo "=== CAT OFFER DEMO ===" +echo "backend: $BACKEND" +echo "======================" +echo "" + +# clean slate +echo "🗑️ resetting simulator..." +$BINARY sim init --reset + +# create wallets +echo "" +echo "👛 creating wallets..." +$BINARY sim wallet maker create +$BINARY sim wallet taker create + +# === STEP 1: MINT A CAT === +# the TAIL program defines the CAT's identity +# tail_hash = compile_hash(TAIL_program) +# for demo, we use a fixed tail_hash (would normally be computed from TAIL source) + +# compute a deterministic tail_hash for demo +# this represents: compile_chialisp_template_hash_default("(mod () 1)") +DEMO_TAIL_HASH="0e68e265035b19b3cf36586a45bd978206b492895046c8d605f800a04f242a9e" + +echo "" +echo "📦 STEP 1: Minting CAT to maker's wallet" +echo " TAIL program: (mod () 1)" +echo " tail_hash: $DEMO_TAIL_HASH" +echo "" + +# use faucet with --tail to mint CAT coins +# the --delegated flag is required for offers +$BINARY sim faucet maker --amount 1000 --count 1 --tail "$DEMO_TAIL_HASH" --delegated +echo " ✅ minted 1000 CAT mojos to maker" + +# === STEP 2: FUND TAKER WITH XCH === +echo "" +echo "💰 STEP 2: Funding taker with XCH" +$BINARY sim faucet taker --amount 500 --count 1 --delegated +echo " ✅ funded taker with 500 XCH mojos" + +# show balances +echo "" +echo "📊 Initial balances:" +echo "" +echo "maker's coins:" +$BINARY sim wallet maker unspent +echo "" +echo "taker's coins:" +$BINARY sim wallet taker unspent + +# === STEP 3: CREATE OFFER === +echo "" +echo "📝 STEP 3: Maker creates offer" +echo " offering: 100 CAT mojos" +echo " requesting: 50 XCH mojos" +echo "" + +# note: currently offer-create uses maker's coin's tail_hash as the offered asset +# and request-tail for requested asset (XCH if omitted) +$BINARY sim offer-create maker --offer 100 --request 50 --coins 0 + +echo "" +echo "📋 Pending offers:" +$BINARY sim offer-list + +# === STEP 4: TAKER TAKES OFFER === +echo "" +echo "🤝 STEP 4: Taker takes the offer" +echo " spending: 50 XCH mojos (from their coin)" +echo " receiving: 100 CAT mojos" +echo "" + +# take offer 0 using taker's coin 0 +$BINARY sim offer-take taker --offer-id 0 --coins 0 + +# === STEP 5: VERIFY RESULTS === +echo "" +echo "✅ SETTLEMENT COMPLETE" +echo "" +echo "📊 Final state:" +echo "" + +echo "maker's coins (after receiving XCH payment + CAT change):" +$BINARY sim wallet maker unspent +echo "" +echo "taker's coins (after receiving CAT + XCH change):" +$BINARY sim wallet taker unspent + +echo "" +echo "=== DEMO COMPLETE ===" +echo "" +echo "what happened:" +echo " 1. minted 1000 CAT to maker (tail_hash = $DEMO_TAIL_HASH)" +echo " 2. funded taker with 500 XCH" +echo " 3. maker created offer: 100 CAT for 50 XCH" +echo " 4. taker took the offer (atomic swap)" +echo "" +echo "key points:" +echo " - CAT identity comes from TAIL program hash" +echo " - offers are ConditionalSpend proofs (locked until settlement)" +echo " - settlement proof atomically swaps assets" +echo " - all amounts/assets hidden in commitments" diff --git a/clvm_zk_core/src/backend_utils.rs b/clvm_zk_core/src/backend_utils.rs index 6af5794..a7da636 100644 --- a/clvm_zk_core/src/backend_utils.rs +++ b/clvm_zk_core/src/backend_utils.rs @@ -39,20 +39,30 @@ pub fn validate_nullifier_proof_output( backend_name: &str, ) -> Result<(), ClvmZkError> { // Make sure the program actually committed values - if output.clvm_res.output.is_empty() && output.nullifier.is_none() { + if output.clvm_res.output.is_empty() && output.nullifiers.is_empty() { return Err(ClvmZkError::ProofGenerationFailed(format!( "{} proof appears to have exited before commit - no outputs generated", backend_name ))); } - // Make sure nullifier was actually generated (required for spend proofs) - if output.nullifier.is_none() || output.nullifier == Some([0u8; 32]) { + // Make sure nullifiers were actually generated (required for spend proofs) + if output.nullifiers.is_empty() { return Err(ClvmZkError::ProofGenerationFailed(format!( - "{} proof missing valid nullifier - execution may have failed", + "{} proof missing valid nullifiers - execution may have failed", backend_name ))); } + // Check for invalid null nullifiers + for nullifier in &output.nullifiers { + if nullifier == &[0u8; 32] { + return Err(ClvmZkError::ProofGenerationFailed(format!( + "{} proof contains invalid null nullifier", + backend_name + ))); + } + } + Ok(()) } diff --git a/clvm_zk_core/src/chialisp/ast.rs b/clvm_zk_core/src/chialisp/ast.rs deleted file mode 100644 index 8766790..0000000 --- a/clvm_zk_core/src/chialisp/ast.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Abstract Syntax Tree definitions for Chialisp -//! -//! Defines the semantic structure of Chialisp programs after parsing. -//! These types are no_std compatible and optimized for guest execution. - -extern crate alloc; - -use alloc::{boxed::Box, string::String, vec::Vec}; - -use crate::operators::ClvmOperator; - -/// A complete Chialisp module with parameters, helpers, and main expression -#[derive(Debug, Clone, PartialEq)] -pub struct ModuleAst { - /// Module parameters: (mod (x y) ...) → ["x", "y"] - pub parameters: Vec, - /// Function definitions and other helpers - pub helpers: Vec, - /// Main expression to execute - pub body: Expression, -} - -/// Helper definitions (functions, macros, constants) -#[derive(Debug, Clone, PartialEq)] -pub enum HelperDefinition { - /// Function definition: (defun name (args) body) - Function { - name: String, - parameters: Vec, - body: Expression, - inline: bool, // true for defun-inline - }, - // TODO: Add Macro, Constant when needed -} - -/// Core expression types in Chialisp -#[derive(Debug, Clone, PartialEq)] -pub enum Expression { - /// Variable reference: x, y, amount, etc. - Variable(String), - - /// Numeric literal: 42, -10, 0 - Number(i64), - - /// String literal: "hello" - String(String), - - /// Raw bytes: for cryptographic keys, signatures, etc. - Bytes(Vec), - - /// Empty list / nil - Nil, - - /// CLVM operation: (+, -, *, create_coin, etc.) - Operation { - operator: ClvmOperator, - arguments: Vec, - }, - - /// Function call: (factorial 5) - FunctionCall { - name: String, - arguments: Vec, - }, - - /// List construction: (list a b c) - List(Vec), - - /// Quoted expression: (q . something) - Quote(Box), -} - -/// Compilation error types -#[derive(Debug, Clone, PartialEq)] -pub enum CompileError { - /// Parse error occurred - ParseError(String), - /// Unknown operator - UnknownOperator(String), - /// Unknown function - UnknownFunction(String), - /// Wrong number of arguments - ArityMismatch { - operator: String, - expected: usize, - actual: usize, - }, - /// Variable not found in scope - UndefinedVariable(String), - /// Invalid mod expression structure - InvalidModStructure(String), - /// Invalid function definition - InvalidFunctionDefinition(String), - /// Recursive compilation limit hit - RecursionLimitExceeded, - /// Recursive function definition not supported - RecursionNotSupported(String), - /// Memory limit exceeded - OutOfMemory, -} - -impl ModuleAst { - /// Create a new module with the given parameters and body - pub fn new(parameters: Vec, body: Expression) -> Self { - Self { - parameters, - helpers: Vec::new(), - body, - } - } - - /// Add a helper definition to the module - pub fn add_helper(&mut self, helper: HelperDefinition) { - self.helpers.push(helper); - } - - /// Find a function definition by name - pub fn find_function(&self, name: &str) -> Option<&HelperDefinition> { - self.helpers.iter().find(|helper| match helper { - HelperDefinition::Function { - name: func_name, .. - } => func_name == name, - }) - } - - /// Get all function names defined in this module - pub fn function_names(&self) -> Vec<&str> { - self.helpers - .iter() - .map(|helper| match helper { - HelperDefinition::Function { name, .. } => name.as_str(), - }) - .collect() - } -} - -impl Expression { - /// Create a variable reference - pub fn variable(name: impl Into) -> Self { - Expression::Variable(name.into()) - } - - /// Create a number literal - pub fn number(value: i64) -> Self { - Expression::Number(value) - } - - /// Create a string literal - pub fn string(value: impl Into) -> Self { - Expression::String(value.into()) - } - - /// Create a nil expression - pub fn nil() -> Self { - Expression::Nil - } - - /// Create an operation with arguments - pub fn operation(operator: ClvmOperator, arguments: Vec) -> Self { - Expression::Operation { - operator, - arguments, - } - } - - /// Create a function call - pub fn function_call(name: impl Into, arguments: Vec) -> Self { - Expression::FunctionCall { - name: name.into(), - arguments, - } - } - - /// Create a list - pub fn list(items: Vec) -> Self { - Expression::List(items) - } - - /// Create a quoted expression - pub fn quote(expr: Expression) -> Self { - Expression::Quote(Box::new(expr)) - } - - /// Check if this expression is a literal value (number, string, nil) - pub fn is_literal(&self) -> bool { - matches!( - self, - Expression::Number(_) | Expression::String(_) | Expression::Nil - ) - } - - /// Check if this expression references variables - pub fn has_variables(&self) -> bool { - match self { - Expression::Variable(_) => true, - Expression::Number(_) - | Expression::String(_) - | Expression::Bytes(_) - | Expression::Nil => false, - Expression::Operation { arguments, .. } - | Expression::FunctionCall { arguments, .. } - | Expression::List(arguments) => arguments.iter().any(|arg| arg.has_variables()), - Expression::Quote(expr) => expr.has_variables(), - } - } - - /// Get all variable names referenced in this expression - pub fn get_variables(&self) -> Vec<&str> { - let mut vars = Vec::new(); - self.collect_variables(&mut vars); - vars.sort(); - vars.dedup(); - vars - } - - /// Recursively collect variable names - fn collect_variables<'a>(&'a self, vars: &mut Vec<&'a str>) { - match self { - Expression::Variable(name) => vars.push(name), - Expression::Operation { arguments, .. } - | Expression::FunctionCall { arguments, .. } - | Expression::List(arguments) => { - for arg in arguments { - arg.collect_variables(vars); - } - } - Expression::Quote(expr) => expr.collect_variables(vars), - Expression::Number(_) - | Expression::String(_) - | Expression::Bytes(_) - | Expression::Nil => {} - } - } -} - -impl HelperDefinition { - /// Create a function definition - pub fn function( - name: impl Into, - parameters: Vec, - body: Expression, - inline: bool, - ) -> Self { - HelperDefinition::Function { - name: name.into(), - parameters, - body, - inline, - } - } - - /// Get the name of this helper - pub fn name(&self) -> &str { - match self { - HelperDefinition::Function { name, .. } => name, - } - } - - /// Check if this is an inline function - pub fn is_inline(&self) -> bool { - match self { - HelperDefinition::Function { inline, .. } => *inline, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloc::{string::ToString, vec}; - - #[test] - fn test_simple_module() { - let module = ModuleAst::new( - vec!["x".to_string(), "y".to_string()], - Expression::operation( - ClvmOperator::Add, - vec![Expression::variable("x"), Expression::variable("y")], - ), - ); - - assert_eq!(module.parameters, vec!["x", "y"]); - assert_eq!(module.helpers.len(), 0); - } - - #[test] - fn test_module_with_function() { - let mut module = ModuleAst::new( - vec!["n".to_string()], - Expression::function_call("double", vec![Expression::variable("n")]), - ); - - let double_func = HelperDefinition::function( - "double", - vec!["x".to_string()], - Expression::operation( - ClvmOperator::Multiply, - vec![Expression::variable("x"), Expression::number(2)], - ), - false, - ); - - module.add_helper(double_func); - - assert_eq!(module.function_names(), vec!["double"]); - assert!(module.find_function("double").is_some()); - assert!(module.find_function("unknown").is_none()); - } - - #[test] - fn test_expression_variable_detection() { - let expr = Expression::operation( - ClvmOperator::Add, - vec![ - Expression::variable("x"), - Expression::number(5), - Expression::variable("y"), - ], - ); - - assert!(expr.has_variables()); - let vars = expr.get_variables(); - assert_eq!(vars, vec!["x", "y"]); - } - - #[test] - fn test_literal_expressions() { - assert!(Expression::number(42).is_literal()); - assert!(Expression::string("hello").is_literal()); - assert!(Expression::nil().is_literal()); - assert!(!Expression::variable("x").is_literal()); - } - - #[test] - fn test_nested_expression_variables() { - let expr = Expression::operation( - ClvmOperator::If, - vec![ - Expression::operation( - ClvmOperator::GreaterThan, - vec![Expression::variable("amount"), Expression::number(1000)], - ), - Expression::variable("amount"), - Expression::number(0), - ], - ); - - let vars = expr.get_variables(); - assert_eq!(vars, vec!["amount"]); - } - - #[test] - fn test_function_call_variables() { - let expr = Expression::function_call( - "factorial", - vec![Expression::operation( - ClvmOperator::Subtract, - vec![Expression::variable("n"), Expression::number(1)], - )], - ); - - let vars = expr.get_variables(); - assert_eq!(vars, vec!["n"]); - } -} - -/// Compilation mode for different use cases -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum CompilationMode { - /// Template mode: preserve parameter structure for consistent hashing - /// Parameters remain as environment references (f env), (f (r env)), etc. - Template, - /// Instance mode: substitute actual parameter values for execution - /// Parameters are replaced with their concrete values - Instance, -} diff --git a/clvm_zk_core/src/chialisp/compiler_utils.rs b/clvm_zk_core/src/chialisp/compiler_utils.rs deleted file mode 100644 index 15d890f..0000000 --- a/clvm_zk_core/src/chialisp/compiler_utils.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! Shared CLVM compilation utilities - -extern crate alloc; - -use alloc::{ - boxed::Box, - string::{String, ToString}, - vec, - vec::Vec, -}; - -use super::{ast::*, CompileError}; -use crate::{operators::ClvmOperator, ClvmValue}; - -/// Convert i64 to ClvmValue using consistent CLVM encoding -/// -/// CLVM uses signed big-endian encoding where the high bit indicates sign. -/// - 0 encodes as empty atom -/// - 1-127 encode as single byte (0x01-0x7F) -/// - 128-255 need leading 0x00 to avoid sign bit (0x0080-0x00FF) -/// - Negative numbers have high bit set -pub fn number_to_clvm_value(num: i64) -> ClvmValue { - if num == 0 { - ClvmValue::Atom(vec![]) - } else if num > 0 && num <= 127 { - // Single byte encoding only for 1-127 (0x01-0x7F) - ClvmValue::Atom(vec![num as u8]) - } else { - // For larger numbers or numbers requiring sign handling, use big endian encoding - let mut bytes = Vec::new(); - let mut n = num.unsigned_abs(); - while n > 0 { - bytes.push((n & 0xFF) as u8); - n >>= 8; - } - bytes.reverse(); - - // For positive numbers where high bit is set, add leading 0x00 - // to prevent sign bit interpretation (e.g., 128 = 0x0080, not 0x80) - if num > 0 && !bytes.is_empty() && (bytes[0] & 0x80) != 0 { - bytes.insert(0, 0x00); - } - // Handle negative numbers by setting high bit - else if num < 0 { - if let Some(first) = bytes.first_mut() { - *first |= 0x80; - } - } - - ClvmValue::Atom(bytes) - } -} - -/// Convert ClvmValue to i64 number - reverse of number_to_clvm_value -pub fn clvm_value_to_number(value: &ClvmValue) -> Result { - match value { - ClvmValue::Atom(bytes) => { - if bytes.is_empty() { - Ok(0) - } else if bytes.len() == 1 { - Ok(bytes[0] as i64) - } else { - // Multi-byte number - decode from big endian - let mut result = 0i64; - let is_negative = bytes.first().is_some_and(|&b| b & 0x80 != 0); - - for &byte in bytes { - result = result.checked_shl(8).ok_or("number too large")?; - result += (byte & 0x7F) as i64; // Clear sign bit for calculation - } - - if is_negative { - result = -result; - } - - Ok(result) - } - } - ClvmValue::Cons(_, _) => Err("cannot convert cons pair to number"), - } -} - -/// Create a cons list from operator and arguments: (operator arg1 arg2 ...) -pub fn create_cons_list( - operator: ClvmValue, - args: Vec, -) -> Result { - // Build the argument list from right to left: (arg1 . (arg2 . (arg3 . nil))) - let mut args_list = ClvmValue::Atom(vec![]); // Start with nil - - for arg in args.into_iter().rev() { - args_list = ClvmValue::Cons(Box::new(arg), Box::new(args_list)); - } - - // Create the final structure: (operator . args_list) - Ok(ClvmValue::Cons(Box::new(operator), Box::new(args_list))) -} - -/// Create a proper list from ClvmValues -pub fn create_list_from_values(values: Vec) -> Result { - let mut result = ClvmValue::Atom(vec![]); // Start with nil - - // Build list from right to left - for value in values.into_iter().rev() { - result = ClvmValue::Cons(Box::new(value), Box::new(result)); - } - - Ok(result) -} - -/// Validate function call arguments -pub fn validate_function_call( - name: &str, - arguments: &[Expression], - expected_params: usize, - _call_stack: &[String], -) -> Result<(), CompileError> { - // Validate argument count - if arguments.len() != expected_params { - return Err(CompileError::ArityMismatch { - operator: name.to_string(), - expected: expected_params, - actual: arguments.len(), - }); - } - - Ok(()) -} - -/// Compile basic expression types - returns raw values -/// Note: In Template mode, these should be wrapped with quote operator -pub fn compile_basic_expression_types( - expr: &Expression, -) -> Option> { - match expr { - Expression::Number(value) => Some(Ok(number_to_clvm_value(*value))), - Expression::String(value) => Some(Ok(ClvmValue::Atom(value.as_bytes().to_vec()))), - Expression::Bytes(bytes) => Some(Ok(ClvmValue::Atom(bytes.clone()))), - Expression::Nil => Some(Ok(ClvmValue::Atom(vec![]))), - _ => None, // Let specific pipeline handle complex cases - } -} - -/// Wrap a ClvmValue with quote operator: (q . value) -pub fn quote_value(value: ClvmValue) -> ClvmValue { - ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()])), - Box::new(value), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_number_encoding() { - assert_eq!(number_to_clvm_value(0), ClvmValue::Atom(vec![])); - assert_eq!(number_to_clvm_value(42), ClvmValue::Atom(vec![42])); - assert_eq!(number_to_clvm_value(127), ClvmValue::Atom(vec![127])); // Max single-byte positive - - // 128+ need leading 0x00 to avoid sign bit interpretation - assert_eq!(number_to_clvm_value(128), ClvmValue::Atom(vec![0, 128])); - assert_eq!(number_to_clvm_value(255), ClvmValue::Atom(vec![0, 255])); - - // Larger numbers should be multi-byte - let large_num = number_to_clvm_value(1000); - if let ClvmValue::Atom(bytes) = large_num { - assert!(bytes.len() > 1); - } else { - panic!("Expected atom for number"); - } - } - - #[test] - fn test_create_cons_list() { - let op = ClvmValue::Atom(vec![ClvmOperator::Add.opcode()]); - let args = vec![ClvmValue::Atom(vec![1]), ClvmValue::Atom(vec![2])]; - - let result = create_cons_list(op, args).unwrap(); - - // Should create nested cons structure - assert!(matches!(result, ClvmValue::Cons(_, _))); - } - - #[test] - fn test_create_list_from_values() { - let values = vec![ - ClvmValue::Atom(vec![1]), - ClvmValue::Atom(vec![2]), - ClvmValue::Atom(vec![3]), - ]; - - let result = create_list_from_values(values).unwrap(); - - // Should create proper list structure - assert!(matches!(result, ClvmValue::Cons(_, _))); - } - - #[test] - fn test_validate_function_call_arity_mismatch() { - let args = vec![Expression::Number(1)]; - let result = validate_function_call("test", &args, 2, &[]); - - assert!(matches!(result, Err(CompileError::ArityMismatch { .. }))); - } - - #[test] - fn test_validate_function_call_recursion_allowed() { - let args = vec![Expression::Number(1)]; - let call_stack = vec!["test".to_string()]; - let result = validate_function_call("test", &args, 1, &call_stack); - - // Recursion is now allowed at compile time, handled at runtime - assert!(result.is_ok()); - } - - #[test] - fn test_validate_function_call_success() { - let args = vec![Expression::Number(1)]; - let result = validate_function_call("test", &args, 1, &[]); - - assert!(result.is_ok()); - } -} diff --git a/clvm_zk_core/src/chialisp/frontend.rs b/clvm_zk_core/src/chialisp/frontend.rs deleted file mode 100644 index f732741..0000000 --- a/clvm_zk_core/src/chialisp/frontend.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! Frontend processing for Chialisp -//! -//! Converts S-expressions into typed AST structures. -//! Handles mod expressions, function definitions, and semantic validation. - -extern crate alloc; - -use alloc::{ - format, - string::{String, ToString}, - vec, - vec::Vec, -}; - -use super::{ast::*, parser::SExp}; -use crate::operators::ClvmOperator; - -/// Convert S-expression to ModuleAst -pub fn sexp_to_module(sexp: SExp) -> Result { - match sexp { - SExp::List(items) => { - if items.is_empty() { - return Err(CompileError::InvalidModStructure( - "Empty list cannot be a module".to_string(), - )); - } - - // Check if this is a mod expression - if let SExp::Atom(name) = &items[0] { - if name == "mod" { - return parse_mod_expression(&items); - } - } - - // Not a mod expression - treat as simple expression - let expr = sexp_to_expression(SExp::List(items))?; - Ok(ModuleAst::new(Vec::new(), expr)) - } - _ => { - // Single atom - treat as simple expression - let expr = sexp_to_expression(sexp)?; - Ok(ModuleAst::new(Vec::new(), expr)) - } - } -} - -/// Parse a mod expression: (mod (params) body) -fn parse_mod_expression(items: &[SExp]) -> Result { - if items.len() < 3 { - return Err(CompileError::InvalidModStructure( - "mod requires at least 2 arguments: parameters and body".to_string(), - )); - } - - // Parse parameters: (mod (x y) ...) - let parameters = parse_parameter_list(&items[1])?; - - // Parse body and helpers - let mut module = ModuleAst::new(parameters, Expression::Nil); - - // Process all remaining items (helpers + main body) - let mut main_body = None; - - for item in &items[2..] { - if let Some(helper) = try_parse_helper(item)? { - module.add_helper(helper); - } else { - // Not a helper - this should be the main body - if main_body.is_some() { - return Err(CompileError::InvalidModStructure( - "Multiple non-helper expressions in mod body".to_string(), - )); - } - main_body = Some(sexp_to_expression(item.clone())?); - } - } - - // Set the main body - module.body = main_body.unwrap_or(Expression::Nil); - - Ok(module) -} - -/// Parse parameter list: (x y) or x -fn parse_parameter_list(sexp: &SExp) -> Result, CompileError> { - match sexp { - SExp::List(items) => { - let mut params = Vec::new(); - for item in items { - if let SExp::Atom(name) = item { - params.push(name.clone()); - } else { - return Err(CompileError::InvalidModStructure( - "Parameter names must be atoms".to_string(), - )); - } - } - Ok(params) - } - SExp::Atom(name) => { - // Single parameter - Ok(vec![name.clone()]) - } - } -} - -/// Try to parse a helper definition (defun, etc.) -fn try_parse_helper(sexp: &SExp) -> Result, CompileError> { - match sexp { - SExp::List(items) => { - if items.is_empty() { - return Ok(None); - } - - if let SExp::Atom(name) = &items[0] { - match name.as_str() { - "defun" => Ok(Some(parse_defun(items, false)?)), - "defun-inline" => Ok(Some(parse_defun(items, true)?)), - _ => Ok(None), // Not a helper - } - } else { - Ok(None) - } - } - _ => Ok(None), - } -} - -/// Parse defun: (defun name (args) body) -fn parse_defun(items: &[SExp], inline: bool) -> Result { - if items.len() != 4 { - return Err(CompileError::InvalidFunctionDefinition( - "defun requires exactly 3 arguments: name, parameters, body".to_string(), - )); - } - - // Parse function name - let name = match &items[1] { - SExp::Atom(name) => name.clone(), - _ => { - return Err(CompileError::InvalidFunctionDefinition( - "Function name must be an atom".to_string(), - )) - } - }; - - // Parse parameters - let parameters = parse_parameter_list(&items[2])?; - - // Parse body - let body = sexp_to_expression(items[3].clone())?; - - Ok(HelperDefinition::function(name, parameters, body, inline)) -} - -/// Convert S-expression to Expression -pub fn sexp_to_expression(sexp: SExp) -> Result { - match sexp { - SExp::Atom(atom) => parse_atom_expression(atom), - SExp::List(items) => { - if items.is_empty() { - return Ok(Expression::Nil); - } - - // Check if this is an operation or function call - if let SExp::Atom(op_name) = &items[0] { - // Handle special cases first - match op_name.as_str() { - "q" | "quote" => { - // (q expr) -> quoted expression - if items.len() != 2 { - return Err(CompileError::ArityMismatch { - operator: "quote".to_string(), - expected: 1, - actual: items.len() - 1, - }); - } - let quoted = sexp_to_expression(items[1].clone())?; - return Ok(Expression::quote(quoted)); - } - "list" => { - // (list a b c) -> list construction - let args = items[1..] - .iter() - .map(|item| sexp_to_expression(item.clone())) - .collect::, _>>()?; - return Ok(Expression::list(args)); - } - _ => {} - } - - // Try to parse as operation - if let Some(operator) = ClvmOperator::parse_operator(op_name) { - // Skip quote since we handled it above - if matches!(operator, ClvmOperator::Quote) { - // This should have been handled above, but just in case - let args = items[1..] - .iter() - .map(|item| sexp_to_expression(item.clone())) - .collect::, _>>()?; - if args.len() != 1 { - return Err(CompileError::ArityMismatch { - operator: "quote".to_string(), - expected: 1, - actual: args.len(), - }); - } - return Ok(Expression::quote(args[0].clone())); - } - - let args = items[1..] - .iter() - .map(|item| sexp_to_expression(item.clone())) - .collect::, _>>()?; - - // Validate arity if known - if let Some(expected_arity) = operator.arity() { - if args.len() != expected_arity { - return Err(CompileError::ArityMismatch { - operator: op_name.clone(), - expected: expected_arity, - actual: args.len(), - }); - } - } - - return Ok(Expression::operation(operator, args)); - } - - // Unknown operator - treat as function call - let args = items[1..] - .iter() - .map(|item| sexp_to_expression(item.clone())) - .collect::, _>>()?; - Ok(Expression::function_call(op_name.clone(), args)) - } else { - Err(CompileError::InvalidModStructure( - "First element of list must be an atom".to_string(), - )) - } - } - } -} - -/// Parse atomic expressions (numbers, strings, variables) -fn parse_atom_expression(atom: String) -> Result { - // Check for invalid floating point syntax before parsing as integer - if atom.contains('.') { - return Err(CompileError::ParseError(format!( - "Floating point numbers not supported: {}", - atom - ))); - } - - // Try to parse as number - if let Ok(num) = atom.parse::() { - return Ok(Expression::number(num)); - } - - // Check for string literals (should start and end with quotes) - if atom.starts_with('"') && atom.ends_with('"') && atom.len() >= 2 { - let content = &atom[1..atom.len() - 1]; - return Ok(Expression::string(content.to_string())); - } - - // Check for special atoms - match atom.as_str() { - "nil" | "()" => Ok(Expression::nil()), - // Chia consensus opcodes - "AGG_SIG_UNSAFE" => Ok(Expression::number(49)), - "AGG_SIG_ME" => Ok(Expression::number(50)), - "CREATE_COIN" => Ok(Expression::number(51)), - "RESERVE_FEE" => Ok(Expression::number(52)), - // Output/Messaging - "REMARK" => Ok(Expression::number(1)), - // Announcements - "CREATE_COIN_ANNOUNCEMENT" => Ok(Expression::number(60)), - "ASSERT_COIN_ANNOUNCEMENT" => Ok(Expression::number(61)), - "CREATE_PUZZLE_ANNOUNCEMENT" => Ok(Expression::number(62)), - "ASSERT_PUZZLE_ANNOUNCEMENT" => Ok(Expression::number(63)), - // Concurrency - "ASSERT_CONCURRENT_SPEND" => Ok(Expression::number(64)), - "ASSERT_CONCURRENT_PUZZLE" => Ok(Expression::number(65)), - // Messaging - "SEND_MESSAGE" => Ok(Expression::number(66)), - "RECEIVE_MESSAGE" => Ok(Expression::number(67)), - // Assertions - "ASSERT_MY_COIN_ID" => Ok(Expression::number(70)), - "ASSERT_MY_PARENT_ID" => Ok(Expression::number(71)), - "ASSERT_MY_PUZZLEHASH" => Ok(Expression::number(72)), - "ASSERT_MY_AMOUNT" => Ok(Expression::number(73)), - _ => Ok(Expression::variable(atom)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::chialisp::parser::parse_chialisp; - use alloc::vec; - - #[test] - fn test_simple_expression() { - let sexp = parse_chialisp("(+ 1 2)").unwrap(); - let expr = sexp_to_expression(sexp).unwrap(); - - match expr { - Expression::Operation { - operator, - arguments, - } => { - assert_eq!(operator, ClvmOperator::Add); - assert_eq!(arguments.len(), 2); - } - _ => panic!("Expected operation"), - } - } - - #[test] - fn test_simple_mod() { - let sexp = parse_chialisp("(mod (x y) (+ x y))").unwrap(); - let module = sexp_to_module(sexp).unwrap(); - - assert_eq!(module.parameters, vec!["x", "y"]); - assert_eq!(module.helpers.len(), 0); - - match module.body { - Expression::Operation { operator, .. } => { - assert_eq!(operator, ClvmOperator::Add); - } - _ => panic!("Expected operation"), - } - } - - #[test] - fn test_mod_with_defun() { - let source = r#" - (mod (n) - (defun double (x) (* x 2)) - (double n)) - "#; - - let sexp = parse_chialisp(source).unwrap(); - let module = sexp_to_module(sexp).unwrap(); - - assert_eq!(module.parameters, vec!["n"]); - assert_eq!(module.helpers.len(), 1); - - // Check function definition - let func = &module.helpers[0]; - match func { - HelperDefinition::Function { - name, - parameters, - body, - inline, - } => { - assert_eq!(name, "double"); - assert_eq!(parameters, &vec!["x"]); - assert!(!inline); - - // Check function body - match body { - Expression::Operation { operator, .. } => { - assert_eq!(*operator, ClvmOperator::Multiply); - } - _ => panic!("Expected multiply operation"), - } - } - } - - // Check main body is function call - match module.body { - Expression::FunctionCall { name, arguments } => { - assert_eq!(name, "double"); - assert_eq!(arguments.len(), 1); - } - _ => panic!("Expected function call"), - } - } - - #[test] - fn test_variable_parsing() { - let expr = sexp_to_expression(parse_chialisp("amount").unwrap()).unwrap(); - assert_eq!(expr, Expression::variable("amount")); - } - - #[test] - fn test_number_parsing() { - let expr = sexp_to_expression(parse_chialisp("42").unwrap()).unwrap(); - assert_eq!(expr, Expression::number(42)); - - let expr = sexp_to_expression(parse_chialisp("-10").unwrap()).unwrap(); - assert_eq!(expr, Expression::number(-10)); - } - - #[test] - fn test_quoted_expression() { - let expr = sexp_to_expression(parse_chialisp("(q 42)").unwrap()).unwrap(); - match expr { - Expression::Quote(inner) => { - assert_eq!(*inner, Expression::number(42)); - } - _ => panic!("Expected quoted expression"), - } - } - - #[test] - fn test_function_call() { - let expr = sexp_to_expression(parse_chialisp("(factorial 5)").unwrap()).unwrap(); - match expr { - Expression::FunctionCall { name, arguments } => { - assert_eq!(name, "factorial"); - assert_eq!(arguments.len(), 1); - assert_eq!(arguments[0], Expression::number(5)); - } - _ => panic!("Expected function call"), - } - } - - #[test] - fn test_nested_expressions() { - let expr = sexp_to_expression(parse_chialisp("(+ (* 2 3) 4)").unwrap()).unwrap(); - match expr { - Expression::Operation { - operator, - arguments, - } => { - assert_eq!(operator, ClvmOperator::Add); - assert_eq!(arguments.len(), 2); - - // First argument should be multiplication - match &arguments[0] { - Expression::Operation { operator, .. } => { - assert_eq!(*operator, ClvmOperator::Multiply); - } - _ => panic!("Expected multiply operation"), - } - } - _ => panic!("Expected add operation"), - } - } - - #[test] - fn test_arity_validation() { - // Test too few arguments - let result = sexp_to_expression(parse_chialisp("(+ 1)").unwrap()); - assert!(matches!(result, Err(CompileError::ArityMismatch { .. }))); - - // Test too many arguments - let result = sexp_to_expression(parse_chialisp("(+ 1 2 3)").unwrap()); - assert!(matches!(result, Err(CompileError::ArityMismatch { .. }))); - } - - #[test] - fn test_invalid_defun() { - let source = "(mod (x) (defun double))"; // Missing args and body - let sexp = parse_chialisp(source).unwrap(); - let result = sexp_to_module(sexp); - assert!(matches!( - result, - Err(CompileError::InvalidFunctionDefinition(_)) - )); - } - - #[test] - fn test_chia_opcode_constants() { - // test that CREATE_COIN is recognized as the number 51 - let expr = sexp_to_expression(parse_chialisp("CREATE_COIN").unwrap()).unwrap(); - assert_eq!(expr, Expression::number(51)); - - // test AGG_SIG_ME - let expr = sexp_to_expression(parse_chialisp("AGG_SIG_ME").unwrap()).unwrap(); - assert_eq!(expr, Expression::number(50)); - - // test in a list expression - let expr = - sexp_to_expression(parse_chialisp("(list CREATE_COIN puzzle_hash amount)").unwrap()) - .unwrap(); - match expr { - Expression::List(items) => { - assert_eq!(items.len(), 3); - assert_eq!(items[0], Expression::number(51)); - assert_eq!(items[1], Expression::variable("puzzle_hash")); - assert_eq!(items[2], Expression::variable("amount")); - } - _ => panic!("expected list expression"), - } - } -} diff --git a/clvm_zk_core/src/chialisp/mod.rs b/clvm_zk_core/src/chialisp/mod.rs deleted file mode 100644 index bafe5d1..0000000 --- a/clvm_zk_core/src/chialisp/mod.rs +++ /dev/null @@ -1,681 +0,0 @@ -// CLVM-ZK Chialisp compiler - -extern crate alloc; - -use alloc::{ - boxed::Box, - collections::BTreeMap, - format, - string::{String, ToString}, - vec, - vec::Vec, -}; - -use crate::{encode_clvm_value, operators::ClvmOperator, types::ClvmValue, Hasher}; - -pub mod ast; -pub mod compiler_utils; -pub mod frontend; -pub mod parser; - -pub use ast::*; -pub use compiler_utils::*; -pub use frontend::*; -pub use parser::*; - -/// Compilation context for tracking functions and variables -/// Stored function definition for inlining -#[derive(Clone)] -pub struct FunctionDef { - pub arity: usize, - pub parameters: Vec, - pub body: Expression, -} - -pub struct CompilerContext { - /// Function definitions: name -> (arity, parameters, body) - pub functions: BTreeMap, - /// Current parameter names in scope - parameters: Vec, - /// Call stack for recursion detection - pub call_stack: Vec, - /// Compilation mode (Template vs Instance) - mode: CompilationMode, -} - -impl CompilerContext { - pub fn with_parameters(parameters: Vec) -> Self { - Self { - functions: BTreeMap::new(), - parameters, - call_stack: Vec::new(), - mode: CompilationMode::Template, // Default to template mode - } - } - - pub fn with_parameters_and_mode(parameters: Vec, mode: CompilationMode) -> Self { - Self { - functions: BTreeMap::new(), - parameters, - call_stack: Vec::new(), - mode, - } - } - - pub fn add_function(&mut self, name: String, parameters: Vec, body: Expression) { - let arity = parameters.len(); - self.functions.insert( - name, - FunctionDef { - arity, - parameters, - body, - }, - ); - } - - pub fn get_function_arity(&self, name: &str) -> Option { - self.functions.get(name).map(|f| f.arity) - } - - pub fn get_function(&self, name: &str) -> Option<&FunctionDef> { - self.functions.get(name) - } - - pub fn get_parameter_index(&self, name: &str) -> Option { - self.parameters.iter().position(|p| p == name) - } - - /// Check if a function call would create recursion - pub fn check_recursion(&self, function_name: &str) -> bool { - self.call_stack.contains(&function_name.to_string()) - } - - /// Push a function onto the call stack - pub fn push_call(&mut self, function_name: String) { - self.call_stack.push(function_name); - } - - /// Pop a function from the call stack - pub fn pop_call(&mut self) { - self.call_stack.pop(); - } - - /// Get the current call stack - pub fn get_call_stack(&self) -> &[String] { - &self.call_stack - } - - /// Get the compilation mode - pub fn get_mode(&self) -> CompilationMode { - self.mode - } -} - -// Compilation using clvm_tools_rs - the official Chia chialisp compiler. -// -// This provides full chialisp support including: -// - defun with proper recursion support -// - defmacro (compile-time macros) -// - if/list and other standard macros -// - All chialisp language features -// -// Parameters are passed at runtime via the CLVM environment, not substituted at compile time. -// Use `serialize_params_to_clvm` to convert ProgramParameters to CLVM args format. - -/// Compile Chialisp to bytecode using clvm_tools_rs compiler. -/// -/// Note: Parameters are NOT substituted at compile time. The compiled bytecode expects -/// parameters to be passed at runtime via the CLVM environment (args). -/// Use `serialize_params_to_clvm` to convert ProgramParameters to CLVM args format. -pub fn compile_chialisp_to_bytecode( - hasher: Hasher, - source: &str, -) -> Result<(Vec, [u8; 32]), CompileError> { - // Use clvm_tools_rs's full compiler with recursion support - let bytecode = clvm_tools_rs::compile_chialisp(source).map_err(|e| { - CompileError::ParseError(format!("clvm_tools_rs compilation failed: {}", e)) - })?; - - let program_hash = generate_program_hash(hasher, &bytecode); - - Ok((bytecode, program_hash)) -} - -/// Get program hash for template -pub fn compile_chialisp_template_hash( - hasher: Hasher, - source: &str, -) -> Result<[u8; 32], CompileError> { - let bytecode = clvm_tools_rs::compile_chialisp(source).map_err(|e| { - CompileError::ParseError(format!("clvm_tools_rs compilation failed: {}", e)) - })?; - Ok(generate_program_hash(hasher, &bytecode)) -} - -/// Compile Chialisp to get template hash using default SHA-256 hasher -/// Only available with sha2-hasher feature -#[cfg(feature = "sha2-hasher")] -pub fn compile_chialisp_template_hash_default(source: &str) -> Result<[u8; 32], CompileError> { - let bytecode = clvm_tools_rs::compile_chialisp(source).map_err(|e| { - CompileError::ParseError(format!("clvm_tools_rs compilation failed: {}", e)) - })?; - Ok(generate_program_hash(crate::hash_data, &bytecode)) -} - -/// Unified expression compiler with mode parameter -pub fn compile_expression_unified( - expr: &Expression, - context: &mut CompilerContext, -) -> Result { - // Handle literals - these are always quoted in BOTH modes - // because they should be treated as values, not environment references - if let Some(result) = compile_basic_expression_types(expr) { - let value = result?; - // Always quote literals so they're not treated as operators or environment refs - return Ok(quote_value(value)); - } - - match expr { - Expression::Variable(name) => compile_variable_unified(name, context), - Expression::Operation { - operator, - arguments, - } => { - let compiled_args = arguments - .iter() - .map(|arg| compile_expression_unified(arg, context)) - .collect::, _>>()?; - - // Check if this is a condition operator (should be compiled as data, not operator call) - if operator.is_condition_operator() { - // Compile condition as a quoted list: (q . (opcode arg1 arg2 ...)) - // This creates a condition VALUE that can be returned as program output - compile_condition_as_list(operator.opcode(), compiled_args) - } else { - let op_atom = ClvmValue::Atom(vec![operator.opcode()]); - create_cons_list(op_atom, compiled_args) - } - } - Expression::FunctionCall { name, arguments } => { - compile_function_call_unified(name, arguments, context) - } - Expression::List(items) => { - // Compile (list a b c) to nested cons operations: (c a (c b (c c (q ())))) - // This builds the list at execution time, not as static structure - - if items.is_empty() { - // Empty list: (q . nil) - quotes nil - let nil = ClvmValue::Atom(vec![]); - let quote_op = ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()]); - return Ok(ClvmValue::Cons(Box::new(quote_op), Box::new(nil))); - } - - // Build nested cons operations from right to left - // Start with (q . nil) for the tail - this quotes an empty list - let nil = ClvmValue::Atom(vec![]); - let quote_op = ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()]); - let mut result = ClvmValue::Cons(Box::new(quote_op), Box::new(nil)); - - // Wrap each item in cons operation - for item in items.iter().rev() { - let compiled_item = compile_expression_unified(item, context)?; - let cons_op = ClvmValue::Atom(vec![ClvmOperator::Cons.opcode()]); - result = create_cons_list(cons_op, vec![compiled_item, result])?; - } - - Ok(result) - } - Expression::Quote(inner) => { - let compiled_inner = compile_expression_unified(inner, context)?; - let quote_op = ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()]); - create_cons_list(quote_op, vec![compiled_inner]) - } - Expression::Number(_) | Expression::String(_) | Expression::Bytes(_) | Expression::Nil => { - unreachable!("Basic types handled by shared utilities") - } - } -} - -fn compile_variable_unified( - name: &str, - context: &CompilerContext, -) -> Result { - if let Some(index) = context.get_parameter_index(name) { - match context.get_mode() { - CompilationMode::Template => Ok(create_parameter_access(index)), - CompilationMode::Instance => Err(CompileError::UndefinedVariable(format!( - "Parameter {} should have been substituted in Instance mode", - name - ))), - } - } else if context.get_function_arity(name).is_some() { - Err(CompileError::InvalidModStructure(format!( - "Bare function reference not supported: {}", - name - ))) - } else { - Err(CompileError::UndefinedVariable(name.to_string())) - } -} - -fn compile_function_call_unified( - name: &str, - arguments: &[Expression], - context: &mut CompilerContext, -) -> Result { - let func_def = context - .get_function(name) - .ok_or_else(|| CompileError::UnknownFunction(name.to_string()))? - .clone(); - - validate_function_call(name, arguments, func_def.arity, context.get_call_stack())?; - - // Check for recursion - if recursive, we need special handling - if context.check_recursion(name) { - return Err(CompileError::InvalidModStructure(format!( - "Recursive function '{}' detected - recursion not yet supported with inlining", - name - ))); - } - - // Push function onto call stack to detect recursion - context.push_call(name.to_string()); - - // Inline the function using CLVM apply pattern: - // (a (q . ) ) - // - // Where environment is a list of the argument values - - // Compile the function body in Template mode since function parameters - // are accessed via environment references (not substituted values) - let mut func_context = CompilerContext::with_parameters_and_mode( - func_def.parameters.clone(), - CompilationMode::Template, - ); - // Copy function definitions so nested calls work - func_context.functions = context.functions.clone(); - func_context.call_stack = context.call_stack.clone(); - - let compiled_body = compile_expression_unified(&func_def.body, &mut func_context)?; - - // Pop function from call stack - context.pop_call(); - - // Quote the function body: (q . ) - let quoted_body = ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()])), - Box::new(compiled_body), - ); - - // Compile arguments and build environment list - let compiled_args: Vec = arguments - .iter() - .map(|arg| compile_expression_unified(arg, context)) - .collect::, _>>()?; - - // Build environment using cons operations so arguments are EVALUATED at runtime. - // This creates: (c arg1 (c arg2 (c arg3 (q . nil)))) - // When evaluated, this becomes (val1 val2 val3) - a list of evaluated values. - // - // Previously we built a quoted static structure (q . (arg1 arg2 arg3)), - // but that meant (f 1) inside the function got the quoted code (q . 5) - // instead of the value 5. - let env = if compiled_args.is_empty() { - // Empty environment: (q . nil) - ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()])), - Box::new(ClvmValue::Atom(vec![])), - ) - } else { - // Build nested cons operations from right to left - // Start with (q . nil) for the tail - let mut env_expr = ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()])), - Box::new(ClvmValue::Atom(vec![])), - ); - - // Wrap each argument with cons: (c arg env_expr) - for arg in compiled_args.into_iter().rev() { - let cons_op = ClvmValue::Atom(vec![ClvmOperator::Cons.opcode()]); - env_expr = create_cons_list(cons_op, vec![arg, env_expr])?; - } - - env_expr - }; - - // Build apply expression: (a ) - let apply_op = ClvmValue::Atom(vec![ClvmOperator::Apply.opcode()]); - create_cons_list(apply_op, vec![quoted_body, env]) -} - -/// Create CLVM code to access parameter at given index (for Template mode) -fn create_parameter_access(index: usize) -> ClvmValue { - // In CLVM: - // Parameter 0: (f 1) - first of environment - // Parameter 1: (f (r 1)) - first of rest of environment - // Parameter 2: (f (r (r 1))) - first of rest of rest of environment - // etc. - // - // The 1 is a special environment reference and should NOT be quoted! - - if index == 0 { - // (f 1) - ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::First.opcode()])), - Box::new(ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![1])), // environment reference (NOT quoted) - Box::new(ClvmValue::Atom(vec![])), // nil - )), - ) - } else { - // (f (r (r ... (r 1)))) with index 'r' operations - let mut inner = ClvmValue::Atom(vec![1]); // start with environment reference - - // Apply 'r' (rest) operations - for _ in 0..index { - inner = ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::Rest.opcode()])), - Box::new(ClvmValue::Cons( - Box::new(inner), - Box::new(ClvmValue::Atom(vec![])), - )), - ); - } - - // Apply 'f' (first) to get the parameter - ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::First.opcode()])), - Box::new(ClvmValue::Cons( - Box::new(inner), - Box::new(ClvmValue::Atom(vec![])), - )), - ) - } -} - -/// Compile a condition operator as a list-building expression -/// Instead of (opcode arg1 arg2) as an operator call, we generate: -/// (c (q . opcode) (c arg1 (c arg2 (q)))) -/// This builds a list (opcode arg1 arg2) at runtime that can be returned as output -fn compile_condition_as_list(opcode: u8, args: Vec) -> Result { - // Start with quoted nil for the tail - let nil = ClvmValue::Atom(vec![]); - let quote_op = ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()]); - let mut result = ClvmValue::Cons(Box::new(quote_op), Box::new(nil)); - - // Wrap each argument with cons (from right to left) - for arg in args.into_iter().rev() { - let cons_op = ClvmValue::Atom(vec![ClvmOperator::Cons.opcode()]); - result = create_cons_list(cons_op, vec![arg, result])?; - } - - // Finally, cons the quoted opcode at the front - let quoted_opcode = ClvmValue::Cons( - Box::new(ClvmValue::Atom(vec![ClvmOperator::Quote.opcode()])), - Box::new(ClvmValue::Atom(vec![opcode])), - ); - let cons_op = ClvmValue::Atom(vec![ClvmOperator::Cons.opcode()]); - create_cons_list(cons_op, vec![quoted_opcode, result]) -} - -pub fn compile_module_unified( - module: &ModuleAst, - mode: CompilationMode, -) -> Result, CompileError> { - let mut context = CompilerContext::with_parameters_and_mode(module.parameters.clone(), mode); - - // Register all functions with their full definitions for inlining - for helper in &module.helpers { - match helper { - HelperDefinition::Function { - name, - parameters, - body, - .. - } => { - context.add_function(name.clone(), parameters.clone(), body.clone()); - } - } - } - - let clvm_value = compile_expression_unified(&module.body, &mut context)?; - - Ok(encode_clvm_value(clvm_value)) -} - -pub fn generate_program_hash(hasher: Hasher, template_bytecode: &[u8]) -> [u8; 32] { - hasher(template_bytecode) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ProgramParameter; - use sha2::{Digest, Sha256}; - - fn hash_data(data: &[u8]) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(data); - hasher.finalize().into() - } - - #[test] - fn test_basic_compilation() { - let result = compile_chialisp_to_bytecode(hash_data, "(mod (x y) (+ x y))"); - assert!(result.is_ok()); - - let (bytecode, program_hash) = result.unwrap(); - assert!(!bytecode.is_empty()); - assert_ne!(program_hash, [0u8; 32]); - } - - #[test] - fn test_function_compilation() { - let source = r#" - (mod (n) - (defun double (x) (* x 2)) - (double n)) - "#; - - let result = compile_chialisp_to_bytecode(hash_data, source); - assert!(result.is_ok()); - - let (bytecode, program_hash) = result.unwrap(); - assert!(!bytecode.is_empty()); - assert_ne!(program_hash, [0u8; 32]); - } - - #[test] - fn test_deterministic_hashing() { - let source = "(mod (x y) (+ x y))"; - - let result1 = compile_chialisp_to_bytecode(hash_data, source); - let result2 = compile_chialisp_to_bytecode(hash_data, source); - - assert!(result1.is_ok()); - assert!(result2.is_ok()); - - let (_, hash1) = result1.unwrap(); - let (_, hash2) = result2.unwrap(); - - // Same source should produce same program hash - assert_eq!(hash1, hash2); - } - - #[test] - fn test_different_programs_different_hashes() { - let source1 = "(mod (x y) (+ x y))"; - let source2 = "(mod (x y) (* x y))"; - - let result1 = compile_chialisp_to_bytecode(hash_data, source1); - let result2 = compile_chialisp_to_bytecode(hash_data, source2); - - assert!(result1.is_ok()); - assert!(result2.is_ok()); - - let (_, hash1) = result1.unwrap(); - let (_, hash2) = result2.unwrap(); - - // Different programs should produce different hashes - assert_ne!(hash1, hash2); - } - - #[test] - fn test_invalid_syntax() { - let result = compile_chialisp_to_bytecode(hash_data, "(mod (x y) (+ x y"); - assert!(result.is_err()); - } - - #[test] - fn test_unified_compiler_template_mode() { - let source = "(mod (x y) (+ x y))"; - let sexp = parse_chialisp(source).unwrap(); - let module = sexp_to_module(sexp).unwrap(); - - let template_bytecode = compile_module_unified(&module, CompilationMode::Template).unwrap(); - assert!(!template_bytecode.is_empty()); - - let template_bytecode2 = - compile_module_unified(&module, CompilationMode::Template).unwrap(); - assert_eq!(template_bytecode, template_bytecode2); - } - - #[test] - fn test_unified_compiler_instance_mode() { - let source = "(mod (x y) (+ x y))"; - let sexp = parse_chialisp(source).unwrap(); - let module = sexp_to_module(sexp).unwrap(); - - let program_parameters = &[ProgramParameter::Int(5), ProgramParameter::Int(3)]; - let substituted_module = substitute_values_in_module(&module, program_parameters).unwrap(); - - let instance_bytecode = - compile_module_unified(&substituted_module, CompilationMode::Instance).unwrap(); - assert!(!instance_bytecode.is_empty()); - } - - #[test] - fn test_unified_compiler_with_functions() { - let source = r#" - (mod (n) - (defun double (x) (* x 2)) - (double n)) - "#; - let sexp = parse_chialisp(source).unwrap(); - let module = sexp_to_module(sexp).unwrap(); - - let template_result = compile_module_unified(&module, CompilationMode::Template); - assert!(template_result.is_ok()); - - let program_parameters = &[ProgramParameter::Int(5)]; - let substituted_module = substitute_values_in_module(&module, program_parameters).unwrap(); - let instance_result = - compile_module_unified(&substituted_module, CompilationMode::Instance); - assert!(instance_result.is_ok()); - } - - #[test] - fn test_template_determinism_analysis() { - // Test 1: Same logic, different parameter names should produce IDENTICAL bytecode - let program1 = "(mod (x y) (+ x y))"; // Parameters: x, y - let program2 = "(mod (a b) (+ a b))"; // Parameters: a, b - let program3 = "(mod (first second) (+ first second))"; // Parameters: first, second - - // Compile all to templates - let module1 = sexp_to_module(parse_chialisp(program1).unwrap()).unwrap(); - let module2 = sexp_to_module(parse_chialisp(program2).unwrap()).unwrap(); - let module3 = sexp_to_module(parse_chialisp(program3).unwrap()).unwrap(); - - let template1 = compile_module_unified(&module1, CompilationMode::Template).unwrap(); - let template2 = compile_module_unified(&module2, CompilationMode::Template).unwrap(); - let template3 = compile_module_unified(&module3, CompilationMode::Template).unwrap(); - - let hash1 = generate_program_hash(hash_data, &template1); - let hash2 = generate_program_hash(hash_data, &template2); - let hash3 = generate_program_hash(hash_data, &template3); - - // Key finding: Parameter names don't affect bytecode! - // Only positional indices are stored: (f env), (f (r env)), etc. - assert_eq!(template1, template2); - assert_eq!(template1, template3); - assert_eq!(hash1, hash2); - assert_eq!(hash1, hash3); - - // Test 2: Different logic should produce different hashes - let program4 = "(mod (x y) (* x y))"; // Multiplication instead of addition - let module4 = sexp_to_module(parse_chialisp(program4).unwrap()).unwrap(); - let template4 = compile_module_unified(&module4, CompilationMode::Template).unwrap(); - let hash4 = generate_program_hash(hash_data, &template4); - - assert_ne!(hash1, hash4); - - // Test 3: Different parameter count should produce different hashes - let program5 = "(mod (x) (* x 2))"; // Single parameter - let module5 = sexp_to_module(parse_chialisp(program5).unwrap()).unwrap(); - let template5 = compile_module_unified(&module5, CompilationMode::Template).unwrap(); - let hash5 = generate_program_hash(hash_data, &template5); - - assert_ne!(hash1, hash5); - - // Test 4: Function names DON'T affect bytecode because functions are inlined - // With inlining, only the function body matters, not the name - let program6 = r#"(mod (x) - (defun double (y) (* y 2)) - (double x))"#; - let program7 = r#"(mod (x) - (defun multiply_by_two (y) (* y 2)) - (multiply_by_two x))"#; - - let module6 = sexp_to_module(parse_chialisp(program6).unwrap()).unwrap(); - let module7 = sexp_to_module(parse_chialisp(program7).unwrap()).unwrap(); - - let template6 = compile_module_unified(&module6, CompilationMode::Template).unwrap(); - let template7 = compile_module_unified(&module7, CompilationMode::Template).unwrap(); - - let hash6 = generate_program_hash(hash_data, &template6); - let hash7 = generate_program_hash(hash_data, &template7); - - // With function inlining, identical function bodies produce identical bytecode - // regardless of function names - this is the correct clvmr-compatible behavior - assert_eq!(hash6, hash7); - } - - #[test] - fn test_clvm_environment_access_patterns() { - // Test program with 4 parameters to show access patterns - let program = "(mod (w x y z) (list w x y z))"; - let module = sexp_to_module(parse_chialisp(program).unwrap()).unwrap(); - let template = compile_module_unified(&module, CompilationMode::Template).unwrap(); - - // The compiled template should contain environment access patterns - assert!(!template.is_empty()); - - // Test what the create_parameter_access function generates - let param0 = create_parameter_access(0); // (f 1) for parameter 0 - let param1 = create_parameter_access(1); // (f (r 1)) for parameter 1 - let param2 = create_parameter_access(2); // (f (r (r 1))) for parameter 2 - let param3 = create_parameter_access(3); // (f (r (r (r 1)))) for parameter 3 - - // Encode to bytecode to see the actual opcodes - let bytes0 = encode_clvm_value(param0); - let bytes1 = encode_clvm_value(param1); - let bytes2 = encode_clvm_value(param2); - let bytes3 = encode_clvm_value(param3); - - // Parameter 0: (f 1) should be shortest - // Parameter 1: (f (r 1)) should contain 'f', 'r', and '1' opcodes - // Pattern should get progressively longer for higher parameters - assert!(bytes0.len() < bytes1.len()); - assert!(bytes1.len() < bytes2.len()); - assert!(bytes2.len() < bytes3.len()); - - // All should contain the 'f' opcode at some point - let f_opcode = ClvmOperator::First.opcode(); - let r_opcode = ClvmOperator::Rest.opcode(); - assert!(bytes0.contains(&f_opcode)); - assert!(bytes1.contains(&f_opcode)); - assert!(bytes1.contains(&r_opcode)); - assert!(bytes2.contains(&r_opcode)); - } -} diff --git a/clvm_zk_core/src/chialisp/parser.rs b/clvm_zk_core/src/chialisp/parser.rs deleted file mode 100644 index be3ee11..0000000 --- a/clvm_zk_core/src/chialisp/parser.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! No-std S-expression parser for Chialisp -//! -//! Parses Chialisp source code into S-expressions without heap allocations. -//! Designed to work in guest environments with limited memory. - -extern crate alloc; - -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; - -/// Parsed S-expression - the raw syntax tree -#[derive(Debug, Clone, PartialEq)] -pub enum SExp { - /// Atomic value: number, string, or symbol - Atom(String), - /// List of S-expressions - List(Vec), -} - -/// Parse error types -#[derive(Debug, Clone, PartialEq)] -pub enum ParseError { - UnexpectedEndOfInput, - UnbalancedParens, - InvalidCharacter(char), - InvalidNumber(String), - EmptyInput, -} - -/// S-expression parser with zero heap allocations during parsing -pub struct SExpParser<'a> { - input: &'a str, - pos: usize, -} - -impl<'a> SExpParser<'a> { - /// Create a new parser for the given input - pub fn new(input: &'a str) -> Self { - Self { input, pos: 0 } - } - - /// Parse the input into an S-expression - pub fn parse(&mut self) -> Result { - self.skip_whitespace(); - - if self.pos >= self.input.len() { - return Err(ParseError::EmptyInput); - } - - self.parse_sexp() - } - - /// Parse a single S-expression (atom or list) - fn parse_sexp(&mut self) -> Result { - self.skip_whitespace(); - - if self.pos >= self.input.len() { - return Err(ParseError::UnexpectedEndOfInput); - } - - let ch = self.current_char(); - match ch { - '(' => self.parse_list(), - _ => self.parse_atom(), - } - } - - /// Parse a list: (item1 item2 ...) - fn parse_list(&mut self) -> Result { - // Consume opening paren - self.advance(); - self.skip_whitespace(); - - let mut items = Vec::new(); - - while self.pos < self.input.len() { - let ch = self.current_char(); - - if ch == ')' { - // Consume closing paren and return - self.advance(); - return Ok(SExp::List(items)); - } - - // Parse next item - let item = self.parse_sexp()?; - items.push(item); - self.skip_whitespace(); - } - - Err(ParseError::UnbalancedParens) - } - - /// Parse an atomic value (symbol, string, or number) - fn parse_atom(&mut self) -> Result { - self.skip_whitespace(); - - if self.pos >= self.input.len() { - return Err(ParseError::UnexpectedEndOfInput); - } - - let ch = self.current_char(); - - // Handle quoted strings - if ch == '"' { - return self.parse_string(); - } - - // Parse regular atom (symbol or number) - let start = self.pos; - - while self.pos < self.input.len() { - let ch = self.current_char(); - - // Stop at delimiters - if ch.is_whitespace() || ch == '(' || ch == ')' { - break; - } - - self.advance(); - } - - if start == self.pos { - return Err(ParseError::InvalidCharacter(ch)); - } - - let atom_str = &self.input[start..self.pos]; - Ok(SExp::Atom(atom_str.to_string())) - } - - /// Parse a quoted string: "hello world" - fn parse_string(&mut self) -> Result { - // Consume opening quote - self.advance(); - let start = self.pos; - - while self.pos < self.input.len() { - let ch = self.current_char(); - - if ch == '"' { - let content = &self.input[start..self.pos]; - self.advance(); // Consume closing quote - return Ok(SExp::Atom(content.to_string())); - } - - // TODO: Handle escape sequences if needed - self.advance(); - } - - Err(ParseError::UnbalancedParens) // Unterminated string - } - - /// Skip whitespace and comments - fn skip_whitespace(&mut self) { - while self.pos < self.input.len() { - let ch = self.current_char(); - - if ch.is_whitespace() { - self.advance(); - } else if ch == ';' { - // Skip comment until end of line - while self.pos < self.input.len() && self.current_char() != '\n' { - self.advance(); - } - } else { - break; - } - } - } - - /// Get current character - fn current_char(&self) -> char { - self.input.chars().nth(self.pos).unwrap_or('\0') - } - - /// Advance position by one character - fn advance(&mut self) { - if self.pos < self.input.len() { - // Find the next character boundary (UTF-8 safe) - let mut next_pos = self.pos + 1; - while next_pos < self.input.len() && !self.input.is_char_boundary(next_pos) { - next_pos += 1; - } - self.pos = next_pos; - } - } -} - -/// Convenience function to parse Chialisp source code -pub fn parse_chialisp(source: &str) -> Result { - let mut parser = SExpParser::new(source); - parser.parse() -} - -#[cfg(test)] -mod tests { - use super::*; - use alloc::vec; - - #[test] - fn test_parse_simple_atom() { - let result = parse_chialisp("hello").unwrap(); - assert_eq!(result, SExp::Atom("hello".to_string())); - } - - #[test] - fn test_parse_number() { - let result = parse_chialisp("42").unwrap(); - assert_eq!(result, SExp::Atom("42".to_string())); - } - - #[test] - fn test_parse_simple_list() { - let result = parse_chialisp("(+ 1 2)").unwrap(); - assert_eq!( - result, - SExp::List(vec![ - SExp::Atom("+".to_string()), - SExp::Atom("1".to_string()), - SExp::Atom("2".to_string()), - ]) - ); - } - - #[test] - fn test_parse_nested_list() { - let result = parse_chialisp("(+ (* 2 3) 4)").unwrap(); - assert_eq!( - result, - SExp::List(vec![ - SExp::Atom("+".to_string()), - SExp::List(vec![ - SExp::Atom("*".to_string()), - SExp::Atom("2".to_string()), - SExp::Atom("3".to_string()), - ]), - SExp::Atom("4".to_string()), - ]) - ); - } - - #[test] - fn test_parse_mod_expression() { - let result = parse_chialisp("(mod (x y) (+ x y))").unwrap(); - assert_eq!( - result, - SExp::List(vec![ - SExp::Atom("mod".to_string()), - SExp::List(vec![ - SExp::Atom("x".to_string()), - SExp::Atom("y".to_string()), - ]), - SExp::List(vec![ - SExp::Atom("+".to_string()), - SExp::Atom("x".to_string()), - SExp::Atom("y".to_string()), - ]), - ]) - ); - } - - #[test] - fn test_parse_defun() { - let result = parse_chialisp("(defun double (x) (* x 2))").unwrap(); - assert_eq!( - result, - SExp::List(vec![ - SExp::Atom("defun".to_string()), - SExp::Atom("double".to_string()), - SExp::List(vec![SExp::Atom("x".to_string())]), - SExp::List(vec![ - SExp::Atom("*".to_string()), - SExp::Atom("x".to_string()), - SExp::Atom("2".to_string()), - ]), - ]) - ); - } - - #[test] - fn test_parse_with_comments() { - let result = parse_chialisp("(+ 1 2) ; this is a comment").unwrap(); - assert_eq!( - result, - SExp::List(vec![ - SExp::Atom("+".to_string()), - SExp::Atom("1".to_string()), - SExp::Atom("2".to_string()), - ]) - ); - } - - #[test] - fn test_parse_quoted_string() { - let result = parse_chialisp(r#""hello world""#).unwrap(); - assert_eq!(result, SExp::Atom("hello world".to_string())); - } - - #[test] - fn test_parse_empty_list() { - let result = parse_chialisp("()").unwrap(); - assert_eq!(result, SExp::List(vec![])); - } - - #[test] - fn test_parse_unbalanced_parens() { - let result = parse_chialisp("(+ 1 2"); - assert!(matches!(result, Err(ParseError::UnbalancedParens))); - } - - #[test] - fn test_parse_empty_input() { - let result = parse_chialisp(""); - assert!(matches!(result, Err(ParseError::EmptyInput))); - } - - #[test] - fn test_parse_whitespace_handling() { - let result = parse_chialisp(" ( + 1 2 ) ").unwrap(); - assert_eq!( - result, - SExp::List(vec![ - SExp::Atom("+".to_string()), - SExp::Atom("1".to_string()), - SExp::Atom("2".to_string()), - ]) - ); - } - - #[test] - fn test_complex_mod_with_defun() { - let source = r#" - (mod (n) - (defun factorial (x) - (if (= x 0) 1 (* x (factorial (- x 1))))) - (factorial n)) - "#; - - let result = parse_chialisp(source).unwrap(); - - // Should parse successfully - exact structure test would be complex - // Just verify it's a list with mod at the start - if let SExp::List(items) = result { - assert_eq!(items[0], SExp::Atom("mod".to_string())); - assert!(items.len() >= 3); // mod, params, body - } else { - panic!("Expected a list"); - } - } -} diff --git a/clvm_zk_core/src/coin_commitment.rs b/clvm_zk_core/src/coin_commitment.rs index 9680a22..77220bd 100644 --- a/clvm_zk_core/src/coin_commitment.rs +++ b/clvm_zk_core/src/coin_commitment.rs @@ -55,6 +55,36 @@ impl SerialCommitment { } } +/// XCH (native currency) tail hash - all zeros +pub const XCH_TAIL: [u8; 32] = [0u8; 32]; + +/// Domain separator for coin commitment v2 +pub const COIN_COMMITMENT_DOMAIN: &[u8; 17] = b"clvm_zk_coin_v2.0"; + +/// Total size of coin commitment preimage: domain(17) + tail(32) + amount(8) + puzzle(32) + serial(32) = 121 +pub const COIN_COMMITMENT_PREIMAGE_SIZE: usize = 121; + +/// Build coin commitment preimage into a fixed-size buffer (zero-allocation) +/// +/// Use this in zkVM guests for efficiency. Returns the filled buffer. +/// +/// Layout: domain(17) || tail_hash(32) || amount_be(8) || puzzle_hash(32) || serial_commitment(32) +#[inline] +pub fn build_coin_commitment_preimage( + tail_hash: &[u8; 32], + amount: u64, + puzzle_hash: &[u8; 32], + serial_commitment: &[u8; 32], +) -> [u8; COIN_COMMITMENT_PREIMAGE_SIZE] { + let mut data = [0u8; COIN_COMMITMENT_PREIMAGE_SIZE]; + data[0..17].copy_from_slice(COIN_COMMITMENT_DOMAIN); + data[17..49].copy_from_slice(tail_hash); + data[49..57].copy_from_slice(&amount.to_be_bytes()); + data[57..89].copy_from_slice(puzzle_hash); + data[89..121].copy_from_slice(serial_commitment); + data +} + /// commitment to full coin data /// /// used as a leaf in the global merkle tree to prove coin existence @@ -72,21 +102,24 @@ impl CoinCommitment { &self.0 } - /// compute commitment: hash(domain || program_hash || serial_commitment) + /// compute commitment v2: hash(domain || tail_hash || amount || program_hash || serial_commitment) + /// + /// tail_hash identifies the asset type: + /// - XCH (native): [0u8; 32] (use XCH_TAIL constant) + /// - CAT: hash of the TAIL program pub fn compute( + tail_hash: &[u8; 32], amount: u64, program_hash: &[u8; 32], serial_commitment: &SerialCommitment, hasher: fn(&[u8]) -> [u8; 32], ) -> Self { - const DOMAIN: &[u8] = b"clvm_zk_coin_v1.0"; - - let mut data = Vec::with_capacity(DOMAIN.len() + 64); - data.extend_from_slice(DOMAIN); - data.extend_from_slice(&amount.to_be_bytes()); - data.extend_from_slice(program_hash); - data.extend_from_slice(serial_commitment.as_bytes()); - + let data = build_coin_commitment_preimage( + tail_hash, + amount, + program_hash, + serial_commitment.as_bytes(), + ); CoinCommitment(hasher(&data)) } } @@ -98,7 +131,7 @@ impl CoinCommitment { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CoinSecrets { /// the serial number - used as input to nullifier computation when spending - /// nullifier = hash(serial_number || program_hash) + /// nullifier = hash(serial_number || program_hash || amount) pub serial_number: [u8; 32], /// randomness used to blind the serial commitment @@ -208,17 +241,39 @@ mod tests { #[test] fn test_coin_commitment_compute() { + let tail_hash = [0u8; 32]; // XCH + let amount = 1000u64; let program_hash = [5u8; 32]; let serial_commitment = SerialCommitment([6u8; 32]); - let amount = 1000u64; - let commitment = - CoinCommitment::compute(amount, &program_hash, &serial_commitment, test_hasher); + let commitment = CoinCommitment::compute( + &tail_hash, + amount, + &program_hash, + &serial_commitment, + test_hasher, + ); // verify consistent - let commitment2 = - CoinCommitment::compute(amount, &program_hash, &serial_commitment, test_hasher); + let commitment2 = CoinCommitment::compute( + &tail_hash, + amount, + &program_hash, + &serial_commitment, + test_hasher, + ); assert_eq!(commitment, commitment2); + + // different tail_hash produces different commitment + let cat_tail = [1u8; 32]; + let commitment3 = CoinCommitment::compute( + &cat_tail, + amount, + &program_hash, + &serial_commitment, + test_hasher, + ); + assert_ne!(commitment, commitment3); } #[test] @@ -255,4 +310,113 @@ mod tests { assert_eq!(secrets.serial_number, [1u8; 32]); assert_eq!(secrets.serial_randomness, [2u8; 32]); } + + // ========== CAT-specific tests ========== + + #[test] + fn test_xch_tail_constant() { + // XCH uses all-zeros tail_hash + assert_eq!(XCH_TAIL, [0u8; 32]); + } + + #[test] + fn test_build_coin_commitment_preimage_layout() { + let tail_hash = [0xAAu8; 32]; + let amount = 0x0102030405060708u64; + let puzzle_hash = [0xBBu8; 32]; + let serial_commitment = [0xCCu8; 32]; + + let preimage = + build_coin_commitment_preimage(&tail_hash, amount, &puzzle_hash, &serial_commitment); + + // verify total size + assert_eq!(preimage.len(), COIN_COMMITMENT_PREIMAGE_SIZE); + assert_eq!(preimage.len(), 121); + + // verify layout: domain(17) || tail(32) || amount(8) || puzzle(32) || serial(32) + assert_eq!(&preimage[0..17], COIN_COMMITMENT_DOMAIN); + assert_eq!(&preimage[17..49], &tail_hash); + assert_eq!(&preimage[49..57], &amount.to_be_bytes()); + assert_eq!(&preimage[57..89], &puzzle_hash); + assert_eq!(&preimage[89..121], &serial_commitment); + } + + #[test] + fn test_cat_vs_xch_commitment_differs() { + let amount = 1000u64; + let puzzle_hash = [5u8; 32]; + let serial_commitment = SerialCommitment([6u8; 32]); + + // XCH commitment + let xch_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &puzzle_hash, + &serial_commitment, + test_hasher, + ); + + // CAT commitment with arbitrary TAIL hash + let cat_tail: [u8; 32] = { + let mut h = [0u8; 32]; + // simulate hash of a TAIL program + for i in 0..32 { + h[i] = (i as u8).wrapping_mul(7); + } + h + }; + + let cat_commitment = CoinCommitment::compute( + &cat_tail, + amount, + &puzzle_hash, + &serial_commitment, + test_hasher, + ); + + // same coin data with different asset type must produce different commitment + assert_ne!(xch_commitment, cat_commitment); + } + + #[test] + fn test_same_cat_tail_same_commitment() { + let cat_tail = [0x42u8; 32]; // some TAIL hash + let amount = 500u64; + let puzzle_hash = [0x11u8; 32]; + let serial_commitment = SerialCommitment([0x22u8; 32]); + + let commitment1 = CoinCommitment::compute( + &cat_tail, + amount, + &puzzle_hash, + &serial_commitment, + test_hasher, + ); + + let commitment2 = CoinCommitment::compute( + &cat_tail, + amount, + &puzzle_hash, + &serial_commitment, + test_hasher, + ); + + // same inputs must produce identical commitment + assert_eq!(commitment1, commitment2); + } + + #[test] + fn test_preimage_determinism() { + // calling build_coin_commitment_preimage multiple times with same inputs + // must produce identical preimage (critical for ZK reproducibility) + let tail = [0xFFu8; 32]; + let amount = 999u64; + let puzzle = [0xEEu8; 32]; + let serial = [0xDDu8; 32]; + + let p1 = build_coin_commitment_preimage(&tail, amount, &puzzle, &serial); + let p2 = build_coin_commitment_preimage(&tail, amount, &puzzle, &serial); + + assert_eq!(p1, p2); + } } diff --git a/clvm_zk_core/src/lib.rs b/clvm_zk_core/src/lib.rs index 2e28b80..e88af95 100644 --- a/clvm_zk_core/src/lib.rs +++ b/clvm_zk_core/src/lib.rs @@ -2,7 +2,7 @@ extern crate alloc; -use alloc::{boxed::Box, vec, vec::Vec}; +use alloc::{boxed::Box, format, string::String, vec, vec::Vec}; use k256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; @@ -10,18 +10,129 @@ use k256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; use sha2::{Digest, Sha256}; pub mod backend_utils; -pub mod chialisp; pub mod clvm_parser; pub mod coin_commitment; pub mod merkle; pub mod operators; pub mod types; -pub use chialisp::*; pub use clvm_parser::*; pub use operators::*; pub use types::*; +// ============================================================================ +// Chialisp compilation utilities (previously in chialisp/mod.rs) +// ============================================================================ + +/// Standard Chia condition codes as defconstant declarations. +/// Prepend this to chialisp source for readable condition opcodes. +pub const STANDARD_CONDITION_CODES: &str = r#" +(defconstant AGG_SIG_UNSAFE 49) +(defconstant AGG_SIG_ME 50) +(defconstant CREATE_COIN 51) +(defconstant RESERVE_FEE 52) +(defconstant CREATE_COIN_ANNOUNCEMENT 60) +(defconstant ASSERT_COIN_ANNOUNCEMENT 61) +(defconstant CREATE_PUZZLE_ANNOUNCEMENT 62) +(defconstant ASSERT_PUZZLE_ANNOUNCEMENT 63) +(defconstant ASSERT_MY_COIN_ID 70) +(defconstant ASSERT_MY_PARENT_ID 71) +(defconstant ASSERT_MY_PUZZLEHASH 72) +(defconstant ASSERT_MY_AMOUNT 73) +"#; + +/// Prepend standard condition codes to chialisp source. +/// Use this when you want CREATE_COIN etc. to be available without defining them. +pub fn with_standard_conditions(source: &str) -> String { + if let Some(mod_pos) = source.find("(mod") { + if let Some(paren_start) = source[mod_pos..].find('(').map(|p| mod_pos + p) { + if let Some(param_start) = source[paren_start + 1..].find('(') { + let param_start = paren_start + 1 + param_start; + let mut depth = 1; + let mut param_end = param_start + 1; + for (i, c) in source[param_start + 1..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + param_end = param_start + 1 + i + 1; + break; + } + } + _ => {} + } + } + let mut result = + String::with_capacity(source.len() + STANDARD_CONDITION_CODES.len()); + result.push_str(&source[..param_end]); + result.push_str(STANDARD_CONDITION_CODES); + result.push_str(&source[param_end..]); + return result; + } + } + } + format!("{}{}", STANDARD_CONDITION_CODES, source) +} + +/// Compilation error types +#[derive(Debug, Clone)] +pub enum CompileError { + ParseError(String), +} + +impl From for ClvmZkError { + fn from(err: CompileError) -> Self { + match err { + CompileError::ParseError(msg) => ClvmZkError::CompilationError(msg), + } + } +} + +/// Compile chialisp source to bytecode and program hash +pub fn compile_chialisp_to_bytecode( + hasher: Hasher, + source: &str, +) -> Result<(Vec, [u8; 32]), CompileError> { + let bytecode = clvm_tools_rs::compile_chialisp(source).map_err(|e| { + CompileError::ParseError(format!("clvm_tools_rs compilation failed: {}", e)) + })?; + let program_hash = hasher(&bytecode); + Ok((bytecode, program_hash)) +} + +/// Compile chialisp source to get program hash only +pub fn compile_chialisp_template_hash( + hasher: Hasher, + source: &str, +) -> Result<[u8; 32], CompileError> { + let bytecode = clvm_tools_rs::compile_chialisp(source).map_err(|e| { + CompileError::ParseError(format!("clvm_tools_rs compilation failed: {}", e)) + })?; + Ok(hasher(&bytecode)) +} + +/// Compile chialisp to get template hash using default SHA-256 hasher +#[cfg(feature = "sha2-hasher")] +pub fn compile_chialisp_template_hash_default(source: &str) -> Result<[u8; 32], CompileError> { + let bytecode = clvm_tools_rs::compile_chialisp(source).map_err(|e| { + CompileError::ParseError(format!("clvm_tools_rs compilation failed: {}", e)) + })?; + Ok(hash_data(&bytecode)) +} + +// ============================================================================ +// End chialisp utilities +// ============================================================================ + +// re-export for convenience +pub use types::AdditionalCoinInput; + +// re-export coin_commitment items for guest programs +pub use coin_commitment::{ + build_coin_commitment_preimage, CoinCommitment, CoinSecrets, SerialCommitment, XCH_TAIL, +}; + // Re-export VeilEvaluator from clvm_tools_rs for direct CLVM execution pub use clvm_tools_rs::VeilEvaluator; @@ -145,7 +256,14 @@ pub fn verify_ecdsa_signature_with_hasher( Err(_) => return Err("invalid public key format - failed to parse"), }; - // parse the signature - handle variable length by padding if needed + // parse the signature - ECDSA signatures must be exactly 64 bytes (r || s, each 32 bytes) + // + // SECURITY: we do NOT pad short signatures with zeros as this could accept + // truncated/malformed signatures. signatures are binary data, not integers, + // so trailing zeros are significant. + // + // if CLVM is stripping trailing zeros from signatures, the signature encoding + // should use DER format or be explicitly prefixed with length bytes. let signature = if signature_bytes.len() == 64 { // 64-byte compact format (r || s, each 32 bytes) match Signature::try_from(signature_bytes) { @@ -153,23 +271,23 @@ pub fn verify_ecdsa_signature_with_hasher( Err(_) => return Err("invalid compact signature format"), } } else if signature_bytes.len() < 64 { - // Pad with trailing zeros to get to 64 bytes (CLVM may have stripped trailing zeros) - let mut padded = signature_bytes.to_vec(); - padded.resize(64, 0); // pad to 64 bytes with trailing zeros - match Signature::try_from(padded.as_slice()) { - Ok(sig) => sig, - Err(_) => return Err("invalid padded signature format"), - } + // reject short signatures - don't pad with zeros + return Err("signature too short - expected exactly 64 bytes for ECDSA compact format"); } else { - return Err("signature too long - expected at most 64 bytes"); + return Err("signature too long - expected exactly 64 bytes for ECDSA compact format"); }; - // check if message is already a hash (32 bytes) or raw message + // the message to verify is expected to be pre-hashed (32 bytes) + // or a raw message that needs hashing. + // + // SECURITY NOTE: if message is exactly 32 bytes, we assume it's a pre-hash. + // this is a common convention but callers should be aware of this behavior. + // to verify a 32-byte raw message, hash it first before passing. let message_hash = if message_bytes.len() == 32 { - // assume it's already a hash, use directly + // treat as pre-hashed message message_bytes.to_vec() } else { - // hash the message using the provided hasher + // hash the raw message hasher(message_bytes).to_vec() }; @@ -183,6 +301,9 @@ pub fn verify_ecdsa_signature_with_hasher( /// compute modular exponentiation: base^exponent mod modulus /// uses binary exponentiation for efficiency pub fn modular_pow(mut base: i64, mut exponent: i64, modulus: i64) -> i64 { + if modulus == 0 { + return 0; + } if modulus == 1 { return 0; } @@ -417,6 +538,118 @@ fn extract_args_from_list(value: &ClvmValue) -> Result>, &'static st Ok(args) } +// Announcement condition opcodes +pub const CREATE_COIN_ANNOUNCEMENT: u8 = 60; +pub const ASSERT_COIN_ANNOUNCEMENT: u8 = 61; +pub const CREATE_PUZZLE_ANNOUNCEMENT: u8 = 62; +pub const ASSERT_PUZZLE_ANNOUNCEMENT: u8 = 63; + +/// Process announcement conditions for privacy +/// +/// This function: +/// 1. Collects all CREATE_*_ANNOUNCEMENT conditions (60, 62) +/// 2. Collects all ASSERT_*_ANNOUNCEMENT conditions (61, 63) +/// 3. Verifies every assertion has a matching announcement +/// 4. Returns filtered conditions with announcements removed +/// +/// Announcements are verified in-circuit and suppressed from output +/// to preserve privacy. This is essential for CATs, atomic swaps, +/// and any cross-coin validation protocol. +/// +/// # Arguments +/// * `conditions` - all conditions from CLVM execution +/// * `puzzle_hash` - the puzzle hash of the coin being spent (for puzzle announcements) +/// * `coin_id` - optional coin ID for coin announcements (None if not available) +/// * `hasher` - hash function for computing announcement hashes +/// +/// # Returns +/// * Ok(filtered_conditions) - conditions with announcements removed +/// * Err - if any assertion doesn't have a matching announcement +pub fn process_announcements( + conditions: Vec, + puzzle_hash: &[u8; 32], + coin_id: Option<&[u8; 32]>, + hasher: fn(&[u8]) -> [u8; 32], +) -> Result, &'static str> { + // Collect announcements: hash -> original message (for debugging) + let mut announcement_hashes: Vec<[u8; 32]> = Vec::new(); + + // Collect assertions to verify + let mut assertion_hashes: Vec<[u8; 32]> = Vec::new(); + + // First pass: collect all announcements and assertions + for condition in &conditions { + match condition.opcode { + CREATE_PUZZLE_ANNOUNCEMENT => { + // CREATE_PUZZLE_ANNOUNCEMENT(message) + // hash = sha256(puzzle_hash || message) + if condition.args.is_empty() { + return Err("CREATE_PUZZLE_ANNOUNCEMENT requires message argument"); + } + let message = &condition.args[0]; + let mut data = Vec::with_capacity(32 + message.len()); + data.extend_from_slice(puzzle_hash); + data.extend_from_slice(message); + let hash = hasher(&data); + announcement_hashes.push(hash); + } + CREATE_COIN_ANNOUNCEMENT => { + // CREATE_COIN_ANNOUNCEMENT(message) + // hash = sha256(coin_id || message) + if condition.args.is_empty() { + return Err("CREATE_COIN_ANNOUNCEMENT requires message argument"); + } + let message = &condition.args[0]; + if let Some(cid) = coin_id { + let mut data = Vec::with_capacity(32 + message.len()); + data.extend_from_slice(cid); + data.extend_from_slice(message); + let hash = hasher(&data); + announcement_hashes.push(hash); + } + // If no coin_id provided, skip coin announcements + // (puzzle announcements still work) + } + ASSERT_PUZZLE_ANNOUNCEMENT | ASSERT_COIN_ANNOUNCEMENT => { + // ASSERT_*_ANNOUNCEMENT(announcement_hash) + if condition.args.is_empty() { + return Err("ASSERT_*_ANNOUNCEMENT requires hash argument"); + } + if condition.args[0].len() != 32 { + return Err("announcement hash must be 32 bytes"); + } + let mut hash = [0u8; 32]; + hash.copy_from_slice(&condition.args[0]); + assertion_hashes.push(hash); + } + _ => {} + } + } + + // Verify all assertions are satisfied + for assertion in &assertion_hashes { + if !announcement_hashes.contains(assertion) { + return Err("announcement assertion not satisfied"); + } + } + + // Filter out announcement conditions from output + let filtered: Vec = conditions + .into_iter() + .filter(|c| { + !matches!( + c.opcode, + CREATE_COIN_ANNOUNCEMENT + | ASSERT_COIN_ANNOUNCEMENT + | CREATE_PUZZLE_ANNOUNCEMENT + | ASSERT_PUZZLE_ANNOUNCEMENT + ) + }) + .collect(); + + Ok(filtered) +} + /// Convert Vec to ClvmValue list representation /// Each condition becomes: (opcode arg1 arg2 ...) /// All conditions form a list: ((opcode1 args...) (opcode2 args...) ...) @@ -481,9 +714,8 @@ pub fn serialize_params_to_clvm(params: &[ProgramParameter]) -> Vec { /// Run CLVM bytecode using VeilEvaluator and parse conditions from output /// -/// This function provides a bridge between the old ClvmEvaluator interface and -/// the new VeilEvaluator. It assumes the program returns a list of conditions -/// as its output (standard Chia model). +/// Executes compiled CLVM bytecode and parses the output as Chia-style conditions. +/// The program is expected to return a list of conditions as its output. /// /// # Arguments /// * `evaluator` - VeilEvaluator with injected crypto functions @@ -521,7 +753,7 @@ pub fn create_veil_evaluator( #[cfg(test)] mod security_tests { - use crate::chialisp::compile_chialisp_to_bytecode; + use crate::compile_chialisp_to_bytecode; use crate::hash_data; #[test] @@ -542,3 +774,222 @@ mod security_tests { assert_ne!(hash1, hash3); } } + +// ============================================================================ +// ring spend balance enforcement +// ============================================================================ + +/// enforce balance and tail_hash consistency for ring spends +/// +/// verifies: +/// - sum(input amounts) == sum(output CREATE_COIN amounts) +/// - all coins have same tail_hash (single-asset ring) +/// +/// # security +/// prevents inflation/deflation attacks where attacker spends N coins +/// but creates outputs totaling more/less than N +/// +/// # returns +/// (total_input_amount, total_output_amount) after validation +pub fn enforce_ring_balance( + private_inputs: &Input, + conditions: &[Condition], +) -> Result<(u64, u64), &'static str> { + // track output amounts from CREATE_COIN conditions + let mut total_output_amount: u64 = 0; + + for condition in conditions { + if condition.opcode == 51 { + // CREATE_COIN + let amount = match condition.args.len() { + 2 | 4 => { + // both transparent (2-arg) and private (4-arg) modes + // handle variable-length amount encoding (chialisp uses compact encoding) + let amount_bytes = &condition.args[1]; + if amount_bytes.len() == 8 { + u64::from_be_bytes(amount_bytes.as_slice().try_into().unwrap()) + } else if amount_bytes.len() < 8 && !amount_bytes.is_empty() { + // pad to 8 bytes (big-endian: zeros on left) + let mut padded = [0u8; 8]; + padded[8 - amount_bytes.len()..].copy_from_slice(amount_bytes); + u64::from_be_bytes(padded) + } else { + 0 + } + } + _ => 0, + }; + total_output_amount = total_output_amount.checked_add(amount).expect("output amount overflow"); + } + } + + // sum input amounts and verify tail_hash consistency + let total_input_amount = if let Some(commitment_data) = &private_inputs.serial_commitment_data { + let mut input_sum = commitment_data.amount; // primary coin + + if let Some(additional_coins) = &private_inputs.additional_coins { + let primary_tail_hash = private_inputs.tail_hash.unwrap_or([0u8; 32]); + + for coin in additional_coins { + // enforce single-asset ring (defense in depth) + if coin.tail_hash != primary_tail_hash { + return Err("ring spend: all coins must have same tail_hash"); + } + + input_sum = input_sum.checked_add(coin.serial_commitment_data.amount).expect("input amount overflow"); + } + } + + // prevent inflation: output cannot exceed input + // allows burning/locking (output < input) for fees, conditional spends, etc. + if total_output_amount > input_sum { + return Err("inflation: output exceeds input"); + } + + input_sum + } else { + 0 // no serial commitment = simple program execution + }; + + Ok((total_input_amount, total_output_amount)) +} + +// ============================================================================ +// cryptographic domain constants +// ============================================================================ + +pub const SERIAL_DOMAIN: &[u8] = b"clvm_zk_serial_v1.0"; +pub const COIN_DOMAIN: &[u8] = b"clvm_zk_coin_v2.0"; +pub const SERIAL_COMMITMENT_SIZE: usize = 83; // domain(19) + serial_number(32) + serial_randomness(32) +pub const COIN_COMMITMENT_SIZE: usize = 121; // domain(17) + tail_hash(32) + amount(8) + puzzle_hash(32) + serial_commitment(32) +pub const NULLIFIER_DATA_SIZE: usize = 72; // serial_number(32) + program_hash(32) + amount(8) + +// ============================================================================ +// commitment computation helpers +// ============================================================================ + +/// compute serial commitment: hash(domain || serial_number || serial_randomness) +pub fn compute_serial_commitment( + hasher: H, + serial_number: &[u8; 32], + serial_randomness: &[u8; 32], +) -> [u8; 32] +where + H: Fn(&[u8]) -> [u8; 32], +{ + let mut serial_data = [0u8; SERIAL_COMMITMENT_SIZE]; + serial_data[..19].copy_from_slice(SERIAL_DOMAIN); + serial_data[19..51].copy_from_slice(serial_number); + serial_data[51..83].copy_from_slice(serial_randomness); + hasher(&serial_data) +} + +/// compute coin commitment v2.0: hash(domain || tail_hash || amount || puzzle_hash || serial_commitment) +pub fn compute_coin_commitment( + hasher: H, + tail_hash: [u8; 32], + amount: u64, + puzzle_hash: &[u8; 32], + serial_commitment: &[u8; 32], +) -> [u8; 32] +where + H: Fn(&[u8]) -> [u8; 32], +{ + let mut coin_data = [0u8; COIN_COMMITMENT_SIZE]; + coin_data[..17].copy_from_slice(COIN_DOMAIN); + coin_data[17..49].copy_from_slice(&tail_hash); + coin_data[49..57].copy_from_slice(&amount.to_be_bytes()); + coin_data[57..89].copy_from_slice(puzzle_hash); + coin_data[89..121].copy_from_slice(serial_commitment); + hasher(&coin_data) +} + +/// compute nullifier: hash(serial_number || program_hash || amount) +pub fn compute_nullifier( + hasher: H, + serial_number: &[u8; 32], + program_hash: &[u8; 32], + amount: u64, +) -> [u8; 32] +where + H: Fn(&[u8]) -> [u8; 32], +{ + let mut nullifier_data = Vec::with_capacity(NULLIFIER_DATA_SIZE); + nullifier_data.extend_from_slice(serial_number); + nullifier_data.extend_from_slice(program_hash); + nullifier_data.extend_from_slice(&amount.to_be_bytes()); + hasher(&nullifier_data) +} + +// ============================================================================ +// merkle proof verification +// ============================================================================ + +/// maximum allowed merkle proof depth (matches merkle.rs MAX_TREE_DEPTH) +/// prevents DoS via excessively long proofs that waste cycles +pub const MAX_MERKLE_PROOF_DEPTH: usize = 64; + +/// verify merkle proof for a leaf at given index +/// +/// # security bounds +/// merkle_path length is bounded to MAX_MERKLE_PROOF_DEPTH (64) to prevent +/// DoS attacks via excessively long proofs. +pub fn verify_merkle_proof( + hasher: H, + leaf_hash: [u8; 32], + merkle_path: &[[u8; 32]], + leaf_index: usize, + expected_root: [u8; 32], +) -> Result<(), &'static str> +where + H: Fn(&[u8]) -> [u8; 32], +{ + // validate merkle path depth to prevent DoS + if merkle_path.len() > MAX_MERKLE_PROOF_DEPTH { + return Err("merkle proof too deep (max 64 levels)"); + } + + let mut current_hash = leaf_hash; + let mut current_index = leaf_index; + + for sibling in merkle_path.iter() { + let mut combined = [0u8; 64]; + if current_index.is_multiple_of(2) { + combined[..32].copy_from_slice(¤t_hash); + combined[32..].copy_from_slice(sibling); + } else { + combined[..32].copy_from_slice(sibling); + combined[32..].copy_from_slice(¤t_hash); + } + current_hash = hasher(&combined); + current_index /= 2; + } + + if current_hash == expected_root { + Ok(()) + } else { + Err("merkle root mismatch") + } +} + +// ============================================================================ +// amount parsing utilities +// ============================================================================ + +/// parse variable-length amount bytes (chialisp uses compact encoding) +/// pads to 8 bytes big-endian if needed +pub fn parse_variable_length_amount(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(0); + } + if bytes.len() > 8 { + return Err("amount too large (max 8 bytes)"); + } + if bytes.len() == 8 { + Ok(u64::from_be_bytes(bytes.try_into().unwrap())) + } else { + let mut padded = [0u8; 8]; + padded[8 - bytes.len()..].copy_from_slice(bytes); + Ok(u64::from_be_bytes(padded)) + } +} diff --git a/clvm_zk_core/src/merkle.rs b/clvm_zk_core/src/merkle.rs index 1950d57..e2ba4ff 100644 --- a/clvm_zk_core/src/merkle.rs +++ b/clvm_zk_core/src/merkle.rs @@ -2,12 +2,24 @@ //! //! provides authenticated membership proofs for coins in the utxo set. //! uses a sparse merkle tree where leaves are coin commitments. +//! +//! ## security bounds +//! +//! tree depth is bounded to prevent DoS attacks via excessive proof sizes. +//! max depth of 64 supports 2^64 leaves which is more than sufficient. extern crate alloc; use alloc::vec::Vec; use serde::{Deserialize, Serialize}; +/// minimum allowed tree depth (2^1 = 2 leaves) +pub const MIN_TREE_DEPTH: usize = 1; + +/// maximum allowed tree depth (2^64 leaves - more than enough for any blockchain) +/// this prevents DoS via excessively deep proofs +pub const MAX_TREE_DEPTH: usize = 64; + /// sparse merkle tree for tracking coin commitments /// /// maintains a merkle tree where each leaf is a coin commitment. @@ -29,7 +41,26 @@ pub struct SparseMerkleTree { impl SparseMerkleTree { /// create new sparse merkle tree with given depth + /// + /// # panics + /// panics if depth is outside [MIN_TREE_DEPTH, MAX_TREE_DEPTH] bounds. + /// use `try_new` for fallible construction. pub fn new(depth: usize, hasher: fn(&[u8]) -> [u8; 32]) -> Self { + Self::try_new(depth, hasher).expect("tree depth out of bounds") + } + + /// create new sparse merkle tree with given depth, fallible version + /// + /// # errors + /// returns error if depth < MIN_TREE_DEPTH or depth > MAX_TREE_DEPTH + pub fn try_new(depth: usize, hasher: fn(&[u8]) -> [u8; 32]) -> Result { + if depth < MIN_TREE_DEPTH { + return Err("tree depth too small (minimum 1)"); + } + if depth > MAX_TREE_DEPTH { + return Err("tree depth too large (maximum 64)"); + } + // precompute empty node hashes for each level let mut empty_hashes = Vec::with_capacity(depth + 1); @@ -47,12 +78,12 @@ impl SparseMerkleTree { let root = empty_hashes[depth]; - Self { + Ok(Self { leaves: Vec::new(), root, depth, empty_hashes, - } + }) } /// insert a new coin commitment and return its leaf index @@ -354,4 +385,28 @@ mod tests { assert!(tree.verify_proof(&proof, test_hasher)); } } + + #[test] + fn test_depth_bounds_validation() { + // minimum depth should work + assert!(SparseMerkleTree::try_new(MIN_TREE_DEPTH, test_hasher).is_ok()); + + // maximum depth should work + assert!(SparseMerkleTree::try_new(MAX_TREE_DEPTH, test_hasher).is_ok()); + + // zero depth should fail + assert!(SparseMerkleTree::try_new(0, test_hasher).is_err()); + + // exceeding max depth should fail + assert!(SparseMerkleTree::try_new(MAX_TREE_DEPTH + 1, test_hasher).is_err()); + } + + #[test] + fn test_reasonable_depths() { + // test common depths work + for depth in [4, 8, 16, 20, 32] { + let tree = SparseMerkleTree::try_new(depth, test_hasher); + assert!(tree.is_ok(), "depth {} should be valid", depth); + } + } } diff --git a/clvm_zk_core/src/types.rs b/clvm_zk_core/src/types.rs index 711ed84..bafe177 100644 --- a/clvm_zk_core/src/types.rs +++ b/clvm_zk_core/src/types.rs @@ -1,7 +1,7 @@ //! core types for clvm evaluation extern crate alloc; -use alloc::{boxed::Box, string::String, vec::Vec}; +use alloc::{boxed::Box, string::String, string::ToString, vec::Vec}; use serde::{Deserialize, Serialize}; @@ -60,6 +60,9 @@ pub enum ClvmValue { } /// unified error type for clvm-zk operations +/// +/// this is the primary error type for the clvm-zk crate. other error types +/// (CompileError, ProtocolError, etc.) can be converted to this type. #[derive(Debug, thiserror::Error)] pub enum ClvmZkError { #[error("Serialization error: {0}")] @@ -91,6 +94,28 @@ pub enum ClvmZkError { #[error("Invalid proof format: {0}")] InvalidProofFormat(String), + + #[error("Compilation error: {0}")] + CompilationError(String), + + #[error("Invalid input: {0}")] + InvalidInput(String), + + #[error("Merkle proof error: {0}")] + MerkleError(String), + + #[error("Cryptographic error: {0}")] + CryptoError(String), + + #[error("Nullifier error: {0}")] + NullifierError(String), +} + +impl ClvmZkError { + /// create from a static str (useful in no_std contexts) + pub fn from_static(msg: &'static str) -> Self { + ClvmZkError::ClvmError(msg.to_string()) + } } /// common zkvm backend types @@ -115,6 +140,103 @@ pub struct Input { /// - None: Simple program execution (BLS tests, basic proving) /// - Some(...): Full serial commitment protocol (blockchain simulator, real spending) pub serial_commitment_data: Option, + + /// Asset type identifier (TAIL hash) + /// - None: XCH (native currency, equivalent to [0u8; 32]) + /// - Some(hash): CAT with this TAIL program hash + /// Used in commitment v2: hash("clvm_zk_coin_v2.0" || tail_hash || amount || puzzle_hash || serial_commitment) + #[serde(default)] + pub tail_hash: Option<[u8; 32]>, + + /// TAIL source for spend-path delta authorization + /// required when sum(outputs) != sum(inputs) during a CAT spend (melt/burn) + /// the TAIL is called with (delta, total_input, total_output, ...extra_params) + /// - delta > 0: rejected (must use mint mode for supply increase) + /// - delta < 0: TAIL must authorize the melt + /// - delta == 0: TAIL not called (pure transfer) + #[serde(default)] + pub tail_source: Option, + + /// additional coins for multi-coin ring spends + /// - None: single coin spend + /// - Some(vec): multi-coin ring spend (all coins share same tail_hash) + /// guest enforces tail_hash matching across all ring coins + #[serde(default)] + pub additional_coins: Option>, + + /// mint data for CAT issuance + /// - None: normal spend or simple execution + /// - Some(...): mint mode - execute TAIL and create new coin + /// mutually exclusive with serial_commitment_data (can't spend and mint in same proof) + #[serde(default)] + pub mint_data: Option, +} + +/// mint data for CAT issuance proofs +/// when present, guest executes TAIL program and creates new coin if authorized +#[derive(Serialize, Deserialize, Debug, Clone, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct MintData { + /// TAIL program source (controls who can mint) + /// e.g., "(mod () 1)" for unlimited, "(mod (pk sig) (bls_verify ...))" for signature-based + pub tail_source: String, + /// parameters to satisfy the TAIL program + /// NOTE: genesis_nullifier is prepended automatically if genesis_coin is present + pub tail_params: Vec, + /// puzzle hash for the new coin (where it can be spent) + pub output_puzzle_hash: [u8; 32], + /// amount to mint + pub output_amount: u64, + /// serial number for the new coin (for nullifier generation when spent) + pub output_serial: [u8; 32], + /// serial randomness for commitment hiding + pub output_rand: [u8; 32], + /// optional genesis coin that authorizes this mint + /// when present, guest verifies genesis coin exists in merkle tree, + /// computes its nullifier, and passes it to TAIL as first param. + /// the genesis nullifier is included in proof output → validators add to nullifier set + /// → genesis can't be reused → prevents infinite minting + #[serde(default)] + pub genesis_coin: Option, +} + +/// genesis coin data for single-issuance CAT minting +/// the genesis coin is spent during mint, producing a nullifier that prevents re-minting +#[derive(Serialize, Deserialize, Debug, Clone, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct GenesisSpend { + /// serial number of the genesis coin + pub serial_number: [u8; 32], + /// serial randomness for opening the commitment + pub serial_randomness: [u8; 32], + /// puzzle hash of the genesis coin + pub puzzle_hash: [u8; 32], + /// amount locked in the genesis coin + pub amount: u64, + /// tail_hash of the genesis coin (typically XCH = [0;32]) + pub tail_hash: [u8; 32], + /// serial commitment (hash(serial_number || serial_randomness)) + pub serial_commitment: [u8; 32], + /// coin commitment (leaf in merkle tree) + pub coin_commitment: [u8; 32], + /// merkle proof path from leaf to root + pub merkle_path: Vec<[u8; 32]>, + /// merkle root (current tree state) + pub merkle_root: [u8; 32], + /// leaf index in tree + pub leaf_index: usize, +} + +/// additional coin input for ring spends +/// each coin evaluates independently but shares announcement verification +#[derive(Serialize, Deserialize, Debug, Clone, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct AdditionalCoinInput { + /// Chialisp source for this coin (typically same CAT puzzle as primary) + pub chialisp_source: String, + /// Parameters for this coin's puzzle + pub program_parameters: Vec, + /// Serial commitment data (required for ring coins) + pub serial_commitment_data: SerialCommitmentData, + /// Asset type (must match primary coin's tail_hash for valid ring) + pub tail_hash: [u8; 32], } /// Serial commitment protocol data for nullifier-based spending @@ -168,8 +290,11 @@ pub struct ClvmResult { pub struct ProofOutput { /// Program hash for verification (hash of template bytecode) pub program_hash: [u8; 32], - /// Nullifier for double-spend prevention - pub nullifier: Option<[u8; 32]>, + /// Nullifiers for double-spend prevention + /// - Empty vec: No nullifier (simple program execution) + /// - Single element: Standard spend (one coin) + /// - Multiple elements: Ring spend (CAT multi-coin transaction) + pub nullifiers: Vec<[u8; 32]>, /// CLVM execution result pub clvm_res: ClvmResult, /// Proof type (Transaction, ConditionalSpend, Settlement) diff --git a/demo.sh b/demo.sh new file mode 100755 index 0000000..2a04a84 --- /dev/null +++ b/demo.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# full e2e demo: stealth sends + CAT offer settlement +# +# usage: ./demo.sh [risc0|sp1] + +set -e + +BACKEND="${1:-risc0}" + +if [[ "$BACKEND" != "risc0" && "$BACKEND" != "sp1" ]]; then + echo "usage: $0 [risc0|sp1]" + exit 1 +fi + +BUILD_DIR="./target/$BACKEND" +BINARY="$BUILD_DIR/release/clvm-zk" + +if [ ! -f "$BINARY" ]; then + echo "building $BACKEND backend..." + cargo build --release --target-dir "$BUILD_DIR" --no-default-features --features "$BACKEND,testing" +fi + +TOTAL_START=$(date +%s) + +echo "=== VEIL E2E DEMO ($BACKEND) ===" +echo "" + +# ── PHASE 1: SETUP ────────────────────────────────────────────────────── + +echo "--- phase 1: setup ---" +$BINARY sim init --reset +$BINARY sim wallet alice create +$BINARY sim wallet bob create +echo "" + +# ── PHASE 2: STEALTH SENDS ────────────────────────────────────────────── + +echo "--- phase 2: stealth address sends ---" +echo "" + +echo "funding alice with 3000 mojos..." +$BINARY sim faucet alice --amount 1000 --count 1 +$BINARY sim faucet alice --amount 2000 --count 1 +$BINARY sim wallet alice balance +echo "" + +echo "alice sends 500 to bob (stealth)..." +$BINARY sim send alice bob 500 --coins auto +echo "" + +echo "bob scans for payments..." +$BINARY sim scan bob +$BINARY sim wallet bob balance +echo "" + +echo "alice sends 500 to bob again..." +$BINARY sim send alice bob 500 --coins auto +echo "" + +echo "bob scans again..." +$BINARY sim scan bob +$BINARY sim wallet bob balance +echo "" + +echo "bob sends 200 back to alice..." +$BINARY sim send bob alice 200 --coins auto +echo "" + +echo "alice scans..." +$BINARY sim scan alice +echo "" + +echo "stealth balances:" +echo " alice:" +$BINARY sim wallet alice balance +echo " bob:" +$BINARY sim wallet bob balance +echo "" + +# ── PHASE 3: CAT OFFER SETTLEMENT ─────────────────────────────────────── + +echo "--- phase 3: CAT offer settlement ---" +echo "" + +# fresh wallets for offer demo (reuse simulator state) +$BINARY sim wallet maker create +$BINARY sim wallet taker create + +# mint CAT to maker (tail_source stored on coin for offer-create TAIL authorization) +DEMO_TAIL_SOURCE="(mod () 1)" +echo "minting 1000 CAT to maker (tail_source=$DEMO_TAIL_SOURCE)..." +$BINARY sim faucet maker --amount 1000 --count 1 --tail-source "$DEMO_TAIL_SOURCE" --delegated +echo "" + +# fund taker with XCH +echo "funding taker with 500 XCH..." +$BINARY sim faucet taker --amount 500 --count 1 --delegated +echo "" + +echo "pre-offer balances:" +echo " maker:" +$BINARY sim wallet maker unspent +echo " taker:" +$BINARY sim wallet taker unspent +echo "" + +# create offer: maker offers 100 CAT, requests 50 XCH +echo "maker creates offer: 100 CAT for 50 XCH..." +$BINARY sim offer-create maker --offer 100 --request 50 --coins 0 +echo "" + +$BINARY sim offer-list +echo "" + +# taker takes offer +echo "taker takes the offer..." +$BINARY sim offer-take taker --offer-id 0 --coins 0 +echo "" + +echo "post-settlement balances:" +echo " maker:" +$BINARY sim wallet maker unspent +echo " taker:" +$BINARY sim wallet taker unspent +echo "" + +# ── SUMMARY ────────────────────────────────────────────────────────────── + +echo "--- simulator state ---" +$BINARY sim status +echo "" + +TOTAL_END=$(date +%s) +TOTAL_DURATION=$((TOTAL_END - TOTAL_START)) + +echo "=== DEMO COMPLETE (${TOTAL_DURATION}s) ===" +echo "" +echo "phase 2: alice sent 1000 to bob, bob sent 200 back (stealth addresses)" +echo "phase 3: maker offered 100 CAT for 50 XCH, taker accepted (atomic swap)" diff --git a/demo_offers.sh b/demo_offers.sh new file mode 100755 index 0000000..087c438 --- /dev/null +++ b/demo_offers.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Demo script showing atomic swap (offer) functionality in the simulator +# +# Demonstrates: +# 1. Wallet creation with encryption keys +# 2. Faucet funding with XCH and CATs +# 3. Creating a conditional offer (maker) +# 4. Taking an offer via settlement proof (taker) +# 5. Verification of atomic swap completion + +set -e # exit on error + +# backend selection (default: risc0) +BACKEND="${1:-risc0}" +if [[ "$BACKEND" != "risc0" && "$BACKEND" != "sp1" ]]; then + echo "usage: $0 [risc0|sp1]" + exit 1 +fi + +# colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # no color + +echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} CLVM-ZK OFFERS DEMO (ATOMIC SWAPS)${NC}" +echo -e "${BLUE} backend: ${YELLOW}${BACKEND}${BLUE}${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}" +echo "" + +# setup +DATA_DIR="/tmp/clvm-zk-offers-demo" +rm -rf "$DATA_DIR" +mkdir -p "$DATA_DIR" + +# build once at the start (not timed) +echo "building clvm-zk ($BACKEND backend)..." +cargo build --no-default-features --features $BACKEND --release --quiet + +# start total timer AFTER compilation +DEMO_START=$SECONDS + +# use pre-built binary directly (no rebuild checks on each command) +BINARY="./target/release/clvm-zk" +CARGO_CMD="$BINARY --data-dir $DATA_DIR" + +echo -e "${GREEN}[1/8]${NC} initializing simulator..." +$CARGO_CMD sim init +echo "" + +echo -e "${GREEN}[2/8]${NC} creating alice's wallet (maker)..." +$CARGO_CMD sim wallet alice create +echo "" + +echo -e "${GREEN}[3/8]${NC} creating bob's wallet (taker)..." +$CARGO_CMD sim wallet bob create +echo "" + +echo -e "${GREEN}[4/8]${NC} funding alice with XCH using delegated puzzle (required for offers)..." +PROOF_START=$SECONDS +$CARGO_CMD sim faucet alice --amount 1000 --count 1 --delegated +PROOF_TIME=$((SECONDS - PROOF_START)) +echo -e "${CYAN}⏱ proof generated in ${PROOF_TIME}s${NC}" +echo "" + +# generate a random tail_hash for the CAT +CAT_TAIL=$(openssl rand -hex 32) +echo -e "${YELLOW} CAT asset ID: ${CAT_TAIL}${NC}" +echo "" + +echo -e "${GREEN}[5/8]${NC} funding bob with CATs using delegated puzzle..." +PROOF_START=$SECONDS +$CARGO_CMD sim faucet bob --amount 500 --count 1 --tail "$CAT_TAIL" --delegated +PROOF_TIME=$((SECONDS - PROOF_START)) +echo -e "${CYAN}⏱ proof generated in ${PROOF_TIME}s${NC}" +echo "" + +echo -e "${GREEN}[6/8]${NC} alice creates offer: 100 XCH for 200 CAT..." +PROOF_START=$SECONDS +$CARGO_CMD sim offer-create alice --offer 100 --request 200 --request-tail "$CAT_TAIL" --coins 0 +PROOF_TIME=$((SECONDS - PROOF_START)) +echo -e "${CYAN}⏱ conditional proof generated in ${PROOF_TIME}s${NC}" +echo "" + +echo -e "${GREEN}[7/8]${NC} viewing pending offers..." +$CARGO_CMD sim offer-list +echo "" + +echo -e "${GREEN}[8/8]${NC} bob takes offer 0 (atomic settlement proof)..." +PROOF_START=$SECONDS +$CARGO_CMD sim offer-take bob --offer-id 0 --coins 0 +PROOF_TIME=$((SECONDS - PROOF_START)) +echo -e "${CYAN}⏱ settlement proof generated in ${PROOF_TIME}s${NC}" +echo "" + +DEMO_TIME=$((SECONDS - DEMO_START)) +echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN}✅ OFFERS DEMO COMPLETE${NC}" +echo -e "${CYAN}⏱ total time: ${DEMO_TIME}s${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}" +echo "" +echo "what happened:" +echo " 1. alice locked 100 XCH in conditional spend proof" +echo " 2. bob created settlement proof that:" +echo " - verified alice's conditional proof" +echo " - spent bob's 200 CAT" +echo " - created 4 outputs:" +echo " • alice gets 200 CAT (goods)" +echo " • bob gets 100 XCH (payment)" +echo " • alice gets 900 XCH (change from 1000 input)" +echo " • bob gets 300 CAT (change from 500 input)" +echo " 3. settlement proof atomically completes the swap" +echo " 4. neither party can back out or cheat" +echo "" +echo "privacy properties:" +echo " - amounts are hidden in ZK proofs" +echo " - only commitments visible on-chain" +echo " - nullifiers prevent double-spending" +echo " - hash-based stealth addresses hide recipient" +echo "" +echo -e "${YELLOW}view final state:${NC}" +echo " cargo run --no-default-features --features $BACKEND -- --data-dir $DATA_DIR sim status" +echo " cargo run --no-default-features --features $BACKEND -- --data-dir $DATA_DIR sim wallet alice show" +echo " cargo run --no-default-features --features $BACKEND -- --data-dir $DATA_DIR sim wallet bob show" diff --git a/docs/CHIA_OPCODES.md b/docs/CHIA_OPCODES.md deleted file mode 100644 index aeec6d8..0000000 --- a/docs/CHIA_OPCODES.md +++ /dev/null @@ -1,140 +0,0 @@ -# Chia-Standard CLVM Opcodes - -This document describes the opcode changes made to align Veil's CLVM implementation with the Chia blockchain standard, enabling compatibility with `clvmr` (the reference Rust CLVM implementation). - -## Overview - -Veil originally used ASCII-based opcodes (e.g., `+` = 43, `q` = 113) for readability. These have been changed to Chia-standard numeric opcodes to enable: - -1. **clvmr compatibility** - Use the battle-tested Chia CLVM runtime -2. **Ecosystem alignment** - Bytecode compatible with Chia tooling -3. **Future-proofing** - Easier to adopt Chia ecosystem improvements - -## Opcode Mapping - -### Core CLVM Operators - -| Operator | Symbol | Old (ASCII) | New (Chia) | Description | -|----------|--------|-------------|------------|-------------| -| Quote | q | 113 | 1 | Return argument unevaluated | -| Apply | a | 97 | 2 | Apply function to arguments | -| If | i | 105 | 3 | Conditional branch | -| Cons | c | 99 | 4 | Construct pair | -| First | f | 102 | 5 | Get first element of pair | -| Rest | r | 114 | 6 | Get rest of pair | -| ListP | l | 108 | 7 | Check if value is a pair | - -### Comparison Operators - -| Operator | Symbol | Old (ASCII) | New (Chia) | Description | -|----------|--------|-------------|------------|-------------| -| Equal | = | 61 | 9 | Equality comparison | -| Greater | > | 62 | 21 | Greater than comparison | - -### Arithmetic Operators - -| Operator | Symbol | Old (ASCII) | New (Chia) | Description | -|----------|--------|-------------|------------|-------------| -| Add | + | 43 | 16 | Addition | -| Subtract | - | 45 | 17 | Subtraction | -| Multiply | * | 42 | 18 | Multiplication | -| Divide | / | 47 | 19 | Division | -| DivMod | divmod | 80 | 20 | Division with modulo | -| Modulo | % | 37 | 61 | Modulo operation | - -### Cryptographic Operators - -| Operator | Old | New (Chia) | Description | -|----------|-----|------------|-------------| -| SHA256 | 2 | 11 | SHA-256 hash | -| BLS Verify | 201 | 59 | BLS signature verification | -| ECDSA Verify | 200 | 200 | ECDSA signature verification (unchanged) | - -### Condition Opcodes (Unchanged) - -These are Chia condition opcodes used in puzzle outputs, not CLVM operators: - -| Condition | Opcode | Description | -|-----------|--------|-------------| -| AGG_SIG_UNSAFE | 49 | Aggregate signature (unsafe) | -| AGG_SIG_ME | 50 | Aggregate signature (with coin ID) | -| CREATE_COIN | 51 | Create new coin | -| RESERVE_FEE | 52 | Reserve transaction fee | - -### Function Handling - -With this update, Veil now uses **function inlining** instead of the previous `CallFunction` opcode (150). This means: - -- Functions defined with `defun` are inlined at the call site during compilation -- The standard CLVM `apply` operator (opcode 2) is used: `(a (q . ) )` -- Function names no longer affect the bytecode - only the function body matters -- This is fully compatible with clvmr and standard Chialisp - -**Note:** Recursive functions are not yet supported with inlining. - -## Files Modified - -### `clvm_zk_core/src/operators.rs` - -- Updated `ClvmOperator::opcode()` to return Chia-standard values -- Updated `ClvmOperator::from_opcode()` to parse Chia-standard values -- Updated tests to use new opcodes - -### `clvm_zk_core/src/chialisp/mod.rs` - -- Changed hardcoded opcodes to use `ClvmOperator::opcode()` -- Updated `create_parameter_access()` to use dynamic opcodes -- Updated test assertions for new opcode values - -### `clvm_zk_core/src/chialisp/compiler_utils.rs` - -- Changed `quote_value()` to use `ClvmOperator::Quote.opcode()` -- Updated test to use `ClvmOperator::Add.opcode()` - -### `clvm_zk_core/src/lib.rs` - -- Updated SHA256 opcode from 2 to 11 in evaluator - -## Bytecode Format - -CLVM bytecode uses a simple encoding: - -- `0x00-0x7F`: Small atoms (literal values 0-127) -- `0x80`: Nil (empty atom) -- `0x81-0xBF`: Atom with 1-64 byte length prefix -- `0xFF`: Cons pair marker - -Example bytecode for `(+ 5 3)`: - -``` -Old (ASCII): [255, 43, 255, 255, 113, 5, 255, 255, 113, 3, 128] -New (Chia): [255, 16, 255, 255, 1, 5, 255, 255, 1, 3, 128] - [cons, +, cons, cons, q, 5, cons, cons, q, 3, nil] -``` - -## Migration Notes - -### For Existing Bytecode - -Any bytecode compiled with the old ASCII opcodes will NOT work with the new evaluator. Bytecode must be recompiled from source. - -### For clvmr Integration - -The opcode changes make Veil bytecode compatible with clvmr. However: - -1. **CallFunction (150)** is Veil-specific and not supported by clvmr -2. Programs using `defun` will need a translation layer or function inlining - -### Testing - -All 69 clvm-zk-core tests pass with the new opcodes: - -```bash -cargo test -p clvm-zk-core --features sha2-hasher -``` - -## References - -- [Chia CLVM Reference](https://chialisp.com/docs/ref/clvm) -- [clvmr Repository](https://github.com/Chia-Network/clvm_rs) -- [CLVM Operator Costs](https://docs.chia.net/clvm-costs/) diff --git a/examples/alice_bob_lock.rs b/examples/alice_bob_lock.rs index 1495c5d..86827e2 100644 --- a/examples/alice_bob_lock.rs +++ b/examples/alice_bob_lock.rs @@ -1,5 +1,5 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::{compile_chialisp_template_hash_default, with_standard_conditions}; use k256::ecdsa::{signature::Signer, Signature, SigningKey, VerifyingKey}; use rand::thread_rng; @@ -103,9 +103,12 @@ fn main() -> Result<(), Box> { println!("\nTesting Chialisp examples with new API..."); - // Test create_coin condition - new API handles everything in guest + // Test create_coin condition - chialisp conditions are (list OPCODE args...) + let create_coin_program = with_standard_conditions( + "(mod (puzzle_hash amount) (list (list CREATE_COIN puzzle_hash amount)))", + ); match ClvmZkProver::prove( - "(mod (puzzle_hash amount) (create_coin puzzle_hash amount))", + &create_coin_program, &[ProgramParameter::int(999), ProgramParameter::int(1000)], ) { Ok(result) => { @@ -118,11 +121,10 @@ fn main() -> Result<(), Box> { } } - // Test reserve_fee condition - much simpler with guest compilation - match ClvmZkProver::prove( - "(mod (fee_amount) (reserve_fee fee_amount))", - &[ProgramParameter::int(50)], - ) { + // Test reserve_fee condition + let reserve_fee_program = + with_standard_conditions("(mod (fee_amount) (list (list RESERVE_FEE fee_amount)))"); + match ClvmZkProver::prove(&reserve_fee_program, &[ProgramParameter::int(50)]) { Ok(result) => { println!("Guest-compiled reserve_fee succeeded!"); println!(" - Output: {:?}", result.proof_output.clvm_res); diff --git a/examples/backend_benchmark.rs b/examples/backend_benchmark.rs index 504cacd..7982a20 100644 --- a/examples/backend_benchmark.rs +++ b/examples/backend_benchmark.rs @@ -1,5 +1,5 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::{compile_chialisp_template_hash_default, with_standard_conditions}; use std::time::{Duration, Instant}; fn main() -> Result<(), Box> { @@ -21,20 +21,21 @@ fn main() -> Result<(), Box> { println!("backend: {}\n", backend_name); // test expressions with varying complexity - let test_cases = vec![ + let blockchain_cond = with_standard_conditions("(mod (a b) (list (list CREATE_COIN a b)))"); + let test_cases: Vec<(&str, String, Vec)> = vec![ ( "simple addition", - "(mod (a b) (+ a b))", + "(mod (a b) (+ a b))".to_string(), vec![ProgramParameter::int(42), ProgramParameter::int(13)], ), ( "multiplication", - "(mod (a b) (* a b))", + "(mod (a b) (* a b))".to_string(), vec![ProgramParameter::int(7), ProgramParameter::int(8)], ), ( "nested operations", - "(mod (a b c d) (+ (* a b) (+ c d)))", + "(mod (a b c d) (+ (* a b) (+ c d)))".to_string(), vec![ ProgramParameter::int(3), ProgramParameter::int(4), @@ -44,12 +45,12 @@ fn main() -> Result<(), Box> { ), ( "comparison", - "(mod (a b) (> a b))", + "(mod (a b) (> a b))".to_string(), vec![ProgramParameter::int(10), ProgramParameter::int(5)], ), ( "blockchain condition", - "(mod (a b) (create_coin a b))", + blockchain_cond, vec![ProgramParameter::int(1000), ProgramParameter::int(500)], ), ]; @@ -60,7 +61,7 @@ fn main() -> Result<(), Box> { } fn run_benchmark( - test_cases: &[(impl AsRef, &str, Vec)], + test_cases: &[(&str, String, Vec)], ) -> Result<(), Box> { let mut total_prove_time = Duration::new(0, 0); let mut total_verify_time = Duration::new(0, 0); @@ -68,7 +69,7 @@ fn run_benchmark( let mut successful_tests = 0; for (test_name, expression, params) in test_cases { - print!("📋 testing {}: ", test_name.as_ref()); + print!("📋 testing {}: ", test_name); // prove using high-level api (uses automatic backend selection) let prove_start = Instant::now(); diff --git a/examples/cat_offer_demo.rs b/examples/cat_offer_demo.rs new file mode 100644 index 0000000..8e78c11 --- /dev/null +++ b/examples/cat_offer_demo.rs @@ -0,0 +1,305 @@ +//! CAT offer demo +//! +//! demonstrates the complete flow of: +//! 1. minting a CAT (computing tail_hash from TAIL program) +//! 2. creating an offer to trade CAT for XCH +//! 3. taking the offer (atomic swap) +//! +//! this uses the mock backend for fast testing +//! run with: cargo run-mock --example cat_offer_demo + +use clvm_zk::protocol::{PrivateCoin, ProofType, Spender}; +use clvm_zk_core::{ + coin_commitment::{CoinCommitment, CoinSecrets}, + compile_chialisp_template_hash_default, with_standard_conditions, ProgramParameter, +}; +use sha2::{Digest, Sha256}; + +fn hash_data(data: &[u8]) -> [u8; 32] { + Sha256::digest(data).into() +} + +/// TAIL program for our demo CAT - unlimited minting for simplicity +/// in production you'd use signature-based or governance-controlled TAILs +const DEMO_TAIL: &str = "(mod () 1)"; + +/// compute tail_hash from TAIL program - this identifies the CAT asset type +fn compute_tail_hash(tail_program: &str) -> [u8; 32] { + compile_chialisp_template_hash_default(tail_program).expect("TAIL should compile") +} + +/// structure to hold a minted CAT with its secrets +struct MintedCat { + coin: PrivateCoin, + secrets: CoinSecrets, + puzzle_code: String, + tail_hash: [u8; 32], +} + +/// mint a new CAT coin with given amount +fn mint_cat(amount: u64) -> MintedCat { + // 1. compute tail_hash from our TAIL program + let tail_hash = compute_tail_hash(DEMO_TAIL); + + // 2. create a delegated puzzle for the CAT (allows offers) + let puzzle_code = with_standard_conditions( + "(mod (offered requested maker_pubkey change_amount change_puzzle change_serial change_rand) + (c + (c 51 (c change_puzzle (c change_amount (c change_serial (c change_rand ()))))) + (c offered (c requested (c maker_pubkey ()))) + ) + )", + ); + let puzzle_hash = compile_chialisp_template_hash_default(&puzzle_code).expect("compile"); + + // 3. create the CAT coin with random secrets + let (coin, secrets) = PrivateCoin::new_with_secrets_and_tail(puzzle_hash, amount, tail_hash); + + MintedCat { + coin, + secrets, + puzzle_code, + tail_hash, + } +} + +/// create XCH coin for the taker +fn create_xch_coin(amount: u64) -> (PrivateCoin, CoinSecrets, String) { + let puzzle_code = with_standard_conditions( + "(mod (out_puzzle out_amount out_serial out_rand) + (list (list CREATE_COIN out_puzzle out_amount out_serial out_rand)))", + ); + let puzzle_hash = compile_chialisp_template_hash_default(&puzzle_code).expect("compile"); + let (coin, secrets) = PrivateCoin::new_with_secrets(puzzle_hash, amount); + (coin, secrets, puzzle_code) +} + +fn main() { + println!("=== CAT OFFER DEMO ==="); + println!(); + + // === STEP 1: MINT A CAT === + println!("📦 STEP 1: Minting a new CAT"); + println!("---------------------------------"); + + let cat_amount = 1000u64; + let minted = mint_cat(cat_amount); + + println!(" TAIL program: {}", DEMO_TAIL); + println!( + " tail_hash: {}", + hex::encode(minted.tail_hash) + ); + println!(" amount: {} CAT mojos", minted.coin.amount); + println!(" is_cat: {}", minted.coin.is_cat()); + println!(); + + // === STEP 2: CREATE XCH FOR TAKER === + println!("💰 STEP 2: Creating XCH for taker"); + println!("---------------------------------"); + + let xch_amount = 500u64; + let (xch_coin, xch_secrets, xch_puzzle) = create_xch_coin(xch_amount); + + println!(" amount: {} XCH mojos", xch_coin.amount); + println!(" is_xch: {}", xch_coin.is_xch()); + println!(); + + // === STEP 3: BUILD MERKLE TREE === + println!("🌲 STEP 3: Building merkle tree"); + println!("---------------------------------"); + + // compute commitments + let cat_commitment = CoinCommitment::compute( + &minted.coin.tail_hash, + minted.coin.amount, + &minted.coin.puzzle_hash, + &minted.coin.serial_commitment, + hash_data, + ); + + let xch_commitment = CoinCommitment::compute( + &xch_coin.tail_hash, + xch_coin.amount, + &xch_coin.puzzle_hash, + &xch_coin.serial_commitment, + hash_data, + ); + + // simple 2-leaf merkle tree + let merkle_root = hash_data( + &[cat_commitment.0.as_slice(), xch_commitment.0.as_slice()].concat(), + ); + + // merkle paths + let cat_merkle_path = vec![xch_commitment.0]; + let xch_merkle_path = vec![cat_commitment.0]; + + println!( + " CAT commitment: {}...", + hex::encode(&cat_commitment.0[..8]) + ); + println!( + " XCH commitment: {}...", + hex::encode(&xch_commitment.0[..8]) + ); + println!(" merkle root: {}...", hex::encode(&merkle_root[..8])); + println!(); + + // === STEP 4: MAKER CREATES OFFER === + println!("📝 STEP 4: Maker creates conditional offer"); + println!("---------------------------------"); + + // offer terms: offering 100 CAT, requesting 50 XCH + let offered_amount = 100u64; + let requested_amount = 50u64; + let change_amount = cat_amount - offered_amount; // 900 CAT change + + // maker's encryption pubkey (random for demo) + let mut maker_pubkey = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut maker_pubkey); + + // change coin params + let mut change_puzzle = [0u8; 32]; + let mut change_serial = [0u8; 32]; + let mut change_rand = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut change_puzzle); + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut change_serial); + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut change_rand); + + let offer_params = vec![ + ProgramParameter::Int(offered_amount), + ProgramParameter::Int(requested_amount), + ProgramParameter::from_bytes(&maker_pubkey), + ProgramParameter::Int(change_amount), + ProgramParameter::from_bytes(&change_puzzle), + ProgramParameter::from_bytes(&change_serial), + ProgramParameter::from_bytes(&change_rand), + ]; + + println!(" offering: {} CAT mojos", offered_amount); + println!(" requesting: {} XCH mojos", requested_amount); + println!(" change: {} CAT mojos (back to maker)", change_amount); + println!(); + + // create conditional spend proof + let conditional_result = Spender::create_conditional_spend( + &minted.coin, + &minted.puzzle_code, + &offer_params, + &minted.secrets, + cat_merkle_path, + merkle_root, + 0, + None, // no tail_source (not compiling tail in-proof) + ); + + match conditional_result { + Ok(bundle) => { + println!(" ✅ conditional offer created!"); + println!(" proof type: {:?}", bundle.proof_type); + println!(" proof size: {} bytes", bundle.zk_proof.len()); + println!(" nullifiers: {}", bundle.nullifiers.len()); + + assert_eq!(bundle.proof_type, ProofType::ConditionalSpend); + assert!(!bundle.nullifiers.is_empty()); + + // === STEP 5: TAKER VIEWS OFFER === + println!(); + println!("👀 STEP 5: Taker views the offer"); + println!("---------------------------------"); + println!(" the taker sees:"); + println!(" - maker offers: {} CAT", offered_amount); + println!(" - maker wants: {} XCH", requested_amount); + println!( + " - CAT asset type (tail_hash): {}...", + hex::encode(&minted.tail_hash[..8]) + ); + println!(" - proof is ConditionalSpend (locked until settlement)"); + println!(); + + // === STEP 6: TAKER WOULD TAKE OFFER === + println!("🤝 STEP 6: Taker would take the offer"); + println!("---------------------------------"); + println!(" in a real flow, taker would:"); + println!(" 1. verify maker's conditional proof"); + println!(" 2. spend their XCH coin"); + println!(" 3. create settlement proof that:"); + println!(" - proves taker has {} XCH", requested_amount); + println!(" - proves maker offers {} CAT", offered_amount); + println!(" - atomically swaps the assets"); + println!(" 4. outputs:"); + println!(" - maker_nullifier (CAT spent)"); + println!(" - taker_nullifier (XCH spent)"); + println!(" - payment_commitment (XCH to maker)"); + println!(" - goods_commitment (CAT to taker)"); + println!(" - change commitments"); + println!(); + + // verify taker has enough XCH + if xch_coin.amount >= requested_amount { + println!(" ✅ taker has {} XCH >= {} requested", xch_coin.amount, requested_amount); + } else { + println!( + " ❌ taker has {} XCH < {} requested", + xch_coin.amount, requested_amount + ); + } + + println!(); + println!("=== DEMO COMPLETE ==="); + println!(); + println!("key insights:"); + println!( + " 1. CAT minting: computed tail_hash from TAIL program: {}...", + hex::encode(&minted.tail_hash[..8]) + ); + println!(" 2. asset isolation: CAT and XCH have different tail_hash"); + println!( + " 3. offers are atomic: conditional proof locks until settlement" + ); + println!(" 4. privacy: amounts/assets hidden in commitments"); + println!(); + println!("note: full settlement requires risc0/sp1 backend for recursive proving"); + println!(" run with: cargo run-risc0 --example cat_offer_demo"); + + // also verify the XCH coin can be spent + let xch_out_puzzle = [1u8; 32]; + let xch_out_serial = [2u8; 32]; + let xch_out_rand = [3u8; 32]; + + let xch_solution = vec![ + ProgramParameter::from_bytes(&xch_out_puzzle), + ProgramParameter::Int(xch_amount), + ProgramParameter::from_bytes(&xch_out_serial), + ProgramParameter::from_bytes(&xch_out_rand), + ]; + + let xch_spend_result = Spender::create_spend_with_serial( + &xch_coin, + &xch_puzzle, + &xch_solution, + &xch_secrets, + xch_merkle_path, + merkle_root, + 1, + ); + + match xch_spend_result { + Ok(xch_bundle) => { + println!(); + println!("bonus: verified taker's XCH coin is spendable"); + println!(" proof size: {} bytes", xch_bundle.zk_proof.len()); + println!(" nullifiers: {}", xch_bundle.nullifiers.len()); + } + Err(e) => { + println!(); + println!("warning: XCH spend test failed: {:?}", e); + } + } + } + Err(e) => { + println!(" ❌ conditional offer failed: {:?}", e); + } + } +} diff --git a/examples/generate_proof_database.rs b/examples/generate_proof_database.rs index b14ee27..12bbe40 100644 --- a/examples/generate_proof_database.rs +++ b/examples/generate_proof_database.rs @@ -1,6 +1,7 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets}; +use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets, XCH_TAIL}; use clvm_zk_core::merkle::SparseMerkleTree; +use clvm_zk_core::with_standard_conditions; use sha2::{Digest, Sha256}; use std::fs; use std::time::Instant; @@ -85,12 +86,9 @@ fn main() { fs::create_dir_all(&output_dir).expect("failed to create output directory"); // chialisp program that creates CREATE_COIN condition - // CREATE_COIN is recognized by the compiler and converted to opcode 51 - let coin_spend_program = r#" - (mod (amount recipient_hash) - (list CREATE_COIN recipient_hash amount) - ) - "#; + let coin_spend_program = with_standard_conditions( + "(mod (amount recipient_hash) (list (list CREATE_COIN recipient_hash amount)))", + ); let mut total_prove_time = std::time::Duration::ZERO; let mut total_proof_size = 0usize; @@ -98,7 +96,7 @@ fn main() { // setup: create merkle tree and coins with proper commitments println!("creating coins with serial commitments..."); - let program_hash = compile_program_hash(coin_spend_program); + let program_hash = compile_program_hash(&coin_spend_program); let mut merkle_tree = SparseMerkleTree::new(20, hash_data); let mut coin_data = Vec::new(); @@ -120,8 +118,13 @@ fn main() { // compute commitments let serial_commitment = coin_secrets.serial_commitment(hash_data); - let coin_commitment = - CoinCommitment::compute(amount, &program_hash, &serial_commitment, hash_data); + let coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &program_hash, + &serial_commitment, + hash_data, + ); // insert into merkle tree let leaf_index = merkle_tree.insert(*coin_commitment.as_bytes(), hash_data); @@ -156,7 +159,7 @@ fn main() { // generate proof with serial commitment (full nullifier protocol) let prove_start = Instant::now(); let result = ClvmZkProver::prove_with_serial_commitment( - coin_spend_program, + &coin_spend_program, &[ ProgramParameter::Int(*amount), ProgramParameter::Bytes(recipient_hash.to_vec()), @@ -169,6 +172,8 @@ fn main() { *leaf_index, program_hash, *amount, + None, // XCH (no CAT tail_hash) + None, // no tail_source ) .expect(&format!("failed to generate proof {}", i)); @@ -183,7 +188,7 @@ fn main() { proofs_data.push(( i, amount, - result.proof_output.nullifier.unwrap(), + result.proof_output.nullifiers.first().copied().unwrap(), prove_time, result.proof_bytes.len(), )); diff --git a/examples/performance_profiling.rs b/examples/performance_profiling.rs index 9283669..78f6c79 100644 --- a/examples/performance_profiling.rs +++ b/examples/performance_profiling.rs @@ -1,5 +1,5 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::{compile_chialisp_template_hash_default, with_standard_conditions}; use std::time::{Duration, Instant}; /// Performance profiling example demonstrating optimization techniques @@ -8,46 +8,50 @@ fn main() -> Result<(), Box> { println!("================================\n"); // Test cases with different complexity levels - let test_cases = vec![ + let test_cases: Vec<(&str, String, Vec)> = vec![ // Simple operations (baseline) - ("Simple Addition", "(mod (a b) (+ a b))", vec![5, 3]), - ("Simple Multiplication", "(mod (a b) (* a b))", vec![7, 8]), + ("Simple Addition", "(mod (a b) (+ a b))".into(), vec![5, 3]), + ( + "Simple Multiplication", + "(mod (a b) (* a b))".into(), + vec![7, 8], + ), // Medium complexity ( "Nested Arithmetic", - "(mod (a b c d) (+ (* a b) (- c d)))", + "(mod (a b c d) (+ (* a b) (- c d)))".into(), vec![5, 3, 10, 4], ), ( "Conditional Logic", - "(mod (a b c d) (i (> a b) c d))", + "(mod (a b c d) (i (> a b) c d))".into(), vec![7, 3, 100, 200], ), // Complex operations ( "Deep Nesting", - "(mod (a b c d e f g h) (+ (+ (+ a b) (+ c d)) (+ (+ e f) (+ g h))))", + "(mod (a b c d e f g h) (+ (+ (+ a b) (+ c d)) (+ (+ e f) (+ g h))))".into(), vec![1, 2, 3, 4, 5, 6, 7, 8], ), ( "Modular Exponentiation", - "(mod (a b c) (modpow a b c))", + "(mod (a b c) (modpow a b c))".into(), vec![5, 3, 13], ), ( "Division with Remainder", - "(mod (a b) (divmod a b))", + "(mod (a b) (divmod a b))".into(), vec![17, 5], ), - // Blockchain conditions + // Blockchain conditions (need constants defined) ( "Create Coin Condition", - "(mod (a b) (create_coin a b))", + with_standard_conditions("(mod (a b) (list (list CREATE_COIN a b)))"), vec![1000, 500], ), ( "Reserve Fee Condition", - "(mod (a) (reserve_fee a))", + with_standard_conditions("(mod (a) (list (list RESERVE_FEE a)))"), vec![100], ), ]; @@ -67,7 +71,7 @@ fn main() -> Result<(), Box> { for _ in 0..3 { let proof_start = Instant::now(); - let result = ClvmZkProver::prove(expression, ¶ms)?; + let result = ClvmZkProver::prove(&expression, ¶ms)?; let proof_time = proof_start.elapsed(); proof_times.push(proof_time); @@ -78,7 +82,7 @@ fn main() -> Result<(), Box> { // Measure verification time (5 runs for average) let mut verification_times = Vec::new(); - let program_hash = compile_chialisp_template_hash_default(expression).unwrap(); + let program_hash = compile_chialisp_template_hash_default(&expression).unwrap(); for _ in 0..5 { let verify_start = Instant::now(); let (_is_valid, _) = diff --git a/examples/precompile_delegated.rs b/examples/precompile_delegated.rs new file mode 100644 index 0000000..56bb5f3 --- /dev/null +++ b/examples/precompile_delegated.rs @@ -0,0 +1,67 @@ +// precompile delegated puzzle to get bytecode and hash for guest caching +// run with: cargo run --example precompile_delegated --no-default-features --features risc0 + +use sha2::{Digest, Sha256}; + +fn sha2_hash(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +fn main() { + let delegated_puzzle = r#"(mod (offered requested maker_pubkey change_amount change_puzzle change_serial change_rand) + (c + (c 51 (c change_puzzle (c change_amount (c change_serial (c change_rand ()))))) + (c offered (c requested (c maker_pubkey ()))) + ) +)"#; + + match clvm_zk_core::compile_chialisp_to_bytecode(sha2_hash, delegated_puzzle) { + Ok((bytecode, hash)) => { + println!("// delegated puzzle precompiled constants for guest"); + println!(); + println!("const DELEGATED_PUZZLE_BYTECODE: &[u8] = &["); + for (i, byte) in bytecode.iter().enumerate() { + if i % 16 == 0 { + print!(" "); + } + print!("0x{:02x}, ", byte); + if i % 16 == 15 { + println!(); + } + } + if bytecode.len() % 16 != 0 { + println!(); + } + println!("];"); + println!(); + println!("const DELEGATED_PUZZLE_HASH: [u8; 32] = ["); + for (i, byte) in hash.iter().enumerate() { + if i % 16 == 0 { + print!(" "); + } + print!("0x{:02x}, ", byte); + if i % 16 == 15 { + println!(); + } + } + if hash.len() % 16 != 0 { + println!(); + } + println!("];"); + println!(); + println!( + "const DELEGATED_PUZZLE_SOURCE: &str = r#\"{}\"#;", + delegated_puzzle + ); + println!(); + println!("bytecode length: {} bytes", bytecode.len()); + println!("puzzle hash: {}", hex::encode(hash)); + } + Err(e) => { + eprintln!("compilation failed: {:?}", e); + std::process::exit(1); + } + } +} diff --git a/examples/profile_compilation.rs b/examples/profile_compilation.rs new file mode 100644 index 0000000..2bf7359 --- /dev/null +++ b/examples/profile_compilation.rs @@ -0,0 +1,47 @@ +// profile chialisp compilation phases +// run with: cargo run --example profile_compilation --no-default-features --features risc0 --release + +use sha2::{Digest, Sha256}; +use std::time::Instant; + +fn sha2_hash(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +fn main() { + let delegated_puzzle = r#"(mod (offered requested maker_pubkey change_amount change_puzzle change_serial change_rand) + (c + (c 51 (c change_puzzle (c change_amount (c change_serial (c change_rand ()))))) + (c offered (c requested (c maker_pubkey ()))) + ) +)"#; + + println!("profiling compilation of delegated puzzle...\n"); + + // warm up + for _ in 0..5 { + let _ = clvm_zk_core::compile_chialisp_to_bytecode(sha2_hash, delegated_puzzle); + } + + // profile 100 runs + let runs = 100; + let start = Instant::now(); + + for _ in 0..runs { + let _ = clvm_zk_core::compile_chialisp_to_bytecode(sha2_hash, delegated_puzzle) + .expect("compilation failed"); + } + + let duration = start.elapsed(); + let avg_micros = duration.as_micros() / runs; + + println!("compilation timing (native, {} runs):", runs); + println!(" total: {:?}", duration); + println!(" average: {} µs per compilation", avg_micros); + println!( + " rate: {:.2} compilations/sec", + 1_000_000.0 / avg_micros as f64 + ); +} diff --git a/examples/recursive_aggregation.rs b/examples/recursive_aggregation.rs index 34480d8..99f36cd 100644 --- a/examples/recursive_aggregation.rs +++ b/examples/recursive_aggregation.rs @@ -1,5 +1,5 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets}; +use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets, XCH_TAIL}; use clvm_zk_core::merkle::SparseMerkleTree; use sha2::{Digest, Sha256}; @@ -38,8 +38,13 @@ fn main() { // compute commitments let serial_commitment = coin_secrets.serial_commitment(hash_data); - let coin_commitment = - CoinCommitment::compute(amount, &program_hash, &serial_commitment, hash_data); + let coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &program_hash, + &serial_commitment, + hash_data, + ); // insert into merkle tree let leaf_index = merkle_tree.insert(*coin_commitment.as_bytes(), hash_data); @@ -79,6 +84,8 @@ fn main() { *leaf_index, program_hash, *amount, + None, // XCH (no CAT tail_hash) + None, // no tail_source ) .unwrap(); diff --git a/scripts/multi_cat_demo.sh b/scripts/multi_cat_demo.sh new file mode 100755 index 0000000..41f423e --- /dev/null +++ b/scripts/multi_cat_demo.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# multi_cat_demo.sh - demonstrates minting multiple different CAT types +# +# usage: ./scripts/multi_cat_demo.sh + +set -e + +BINARY="cargo run --release --features risc0 --" + +echo "=== MULTI-CAT MINTING DEMO ===" +echo "" + +# init simulator +echo "initializing simulator..." +$BINARY sim init --reset 2>/dev/null || $BINARY sim init + +# create wallet +echo "creating wallet 'trader'..." +$BINARY sim wallet trader create 2>/dev/null || true + +# mint XCH for fees +echo "" +echo "minting XCH for fees..." +$BINARY sim faucet trader --amount 100000 --count 1 + +# define our CAT types +GOLD_TAIL='(mod () 1)' +SILVER_TAIL='(mod () 2)' +BRONZE_TAIL='(mod (x) (> x 0))' + +echo "" +echo "=== MINTING 3 DIFFERENT CAT TYPES ===" +echo "" + +# mint GOLD +echo "minting GOLD CAT..." +echo " TAIL: $GOLD_TAIL" +$BINARY sim mint trader --tail "$GOLD_TAIL" --amount 10000 --count 2 + +# mint SILVER +echo "" +echo "minting SILVER CAT..." +echo " TAIL: $SILVER_TAIL" +$BINARY sim mint trader --tail "$SILVER_TAIL" --amount 5000 --count 3 + +# mint BRONZE (requires param) +echo "" +echo "minting BRONZE CAT..." +echo " TAIL: $BRONZE_TAIL" +$BINARY sim mint trader --tail "$BRONZE_TAIL" --amount 1000 --count 5 --params "100" + +# show balances +echo "" +echo "=== WALLET BALANCES ===" +$BINARY sim wallet trader coins + +echo "" +echo "=== DEMO COMPLETE ===" +echo "" +echo "minted:" +echo " - GOLD: 2 coins x 10000 = 20000 mojos" +echo " - SILVER: 3 coins x 5000 = 15000 mojos" +echo " - BRONZE: 5 coins x 1000 = 5000 mojos" +echo " - XCH: 1 coin x 100000 (for fees)" diff --git a/scripts/precompile_puzzles.rs b/scripts/precompile_puzzles.rs new file mode 100644 index 0000000..46d9733 --- /dev/null +++ b/scripts/precompile_puzzles.rs @@ -0,0 +1,34 @@ +// utility to precompile standard puzzles for guest caching +// run with: cargo run --bin precompile_puzzles + +use sha2::{Digest, Sha256}; + +fn sha2_hash(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +fn main() { + let delegated_puzzle = r#"(mod (offered requested maker_pubkey change_amount change_puzzle change_serial change_rand) + (c + (c 51 (c change_puzzle (c change_amount (c change_serial (c change_rand ()))))) + (c offered (c requested (c maker_pubkey ()))) + ) +)"#; + + match clvm_zk_core::compile_chialisp_to_bytecode(sha2_hash, delegated_puzzle) { + Ok((bytecode, hash)) => { + println!("// delegated puzzle precompiled bytecode"); + println!("const DELEGATED_PUZZLE_BYTECODE: &[u8] = &{:?};", bytecode); + println!(); + println!("const DELEGATED_PUZZLE_HASH: [u8; 32] = {:?};", hash); + println!(); + println!("const DELEGATED_PUZZLE_SOURCE: &str = r#\"{}\"#;", delegated_puzzle); + } + Err(e) => { + eprintln!("compilation failed: {:?}", e); + std::process::exit(1); + } + } +} diff --git a/sim_demo.sh b/sim_demo.sh index 0ba71ca..3a73578 100755 --- a/sim_demo.sh +++ b/sim_demo.sh @@ -31,8 +31,8 @@ time_end() { fi } -# backend selection (default: risc0) -BACKEND="${1:-risc0}" +# backend selection (default: sp1) +BACKEND="${1:-sp1}" if [[ "$BACKEND" != "risc0" && "$BACKEND" != "sp1" ]]; then echo "❌ invalid backend: $BACKEND" diff --git a/src/backends.rs b/src/backends.rs index 1ee7e07..cea1ec6 100644 --- a/src/backends.rs +++ b/src/backends.rs @@ -24,6 +24,7 @@ pub trait ZKCLVMBackend { } } +#[allow(clippy::needless_return)] pub fn backend() -> Result, ClvmZkError> { #[cfg(feature = "risc0")] { diff --git a/src/cli.rs b/src/cli.rs index cf01da2..2f44033 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use crate::simulator::{CLVMZkSimulator, CoinMetadata, CoinType}; use crate::wallet::{CLVMHDWallet, Network, WalletError}; use crate::{ClvmZkError, ClvmZkProver, ProgramParameter}; use clap::{Parser, Subcommand}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use clvm_zk_core::{atom_to_number, ClvmParser}; use rand::{thread_rng, RngCore}; use serde::{Deserialize, Serialize}; @@ -102,6 +102,35 @@ pub enum SimAction { /// Number of coins #[arg(long, default_value = "1")] count: u32, + /// Asset ID (tail_hash) - hex string. Omit for XCH. + #[arg(long)] + tail: Option, + /// TAIL program source (chialisp) - computes tail_hash automatically, stored on coin for offer-create + #[arg(long)] + tail_source: Option, + /// Use delegated puzzle (required for offers) + #[arg(long)] + delegated: bool, + }, + /// Mint CAT with proper TAIL verification (generates ZK proof) + Mint { + /// Wallet name to receive minted coins + wallet: String, + /// TAIL program source (chialisp) + #[arg(long)] + tail: String, + /// Amount to mint per coin + #[arg(long)] + amount: u64, + /// Number of coins to mint + #[arg(long, default_value = "1")] + count: u32, + /// TAIL program parameters (comma-separated) + #[arg(long, default_value = "")] + params: String, + /// Use delegated puzzle (required for offers) + #[arg(long)] + delegated: bool, }, /// Wallet operations Wallet { @@ -124,6 +153,9 @@ pub enum SimAction { /// Coin indices to spend (comma-separated, e.g. "0,1,2" or "auto" for automatic selection) #[arg(long)] coins: String, + /// Stealth address mode: "nullifier" (fast, default) or "signature" (secure) + #[arg(long, default_value = "nullifier")] + stealth_mode: String, }, /// Spend coins to create a puzzle-locked coin #[command(name = "spend-to-puzzle")] @@ -174,6 +206,9 @@ pub enum SimAction { /// Amount maker is requesting #[arg(long)] request: u64, + /// Asset ID (tail_hash) maker is requesting - hex string. Omit for XCH. + #[arg(long)] + request_tail: Option, /// Coin indices to use (comma-separated or "auto") #[arg(long)] coins: String, @@ -741,8 +776,6 @@ struct SimulatorState { #[serde(default)] observer_wallets: HashMap, #[serde(default)] - encrypted_notes: Vec, - #[serde(default)] spend_bundles: Vec, #[serde(default)] simulator: CLVMZkSimulator, @@ -764,6 +797,11 @@ struct StoredOffer { change_puzzle: [u8; 32], change_serial: [u8; 32], change_rand: [u8; 32], + // asset type identifiers (v2.0) + #[serde(default)] + offered_tail_hash: [u8; 32], // asset type maker is offering + #[serde(default)] + requested_tail_hash: [u8; 32], // asset type maker is requesting } #[derive(Serialize, Deserialize, Clone)] @@ -774,11 +812,20 @@ struct WalletData { account_index: u32, next_coin_index: u32, // Track next coin index for HD derivation coins: Vec, - // Encryption keys for receiving payments + // Stealth address pubkeys for receiving payments (33 bytes compressed secp256k1 each) + // Private keys are derived from seed via StealthKeys + #[serde(default)] + stealth_view_pubkey: Option>, + #[serde(default)] + stealth_spend_pubkey: Option>, + // x25519 encryption keys for nonce encryption in offers (stealth uses hash-based derivation) #[serde(default)] note_encryption_public: Option<[u8; 32]>, #[serde(default)] note_encryption_private: Option<[u8; 32]>, + // per-recipient nonce counter for stealth payments (key = hex(recipient_view_pubkey)) + #[serde(default)] + stealth_nonce_counters: std::collections::HashMap, } /// wrapper around WalletPrivateCoin with additional CLI-specific state @@ -790,6 +837,9 @@ struct WalletCoinWrapper { program: String, /// whether this coin has been spent spent: bool, + /// TAIL program source (chialisp) for CAT coins — needed for offer-create delta authorization + #[serde(default)] + tail_source: Option, } impl WalletCoinWrapper { @@ -839,20 +889,41 @@ impl WalletData { puzzle_hash: [u8; 32], amount: u64, program: String, + ) -> Result { + self.create_coin_with_tail(puzzle_hash, amount, program, None, None) + } + + fn create_coin_with_tail( + &mut self, + puzzle_hash: [u8; 32], + amount: u64, + program: String, + tail_hash: Option<[u8; 32]>, + tail_source: Option, ) -> Result { let coin_index = self.next_coin_index(); - let wallet_coin = crate::wallet::WalletPrivateCoin::new( - puzzle_hash, - amount, - self.account_index, - coin_index, - ); + let wallet_coin = match tail_hash { + Some(tail) => crate::wallet::WalletPrivateCoin::new_with_tail( + puzzle_hash, + amount, + self.account_index, + coin_index, + tail, + ), + None => crate::wallet::WalletPrivateCoin::new( + puzzle_hash, + amount, + self.account_index, + coin_index, + ), + }; Ok(WalletCoinWrapper { wallet_coin, program, spent: false, + tail_source, }) } } @@ -922,7 +993,6 @@ impl SimulatorState { faucet_nonce: 0, puzzle_coins: None, observer_wallets: HashMap::new(), - encrypted_notes: Vec::new(), spend_bundles: Vec::new(), simulator: CLVMZkSimulator::new(), pending_offers: Vec::new(), @@ -981,8 +1051,22 @@ fn run_simulator_command(data_dir: &Path, action: SimAction) -> Result<(), ClvmZ wallet, amount, count, + tail, + tail_source, + delegated, } => { - faucet_command(data_dir, &wallet, amount, count)?; + faucet_command(data_dir, &wallet, amount, count, tail, tail_source, delegated)?; + } + + SimAction::Mint { + wallet, + tail, + amount, + count, + params, + delegated, + } => { + mint_command(data_dir, &wallet, &tail, amount, count, ¶ms, delegated)?; } SimAction::Wallet { name, action } => { @@ -1002,8 +1086,9 @@ fn run_simulator_command(data_dir: &Path, action: SimAction) -> Result<(), ClvmZ to, amount, coins, + stealth_mode, } => { - send_command(data_dir, &from, &to, amount, &coins)?; + send_command(data_dir, &from, &to, amount, &coins, &stealth_mode)?; } SimAction::SpendToPuzzle { @@ -1040,9 +1125,17 @@ fn run_simulator_command(data_dir: &Path, action: SimAction) -> Result<(), ClvmZ maker, offer, request, + request_tail, coins, } => { - offer_create_command(data_dir, &maker, offer, request, &coins)?; + offer_create_command( + data_dir, + &maker, + offer, + request, + request_tail.as_deref(), + &coins, + )?; } SimAction::OfferTake { @@ -1066,9 +1159,33 @@ fn faucet_command( wallet_name: &str, amount: u64, count: u32, + tail_hex: Option, + tail_source: Option, + use_delegated: bool, ) -> Result<(), ClvmZkError> { let mut state = SimulatorState::load(data_dir)?; + // compute tail_hash: from --tail-source (auto-compute) or --tail (explicit hex) + let tail_hash: Option<[u8; 32]> = if let Some(ref source) = tail_source { + let hash = compile_chialisp_template_hash_default(source) + .map_err(|e| ClvmZkError::InvalidProgram(format!("failed to compile tail source: {:?}", e)))?; + Some(hash) + } else if let Some(ref hex_str) = tail_hex { + let bytes = hex::decode(hex_str) + .map_err(|e| ClvmZkError::InvalidProgram(format!("invalid tail hex: {}", e)))?; + if bytes.len() != 32 { + return Err(ClvmZkError::InvalidProgram(format!( + "tail must be 32 bytes (64 hex chars), got {} bytes", + bytes.len() + ))); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Some(arr) + } else { + None + }; + // ensure wallet exists if !state.wallets.contains_key(wallet_name) { return Err(ClvmZkError::InvalidProgram(format!( @@ -1077,29 +1194,41 @@ fn faucet_command( ))); } - // create delegated puzzle for faucet coins (universal spending model) - let (program, puzzle_hash) = crate::protocol::create_delegated_puzzle()?; + // create puzzle (faucet or delegated) + let (program, puzzle_hash) = if use_delegated { + crate::protocol::create_delegated_puzzle()? + } else { + create_faucet_puzzle(amount) + }; // generate coins for the wallet let wallet = state.wallets.get_mut(wallet_name).unwrap(); let mut total_funded = 0; for _ in 0..count { - // Use HD wallet to create new coin + // Use HD wallet to create new coin (with optional tail_hash for CATs) let wallet_coin = wallet - .create_coin(puzzle_hash, amount, program.clone()) + .create_coin_with_tail(puzzle_hash, amount, program.clone(), tail_hash, tail_source.clone()) .map_err(|e| ClvmZkError::InvalidProgram(format!("HD wallet error: {}", e)))?; // add coin to global simulator state let coin = wallet_coin.to_private_coin(); let secrets = wallet_coin.secrets(); + let coin_type = if tail_hash.is_some() { + CoinType::Cat + } else { + CoinType::Regular + }; state.simulator.add_coin( coin, secrets, CoinMetadata { owner: wallet_name.to_string(), - coin_type: CoinType::Regular, - notes: "faucet".to_string(), + coin_type, + notes: match &tail_hex { + Some(t) => format!("faucet CAT:{}", &t[..8]), + None => "faucet".to_string(), + }, }, ); @@ -1110,9 +1239,139 @@ fn faucet_command( state.faucet_nonce += count as u64; state.save(data_dir)?; + let asset_str = match &tail_hex { + Some(t) => format!("CAT:{}", &t[..8.min(t.len())]), + None => "XCH".to_string(), + }; println!( - "funded wallet '{}' with {} coins of {} each (total: {})", - wallet_name, count, amount, total_funded + "funded wallet '{}' with {} {} coins of {} each (total: {})", + wallet_name, count, asset_str, amount, total_funded + ); + + Ok(()) +} + +fn mint_command( + data_dir: &Path, + wallet_name: &str, + tail_source: &str, + amount: u64, + count: u32, + params: &str, + use_delegated: bool, +) -> Result<(), ClvmZkError> { + let mut state = SimulatorState::load(data_dir)?; + + // ensure wallet exists + if !state.wallets.contains_key(wallet_name) { + return Err(ClvmZkError::InvalidProgram(format!( + "wallet '{}' not found. create it first with: sim wallet {} create", + wallet_name, wallet_name + ))); + } + + // helper hasher for local execution + fn cli_hasher(d: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(d); + hasher.finalize().into() + } + + // dummy verifiers (TAIL programs typically don't use crypto) + fn dummy_bls(_pk: &[u8], _msg: &[u8], _sig: &[u8]) -> Result { + Ok(true) + } + fn dummy_ecdsa(_pk: &[u8], _msg: &[u8], _sig: &[u8]) -> Result { + Ok(true) + } + + // compile TAIL to get tail_hash + let tail_hash = compile_chialisp_template_hash_default(tail_source) + .map_err(|e| ClvmZkError::InvalidProgram(format!("failed to compile TAIL: {:?}", e)))?; + + println!("compiled TAIL program"); + println!(" tail_hash: {}", hex::encode(tail_hash)); + + // parse TAIL parameters (for verification) + let tail_params: Vec = if params.is_empty() { + vec![] + } else { + params + .split(',') + .map(|s| { + let s = s.trim(); + if let Ok(n) = s.parse::() { + ProgramParameter::Int(n as u64) + } else { + ProgramParameter::Bytes(s.as_bytes().to_vec()) + } + }) + .collect() + }; + + // for simulator: execute TAIL locally to verify it returns truthy + // (in production, the zkVM guest does this verification) + let evaluator = clvm_zk_core::create_veil_evaluator(cli_hasher, dummy_bls, dummy_ecdsa); + let (tail_bytecode, _) = clvm_zk_core::compile_chialisp_to_bytecode(cli_hasher, tail_source) + .map_err(|e| ClvmZkError::InvalidProgram(format!("TAIL compilation failed: {:?}", e)))?; + + let tail_args = clvm_zk_core::serialize_params_to_clvm(&tail_params); + let (tail_output, _) = + clvm_zk_core::run_clvm_with_conditions(&evaluator, &tail_bytecode, &tail_args, 1_000_000) + .map_err(|e| ClvmZkError::InvalidProgram(format!("TAIL execution failed: {}", e)))?; + + // verify TAIL returns truthy + let is_truthy = !tail_output.is_empty() && tail_output != vec![0x80]; + if !is_truthy { + return Err(ClvmZkError::InvalidProgram( + "TAIL program did not authorize mint (returned nil)".to_string(), + )); + } + println!(" TAIL authorization: ✓"); + + // create puzzle (faucet or delegated) + let (program, puzzle_hash) = if use_delegated { + crate::protocol::create_delegated_puzzle()? + } else { + create_faucet_puzzle(amount) + }; + + // generate coins for the wallet + let wallet = state.wallets.get_mut(wallet_name).unwrap(); + let mut total_minted = 0; + + for _ in 0..count { + // create coin with proper tail_hash + let wallet_coin = wallet + .create_coin_with_tail(puzzle_hash, amount, program.clone(), Some(tail_hash), Some(tail_source.to_string())) + .map_err(|e| ClvmZkError::InvalidProgram(format!("HD wallet error: {}", e)))?; + + // add coin to global simulator state + let coin = wallet_coin.to_private_coin(); + let secrets = wallet_coin.secrets(); + state.simulator.add_coin( + coin, + secrets, + CoinMetadata { + owner: wallet_name.to_string(), + coin_type: CoinType::Cat, + notes: format!("mint CAT:{}", &hex::encode(&tail_hash)[..8]), + }, + ); + + wallet.coins.push(wallet_coin); + total_minted += amount; + } + + state.save(data_dir)?; + + println!( + "minted {} CAT:{} coins of {} each (total: {}) to wallet '{}'", + count, + &hex::encode(&tail_hash)[..8], + amount, + total_minted, + wallet_name ); Ok(()) @@ -1142,6 +1401,23 @@ fn wallet_command(data_dir: &Path, name: &str, action: WalletAction) -> Result<( ClvmZkError::InvalidProgram(format!("Account derivation error: {}", e)) })?; + // Get stealth address from account keys + let stealth_address = account_keys.stealth_keys.stealth_address(); + + // derive x25519 encryption keys from seed (HD wallet compatible) + // (stealth uses hash-based derivation, x25519 just encrypts the nonce to receiver) + let note_encryption_private: [u8; 32] = { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"note_encryption_v1"); + hasher.update(&seed); + hasher.finalize().into() + }; + let note_encryption_public = x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::from(note_encryption_private), + ) + .to_bytes(); + let wallet = WalletData { name: name.to_string(), seed, @@ -1149,8 +1425,11 @@ fn wallet_command(data_dir: &Path, name: &str, action: WalletAction) -> Result<( account_index: 0, // Use account 0 for simplicity next_coin_index: 0, // Start from coin index 0 coins: Vec::new(), - note_encryption_public: Some(account_keys.note_encryption_public), - note_encryption_private: Some(account_keys.note_encryption_private), + stealth_view_pubkey: Some(stealth_address.view_pubkey.to_vec()), + stealth_spend_pubkey: Some(stealth_address.spend_pubkey.to_vec()), + note_encryption_public: Some(note_encryption_public), + note_encryption_private: Some(note_encryption_private), + stealth_nonce_counters: std::collections::HashMap::new(), }; state.wallets.insert(name.to_string(), wallet); @@ -1158,8 +1437,8 @@ fn wallet_command(data_dir: &Path, name: &str, action: WalletAction) -> Result<( println!("created wallet '{}'", name); println!( - "payment address (viewing public key): {}", - hex::encode(account_keys.note_encryption_public) + "stealth address: {}", + hex::encode(stealth_address.to_bytes()) ); } @@ -1318,7 +1597,6 @@ fn status_command(data_dir: &Path) -> Result<(), ClvmZkError> { .count(); println!("total coins: {} ({} unspent)", total_coins, total_unspent); - println!("encrypted notes: {}", state.encrypted_notes.len()); println!("saved proofs: {}", state.spend_bundles.len()); Ok(()) @@ -1383,7 +1661,10 @@ fn send_command( to: &str, amount: u64, coin_indices: &str, + stealth_mode: &str, ) -> Result<(), ClvmZkError> { + // nullifier mode only (signature mode removed) + let _ = stealth_mode; // ignored for now, always nullifier let mut state = SimulatorState::load(data_dir)?; // validate wallets exist @@ -1515,57 +1796,110 @@ fn send_command( // create new coin for recipient if amount > 0 if amount > 0 { let to_wallet = state.wallets.get_mut(to).unwrap(); - let (program, puzzle_hash) = create_faucet_puzzle(amount); - // Get recipient's encryption public key - let recipient_public_key = to_wallet.note_encryption_public.ok_or_else(|| { - ClvmZkError::InvalidProgram(format!( - "recipient wallet '{}' has no encryption key (old wallet, recreate it)", - to - )) + // Get recipient's stealth address + let recipient_stealth = { + let view_pub = to_wallet.stealth_view_pubkey.as_ref().ok_or_else(|| { + ClvmZkError::InvalidProgram(format!( + "recipient wallet '{}' has no stealth address (old wallet, recreate it)", + to + )) + })?; + let spend_pub = to_wallet.stealth_spend_pubkey.as_ref().ok_or_else(|| { + ClvmZkError::InvalidProgram(format!( + "recipient wallet '{}' has no stealth address", + to + )) + })?; + let mut view_arr = [0u8; 32]; + let mut spend_arr = [0u8; 32]; + // stealth addresses now use 32-byte hash-based pubkeys + if view_pub.len() == 32 { + view_arr.copy_from_slice(view_pub); + } else { + // legacy 33-byte compressed EC pubkey - take first 32 bytes + view_arr.copy_from_slice(&view_pub[..32]); + } + if spend_pub.len() == 32 { + spend_arr.copy_from_slice(spend_pub); + } else { + spend_arr.copy_from_slice(&spend_pub[..32]); + } + crate::wallet::StealthAddress { + view_pubkey: view_arr, + spend_pubkey: spend_arr, + } + }; + + // look up per-recipient nonce counter (prevents same sender→recipient collision) + let recipient_key = hex::encode(recipient_stealth.view_pubkey); + let nonce_index = { + let sender = state.wallets.get(from).unwrap(); + *sender.stealth_nonce_counters.get(&recipient_key).unwrap_or(&0) + }; + + // create stealth payment (nullifier mode) - derives shared_secret via hash + let sender_hd = state + .wallets + .get(from) + .unwrap() + .get_hd_wallet() + .map_err(|e| ClvmZkError::InvalidProgram(format!("hd wallet error: {}", e)))?; + let sender_account = sender_hd.derive_account(0).map_err(|e| { + ClvmZkError::InvalidProgram(format!("account derivation error: {}", e)) })?; + let stealth_payment = crate::wallet::create_stealth_payment_hd( + &sender_account.stealth_keys, + nonce_index, + &recipient_stealth, + ); - // Use HD wallet to create new coin for recipient - let wallet_coin = to_wallet - .create_coin(puzzle_hash, amount, program) - .map_err(|e| ClvmZkError::InvalidProgram(format!("HD wallet error: {}", e)))?; + // derive coin secrets using nullifier mode (fast proving) + let secrets = crate::wallet::derive_nullifier_secrets_from_shared_secret( + &stealth_payment.shared_secret, + ); - // Extract coin secrets for encryption - let secrets = wallet_coin.secrets(); + // Create coin with stealth-derived puzzle_hash and deterministic secrets + let puzzle_hash = stealth_payment.puzzle_hash; + let serial_commitment = + secrets.serial_commitment(crate::crypto_utils::hash_data_default); + let coin = + crate::protocol::PrivateCoin::new(puzzle_hash, amount, serial_commitment); + + // encrypt nonce to recipient's x25519 key before storing + let recipient_enc_pubkey = state.wallets.get(to).unwrap() + .note_encryption_public + .ok_or_else(|| ClvmZkError::InvalidProgram(format!( + "recipient wallet '{}' has no encryption key (old wallet, recreate it)", to + )))?; + let encrypted_nonce = crate::crypto_utils::encrypt_stealth_nonce( + &stealth_payment.nonce, + &recipient_enc_pubkey, + ); - // add coin to global simulator state - let coin = wallet_coin.to_private_coin(); - state.simulator.add_coin( + // add coin to global simulator state with encrypted nonce and puzzle_source + state.simulator.add_coin_with_stealth_nonce( coin, - secrets, + &secrets, + encrypted_nonce, + stealth_payment.puzzle_source.clone(), CoinMetadata { owner: to.to_string(), coin_type: CoinType::Regular, - notes: format!("payment from {}", from), + notes: format!("stealth payment from {} (nullifier mode)", from), }, ); - // Create encrypted payment note - let payment_note = crate::protocol::PaymentNote { - serial_number: secrets.serial_number, - serial_randomness: secrets.serial_randomness, - amount, - puzzle_hash, - memo: format!("payment from {}", from).into_bytes(), - }; - - let encrypted_note = - crate::protocol::EncryptedNote::encrypt(&recipient_public_key, &payment_note) - .map_err(|e| { - ClvmZkError::InvalidProgram(format!("failed to encrypt note: {}", e)) - })?; - - // Add note to global pool - state.encrypted_notes.push(encrypted_note); - // NOTE: coin is NOT added to recipient's wallet directly - // recipient must run 'sim scan' to discover and decrypt the note - println!("created encrypted note for '{}' with amount {} (recipient must scan to receive)", to, amount); + // recipient must run 'sim scan' to discover via stealth scanning + println!( + "created stealth payment for '{}' with amount {} [nullifier mode] (recipient must scan to receive)", + to, amount + ); + + // increment per-recipient nonce counter + let sender_mut = state.wallets.get_mut(from).unwrap(); + *sender_mut.stealth_nonce_counters.entry(recipient_key).or_insert(0) += 1; } // handle change if any @@ -1619,67 +1953,96 @@ fn send_command( fn scan_command(data_dir: &Path, wallet_name: &str) -> Result<(), ClvmZkError> { let mut state = SimulatorState::load(data_dir)?; - // Get wallet - let wallet = state.wallets.get_mut(wallet_name).ok_or_else(|| { - ClvmZkError::InvalidProgram(format!("wallet '{}' not found", wallet_name)) - })?; + // Get wallet and derive stealth view key + encryption private key + let (view_key, existing_commitments, enc_privkey) = { + let wallet = state.wallets.get(wallet_name).ok_or_else(|| { + ClvmZkError::InvalidProgram(format!("wallet '{}' not found", wallet_name)) + })?; - // Get decryption key - let decryption_key = wallet.note_encryption_private.ok_or_else(|| { - ClvmZkError::InvalidProgram(format!( - "wallet '{}' has no encryption key (old wallet, recreate it)", - wallet_name - )) - })?; + // Derive stealth keys from seed + let hd_wallet = crate::wallet::CLVMHDWallet::from_seed(&wallet.seed, wallet.network) + .map_err(|e| ClvmZkError::InvalidProgram(format!("wallet error: {}", e)))?; + let account_keys = hd_wallet + .derive_account(wallet.account_index) + .map_err(|e| ClvmZkError::InvalidProgram(format!("key derivation error: {}", e)))?; + + let view_key = account_keys.stealth_keys.view_only(); + + let enc_privkey = wallet.note_encryption_private.ok_or_else(|| { + ClvmZkError::InvalidProgram(format!( + "wallet '{}' has no encryption key (old wallet, recreate it)", wallet_name + )) + })?; + // Get existing serial commitments to avoid duplicates + // (puzzle_hash is NOT unique in nullifier mode — all stealth coins share one) + let existing: std::collections::HashSet<[u8; 32]> = wallet + .coins + .iter() + .map(|c| *c.wallet_coin.coin.serial_commitment.as_bytes()) + .collect(); + + (view_key, existing, enc_privkey) + }; + + // Get stealth-scannable coins from simulator (now returns nonces instead of ephemeral pubkeys) + let scannable_coins = state.simulator.get_stealth_scannable_coins(); println!( - "scanning {} encrypted notes for wallet '{}'...", - state.encrypted_notes.len(), + "scanning {} stealth coins for wallet '{}'...", + scannable_coins.len(), wallet_name ); + // scan each coin using hash-based stealth (try_scan_with_nonce) let mut found_count = 0; let mut total_amount = 0u64; - // Try to decrypt each note - for (i, note) in state.encrypted_notes.iter().enumerate() { - if let Ok(payment_note) = note.decrypt(&decryption_key) { - // This note is for us! - println!(" found payment note #{}: {} mojos", i, payment_note.amount); + for (puzzle_hash, nonce_bytes, info) in &scannable_coins { + // Skip if already in wallet (dedup by serial_commitment, not puzzle_hash) + let sc = *info.coin.serial_commitment.as_bytes(); + if existing_commitments.contains(&sc) { + println!( + " found coin {} (already in wallet, skipping)", + hex::encode(&sc[..4]) + ); + continue; + } - // Check if we already have this coin - let nullifier = payment_note.serial_number; - let already_have = wallet.coins.iter().any(|c| c.serial_number() == nullifier); + // decrypt encrypted nonce (80 bytes: ephemeral_pub || ciphertext) + let nonce = match crate::crypto_utils::decrypt_stealth_nonce(nonce_bytes, &enc_privkey) { + Some(n) => n, + None => continue, // not our coin (decryption failed) + }; - if already_have { - println!(" (already in wallet, skipping)"); - continue; - } + // try to scan this coin with the nonce + let scanned = match view_key.try_scan_with_nonce(puzzle_hash, &nonce) { + Some(s) => s, + None => continue, // not our coin + }; - // Reconstruct the coin - let serial_commitment = clvm_zk_core::coin_commitment::SerialCommitment::compute( - &payment_note.serial_number, - &payment_note.serial_randomness, - crate::crypto_utils::hash_data_default, - ); + // found a coin! + let coin_info = Some(*info); - let coin = crate::protocol::PrivateCoin::new( - payment_note.puzzle_hash, - payment_note.amount, - serial_commitment, + if let Some(info) = coin_info { + println!( + " found stealth coin: {} mojos [nullifier mode]", + info.coin.amount ); - let secrets = clvm_zk_core::coin_commitment::CoinSecrets { - serial_number: payment_note.serial_number, - serial_randomness: payment_note.serial_randomness, - }; + // derive secrets using nullifier mode (fast proving) + let secrets = + crate::wallet::derive_nullifier_secrets_from_shared_secret(&scanned.shared_secret); + + // reconstruct the coin for the wallet + let coin = info.coin.clone(); + + // use puzzle_source from stealth scanning + let program = scanned.puzzle_source.clone(); - // Create wallet coin wrapper (using dummy indices for scanned coins) - let (program, _) = create_faucet_puzzle(payment_note.amount); let wallet_coin = crate::wallet::WalletPrivateCoin { coin, secrets, - account_index: 0, // scanned coins don't have HD derivation path + account_index: 0, // stealth coins don't have HD derivation path coin_index: 0, }; @@ -1687,25 +2050,21 @@ fn scan_command(data_dir: &Path, wallet_name: &str) -> Result<(), ClvmZkError> { wallet_coin, program, spent: false, + tail_source: None, }; + // Add to wallet + let wallet = state.wallets.get_mut(wallet_name).unwrap(); wallet.coins.push(wrapper); found_count += 1; - total_amount += payment_note.amount; - - // Show memo if present - if !payment_note.memo.is_empty() { - if let Ok(memo_str) = String::from_utf8(payment_note.memo.clone()) { - println!(" memo: \"{}\"", memo_str); - } - } + total_amount += info.coin.amount; } } state.save(data_dir)?; println!("\nscan complete:"); - println!(" found {} new coins", found_count); + println!(" found {} new stealth coins", found_count); println!(" total value: {} mojos", total_amount); Ok(()) @@ -2123,16 +2482,36 @@ fn observer_command(data_dir: &Path, action: ObserverAction) -> Result<(), ClvmZ Ok(()) } -// Offer commands - working implementation with local storage +// Offer commands - create conditional spend proof for atomic swaps fn offer_create_command( data_dir: &Path, maker_name: &str, offered_amount: u64, requested_amount: u64, + request_tail_hex: Option<&str>, coins: &str, ) -> Result<(), ClvmZkError> { let mut state = SimulatorState::load(data_dir)?; + // parse requested tail_hash if provided + let requested_tail_hash: [u8; 32] = match request_tail_hex { + Some(hex_str) => { + let bytes = hex::decode(hex_str).map_err(|e| { + ClvmZkError::InvalidProgram(format!("invalid request-tail hex: {}", e)) + })?; + if bytes.len() != 32 { + return Err(ClvmZkError::InvalidProgram(format!( + "request-tail must be 32 bytes (64 hex chars), got {} bytes", + bytes.len() + ))); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + } + None => [0u8; 32], // default to XCH + }; + // get wallet let wallet = state .wallets @@ -2222,6 +2601,7 @@ fn offer_create_command( .ok_or_else(|| ClvmZkError::InvalidProgram("merkle tree has no root".to_string()))?; // create conditional spend proof using delegated puzzle + // pass tail_source for CAT coins so TAIL can authorize the balance delta let conditional_proof = crate::protocol::Spender::create_conditional_spend( &spend_coin.to_private_coin(), &delegated_code, @@ -2230,6 +2610,7 @@ fn offer_create_command( merkle_path, merkle_root, leaf_index, + spend_coin.tail_source.clone(), ) .map_err(|e| ClvmZkError::InvalidProgram(format!("conditional proof failed: {:?}", e)))?; @@ -2243,8 +2624,7 @@ fn offer_create_command( } } - // note: maker's change coin secrets are stored in the offer and will be - // added to maker's wallet when offer_take_command processes settlement + // change secrets are stored in StoredOffer and added to maker's wallet during offer-take // store the offer let offer_id = state.pending_offers.len(); @@ -2260,6 +2640,8 @@ fn offer_create_command( change_puzzle, change_serial, change_rand, + offered_tail_hash: spend_coin.to_private_coin().tail_hash, + requested_tail_hash, }); state.save(data_dir)?; @@ -2293,12 +2675,10 @@ fn offer_take_command( ) -> Result<(), ClvmZkError> { let mut state = SimulatorState::load(data_dir)?; - // get offer - if offer_id >= state.pending_offers.len() { - return Err(ClvmZkError::InvalidProgram("offer not found".to_string())); - } - - let offer = state.pending_offers[offer_id].clone(); + // get offer by stable ID (not vec index — indices shift after removal) + let offer_pos = state.pending_offers.iter().position(|o| o.id == offer_id) + .ok_or_else(|| ClvmZkError::InvalidProgram("offer not found".into()))?; + let offer = state.pending_offers[offer_pos].clone(); // get taker wallet let wallet = state @@ -2316,6 +2696,15 @@ fn offer_take_command( let taker_coin = &spend_coins[0]; let total_input = taker_coin.amount(); + // validate taker's coin matches maker's requested asset type + if taker_coin.to_private_coin().tail_hash != offer.requested_tail_hash { + return Err(ClvmZkError::InvalidProgram(format!( + "asset type mismatch: maker requests {:?}, taker has {:?}", + offer.requested_tail_hash, + taker_coin.to_private_coin().tail_hash + ))); + } + if total_input < offer.requested { return Err(ClvmZkError::InvalidProgram(format!( "insufficient funds: need {}, have {}", @@ -2325,9 +2714,10 @@ fn offer_take_command( println!("generating settlement proof (this may take a moment)..."); - // generate ephemeral keypair for ECDH - let mut taker_ephemeral_privkey = [0u8; 32]; - rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut taker_ephemeral_privkey); + // generate random nonce for hash-based stealth address + // payment_puzzle = sha256("stealth_v1" || maker_pubkey || nonce) + let mut payment_nonce = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut payment_nonce); // generate coin secrets for payment, goods, and change let mut payment_serial = [0u8; 32]; @@ -2344,8 +2734,8 @@ fn offer_take_command( rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut change_rand); // use faucet puzzle for taker's goods and change - let (_, taker_goods_puzzle) = create_faucet_puzzle(offer.offered); - let (_, taker_change_puzzle) = create_faucet_puzzle(offer.offered); + let (taker_goods_program, taker_goods_puzzle) = create_faucet_puzzle(offer.offered); + let (taker_change_program, taker_change_puzzle) = create_faucet_puzzle(offer.offered); // get merkle path for taker's coin let (merkle_path, leaf_index) = state @@ -2366,7 +2756,7 @@ fn offer_take_command( taker_merkle_path: merkle_path, merkle_root, taker_leaf_index: leaf_index, - taker_ephemeral_privkey, + payment_nonce, taker_goods_puzzle, taker_change_puzzle, payment_serial, @@ -2375,6 +2765,11 @@ fn offer_take_command( goods_rand, change_serial, change_rand, + // v2.0: tail_hash identifies asset type + // taker coin must match maker's requested asset type + taker_tail_hash: taker_coin.to_private_coin().tail_hash, + // goods (what taker receives) match maker's offered asset type + goods_tail_hash: offer.offered_tail_hash, }; // generate settlement proof @@ -2383,22 +2778,144 @@ fn offer_take_command( println!("✅ settlement proof generated"); + // V2: validate BOTH proofs concurrently before processing settlement + println!("validating maker + taker proofs concurrently..."); + #[cfg(feature = "risc0")] + { + use std::thread; + + // clone proof data for concurrent verification + let maker_proof_bytes = offer.maker_bundle.zk_proof.clone(); + let taker_proof_bytes = settlement_proof.zk_proof.clone(); + + // spawn verification threads (each deserializes + verifies independently) + let maker_handle = thread::spawn(move || -> Result<(), ClvmZkError> { + let receipt: risc0_zkvm::Receipt = + borsh::from_slice(&maker_proof_bytes).map_err(|e| { + ClvmZkError::InvalidProofFormat(format!("maker receipt deserialize: {e}")) + })?; + + receipt + .verify(clvm_zk_risc0::CLVM_RISC0_GUEST_ID) + .map_err(|e| { + ClvmZkError::VerificationFailed(format!("maker proof invalid: {e}")) + })?; + + Ok(()) + }); + + let taker_handle = thread::spawn(move || -> Result<(), ClvmZkError> { + let receipt: risc0_zkvm::Receipt = + borsh::from_slice(&taker_proof_bytes).map_err(|e| { + ClvmZkError::InvalidProofFormat(format!("taker receipt deserialize: {e}")) + })?; + + receipt.verify(clvm_zk_risc0::SETTLEMENT_ID).map_err(|e| { + ClvmZkError::VerificationFailed(format!("taker proof invalid: {e}")) + })?; + + Ok(()) + }); + + // wait for both verifications + maker_handle.join().map_err(|_| { + ClvmZkError::VerificationFailed("maker verification thread panicked".to_string()) + })??; + + taker_handle.join().map_err(|_| { + ClvmZkError::VerificationFailed("taker verification thread panicked".to_string()) + })??; + + println!("✅ both proofs verified concurrently"); + + // linkage is guaranteed by prove_settlement extracting from maker's verified journal + // maker_pubkey equality enforced below (NM-002) + } + + #[cfg(feature = "sp1")] + { + use clvm_zk_sp1::bincode; + use clvm_zk_sp1::sp1_sdk::{ProverClient, SP1ProofWithPublicValues}; + use std::thread; + + // clone proof data for concurrent verification + let maker_proof_bytes = offer.maker_bundle.zk_proof.clone(); + let taker_proof_bytes = settlement_proof.zk_proof.clone(); + + // spawn verification threads + let maker_handle = thread::spawn(move || -> Result<(), ClvmZkError> { + let proof: SP1ProofWithPublicValues = bincode::deserialize(&maker_proof_bytes) + .map_err(|e| { + ClvmZkError::InvalidProofFormat(format!("maker proof deserialize: {e}")) + })?; + + let client = ProverClient::from_env(); + let (_, vk) = client.setup(clvm_zk_sp1::CLVM_ZK_SP1_ELF); + client.verify(&proof, &vk).map_err(|e| { + ClvmZkError::VerificationFailed(format!("maker proof invalid: {e}")) + })?; + + Ok(()) + }); + + let taker_handle = thread::spawn(move || -> Result<(), ClvmZkError> { + let proof: SP1ProofWithPublicValues = bincode::deserialize(&taker_proof_bytes) + .map_err(|e| { + ClvmZkError::InvalidProofFormat(format!("taker proof deserialize: {e}")) + })?; + + let client = ProverClient::from_env(); + let (_, vk) = client.setup(clvm_zk_sp1::SETTLEMENT_SP1_ELF); + client.verify(&proof, &vk).map_err(|e| { + ClvmZkError::VerificationFailed(format!("taker proof invalid: {e}")) + })?; + + Ok(()) + }); + + // wait for both verifications + maker_handle.join().map_err(|_| { + ClvmZkError::VerificationFailed("maker verification thread panicked".to_string()) + })??; + + taker_handle.join().map_err(|_| { + ClvmZkError::VerificationFailed("taker verification thread panicked".to_string()) + })??; + + println!("✅ both proofs verified concurrently"); + } + + // NM-002: enforce maker pubkey linkage before state mutation + if settlement_proof.output.maker_pubkey != offer.maker_pubkey { + return Err(ClvmZkError::InvalidProgram( + "settlement/offer maker_pubkey mismatch".to_string(), + )); + } + // process settlement output: add nullifiers and commitments to simulator state - state.simulator.process_settlement(&settlement_proof.output); + state.simulator.process_settlement(&settlement_proof.output) + .map_err(|e| ClvmZkError::InvalidProgram(format!("settlement failed: {}", e)))?; println!(" added 2 nullifiers and 4 commitments to state"); - // 3. create taker's 3 coins with full secrets (payment, goods, change) + // mark taker's original coin as spent + let taker_serial_commitment = taker_coin.to_private_coin().serial_commitment; + let taker_wallet = state.wallets.get_mut(taker_name).unwrap(); + for coin in &mut taker_wallet.coins { + if coin.wallet_coin.coin.serial_commitment == taker_serial_commitment { + coin.spent = true; + break; + } + } - // compute payment_puzzle via ECDH (same as guest does) - use x25519_dalek::{PublicKey, StaticSecret}; - let taker_secret = StaticSecret::from(taker_ephemeral_privkey); - let maker_public = PublicKey::from(offer.maker_pubkey); - let shared_secret = taker_secret.diffie_hellman(&maker_public); + // 3. create taker's 3 coins with full secrets (payment, goods, change) + // compute payment_puzzle via hash-based stealth (same as guest does) + // payment_puzzle = sha256("stealth_v1" || maker_pubkey || nonce) let mut payment_puzzle_data = Vec::new(); - payment_puzzle_data.extend_from_slice(b"ecdh_payment_v1"); - payment_puzzle_data.extend_from_slice(shared_secret.as_bytes()); + payment_puzzle_data.extend_from_slice(b"stealth_v1"); + payment_puzzle_data.extend_from_slice(&offer.maker_pubkey); + payment_puzzle_data.extend_from_slice(&payment_nonce); let payment_puzzle = crate::crypto_utils::hash_data_default(&payment_puzzle_data); // calculate amounts @@ -2406,6 +2923,10 @@ fn offer_take_command( let goods_amount = offer.offered; let change_amount = taker_coin.amount() - offer.requested; + // NM-001: correct tail_hash per output (not XCH-default for everything) + let taker_tail = taker_coin.to_private_coin().tail_hash; // asset B (what taker spends) + let goods_tail = offer.offered_tail_hash; // asset A (what maker offers) + // create 3 coins for taker's wallet let taker_wallet = state.wallets.get_mut(taker_name).unwrap(); @@ -2415,10 +2936,11 @@ fn offer_take_command( &payment_rand, crate::crypto_utils::hash_data_default, ); - let payment_coin = crate::protocol::PrivateCoin::new( + let payment_coin = crate::protocol::PrivateCoin::new_with_tail( payment_puzzle, payment_amount, payment_serial_commitment, + taker_tail, // asset B ); let payment_secrets = clvm_zk_core::coin_commitment::CoinSecrets::new(payment_serial, payment_rand); @@ -2430,8 +2952,9 @@ fn offer_take_command( }; taker_wallet.coins.push(crate::cli::WalletCoinWrapper { wallet_coin: payment_wallet_coin, - program: "(mod () (q . ()))".to_string(), // placeholder program + program: "(mod () (q . ()))".to_string(), // stealth address — no compilable source spent: false, + tail_source: None, }); // 2. goods coin (maker → taker, asset A) @@ -2440,10 +2963,11 @@ fn offer_take_command( &goods_rand, crate::crypto_utils::hash_data_default, ); - let goods_coin = crate::protocol::PrivateCoin::new( + let goods_coin = crate::protocol::PrivateCoin::new_with_tail( taker_goods_puzzle, goods_amount, goods_serial_commitment, + goods_tail, // asset A ); let goods_secrets = clvm_zk_core::coin_commitment::CoinSecrets::new(goods_serial, goods_rand); let goods_wallet_coin = crate::wallet::hd_wallet::WalletPrivateCoin { @@ -2454,8 +2978,9 @@ fn offer_take_command( }; taker_wallet.coins.push(crate::cli::WalletCoinWrapper { wallet_coin: goods_wallet_coin, - program: "(mod () (q . ()))".to_string(), + program: taker_goods_program.clone(), spent: false, + tail_source: None, }); // 3. change coin (taker's leftover, asset B) @@ -2464,10 +2989,11 @@ fn offer_take_command( &change_rand, crate::crypto_utils::hash_data_default, ); - let change_coin = crate::protocol::PrivateCoin::new( + let change_coin = crate::protocol::PrivateCoin::new_with_tail( taker_change_puzzle, change_amount, change_serial_commitment, + taker_tail, // asset B ); let change_secrets = clvm_zk_core::coin_commitment::CoinSecrets::new(change_serial, change_rand); @@ -2479,8 +3005,9 @@ fn offer_take_command( }; taker_wallet.coins.push(crate::cli::WalletCoinWrapper { wallet_coin: change_wallet_coin, - program: "(mod () (q . ()))".to_string(), + program: taker_change_program.clone(), spent: false, + tail_source: None, }); println!(" added 3 coins to taker's wallet (payment, goods, change)"); @@ -2496,10 +3023,11 @@ fn offer_take_command( &offer.change_rand, crate::crypto_utils::hash_data_default, ); - let maker_change_coin = crate::protocol::PrivateCoin::new( + let maker_change_coin = crate::protocol::PrivateCoin::new_with_tail( offer.change_puzzle, offer.change_amount, maker_change_serial_commitment, + goods_tail, // asset A (maker's original asset) ); let maker_change_secrets = clvm_zk_core::coin_commitment::CoinSecrets::new(offer.change_serial, offer.change_rand); @@ -2509,23 +3037,26 @@ fn offer_take_command( account_index: 0, coin_index: 0, }; + let (maker_change_program, _) = crate::protocol::create_delegated_puzzle()?; maker_wallet.coins.push(crate::cli::WalletCoinWrapper { wallet_coin: maker_change_wallet_coin, - program: "(mod () (q . ()))".to_string(), + program: maker_change_program, spent: false, + tail_source: None, }); // 4b. maker's payment coin (taker → maker, asset B) - // the payment_puzzle was derived via ECDH above, amount is offer.requested + // the payment_puzzle was derived via hash-based stealth above, amount is offer.requested let maker_payment_serial_commitment = clvm_zk_core::coin_commitment::SerialCommitment::compute( &payment_serial, &payment_rand, crate::crypto_utils::hash_data_default, ); - let maker_payment_coin = crate::protocol::PrivateCoin::new( + let maker_payment_coin = crate::protocol::PrivateCoin::new_with_tail( payment_puzzle, payment_amount, maker_payment_serial_commitment, + taker_tail, // asset B (what maker receives) ); let maker_payment_secrets = clvm_zk_core::coin_commitment::CoinSecrets::new(payment_serial, payment_rand); @@ -2537,14 +3068,15 @@ fn offer_take_command( }; maker_wallet.coins.push(crate::cli::WalletCoinWrapper { wallet_coin: maker_payment_wallet_coin, - program: "(mod () (q . ()))".to_string(), + program: "(mod () (q . ()))".to_string(), // stealth address — no compilable source spent: false, + tail_source: None, }); println!(" added 2 coins to maker's wallet (change, payment)"); - // 5. remove offer from pending - state.pending_offers.remove(offer_id); + // 5. remove offer from pending (by position, not by ID) + state.pending_offers.remove(offer_pos); state.save(data_dir)?; diff --git a/src/crypto_utils.rs b/src/crypto_utils.rs index b7979d8..bb26fe4 100644 --- a/src/crypto_utils.rs +++ b/src/crypto_utils.rs @@ -1,7 +1,12 @@ // crypto utilities // shared stuff to avoid copy-pasting code everywhere +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Nonce, +}; use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret as X25519Secret}; /// sha256 hash function for general use pub fn hash_data_default(data: &[u8]) -> [u8; 32] { @@ -56,6 +61,102 @@ pub fn find_coin_index_by_viewing_tag( None } +/// encrypt a 32-byte stealth nonce to a recipient's x25519 public key. +/// returns 80 bytes: ephemeral_pubkey (32) || ciphertext (32 + 16 tag). +pub fn encrypt_stealth_nonce( + nonce: &[u8; 32], + recipient_pubkey: &[u8; 32], +) -> Vec { + // 1. ephemeral x25519 keypair + let ephemeral_secret = X25519Secret::random_from_rng(rand::thread_rng()); + let ephemeral_public = X25519Public::from(&ephemeral_secret); + + // 2. ECDH shared secret + let recipient = X25519Public::from(*recipient_pubkey); + let dh_shared = ephemeral_secret.diffie_hellman(&recipient); + + // 3. derive chacha key (separate domain from nonce) + let chacha_key: [u8; 32] = { + let mut h = Sha256::new(); + h.update(b"veil_note_key_v1"); + h.update(dh_shared.as_bytes()); + h.finalize().into() + }; + + // 4. derive chacha nonce (first 12 bytes of separate hash) + let chacha_nonce: [u8; 12] = { + let mut h = Sha256::new(); + h.update(b"veil_note_encrypt_v1"); + h.update(dh_shared.as_bytes()); + let hash: [u8; 32] = h.finalize().into(); + let mut n = [0u8; 12]; + n.copy_from_slice(&hash[..12]); + n + }; + + // 5. encrypt + let cipher = ChaCha20Poly1305::new_from_slice(&chacha_key).expect("valid key length"); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&chacha_nonce), nonce.as_slice()) + .expect("encryption should not fail"); + + // 6. ephemeral_pubkey || ciphertext + let mut out = Vec::with_capacity(80); + out.extend_from_slice(ephemeral_public.as_bytes()); + out.extend_from_slice(&ciphertext); + out +} + +/// decrypt an 80-byte encrypted stealth nonce using our x25519 private key. +/// returns None if auth tag fails (not our coin). +pub fn decrypt_stealth_nonce( + encrypted_note: &[u8], + recipient_privkey: &[u8; 32], +) -> Option<[u8; 32]> { + if encrypted_note.len() != 80 { + return None; + } + + // 1. extract ephemeral pubkey + let mut ephem_bytes = [0u8; 32]; + ephem_bytes.copy_from_slice(&encrypted_note[..32]); + let ephemeral_public = X25519Public::from(ephem_bytes); + + // 2. ECDH + let secret = X25519Secret::from(*recipient_privkey); + let dh_shared = secret.diffie_hellman(&ephemeral_public); + + // 3. derive same key and nonce + let chacha_key: [u8; 32] = { + let mut h = Sha256::new(); + h.update(b"veil_note_key_v1"); + h.update(dh_shared.as_bytes()); + h.finalize().into() + }; + let chacha_nonce: [u8; 12] = { + let mut h = Sha256::new(); + h.update(b"veil_note_encrypt_v1"); + h.update(dh_shared.as_bytes()); + let hash: [u8; 32] = h.finalize().into(); + let mut n = [0u8; 12]; + n.copy_from_slice(&hash[..12]); + n + }; + + // 4. decrypt + let cipher = ChaCha20Poly1305::new_from_slice(&chacha_key).ok()?; + let plaintext = cipher + .decrypt(Nonce::from_slice(&chacha_nonce), &encrypted_note[32..]) + .ok()?; + + if plaintext.len() != 32 { + return None; + } + let mut out = [0u8; 32]; + out.copy_from_slice(&plaintext); + Some(out) +} + #[cfg(test)] mod tests { use super::*; @@ -125,4 +226,41 @@ mod tests { assert!(tags.insert(tag), "Collision detected at index {}", i); } } + + #[test] + fn test_stealth_nonce_encrypt_decrypt_roundtrip() { + let privkey: [u8; 32] = { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(b"test_recipient_seed"); + h.finalize().into() + }; + let pubkey = x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::from(privkey), + ) + .to_bytes(); + + let nonce = [0x42u8; 32]; + let encrypted = encrypt_stealth_nonce(&nonce, &pubkey); + assert_eq!(encrypted.len(), 80); + + let decrypted = decrypt_stealth_nonce(&encrypted, &privkey).unwrap(); + assert_eq!(decrypted, nonce); + } + + #[test] + fn test_stealth_nonce_wrong_key_fails() { + let privkey: [u8; 32] = [0xAA; 32]; + let pubkey = x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::from(privkey), + ) + .to_bytes(); + + let nonce = [0x42u8; 32]; + let encrypted = encrypt_stealth_nonce(&nonce, &pubkey); + + // wrong key should fail decryption + let wrong_key: [u8; 32] = [0xBB; 32]; + assert!(decrypt_stealth_nonce(&encrypted, &wrong_key).is_none()); + } } diff --git a/src/lib.rs b/src/lib.rs index af45411..90ab61f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,8 +97,9 @@ pub struct ClvmZkProver; impl ClvmZkProver { fn validate_chialisp_syntax(expression: &str) -> Result<(), ClvmZkError> { - use clvm_zk_core::chialisp::parse_chialisp; - parse_chialisp(expression) + // use clvm_tools_rs for syntax validation (will fail on invalid syntax) + use clvm_zk_core::compile_chialisp_template_hash_default; + compile_chialisp_template_hash_default(expression) .map_err(|e| ClvmZkError::InvalidProgram(format!("Syntax error: {:?}", e)))?; Ok(()) } @@ -145,7 +146,7 @@ impl ClvmZkProver { } /// prove spending with serial commitment verification and merkle membership - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments, clippy::needless_return)] pub fn prove_with_serial_commitment( expression: &str, parameters: &[ProgramParameter], @@ -157,6 +158,8 @@ impl ClvmZkProver { leaf_index: usize, program_hash: [u8; 32], amount: u64, + tail_hash: Option<[u8; 32]>, + tail_source: Option, ) -> Result { if parameters.len() > 10 { return Err(ClvmZkError::InvalidProgram( @@ -180,6 +183,59 @@ impl ClvmZkProver { program_hash, amount, }), + tail_hash, + additional_coins: None, // single-coin API + mint_data: None, + tail_source, + }; + + #[cfg(feature = "risc0")] + { + let backend = clvm_zk_risc0::Risc0Backend::new()?; + return backend.prove_with_input(input); + } + + #[cfg(feature = "sp1")] + { + let backend = clvm_zk_sp1::Sp1Backend::new()?; + return backend.prove_with_input(input); + } + + #[cfg(feature = "mock")] + { + let backend = clvm_zk_mock::MockBackend::new()?; + backend.prove_with_input(input) + } + } + + /// prove multi-coin ring spend (CATs) + /// + /// generates a single proof that spends multiple coins atomically + /// all coins must have the same tail_hash (enforced in guest) + #[allow(clippy::needless_return)] + pub fn prove_ring_spend( + expression: &str, + parameters: &[ProgramParameter], + serial_data: SerialCommitmentData, + tail_hash: Option<[u8; 32]>, + additional_coins: Vec, + ) -> Result { + if parameters.len() > 10 { + return Err(ClvmZkError::InvalidProgram( + "Too many parameters (maximum 10: a-j)".to_string(), + )); + } + + Self::validate_chialisp_syntax(expression)?; + + let input = Input { + chialisp_source: expression.to_string(), + program_parameters: parameters.to_vec(), + serial_commitment_data: Some(serial_data), + tail_hash, + additional_coins: Some(additional_coins), + mint_data: None, + tail_source: None, }; #[cfg(feature = "risc0")] diff --git a/src/payment_keys.rs b/src/payment_keys.rs index a3a5d01..f4c8df7 100644 --- a/src/payment_keys.rs +++ b/src/payment_keys.rs @@ -1,31 +1,33 @@ -//! ECDH payment keys for unlinkable offers +//! Hash-based stealth payment keys for unlinkable transfers //! //! Payment keys enable receiver-derived addresses: -//! - maker publishes ephemeral payment pubkey -//! - taker derives shared secret via ECDH -//! - payment address unlinkable to maker's identity +//! - sender picks random nonce, computes puzzle = hash(receiver_pubkey || nonce) +//! - sender encrypts nonce to receiver's pubkey +//! - receiver decrypts nonce, derives same puzzle //! -//! Uses x25519 (Curve25519) for ECDH, consistent with encrypted note infrastructure +//! Uses hash-based stealth (consistent with settlement proofs) instead of ECDH use sha2::{Digest, Sha256}; -use x25519_dalek::{PublicKey, StaticSecret}; -/// payment key for ECDH address derivation +/// payment key for stealth address derivation #[derive(Debug, Clone)] pub struct PaymentKey { - /// x25519 public key (32 bytes) + /// public key (32 bytes) - used in puzzle derivation pub pubkey: [u8; 32], - /// private scalar (only holder knows, None for receive-only keys) + /// private key (only holder knows, None for receive-only keys) + /// used for decrypting nonces sent by senders pub privkey: Option<[u8; 32]>, } impl PaymentKey { - /// create payment key from private scalar + /// create payment key from private key pub fn from_privkey(privkey: [u8; 32]) -> Self { - // derive public key using x25519 - let secret = StaticSecret::from(privkey); - let pubkey = PublicKey::from(&secret).to_bytes(); + // derive public key via hash (deterministic, no EC math needed) + let mut hasher = Sha256::new(); + hasher.update(b"stealth_pubkey_v1"); + hasher.update(privkey); + let pubkey: [u8; 32] = hasher.finalize().into(); Self { pubkey, @@ -55,9 +57,8 @@ impl PaymentKey { pub fn derive_offer_key(&self, offer_index: u32) -> Result { let parent_privkey = self.privkey.ok_or("cannot derive from pubkey-only key")?; - let domain = b"offer_derivation"; let mut hasher = Sha256::new(); - hasher.update(domain); + hasher.update(b"offer_derivation"); hasher.update(parent_privkey); hasher.update(offer_index.to_be_bytes()); @@ -74,52 +75,26 @@ impl PaymentKey { Ok(current) } - /// check if this key can spend a coin derived via ecdh - pub fn can_spend_ecdh_coin(&self, sender_pubkey: &[u8; 32], puzzle_hash: &[u8; 32]) -> bool { - if self.privkey.is_none() { - return false; // can't spend without privkey - } - - // derive what puzzle_hash should be for this ecdh pair - let derived_result = - derive_ecdh_puzzle_hash_from_receiver(sender_pubkey, &self.privkey.unwrap()); - - match derived_result { - Ok(derived) => derived == *puzzle_hash, - Err(_) => false, - } + /// check if this key can spend a coin created with given nonce + /// + /// receiver uses their pubkey + the nonce to derive the expected puzzle + pub fn can_spend_stealth_coin(&self, nonce: &[u8; 32], puzzle_hash: &[u8; 32]) -> bool { + let derived = derive_stealth_puzzle_hash(&self.pubkey, nonce); + derived == *puzzle_hash } - /// scan for payments made via ecdh + /// derive encryption key for receiving encrypted nonces /// - /// given a list of (coin, sender_ephemeral_pubkey) pairs, returns indices of coins - /// that can be spent by this payment key - /// - /// usage: - /// ```ignore - /// let payment_key = PaymentKey::generate(); - /// let coins_with_pubkeys = vec![ - /// (coin1, taker_pubkey1), - /// (coin2, taker_pubkey2), - /// ]; - /// let spendable = payment_key.scan_for_payments(&coins_with_pubkeys); - /// // spendable contains indices of coins this key can spend - /// ``` - pub fn scan_for_payments( - &self, - coins: &[(crate::protocol::PrivateCoin, [u8; 32])], - ) -> Vec { - if self.privkey.is_none() { - return vec![]; // can't spend without privkey - } + /// senders encrypt nonces to this key so receiver can decrypt and derive puzzle + pub fn derive_encryption_key(&self) -> Result<[u8; 32], &'static str> { + let privkey = self + .privkey + .ok_or("cannot derive encryption key from pubkey-only")?; - let mut spendable_indices = Vec::new(); - for (i, (coin, sender_pubkey)) in coins.iter().enumerate() { - if self.can_spend_ecdh_coin(sender_pubkey, &coin.puzzle_hash) { - spendable_indices.push(i); - } - } - spendable_indices + let mut hasher = Sha256::new(); + hasher.update(b"stealth_encryption_v1"); + hasher.update(privkey); + Ok(hasher.finalize().into()) } /// get public key bytes @@ -128,78 +103,27 @@ impl PaymentKey { } } -/// derive ecdh puzzle hash using sender's private key (real ecdh) -/// -/// sender side: has receiver_pubkey and sender_privkey -/// computes shared_secret = receiver_pubkey * sender_privkey -/// -/// uses x25519 for ECDH (compatible with encrypted notes) -pub fn derive_ecdh_puzzle_hash_from_sender( - receiver_pubkey: &[u8; 32], - sender_privkey: &[u8; 32], -) -> Result<[u8; 32], &'static str> { - // parse receiver's public key - let receiver_pk = PublicKey::from(*receiver_pubkey); - - // parse sender's private key - let sender_sk = StaticSecret::from(*sender_privkey); - - // ecdh: x25519 diffie-hellman - let shared_secret = sender_sk.diffie_hellman(&receiver_pk); - - // derive puzzle_hash from shared secret - let domain = b"ecdh_payment_v1"; - let mut hasher = Sha256::new(); - hasher.update(domain); - hasher.update(shared_secret.as_bytes()); - - Ok(hasher.finalize().into()) -} - -/// derive ecdh puzzle hash using receiver's private key (real ecdh) -/// -/// receiver side: has sender_pubkey and receiver_privkey -/// computes shared_secret = sender_pubkey * receiver_privkey +/// derive stealth puzzle hash from receiver's pubkey and sender's nonce /// -/// produces SAME result as derive_ecdh_puzzle_hash_from_sender (ecdh property) -pub fn derive_ecdh_puzzle_hash_from_receiver( - sender_pubkey: &[u8; 32], - receiver_privkey: &[u8; 32], -) -> Result<[u8; 32], &'static str> { - // parse sender's public key - let sender_pk = PublicKey::from(*sender_pubkey); - - // parse receiver's private key - let receiver_sk = StaticSecret::from(*receiver_privkey); - - // ecdh: x25519 diffie-hellman - // CRITICAL: same result as sender side due to ecdh property - let shared_secret = receiver_sk.diffie_hellman(&sender_pk); - - // derive puzzle_hash from shared secret - let domain = b"ecdh_payment_v1"; - let mut hasher = Sha256::new(); - hasher.update(domain); - hasher.update(shared_secret.as_bytes()); - - Ok(hasher.finalize().into()) -} - -/// simplified public-key-only derivation (fallback for testing/compatibility) +/// sender side: generates random nonce, computes puzzle, encrypts nonce to receiver +/// receiver side: decrypts nonce, computes same puzzle /// -/// in production, use derive_ecdh_puzzle_hash_from_sender or derive_ecdh_puzzle_hash_from_receiver -pub fn derive_ecdh_puzzle_hash(receiver_pubkey: &[u8; 32], sender_pubkey: &[u8; 32]) -> [u8; 32] { - // simplified hash-based derivation when only pubkeys available - // NOTE: this is NOT real ECDH, just for compatibility - let domain = b"ecdh_payment_v1_pubkey_only"; +/// consistent with settlement proof stealth address derivation +pub fn derive_stealth_puzzle_hash(receiver_pubkey: &[u8; 32], nonce: &[u8; 32]) -> [u8; 32] { let mut hasher = Sha256::new(); - hasher.update(domain); + hasher.update(b"stealth_v1"); hasher.update(receiver_pubkey); - hasher.update(sender_pubkey); - + hasher.update(nonce); hasher.finalize().into() } +/// generate a random nonce for stealth payment +pub fn generate_stealth_nonce() -> [u8; 32] { + let mut nonce = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce); + nonce +} + #[cfg(test)] mod tests { use super::*; @@ -212,21 +136,23 @@ mod tests { } #[test] - fn test_ecdh_commutative_property() { - // generate two keypairs - let alice = PaymentKey::generate(); - let bob = PaymentKey::generate(); + fn test_stealth_puzzle_derivation() { + let receiver = PaymentKey::generate(); + let nonce = generate_stealth_nonce(); - // alice computes: shared = bob_pub * alice_priv - let shared_alice = - derive_ecdh_puzzle_hash_from_sender(&bob.pubkey, &alice.privkey.unwrap()).unwrap(); + // derive puzzle hash + let puzzle = derive_stealth_puzzle_hash(&receiver.pubkey, &nonce); - // bob computes: shared = alice_pub * bob_priv - let shared_bob = - derive_ecdh_puzzle_hash_from_receiver(&alice.pubkey, &bob.privkey.unwrap()).unwrap(); + // receiver can verify with same nonce + assert!(receiver.can_spend_stealth_coin(&nonce, &puzzle)); - // both should get same result! - assert_eq!(shared_alice, shared_bob); + // different nonce = different puzzle + let other_nonce = generate_stealth_nonce(); + assert!(!receiver.can_spend_stealth_coin(&other_nonce, &puzzle)); + + // different receiver can't spend + let other = PaymentKey::generate(); + assert!(!other.can_spend_stealth_coin(&nonce, &puzzle)); } #[test] @@ -245,19 +171,13 @@ mod tests { } #[test] - fn test_can_spend_ecdh_coin() { - let maker = PaymentKey::generate(); - let taker = PaymentKey::generate(); - - // taker creates payment to maker - let payment_puzzle = - derive_ecdh_puzzle_hash_from_sender(&maker.pubkey, &taker.privkey.unwrap()).unwrap(); + fn test_deterministic_pubkey_derivation() { + let privkey = [42u8; 32]; - // maker should be able to identify the coin - assert!(maker.can_spend_ecdh_coin(&taker.pubkey, &payment_puzzle)); + let key1 = PaymentKey::from_privkey(privkey); + let key2 = PaymentKey::from_privkey(privkey); - // random key should not be able to spend - let random = PaymentKey::generate(); - assert!(!random.can_spend_ecdh_coin(&taker.pubkey, &payment_puzzle)); + // same privkey = same pubkey + assert_eq!(key1.pubkey, key2.pubkey); } } diff --git a/src/protocol/encrypted_notes.rs b/src/protocol/encrypted_notes.rs deleted file mode 100644 index 315b6f2..0000000 --- a/src/protocol/encrypted_notes.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Encrypted payment notes for cross-wallet transfers -//! -//! This module implements the payment note system that allows alice to send coins to bob -//! even when bob is offline. The note contains the serial_number and serial_randomness -//! needed to spend the coin, encrypted to bob's viewing key. - -use chacha20poly1305::{ - aead::{Aead, KeyInit, OsRng}, - ChaCha20Poly1305, Nonce, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; - -/// Encrypted payment note that can be decrypted by recipient -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EncryptedNote { - /// Ephemeral public key for ECDH - pub ephemeral_key: [u8; 32], - - /// Encrypted payload containing PaymentNote - pub ciphertext: Vec, - - /// Nonce for ChaCha20-Poly1305 - pub nonce: [u8; 12], -} - -/// Decrypted payment note containing coin secrets and metadata -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PaymentNote { - /// Serial number (becomes nullifier when spent) - pub serial_number: [u8; 32], - - /// Serial randomness (proves knowledge of serial) - pub serial_randomness: [u8; 32], - - /// Coin amount - pub amount: u64, - - /// Puzzle hash for the coin - pub puzzle_hash: [u8; 32], - - /// Optional memo (arbitrary data) - pub memo: Vec, -} - -impl EncryptedNote { - /// Encrypt a payment note to a recipient's viewing public key - /// - /// Uses ECDH with ephemeral key + ChaCha20-Poly1305 for authenticated encryption - pub fn encrypt( - recipient_viewing_public: &[u8; 32], - note: &PaymentNote, - ) -> Result { - // Generate ephemeral keypair for ECDH - let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng); - let ephemeral_public = PublicKey::from(&ephemeral_secret); - - // Perform ECDH - let recipient_public = PublicKey::from(*recipient_viewing_public); - let shared_secret = ephemeral_secret.diffie_hellman(&recipient_public); - - // Derive encryption key from shared secret - let key = Self::derive_encryption_key(shared_secret.as_bytes()); - - // Serialize payment note - let plaintext = - serde_json::to_vec(note).map_err(|e| format!("failed to serialize note: {e}"))?; - - // Generate random nonce - let nonce_bytes = rand::random::<[u8; 12]>(); - let nonce = Nonce::from(nonce_bytes); - - // Encrypt with ChaCha20-Poly1305 - let cipher = ChaCha20Poly1305::new(&key.into()); - let ciphertext = cipher - .encrypt(&nonce, plaintext.as_ref()) - .map_err(|e| format!("encryption failed: {e}"))?; - - Ok(EncryptedNote { - ephemeral_key: ephemeral_public.to_bytes(), - ciphertext, - nonce: nonce_bytes, - }) - } - - /// Decrypt a payment note using recipient's viewing private key - pub fn decrypt(&self, viewing_private: &[u8; 32]) -> Result { - // Reconstruct ephemeral public key - let ephemeral_public = PublicKey::from(self.ephemeral_key); - - // Perform ECDH with our private key - let static_secret = StaticSecret::from(*viewing_private); - let shared_secret = static_secret.diffie_hellman(&ephemeral_public); - - // Derive same encryption key - let key = Self::derive_encryption_key(shared_secret.as_bytes()); - - // Decrypt with ChaCha20-Poly1305 - let nonce = Nonce::from(self.nonce); - let cipher = ChaCha20Poly1305::new(&key.into()); - let plaintext = cipher - .decrypt(&nonce, self.ciphertext.as_ref()) - .map_err(|_| "decryption failed (wrong key or corrupted data)")?; - - // Deserialize payment note - serde_json::from_slice(&plaintext).map_err(|e| format!("failed to deserialize note: {e}")) - } - - /// Derive encryption key from ECDH shared secret using HKDF - fn derive_encryption_key(shared_secret: &[u8]) -> [u8; 32] { - // Use SHA256 as simple KDF - // In production, use proper HKDF - let mut hasher = Sha256::new(); - hasher.update(b"clvm_zk_note_encryption_v1"); - hasher.update(shared_secret); - hasher.finalize().into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encrypt_decrypt_roundtrip() { - // Generate viewing keypair - let viewing_private = rand::random::<[u8; 32]>(); - let viewing_public = PublicKey::from(&StaticSecret::from(viewing_private)).to_bytes(); - - // Create payment note - let note = PaymentNote { - serial_number: rand::random(), - serial_randomness: rand::random(), - amount: 1000, - puzzle_hash: rand::random(), - memo: b"test payment".to_vec(), - }; - - // Encrypt - let encrypted = - EncryptedNote::encrypt(&viewing_public, ¬e).expect("encryption should succeed"); - - // Decrypt - let decrypted = encrypted - .decrypt(&viewing_private) - .expect("decryption should succeed"); - - // Verify - assert_eq!(decrypted.serial_number, note.serial_number); - assert_eq!(decrypted.serial_randomness, note.serial_randomness); - assert_eq!(decrypted.amount, note.amount); - assert_eq!(decrypted.puzzle_hash, note.puzzle_hash); - assert_eq!(decrypted.memo, note.memo); - } - - #[test] - fn test_decrypt_with_wrong_key_fails() { - let viewing_private1 = rand::random::<[u8; 32]>(); - let viewing_public1 = PublicKey::from(&StaticSecret::from(viewing_private1)).to_bytes(); - - let viewing_private2 = rand::random::<[u8; 32]>(); - - let note = PaymentNote { - serial_number: rand::random(), - serial_randomness: rand::random(), - amount: 1000, - puzzle_hash: rand::random(), - memo: vec![], - }; - - let encrypted = EncryptedNote::encrypt(&viewing_public1, ¬e).unwrap(); - - // Try to decrypt with wrong key - let result = encrypted.decrypt(&viewing_private2); - assert!(result.is_err()); - } -} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 8a1189c..efdd456 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -20,7 +20,6 @@ //! 5. L1 blockchain validates nullifiers haven't been used before //! ``` -pub mod encrypted_notes; pub mod puzzles; pub mod recursive; pub mod settlement; @@ -28,7 +27,6 @@ pub mod spender; pub mod structures; // Re-export main types for convenient access -pub use encrypted_notes::{EncryptedNote, PaymentNote}; pub use puzzles::{ compile_to_template_bytecode, create_delegated_puzzle, create_password_puzzle, create_password_puzzle_program, create_password_spend_parameters, create_password_spend_params, @@ -39,4 +37,6 @@ pub use puzzles::{ pub use recursive::{AggregatedOutput, AggregatedProof}; pub use settlement::{prove_settlement, SettlementOutput, SettlementParams, SettlementProof}; pub use spender::Spender; -pub use structures::{PrivateCoin, PrivateSpendBundle, ProofType, ProtocolError}; +pub use structures::{ + CreatedCoinOutput, PrivateCoin, PrivateSpendBundle, ProofType, ProtocolError, +}; diff --git a/src/protocol/puzzles.rs b/src/protocol/puzzles.rs index 063810c..864f443 100644 --- a/src/protocol/puzzles.rs +++ b/src/protocol/puzzles.rs @@ -1,5 +1,5 @@ use crate::ProgramParameter; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use k256::ecdsa::{signature::Signer, Signature, SigningKey, VerifyingKey}; /// Signature-enabled puzzle programs for secure spend authorization /// @@ -203,25 +203,18 @@ pub fn create_password_spend_parameters(password: &str) -> Vec /// # returns /// * template bytecode ready to pass as ProgramParameter::Bytes pub fn compile_to_template_bytecode(source: &str) -> Result, crate::ClvmZkError> { - use clvm_zk_core::chialisp::{ - compile_module_unified, parse_chialisp, sexp_to_module, CompilationMode, - }; - - // parse and convert to module - let sexp = parse_chialisp(source) - .map_err(|e| crate::ClvmZkError::InvalidProgram(format!("parse error: {:?}", e)))?; - - let module = sexp_to_module(sexp).map_err(|e| { - crate::ClvmZkError::InvalidProgram(format!("module conversion error: {:?}", e)) - })?; - - // compile in template mode - preserves parameter structure - let template_bytecode = - compile_module_unified(&module, CompilationMode::Template).map_err(|e| { - crate::ClvmZkError::InvalidProgram(format!("template compilation error: {:?}", e)) - })?; + // clvm_tools_rs produces template-compatible bytecode (env references, not substituted values) + // use the wrapper from clvm_zk_core which handles the clvm_tools_rs dependency + let (bytecode, _hash) = clvm_zk_core::compile_chialisp_to_bytecode(sha2_hash, source) + .map_err(|e| crate::ClvmZkError::InvalidProgram(format!("compilation error: {:?}", e)))?; + Ok(bytecode) +} - Ok(template_bytecode) +fn sha2_hash(data: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() } /// create delegated puzzle for offers (settlement-specific) @@ -292,7 +285,7 @@ pub fn create_settlement_assertion_puzzle() -> Result<(String, [u8; 32]), crate: /// # arguments /// * `offered` - amount maker is offering to taker /// * `requested` - amount maker requests in return -/// * `maker_pubkey` - maker's x25519 public key for ECDH payment derivation +/// * `maker_pubkey` - maker's public key for hash-based stealth payment derivation /// * `change_amount` - amount maker gets as change (coin_amount - offered) /// * `change_puzzle` - puzzle hash for maker's change coin /// * `change_serial` - serial number for maker's change coin diff --git a/src/protocol/settlement.rs b/src/protocol/settlement.rs index a3d4f4e..982c746 100644 --- a/src/protocol/settlement.rs +++ b/src/protocol/settlement.rs @@ -1,10 +1,9 @@ use crate::protocol::{PrivateCoin, PrivateSpendBundle, ProofType, ProtocolError}; use clvm_zk_core::coin_commitment::CoinSecrets; +#[cfg(any(feature = "risc0", feature = "sp1"))] +use clvm_zk_core::types::ClvmValue; use serde::{Deserialize, Serialize}; -#[cfg(feature = "risc0")] -use clvm_zk_risc0::CLVM_RISC0_GUEST_ID; - /// settlement proof output from taker's recursive proof #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SettlementOutput { @@ -14,6 +13,8 @@ pub struct SettlementOutput { pub payment_commitment: [u8; 32], // taker → maker (asset B, requested amount) pub taker_goods_commitment: [u8; 32], // maker → taker (asset A, offered amount) pub taker_change_commitment: [u8; 32], // taker's change (asset B, Y - requested) + // PUBLIC: validator checks this matches offer's maker_pubkey + pub maker_pubkey: [u8; 32], } /// complete settlement proof bundle @@ -71,8 +72,10 @@ pub struct SettlementParams { /// taker's leaf index in merkle tree pub taker_leaf_index: usize, - /// taker's ephemeral private key for ECDH - pub taker_ephemeral_privkey: [u8; 32], + /// payment nonce for hash-based stealth address + /// payment_puzzle = sha256("stealth_v1" || maker_pubkey || nonce) + /// host encrypts nonce to maker_pubkey, includes in tx metadata + pub payment_nonce: [u8; 32], /// puzzle hash for taker receiving goods (asset A) pub taker_goods_puzzle: [u8; 32], @@ -91,16 +94,32 @@ pub struct SettlementParams { /// taker's change coin secrets pub change_serial: [u8; 32], pub change_rand: [u8; 32], + + /// v2.0 coin commitment: tail_hash identifies asset type + /// taker's asset (what taker is spending, XCH = zeros) + pub taker_tail_hash: [u8; 32], + /// goods asset (what maker is offering, XCH = zeros) + pub goods_tail_hash: [u8; 32], } -/// prove settlement transaction +/// prove settlement transaction (V2: optimized without recursive verification) /// -/// taker calls this to create a recursive proof that: -/// 1. verifies maker's conditional spend proof -/// 2. extracts settlement terms from maker's proof +/// taker calls this to create a proof that: +/// 1. accepts maker's journal as PUBLIC input (not recursively verified) +/// 2. extracts settlement terms from maker's journal /// 3. proves taker is creating correct payment/change outputs /// 4. outputs both nullifiers + all commitments /// +/// VALIDATOR REQUIREMENTS: +/// - must verify BOTH maker's proof AND taker's settlement proof independently +/// - must check taker's proof references correct maker journal hash +/// - atomicity preserved: both proofs must be valid or transaction rejected +/// +/// OPTIMIZATION: removes 290s recursive verification overhead while preserving: +/// - privacy: maker's journal was already public when offer posted +/// - atomicity: validator checks both proofs together +/// - security: both proofs cryptographically validated +/// /// # arguments /// * `params` - settlement parameters including maker's proof and taker's coin data /// @@ -126,7 +145,7 @@ pub fn prove_settlement(params: SettlementParams) -> Result Result, + // maker's proof outputs (PUBLIC, extracted by host from verified journal) + maker_nullifier: [u8; 32], + maker_change_commitment: [u8; 32], + offered: u64, + requested: u64, + maker_pubkey: [u8; 32], taker_coin: TakerCoinData, merkle_root: [u8; 32], - taker_ephemeral_privkey: [u8; 32], + // hash-based stealth: payment_puzzle = sha256("stealth_v1" || maker_pubkey || nonce) + payment_nonce: [u8; 32], taker_goods_puzzle: [u8; 32], taker_change_puzzle: [u8; 32], payment_serial: [u8; 32], @@ -155,6 +191,9 @@ pub fn prove_settlement(params: SettlementParams) -> Result Result [u8; 32] { - let mut bytes = [0u8; 32]; - for (i, word) in id.iter().enumerate() { - bytes[i * 4..(i + 1) * 4].copy_from_slice(&word.to_le_bytes()); - } - bytes - } - let input = SettlementInput { - #[cfg(feature = "risc0")] - standard_guest_image_id: image_id_to_bytes(CLVM_RISC0_GUEST_ID), - #[cfg(not(feature = "risc0"))] - standard_guest_image_id: [0u8; 32], // placeholder for non-risc0 backends - maker_journal_bytes, + maker_nullifier, + maker_change_commitment, + offered, + requested, + maker_pubkey, taker_coin: TakerCoinData { amount: params.taker_coin.amount, puzzle_hash: params.taker_coin.puzzle_hash, @@ -194,7 +223,7 @@ pub fn prove_settlement(params: SettlementParams) -> Result Result Result, + leaf_index: usize, + } + + let input = SettlementInput { + maker_nullifier, + maker_change_commitment, + offered, + requested, + maker_pubkey, + taker_coin: TakerCoinData { + amount: params.taker_coin.amount, + puzzle_hash: params.taker_coin.puzzle_hash, + serial_commitment, + serial_number: params.taker_secrets.serial_number, + serial_randomness: params.taker_secrets.serial_randomness, + merkle_path: params.taker_merkle_path, + leaf_index: params.taker_leaf_index, + }, + merkle_root: params.merkle_root, + payment_nonce: params.payment_nonce, + taker_goods_puzzle: params.taker_goods_puzzle, + taker_change_puzzle: params.taker_change_puzzle, + payment_serial: params.payment_serial, + payment_rand: params.payment_rand, + goods_serial: params.goods_serial, + goods_rand: params.goods_rand, + change_serial: params.change_serial, + change_rand: params.change_rand, + taker_tail_hash: params.taker_tail_hash, + goods_tail_hash: params.goods_tail_hash, + }; + + let mut stdin = SP1Stdin::new(); + stdin.write(&input); + + let client = ProverClient::from_env(); + let (pk, _vk) = client.setup(SETTLEMENT_SP1_ELF); + + let mut proof = client.prove(&pk, &stdin).run().map_err(|e| { + ProtocolError::ProofGenerationFailed(format!("sp1 settlement proof failed: {e}")) + })?; + + let output: SettlementOutput = proof.public_values.read(); + + let proof_bytes = bincode::serialize(&proof).map_err(|e| { + ProtocolError::ProofGenerationFailed(format!("failed to serialize proof: {e}")) + })?; + + Ok(SettlementProof::new(proof_bytes, output)) + } + + #[cfg(not(any(feature = "risc0", feature = "sp1")))] { Err(ProtocolError::ProofGenerationFailed( - "settlement proving requires risc0 backend".to_string(), + "settlement proving requires risc0 or sp1 backend".to_string(), )) } } + +#[cfg(any(feature = "risc0", feature = "sp1"))] +/// parse maker's CLVM output to extract settlement terms (HOST-side parsing) +/// expected format: ((51 change_puzzle change_amount change_serial change_rand) (offered requested maker_pubkey)) +fn parse_maker_clvm_output( + clvm_output: &[u8], + tail_hash: &[u8; 32], +) -> Result<([u8; 32], u64, u64, [u8; 32]), ProtocolError> { + use clvm_zk_core::clvm_parser::ClvmParser; + use clvm_zk_core::types::ClvmValue; + + let mut parser = ClvmParser::new(clvm_output); + let value = parser.parse().map_err(|e| { + ProtocolError::ProofGenerationFailed(format!( + "failed to parse maker's CLVM output: {:?}", + e + )) + })?; + + match value { + ClvmValue::Cons(create_coin_box, settlement_terms_box) => { + // extract maker_change_commitment from CREATE_COIN + let maker_change_commitment = + extract_create_coin_commitment_host(&create_coin_box, tail_hash)?; + + // extract settlement terms (offered, requested, maker_pubkey) + let (offered, requested, maker_pubkey) = + extract_settlement_terms_host(&settlement_terms_box)?; + + Ok((maker_change_commitment, offered, requested, maker_pubkey)) + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid maker output structure - expected cons pair".to_string(), + )), + } +} + +#[cfg(any(feature = "risc0", feature = "sp1"))] +fn extract_create_coin_commitment_host( + create_coin: &ClvmValue, + tail_hash: &[u8; 32], +) -> Result<[u8; 32], ProtocolError> { + use clvm_zk_core::types::ClvmValue; + use sha2::{Digest, Sha256}; + + // parse (51 change_puzzle change_amount change_serial change_rand) + match create_coin { + ClvmValue::Cons(opcode_box, args_box) => { + match opcode_box.as_ref() { + ClvmValue::Atom(opcode) if opcode.as_slice() == [51u8] => { + match args_box.as_ref() { + ClvmValue::Cons(puzzle_box, rest1) => { + let change_puzzle = extract_bytes_32_host(puzzle_box.as_ref())?; + + match rest1.as_ref() { + ClvmValue::Cons(amount_box, rest2) => { + let change_amount = extract_u64_host(amount_box.as_ref())?; + + match rest2.as_ref() { + ClvmValue::Cons(serial_box, rest3) => { + let change_serial = + extract_bytes_32_host(serial_box.as_ref())?; + + match rest3.as_ref() { + ClvmValue::Cons(rand_box, _) => { + let change_rand = + extract_bytes_32_host(rand_box.as_ref())?; + + // compute commitment (v2.0 format) + let serial_commitment = { + let mut data = Vec::new(); + data.extend_from_slice( + b"clvm_zk_serial_v1.0", + ); + data.extend_from_slice(&change_serial); + data.extend_from_slice(&change_rand); + let hash: [u8; 32] = + Sha256::digest(&data).into(); + hash + }; + + let coin_commitment = { + let mut data = Vec::new(); + data.extend_from_slice( + b"clvm_zk_coin_v2.0", + ); + data.extend_from_slice(tail_hash); + data.extend_from_slice( + &change_amount.to_be_bytes(), + ); + data.extend_from_slice(&change_puzzle); + data.extend_from_slice(&serial_commitment); + let hash: [u8; 32] = + Sha256::digest(&data).into(); + hash + }; + + Ok(coin_commitment) + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid CREATE_COIN: missing rand".to_string(), + )), + } + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid CREATE_COIN: missing serial".to_string(), + )), + } + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid CREATE_COIN: missing amount".to_string(), + )), + } + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid CREATE_COIN: missing puzzle".to_string(), + )), + } + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid CREATE_COIN opcode".to_string(), + )), + } + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid CREATE_COIN structure".to_string(), + )), + } +} + +#[cfg(any(feature = "risc0", feature = "sp1"))] +fn extract_settlement_terms_host(terms: &ClvmValue) -> Result<(u64, u64, [u8; 32]), ProtocolError> { + use clvm_zk_core::types::ClvmValue; + + match terms { + ClvmValue::Cons(offered_box, rest1) => { + let offered = extract_u64_host(offered_box.as_ref())?; + + match rest1.as_ref() { + ClvmValue::Cons(requested_box, rest2) => { + let requested = extract_u64_host(requested_box.as_ref())?; + + match rest2.as_ref() { + ClvmValue::Cons(pubkey_box, _) => { + let maker_pubkey = extract_bytes_32_host(pubkey_box.as_ref())?; + Ok((offered, requested, maker_pubkey)) + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid settlement terms: missing maker_pubkey".to_string(), + )), + } + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid settlement terms: missing requested".to_string(), + )), + } + } + _ => Err(ProtocolError::ProofGenerationFailed( + "invalid settlement terms structure".to_string(), + )), + } +} + +#[cfg(any(feature = "risc0", feature = "sp1"))] +fn extract_bytes_32_host(value: &ClvmValue) -> Result<[u8; 32], ProtocolError> { + use clvm_zk_core::types::ClvmValue; + + match value { + ClvmValue::Atom(bytes) => { + if bytes.len() != 32 { + return Err(ProtocolError::ProofGenerationFailed(format!( + "expected 32 bytes, got {}", + bytes.len() + ))); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(bytes); + Ok(arr) + } + _ => Err(ProtocolError::ProofGenerationFailed( + "expected atom for bytes".to_string(), + )), + } +} + +#[cfg(any(feature = "risc0", feature = "sp1"))] +fn extract_u64_host(value: &ClvmValue) -> Result { + use clvm_zk_core::types::ClvmValue; + + match value { + ClvmValue::Atom(bytes) => { + if bytes.is_empty() { + return Ok(0); + } + if bytes.len() > 8 { + return Err(ProtocolError::ProofGenerationFailed(format!( + "u64 value too large: {} bytes", + bytes.len() + ))); + } + + // CLVM uses big-endian encoding + let mut result: u64 = 0; + for &byte in bytes { + result = (result << 8) | (byte as u64); + } + Ok(result) + } + _ => Err(ProtocolError::ProofGenerationFailed( + "expected atom for u64".to_string(), + )), + } +} diff --git a/src/protocol/spender.rs b/src/protocol/spender.rs index ab0a7d3..b4d3327 100644 --- a/src/protocol/spender.rs +++ b/src/protocol/spender.rs @@ -1,6 +1,7 @@ use crate::protocol::{PrivateCoin, PrivateSpendBundle, ProtocolError}; use crate::ProgramParameter; -use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets}; +use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets, XCH_TAIL}; +use clvm_zk_core::{AdditionalCoinInput, SerialCommitmentData}; pub struct Spender; @@ -19,12 +20,20 @@ impl Spender { .map_err(|e| ProtocolError::ProofGenerationFailed(format!("invalid coin: {e}")))?; let coin_commitment = CoinCommitment::compute( + &coin.tail_hash, coin.amount, &coin.puzzle_hash, &coin.serial_commitment, crate::crypto_utils::hash_data_default, ); + // pass tail_hash: None for XCH, Some for CATs + let tail_hash = if coin.tail_hash == XCH_TAIL { + None + } else { + Some(coin.tail_hash) + }; + let zkvm_result = crate::ClvmZkProver::prove_with_serial_commitment( puzzle_code, solution_params, @@ -36,17 +45,21 @@ impl Spender { leaf_index, coin.puzzle_hash, coin.amount, + tail_hash, + None, // regular spend — no tail_source needed when delta == 0 ) .map_err(|e| ProtocolError::ProofGenerationFailed(format!("zk proof failed: {e}")))?; - let actual_nullifier = zkvm_result - .proof_output - .nullifier - .ok_or_else(|| ProtocolError::InvalidNullifier("no nullifier in proof".to_string()))?; + // single-coin spend should have exactly one nullifier + if zkvm_result.proof_output.nullifiers.is_empty() { + return Err(ProtocolError::InvalidNullifier( + "no nullifier in proof".to_string(), + )); + } let spend_bundle = PrivateSpendBundle::new( zkvm_result.proof_bytes, - actual_nullifier, + zkvm_result.proof_output.nullifiers, zkvm_result.proof_output.clvm_res.output.clone(), ); @@ -57,7 +70,199 @@ impl Spender { Ok(spend_bundle) } - /// create conditional spend proof (not directly submittable) + /// spend multiple coins in a ring (for CAT multi-coin transactions) + /// + /// all coins must have the same tail_hash + /// returns a single spend bundle with multiple nullifiers + #[allow(clippy::type_complexity)] + pub fn create_ring_spend( + coins: Vec<( + &PrivateCoin, + &str, // puzzle_code + &[ProgramParameter], // solution_params + &CoinSecrets, + Vec<[u8; 32]>, // merkle_path + usize, // leaf_index + )>, + merkle_root: [u8; 32], + ) -> Result { + // debug logging only enabled via RUST_LOG or similar + #[cfg(feature = "debug-logging")] + { + eprintln!("\n=== SPENDER: CREATE_RING_SPEND ==="); + eprintln!(" num_coins: {}", coins.len()); + eprintln!(" merkle_root: {}", hex::encode(merkle_root)); + } + + if coins.is_empty() { + return Err(ProtocolError::ProofGenerationFailed( + "no coins to spend".to_string(), + )); + } + + // verify all coins have the same tail_hash + let primary_tail = coins[0].0.tail_hash; + for (coin, _, _, _, _, _) in &coins { + if coin.tail_hash != primary_tail { + return Err(ProtocolError::ProofGenerationFailed( + "all coins in ring must have same tail_hash".to_string(), + )); + } + } + + // primary coin (first one) + let ( + primary_coin, + primary_puzzle, + primary_params, + primary_secrets, + primary_path, + primary_leaf_idx, + ) = &coins[0]; + + primary_coin.validate().map_err(|e| { + ProtocolError::ProofGenerationFailed(format!("invalid primary coin: {e}")) + })?; + + let primary_coin_commitment = CoinCommitment::compute( + &primary_coin.tail_hash, + primary_coin.amount, + &primary_coin.puzzle_hash, + &primary_coin.serial_commitment, + crate::crypto_utils::hash_data_default, + ); + + #[cfg(feature = "debug-logging")] + { + eprintln!("\n --- PRIMARY COIN (idx 0) ---"); + eprintln!(" tail_hash: {}", hex::encode(primary_coin.tail_hash)); + eprintln!(" amount: {}", primary_coin.amount); + eprintln!(" puzzle_hash: {}", hex::encode(primary_coin.puzzle_hash)); + eprintln!( + " serial_commitment: {}", + hex::encode(primary_coin.serial_commitment.as_bytes()) + ); + eprintln!( + " coin_commitment: {}", + hex::encode(primary_coin_commitment.0) + ); + eprintln!(" leaf_index: {}", primary_leaf_idx); + eprintln!(" merkle_path length: {}", primary_path.len()); + } + + // pass tail_hash: None for XCH, Some for CATs + let tail_hash = if primary_tail == XCH_TAIL { + None + } else { + Some(primary_tail) + }; + + // construct additional_coins for ring + let mut additional_coins = Vec::new(); + for (coin, puzzle_code, params, secrets, merkle_path, leaf_index) in coins.iter().skip(1) { + coin.validate().map_err(|e| { + ProtocolError::ProofGenerationFailed(format!("invalid coin in ring: {e}")) + })?; + + let coin_commitment = CoinCommitment::compute( + &coin.tail_hash, + coin.amount, + &coin.puzzle_hash, + &coin.serial_commitment, + crate::crypto_utils::hash_data_default, + ); + + #[cfg(feature = "debug-logging")] + { + eprintln!("\n --- ADDITIONAL COIN (idx {}) ---", _i + 1); + eprintln!(" tail_hash: {}", hex::encode(coin.tail_hash)); + eprintln!(" amount: {}", coin.amount); + eprintln!(" puzzle_hash: {}", hex::encode(coin.puzzle_hash)); + eprintln!( + " serial_commitment: {}", + hex::encode(coin.serial_commitment.as_bytes()) + ); + eprintln!(" coin_commitment: {}", hex::encode(coin_commitment.0)); + eprintln!(" leaf_index: {}", leaf_index); + eprintln!(" merkle_path length: {}", merkle_path.len()); + eprintln!( + " secrets.serial_number: {}", + hex::encode(secrets.serial_number) + ); + } + + additional_coins.push(AdditionalCoinInput { + chialisp_source: puzzle_code.to_string(), + program_parameters: params.to_vec(), + serial_commitment_data: SerialCommitmentData { + serial_number: secrets.serial_number, + serial_randomness: secrets.serial_randomness, + merkle_path: merkle_path.clone(), + coin_commitment: coin_commitment.0, + serial_commitment: coin.serial_commitment.0, + merkle_root, + leaf_index: *leaf_index, + program_hash: coin.puzzle_hash, + amount: coin.amount, + }, + tail_hash: coin.tail_hash, + }); + } + + let primary_serial_data = SerialCommitmentData { + serial_number: primary_secrets.serial_number, + serial_randomness: primary_secrets.serial_randomness, + merkle_path: primary_path.clone(), + coin_commitment: primary_coin_commitment.0, + serial_commitment: primary_coin.serial_commitment.0, + merkle_root, + leaf_index: *primary_leaf_idx, + program_hash: primary_coin.puzzle_hash, + amount: primary_coin.amount, + }; + + let zkvm_result = crate::ClvmZkProver::prove_ring_spend( + primary_puzzle, + primary_params, + primary_serial_data, + tail_hash, + additional_coins, + ) + .map_err(|e| ProtocolError::ProofGenerationFailed(format!("zk ring proof failed: {e}")))?; + + // for ring spends, we should have N nullifiers (one per coin) + if zkvm_result.proof_output.nullifiers.len() != coins.len() { + return Err(ProtocolError::InvalidNullifier(format!( + "ring proof should have {} nullifiers but has {}", + coins.len(), + zkvm_result.proof_output.nullifiers.len() + ))); + } + + let spend_bundle = PrivateSpendBundle::new( + zkvm_result.proof_bytes, + zkvm_result.proof_output.nullifiers, + zkvm_result.proof_output.clvm_res.output.clone(), + ); + + spend_bundle.validate().map_err(|e| { + ProtocolError::ProofGenerationFailed(format!("invalid ring bundle: {e}")) + })?; + + Ok(spend_bundle) + } + + /// create conditional spend proof (not directly submittable to blockchain) + /// + /// conditional spend proofs are locked - they can only be settled by wrapping + /// them in a Settlement proof that validates the trade logic. + /// + /// this is used for creating offers: + /// - maker creates ConditionalSpend with output encoding offer terms + /// - taker wraps it in Settlement proof that validates payment/goods exchange + /// + /// # v2.0 coin commitments + /// uses v2.0 format with tail_hash for CAT support pub fn create_conditional_spend( coin: &PrivateCoin, puzzle_code: &str, @@ -66,17 +271,26 @@ impl Spender { merkle_path: Vec<[u8; 32]>, merkle_root: [u8; 32], leaf_index: usize, + tail_source: Option, ) -> Result { coin.validate() .map_err(|e| ProtocolError::ProofGenerationFailed(format!("invalid coin: {e}")))?; let coin_commitment = CoinCommitment::compute( + &coin.tail_hash, coin.amount, &coin.puzzle_hash, &coin.serial_commitment, crate::crypto_utils::hash_data_default, ); + // pass tail_hash: None for XCH, Some for CATs + let tail_hash = if coin.tail_hash == XCH_TAIL { + None + } else { + Some(coin.tail_hash) + }; + let zkvm_result = crate::ClvmZkProver::prove_with_serial_commitment( puzzle_code, solution_params, @@ -88,17 +302,22 @@ impl Spender { leaf_index, coin.puzzle_hash, coin.amount, + tail_hash, + tail_source, // TAIL program for CAT delta authorization ) .map_err(|e| ProtocolError::ProofGenerationFailed(format!("zk proof failed: {e}")))?; - let actual_nullifier = zkvm_result - .proof_output - .nullifier - .ok_or_else(|| ProtocolError::InvalidNullifier("no nullifier in proof".to_string()))?; + // single-coin spend should have exactly one nullifier + if zkvm_result.proof_output.nullifiers.is_empty() { + return Err(ProtocolError::InvalidNullifier( + "no nullifier in proof".to_string(), + )); + } + // create bundle with ConditionalSpend type (prevents direct submission) let spend_bundle = PrivateSpendBundle::new_with_type( zkvm_result.proof_bytes, - actual_nullifier, + zkvm_result.proof_output.nullifiers[0], // single nullifier for conditional spend zkvm_result.proof_output.clvm_res.output.clone(), crate::protocol::ProofType::ConditionalSpend, ); @@ -119,6 +338,6 @@ impl Spender { .validate() .map_err(|e| ProtocolError::SerializationError(format!("Invalid bundle: {e}")))?; - Ok(bundle.nullifier == *expected_nullifier) + Ok(bundle.nullifiers.contains(expected_nullifier)) } } diff --git a/src/protocol/structures.rs b/src/protocol/structures.rs index 3c1ad8f..751955b 100644 --- a/src/protocol/structures.rs +++ b/src/protocol/structures.rs @@ -1,5 +1,5 @@ -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; -use clvm_zk_core::coin_commitment::SerialCommitment; +use clvm_zk_core::coin_commitment::{SerialCommitment, XCH_TAIL}; +use clvm_zk_core::compile_chialisp_template_hash_default; use rand::RngCore; use serde::{Deserialize, Serialize}; use std::fmt; @@ -13,9 +13,22 @@ pub enum ProofType { ConditionalSpend = 1, /// settlement proof - combines conditional proof with payment Settlement = 2, + /// mint proof - creates new CAT supply (TAIL program verified) + Mint = 3, +} + +/// represents a created coin output from a proof +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreatedCoinOutput { + /// private coin - only commitment revealed + Private { commitment: [u8; 32] }, + /// transparent coin - puzzle_hash and amount visible + Transparent { puzzle_hash: [u8; 32], amount: u64 }, } /// errors that can happen in the protocol +/// +/// can be converted to/from `ClvmZkError` for unified error handling #[derive(Debug, thiserror::Error)] pub enum ProtocolError { #[error("Invalid spend secret: {0}")] @@ -32,6 +45,33 @@ pub enum ProtocolError { ProofExtractionFailed(String), } +impl From for clvm_zk_core::ClvmZkError { + fn from(err: ProtocolError) -> Self { + match err { + ProtocolError::InvalidSpendSecret(msg) => clvm_zk_core::ClvmZkError::InvalidInput(msg), + ProtocolError::ProofGenerationFailed(msg) => { + clvm_zk_core::ClvmZkError::ProofGenerationFailed(msg) + } + ProtocolError::InvalidNullifier(msg) => clvm_zk_core::ClvmZkError::NullifierError(msg), + ProtocolError::SerializationError(msg) => { + clvm_zk_core::ClvmZkError::SerializationError(msg) + } + ProtocolError::InvalidProofType(msg) => { + clvm_zk_core::ClvmZkError::InvalidProofFormat(msg) + } + ProtocolError::ProofExtractionFailed(msg) => { + clvm_zk_core::ClvmZkError::VerificationError(msg) + } + } + } +} + +impl From for ProtocolError { + fn from(err: clvm_zk_core::ClvmZkError) -> Self { + ProtocolError::ProofGenerationFailed(err.to_string()) + } +} + /// a private coin that can be spent with zk proofs /// /// the coin contains only public data. secrets (serial_number, serial_randomness) @@ -46,14 +86,34 @@ pub struct PrivateCoin { /// commitment that binds this coin to specific spending secrets pub serial_commitment: SerialCommitment, + + /// asset type identifier: XCH_TAIL ([0u8; 32]) for native, hash(TAIL) for CATs + #[serde(default = "default_tail_hash")] + pub tail_hash: [u8; 32], +} + +fn default_tail_hash() -> [u8; 32] { + XCH_TAIL } impl PrivateCoin { + /// create XCH coin (native currency) pub fn new(puzzle_hash: [u8; 32], amount: u64, serial_commitment: SerialCommitment) -> Self { + Self::new_with_tail(puzzle_hash, amount, serial_commitment, XCH_TAIL) + } + + /// create coin with specific tail_hash (CAT or XCH) + pub fn new_with_tail( + puzzle_hash: [u8; 32], + amount: u64, + serial_commitment: SerialCommitment, + tail_hash: [u8; 32], + ) -> Self { Self { puzzle_hash, amount, serial_commitment, + tail_hash, } } @@ -77,6 +137,15 @@ impl PrivateCoin { pub fn new_with_secrets( puzzle_hash: [u8; 32], amount: u64, + ) -> (Self, clvm_zk_core::coin_commitment::CoinSecrets) { + Self::new_with_secrets_and_tail(puzzle_hash, amount, XCH_TAIL) + } + + /// create CAT coin with random secrets + pub fn new_with_secrets_and_tail( + puzzle_hash: [u8; 32], + amount: u64, + tail_hash: [u8; 32], ) -> (Self, clvm_zk_core::coin_commitment::CoinSecrets) { let mut serial_number = [0u8; 32]; let mut serial_randomness = [0u8; 32]; @@ -91,7 +160,7 @@ impl PrivateCoin { crate::crypto_utils::hash_data_default, ); - let coin = Self::new(puzzle_hash, amount, serial_commitment); + let coin = Self::new_with_tail(puzzle_hash, amount, serial_commitment, tail_hash); let secrets = clvm_zk_core::coin_commitment::CoinSecrets::new(serial_number, serial_randomness); @@ -115,6 +184,16 @@ impl PrivateCoin { Self::from_program(puzzle_code, amount, serial_commitment) } + /// returns true if this is native XCH + pub fn is_xch(&self) -> bool { + self.tail_hash == XCH_TAIL + } + + /// returns true if this is a CAT (non-XCH asset) + pub fn is_cat(&self) -> bool { + !self.is_xch() + } + pub fn validate(&self) -> Result<(), ProtocolError> { if self.puzzle_hash == [0u8; 32] { return Err(ProtocolError::InvalidSpendSecret( @@ -134,11 +213,16 @@ impl PrivateCoin { impl fmt::Display for PrivateCoin { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let asset = if self.is_xch() { + "XCH".to_string() + } else { + format!("CAT:{}", &hex::encode(self.tail_hash)[..8]) + }; write!( f, - "PrivateCoin {{ amount: {}, serial_commitment: {}..., puzzle: {}... }}", + "PrivateCoin {{ {}, amount: {}, puzzle: {}... }}", + asset, self.amount, - &hex::encode(self.serial_commitment.as_bytes())[..16], &hex::encode(self.puzzle_hash)[..16] ) } @@ -150,8 +234,10 @@ pub struct PrivateSpendBundle { /// zk proof that validates the spend pub zk_proof: Vec, - /// nullifier for the coin being spent (prevents double-spending) - pub nullifier: [u8; 32], + /// nullifiers for the coin(s) being spent (prevents double-spending) + /// for single-coin spends: vec with 1 nullifier + /// for ring spends: vec with N nullifiers (one per coin) + pub nullifiers: Vec<[u8; 32]>, /// public output conditions from running the puzzle (clvm-encoded) pub public_conditions: Vec, @@ -166,10 +252,10 @@ fn default_proof_type() -> ProofType { } impl PrivateSpendBundle { - pub fn new(zk_proof: Vec, nullifier: [u8; 32], public_conditions: Vec) -> Self { + pub fn new(zk_proof: Vec, nullifiers: Vec<[u8; 32]>, public_conditions: Vec) -> Self { Self { zk_proof, - nullifier, + nullifiers, public_conditions, proof_type: ProofType::Transaction, } @@ -183,14 +269,22 @@ impl PrivateSpendBundle { ) -> Self { Self { zk_proof, - nullifier, + nullifiers: vec![nullifier], public_conditions, proof_type, } } pub fn nullifier_hex(&self) -> String { - hex::encode(self.nullifier) + // for backward compat, return first nullifier + self.nullifiers + .first() + .map(hex::encode) + .unwrap_or_else(|| "no-nullifier".to_string()) + } + + pub fn nullifiers_hex(&self) -> Vec { + self.nullifiers.iter().map(hex::encode).collect() } pub fn proof_size(&self) -> usize { @@ -208,12 +302,20 @@ impl PrivateSpendBundle { )); } - if self.nullifier == [0u8; 32] { + if self.nullifiers.is_empty() { return Err(ProtocolError::InvalidNullifier( - "Nullifier cannot be all zeros".to_string(), + "Bundle must have at least one nullifier".to_string(), )); } + for nullifier in &self.nullifiers { + if nullifier == &[0u8; 32] { + return Err(ProtocolError::InvalidNullifier( + "Nullifier cannot be all zeros".to_string(), + )); + } + } + Ok(()) } @@ -230,11 +332,77 @@ impl PrivateSpendBundle { self.proof_type == ProofType::ConditionalSpend } - /// extract public outputs from proof (implementation depends on proof format) + /// extract public outputs from proof by parsing the CLVM conditions + /// + /// returns a vec of serialized conditions, each condition as raw bytes. + /// for more structured access, use `extract_conditions()` instead. pub fn extract_public_outputs(&self) -> Result>, ProtocolError> { - // for now, return empty vec - this will be implemented when we add - // structured proof output format - Ok(vec![]) + if self.public_conditions.is_empty() { + return Ok(vec![]); + } + + // parse CLVM conditions + let conditions = + clvm_zk_core::deserialize_clvm_output_to_conditions(&self.public_conditions) + .map_err(|e| ProtocolError::ProofExtractionFailed(e.to_string()))?; + + // return each condition's args as separate output blobs + Ok(conditions.into_iter().flat_map(|c| c.args).collect()) + } + + /// extract structured conditions from proof output + /// + /// returns parsed `Condition` structs with opcode and args. + pub fn extract_conditions(&self) -> Result, ProtocolError> { + if self.public_conditions.is_empty() { + return Ok(vec![]); + } + + clvm_zk_core::deserialize_clvm_output_to_conditions(&self.public_conditions) + .map_err(|e| ProtocolError::ProofExtractionFailed(e.to_string())) + } + + /// extract CREATE_COIN outputs (coin commitments or puzzle_hash + amount pairs) + /// + /// returns tuples of (puzzle_hash_or_commitment, amount_option) + /// - for private coins: (coin_commitment, None) - commitment is 32 bytes + /// - for transparent coins: (puzzle_hash, Some(amount)) - puzzle_hash + amount + pub fn extract_created_coins(&self) -> Result, ProtocolError> { + let conditions = self.extract_conditions()?; + let mut outputs = Vec::new(); + + for condition in conditions { + if condition.opcode == 51 { + // CREATE_COIN + match condition.args.len() { + 1 => { + // private coin: single arg is coin_commitment + if condition.args[0].len() == 32 { + let mut commitment = [0u8; 32]; + commitment.copy_from_slice(&condition.args[0]); + outputs.push(CreatedCoinOutput::Private { commitment }); + } + } + 2 | 4 => { + // transparent coin: puzzle_hash + amount (or with serial data) + if condition.args[0].len() == 32 { + let mut puzzle_hash = [0u8; 32]; + puzzle_hash.copy_from_slice(&condition.args[0]); + let amount = + clvm_zk_core::parse_variable_length_amount(&condition.args[1]) + .unwrap_or(0); + outputs.push(CreatedCoinOutput::Transparent { + puzzle_hash, + amount, + }); + } + } + _ => {} + } + } + } + + Ok(outputs) } } @@ -276,7 +444,7 @@ mod tests { assert_eq!(coin.amount, amount); let expected_hash = - clvm_zk_core::chialisp::compile_chialisp_template_hash_default(puzzle_code).unwrap(); + clvm_zk_core::compile_chialisp_template_hash_default(puzzle_code).unwrap(); assert_eq!(coin.puzzle_hash, expected_hash); } @@ -323,25 +491,58 @@ mod tests { #[test] fn test_spend_bundle_creation() { let proof = vec![0x01, 0x02, 0x03]; - let nullifier = [0x42; 32]; + let nullifiers = vec![[0x42; 32]]; let conditions = vec![0x04, 0x05, 0x06]; - let bundle = PrivateSpendBundle::new(proof.clone(), nullifier, conditions.clone()); + let bundle = PrivateSpendBundle::new(proof.clone(), nullifiers.clone(), conditions.clone()); assert_eq!(bundle.zk_proof, proof); - assert_eq!(bundle.nullifier, nullifier); + assert_eq!(bundle.nullifiers, nullifiers); assert_eq!(bundle.public_conditions, conditions); } #[test] fn test_spend_bundle_validation() { - let valid_bundle = PrivateSpendBundle::new(vec![0x01, 0x02], [0x42; 32], vec![0x03]); + let valid_bundle = PrivateSpendBundle::new(vec![0x01, 0x02], vec![[0x42; 32]], vec![0x03]); assert!(valid_bundle.validate().is_ok()); - let invalid_bundle = PrivateSpendBundle::new(vec![], [0x42; 32], vec![0x03]); + let invalid_bundle = PrivateSpendBundle::new(vec![], vec![[0x42; 32]], vec![0x03]); assert!(invalid_bundle.validate().is_err()); - let invalid_bundle = PrivateSpendBundle::new(vec![0x01], [0x00; 32], vec![0x03]); + let invalid_bundle = PrivateSpendBundle::new(vec![0x01], vec![[0x00; 32]], vec![0x03]); assert!(invalid_bundle.validate().is_err()); + + let empty_nullifiers_bundle = PrivateSpendBundle::new(vec![0x01], vec![], vec![0x03]); + assert!(empty_nullifiers_bundle.validate().is_err()); + } + + #[test] + fn test_extract_public_outputs_empty() { + let bundle = PrivateSpendBundle::new(vec![0x01], vec![[0x42; 32]], vec![]); + let outputs = bundle.extract_public_outputs().unwrap(); + assert!(outputs.is_empty()); + } + + #[test] + fn test_extract_conditions_empty() { + let bundle = PrivateSpendBundle::new(vec![0x01], vec![[0x42; 32]], vec![]); + let conditions = bundle.extract_conditions().unwrap(); + assert!(conditions.is_empty()); + } + + #[test] + fn test_created_coin_output_types() { + // test private variant + let private = CreatedCoinOutput::Private { + commitment: [0x42; 32], + }; + assert!(matches!(private, CreatedCoinOutput::Private { .. })); + + // test transparent variant + let transparent = CreatedCoinOutput::Transparent { + puzzle_hash: [0x13; 32], + amount: 1000, + }; + assert!(matches!(transparent, CreatedCoinOutput::Transparent { .. })); } } diff --git a/src/simulator.rs b/src/simulator.rs index b723e5f..c77c0cb 100644 --- a/src/simulator.rs +++ b/src/simulator.rs @@ -2,12 +2,23 @@ use crate::protocol::{PrivateCoin, PrivateSpendBundle, ProtocolError, Spender}; use clvm_zk_core::coin_commitment::CoinCommitment; -use rs_merkle::{algorithms::Sha256 as MerkleHasher, MerkleTree}; +use clvm_zk_core::merkle::SparseMerkleTree; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fmt; +/// tree depth for simulator merkle tree (supports 2^20 = ~1M coins) +const SIMULATOR_TREE_DEPTH: usize = 20; + +fn hasher() -> fn(&[u8]) -> [u8; 32] { + crate::crypto_utils::hash_data_default +} + +fn default_coin_tree() -> SparseMerkleTree { + SparseMerkleTree::new(SIMULATOR_TREE_DEPTH, hasher()) +} + /// simulated blockchain state for testing #[derive(Clone, Serialize, Deserialize)] pub struct CLVMZkSimulator { @@ -20,8 +31,8 @@ pub struct CLVMZkSimulator { #[serde(with = "hex_hashmap")] utxo_set: HashMap<[u8; 32], CoinInfo>, #[serde(skip)] - #[serde(default = "MerkleTree::new")] - coin_tree: MerkleTree, + #[serde(default = "default_coin_tree")] + coin_tree: SparseMerkleTree, #[serde(with = "hex_hashmap")] commitment_to_index: HashMap<[u8; 32], usize>, merkle_leaves: Vec<[u8; 32]>, // persisted leaves to rebuild tree @@ -101,7 +112,7 @@ impl CLVMZkSimulator { Self { nullifier_set: HashSet::new(), utxo_set: HashMap::new(), - coin_tree: MerkleTree::::new(), + coin_tree: default_coin_tree(), commitment_to_index: HashMap::new(), merkle_leaves: Vec::new(), transactions: Vec::new(), @@ -111,11 +122,11 @@ impl CLVMZkSimulator { /// rebuild merkle tree from persisted leaves (call after deserialization) pub fn rebuild_tree(&mut self) { - self.coin_tree = MerkleTree::::new(); + let h = hasher(); + self.coin_tree = SparseMerkleTree::new(SIMULATOR_TREE_DEPTH, h); for leaf in &self.merkle_leaves { - self.coin_tree.insert(*leaf); + self.coin_tree.insert(*leaf, h); } - self.coin_tree.commit(); } pub fn add_coin( @@ -129,18 +140,21 @@ impl CLVMZkSimulator { coin: coin.clone(), metadata, created_at_height: self.block_height, + stealth_nonce: None, + puzzle_source: None, }; let coin_commitment = CoinCommitment::compute( + &coin.tail_hash, coin.amount, &coin.puzzle_hash, &coin.serial_commitment, crate::crypto_utils::hash_data_default, ); - let leaf_index = self.coin_tree.leaves_len(); - self.coin_tree.insert(coin_commitment.0); - self.coin_tree.commit(); + let h = hasher(); + let leaf_index = self.coin_tree.len(); + self.coin_tree.insert(coin_commitment.0, h); self.merkle_leaves.push(coin_commitment.0); // track leaf for persistence self.commitment_to_index .insert(coin_commitment.0, leaf_index); @@ -149,6 +163,43 @@ impl CLVMZkSimulator { serial_number } + /// Add coin with stealth nonce for hash-based stealth address scanning + pub fn add_coin_with_stealth_nonce( + &mut self, + coin: PrivateCoin, + secrets: &clvm_zk_core::coin_commitment::CoinSecrets, + stealth_nonce: Vec, + puzzle_source: String, + metadata: CoinMetadata, + ) -> [u8; 32] { + let serial_number = secrets.serial_number(); + let info = CoinInfo { + coin: coin.clone(), + metadata, + created_at_height: self.block_height, + stealth_nonce: Some(stealth_nonce), + puzzle_source: Some(puzzle_source), + }; + + let coin_commitment = CoinCommitment::compute( + &coin.tail_hash, + coin.amount, + &coin.puzzle_hash, + &coin.serial_commitment, + crate::crypto_utils::hash_data_default, + ); + + let h = hasher(); + let leaf_index = self.coin_tree.len(); + self.coin_tree.insert(coin_commitment.0, h); + self.merkle_leaves.push(coin_commitment.0); + self.commitment_to_index + .insert(coin_commitment.0, leaf_index); + + self.utxo_set.insert(serial_number, info); + serial_number + } + pub fn spend_coins( &mut self, spends: Vec<( @@ -191,45 +242,86 @@ impl CLVMZkSimulator { CoinMetadata, )>, ) -> Result { - let merkle_root = self - .coin_tree - .root() - .ok_or_else(|| SimulatorError::TestFailed("merkle tree has no root".to_string()))?; + let merkle_root = self.coin_tree.root(); let mut spend_bundles = Vec::new(); let mut spent_serial_numbers = Vec::new(); - for (coin, program, params, secrets) in spends { - let (merkle_path, leaf_index) = - self.get_merkle_path_and_index(&coin).ok_or_else(|| { - SimulatorError::TestFailed("coin not found in merkle tree".to_string()) - })?; - - match Spender::create_spend_with_serial( - &coin, - &program, - ¶ms, - &secrets, - merkle_path, - merkle_root, - leaf_index, - ) { + // check if all coins have same tail_hash for ring spend optimization + let can_use_ring = if spends.len() > 1 { + let first_tail = spends[0].0.tail_hash; + spends + .iter() + .all(|(coin, _, _, _)| coin.tail_hash == first_tail) + } else { + false + }; + + if can_use_ring { + // multi-coin ring spend (single proof for all coins) + let coin_data: Vec<_> = spends + .iter() + .map(|(coin, program, params, secrets)| { + let (merkle_path, leaf_index) = + self.get_merkle_path_and_index(coin).ok_or_else(|| { + SimulatorError::TestFailed("coin not found in merkle tree".to_string()) + })?; + Ok(( + coin, + program.as_str(), + params.as_slice(), + secrets, + merkle_path, + leaf_index, + )) + }) + .collect::, SimulatorError>>()?; + + match Spender::create_ring_spend(coin_data, merkle_root) { Ok(bundle) => { spend_bundles.push(bundle); - spent_serial_numbers.push(secrets.serial_number); + for (_, _, _, secrets) in &spends { + spent_serial_numbers.push(secrets.serial_number); + } } Err(e) => return Err(SimulatorError::ProofGeneration(format!("{:?}", e))), } + } else { + // separate proofs for each coin (different tail_hash or single coin) + for (coin, program, params, secrets) in spends { + let (merkle_path, leaf_index) = + self.get_merkle_path_and_index(&coin).ok_or_else(|| { + SimulatorError::TestFailed("coin not found in merkle tree".to_string()) + })?; + + match Spender::create_spend_with_serial( + &coin, + &program, + ¶ms, + &secrets, + merkle_path, + merkle_root, + leaf_index, + ) { + Ok(bundle) => { + spend_bundles.push(bundle); + spent_serial_numbers.push(secrets.serial_number); + } + Err(e) => return Err(SimulatorError::ProofGeneration(format!("{:?}", e))), + } + } } // Extract nullifiers from proof outputs (not pre-computed) + // each bundle may have multiple nullifiers (ring spends) let mut new_nullifiers = Vec::new(); for bundle in &spend_bundles { - let nullifier = bundle.nullifier; - if self.nullifier_set.contains(&nullifier) { - return Err(SimulatorError::DoubleSpend(hex::encode(nullifier))); + for nullifier in &bundle.nullifiers { + if self.nullifier_set.contains(nullifier) { + return Err(SimulatorError::DoubleSpend(hex::encode(nullifier))); + } + new_nullifiers.push(*nullifier); } - new_nullifiers.push(nullifier); } // Extract coin_commitments from CREATE_COIN conditions in proof outputs @@ -277,18 +369,14 @@ impl CLVMZkSimulator { } // Add new coin_commitments to merkle tree + let h = hasher(); for commitment in &new_coin_commitments { - let leaf_index = self.coin_tree.leaves_len(); - self.coin_tree.insert(*commitment); + let leaf_index = self.coin_tree.len(); + self.coin_tree.insert(*commitment, h); self.commitment_to_index.insert(*commitment, leaf_index); self.merkle_leaves.push(*commitment); } - // Commit tree after adding all new coins - if !new_coin_commitments.is_empty() { - self.coin_tree.commit(); - } - // If output coins provided (for simulator testing), validate and track them if !output_coins.is_empty() { if output_coins.len() != new_coin_commitments.len() { @@ -302,6 +390,7 @@ impl CLVMZkSimulator { // Validate commitments match and add to utxo_set for (i, (coin, secrets, metadata)) in output_coins.into_iter().enumerate() { let expected_commitment = CoinCommitment::compute( + &coin.tail_hash, coin.amount, &coin.puzzle_hash, &coin.serial_commitment, @@ -318,12 +407,16 @@ impl CLVMZkSimulator { } // Add to utxo_set + // NOTE: stealth outputs use separate add_coin_with_stealth_nonce flow + // ZK proof outputs don't include stealth metadata (nonces are out-of-band) self.utxo_set.insert( secrets.serial_number, CoinInfo { coin, metadata, created_at_height: self.block_height, + stealth_nonce: None, + puzzle_source: None, }, ); } @@ -348,8 +441,28 @@ impl CLVMZkSimulator { self.utxo_set.get(serial_number) } + /// Iterate over all UTXOs (serial_number, CoinInfo) + pub fn utxo_iter(&self) -> impl Iterator { + self.utxo_set.iter() + } + + /// Get all coins with stealth nonces for hash-based stealth scanning + /// Returns (puzzle_hash, stealth_nonce_bytes, coin_info) for each stealth coin. + /// Nonce bytes are 80 (encrypted: ephemeral_pub || ciphertext). Caller decrypts. + pub fn get_stealth_scannable_coins(&self) -> Vec<(&[u8; 32], &Vec, &CoinInfo)> { + self.utxo_set + .iter() + .filter_map(|(_serial, info)| { + info.stealth_nonce.as_ref().map(|nonce| { + (&info.coin.puzzle_hash, nonce, info) + }) + }) + .collect() + } + pub fn get_merkle_path_and_index(&self, coin: &PrivateCoin) -> Option<(Vec<[u8; 32]>, usize)> { let coin_commitment = CoinCommitment::compute( + &coin.tail_hash, coin.amount, &coin.puzzle_hash, &coin.serial_commitment, @@ -357,27 +470,131 @@ impl CLVMZkSimulator { ); let leaf_index = *self.commitment_to_index.get(&coin_commitment.0)?; - let proof = self.coin_tree.proof(&[leaf_index]); - let proof_hashes = proof.proof_hashes(); + let proof = self.coin_tree.generate_proof(leaf_index, hasher()).ok()?; - let path = proof_hashes - .iter() - .map(|hash| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(hash); - arr - }) - .collect(); + Some((proof.path, leaf_index)) + } + + /// Debug helper: verify a merkle path manually and print diagnostic info + pub fn debug_verify_merkle_path(&self, coin: &PrivateCoin, label: &str) -> Result<(), String> { + eprintln!("\n=== MERKLE DEBUG: {} ===", label); + + // Compute coin commitment + let coin_commitment = CoinCommitment::compute( + &coin.tail_hash, + coin.amount, + &coin.puzzle_hash, + &coin.serial_commitment, + crate::crypto_utils::hash_data_default, + ); + eprintln!(" coin_commitment: {}", hex::encode(coin_commitment.0)); + eprintln!(" tail_hash: {}", hex::encode(coin.tail_hash)); + eprintln!(" amount: {}", coin.amount); + eprintln!(" puzzle_hash: {}", hex::encode(coin.puzzle_hash)); + eprintln!( + " serial_commitment: {}", + hex::encode(coin.serial_commitment.as_bytes()) + ); - Some((path, leaf_index)) + // Check if commitment is in the index + let leaf_index = match self.commitment_to_index.get(&coin_commitment.0) { + Some(&idx) => { + eprintln!(" leaf_index: {} (found in commitment_to_index)", idx); + idx + } + None => { + eprintln!(" ERROR: coin_commitment NOT FOUND in commitment_to_index!"); + eprintln!(" Known commitments:"); + for (comm, idx) in &self.commitment_to_index { + eprintln!(" idx {}: {}", idx, hex::encode(comm)); + } + return Err("coin_commitment not found in tree".to_string()); + } + }; + + // Get the merkle proof + let h = hasher(); + let proof = self + .coin_tree + .generate_proof(leaf_index, h) + .map_err(|e| e.to_string())?; + let proof_hashes = &proof.path; + eprintln!(" merkle_path length: {}", proof_hashes.len()); + for (i, hash) in proof_hashes.iter().enumerate() { + eprintln!(" path[{}]: {}", i, hex::encode(hash)); + } + + // Get the expected root + let expected_root = self.coin_tree.root(); + eprintln!(" expected_root: {}", hex::encode(expected_root)); + + // Manually verify the path (same logic as guest) + let mut current_hash = coin_commitment.0; + let mut current_index = leaf_index; + eprintln!(" === PATH TRAVERSAL ==="); + for (i, sibling) in proof_hashes.iter().enumerate() { + let mut combined = [0u8; 64]; + let position = if current_index % 2 == 0 { + "LEFT" + } else { + "RIGHT" + }; + if current_index % 2 == 0 { + combined[..32].copy_from_slice(¤t_hash); + combined[32..].copy_from_slice(sibling); + } else { + combined[..32].copy_from_slice(sibling); + combined[32..].copy_from_slice(¤t_hash); + } + let new_hash = crate::crypto_utils::hash_data_default(&combined); + eprintln!( + " step {}: idx={} ({}) hash={} -> {}", + i, + current_index, + position, + hex::encode(¤t_hash[..8]), + hex::encode(&new_hash[..8]) + ); + current_hash = new_hash; + current_index /= 2; + } + + let computed_root = current_hash; + eprintln!(" computed_root: {}", hex::encode(computed_root)); + + if computed_root == expected_root { + eprintln!(" RESULT: ✓ MERKLE PROOF VALID"); + Ok(()) + } else { + eprintln!(" RESULT: ✗ MERKLE PROOF INVALID!"); + Err(format!( + "root mismatch: computed={}, expected={}", + hex::encode(computed_root), + hex::encode(expected_root) + )) + } } - pub fn get_merkle_root(&self) -> Option<[u8; 32]> { - self.coin_tree.root() + /// Debug helper: dump entire merkle tree state + pub fn debug_dump_tree_state(&self) { + eprintln!("\n=== MERKLE TREE STATE ==="); + eprintln!(" leaves_len: {}", self.coin_tree.len()); + eprintln!(" root: {}", hex::encode(self.coin_tree.root())); + eprintln!(" merkle_leaves ({}):", self.merkle_leaves.len()); + for (i, leaf) in self.merkle_leaves.iter().enumerate() { + eprintln!(" [{}]: {}", i, hex::encode(leaf)); + } + eprintln!( + " commitment_to_index ({}):", + self.commitment_to_index.len() + ); + for (comm, idx) in &self.commitment_to_index { + eprintln!(" {} -> idx {}", hex::encode(comm), idx); + } } - pub fn utxo_iter(&self) -> impl Iterator { - self.utxo_set.iter() + pub fn get_merkle_root(&self) -> Option<[u8; 32]> { + Some(self.coin_tree.root()) } fn generate_tx_id(&self) -> [u8; 32] { @@ -405,7 +622,7 @@ impl CLVMZkSimulator { pub fn reset(&mut self) { self.nullifier_set.clear(); self.utxo_set.clear(); - self.coin_tree = MerkleTree::::new(); + self.coin_tree = default_coin_tree(); self.commitment_to_index.clear(); self.merkle_leaves.clear(); self.transactions.clear(); @@ -413,7 +630,14 @@ impl CLVMZkSimulator { } /// process settlement output: add nullifiers and commitments to simulator state - pub fn process_settlement(&mut self, output: &crate::protocol::SettlementOutput) { + pub fn process_settlement(&mut self, output: &crate::protocol::SettlementOutput) -> Result<(), String> { + // reject already-spent nullifiers (double-spend protection) + if self.nullifier_set.contains(&output.maker_nullifier) { + return Err("maker nullifier already spent".into()); + } + if self.nullifier_set.contains(&output.taker_nullifier) { + return Err("taker nullifier already spent".into()); + } // add nullifiers to nullifier set self.nullifier_set.insert(output.maker_nullifier); self.nullifier_set.insert(output.taker_nullifier); @@ -426,15 +650,14 @@ impl CLVMZkSimulator { output.taker_change_commitment, ]; + let h = hasher(); for commitment in &commitments { - let leaf_index = self.coin_tree.leaves_len(); - self.coin_tree.insert(*commitment); + let leaf_index = self.coin_tree.len(); + self.coin_tree.insert(*commitment, h); self.commitment_to_index.insert(*commitment, leaf_index); self.merkle_leaves.push(*commitment); } - - // commit tree after adding all commitments - self.coin_tree.commit(); + Ok(()) } } @@ -444,6 +667,13 @@ pub struct CoinInfo { pub coin: PrivateCoin, pub metadata: CoinMetadata, pub created_at_height: u64, + /// stealth nonce for hash-based stealth address scanning (32 bytes) + /// sender transmits this encrypted; receiver decrypts to derive shared_secret + #[serde(default, alias = "ephemeral_pubkey")] + pub stealth_nonce: Option>, + /// chialisp source for stealth coins (needed for spending) + #[serde(default)] + pub puzzle_source: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -456,6 +686,7 @@ pub struct CoinMetadata { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum CoinType { Regular, + Cat, Multisig, Timelocked, Atomic, diff --git a/src/wallet/hd_wallet.rs b/src/wallet/hd_wallet.rs index df1ad68..6e8e89a 100644 --- a/src/wallet/hd_wallet.rs +++ b/src/wallet/hd_wallet.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fmt; +use super::stealth::StealthKeys; + pub struct CLVMHDWallet { master: XPrv, network: crate::wallet::Network, @@ -73,19 +75,13 @@ impl CLVMHDWallet { hasher.finalize().into() }; - // Derive x25519 key for encrypted note decryption - let note_encryption_private = { + // Derive stealth keys (secp256k1) for stealth address payments + let stealth_keys = { let mut hasher = Sha256::new(); - hasher.update(b"clvm_zk_note_encryption_v1"); + hasher.update(b"clvm_zk_stealth_seed_v1"); hasher.update(account_bytes); - hasher.finalize().into() - }; - - // Compute x25519 public key - let note_encryption_public = { - use x25519_dalek::{PublicKey, StaticSecret}; - let secret = StaticSecret::from(note_encryption_private); - PublicKey::from(&secret).to_bytes() + let seed: [u8; 32] = hasher.finalize().into(); + StealthKeys::from_seed(&seed) }; let nullifier_key = { @@ -99,8 +95,7 @@ impl CLVMHDWallet { spending_key, viewing_key, nullifier_key, - note_encryption_private, - note_encryption_public, + stealth_keys, account_index, network: self.network, _account_xprv: account_xprv, @@ -117,8 +112,7 @@ pub struct AccountKeys { pub spending_key: [u8; 32], pub viewing_key: [u8; 32], pub nullifier_key: [u8; 32], - pub note_encryption_private: [u8; 32], // x25519 private key for decrypting notes - pub note_encryption_public: [u8; 32], // x25519 public key for receiving notes + pub stealth_keys: StealthKeys, // secp256k1 stealth keys for stealth address payments pub account_index: u32, pub network: crate::wallet::Network, _account_xprv: XPrv, // Keep for potential child derivation @@ -179,6 +173,25 @@ impl WalletPrivateCoin { } } + /// create CAT coin with specific tail_hash + pub fn new_with_tail( + puzzle_hash: [u8; 32], + amount: u64, + account_index: u32, + coin_index: u32, + tail_hash: [u8; 32], + ) -> Self { + let (coin, secrets) = + crate::protocol::PrivateCoin::new_with_secrets_and_tail(puzzle_hash, amount, tail_hash); + + Self { + coin, + secrets, + account_index, + coin_index, + } + } + pub fn serial_number(&self) -> [u8; 32] { self.secrets.serial_number() } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 25aa55b..051218b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1,7 +1,13 @@ // Wallet module organization pub mod hd_wallet; +pub mod stealth; pub mod tests; pub mod types; -pub use hd_wallet::{AccountKeys, CLVMHDWallet, ValidationError, ViewingKey, WalletPrivateCoin}; +pub use hd_wallet::{AccountKeys, CLVMHDWallet, ValidationError, WalletPrivateCoin}; +pub use stealth::{ + create_stealth_payment_hd, derive_nullifier_secrets_from_shared_secret, derive_stealth_tag, + ScannedStealthCoin, StealthAddress, StealthKeys, StealthPayment, StealthSpendAuth, + StealthViewKey, STEALTH_NULLIFIER_PUZZLE_HASH, +}; pub use types::{Network, WalletError}; diff --git a/src/wallet/stealth.rs b/src/wallet/stealth.rs new file mode 100644 index 0000000..5bc03ae --- /dev/null +++ b/src/wallet/stealth.rs @@ -0,0 +1,557 @@ +//! Hash-based stealth address implementation +//! +//! provides unlinkable payments where: +//! - sender derives unique puzzle_hash per payment via hash(view_pubkey || nonce) +//! - sender includes encrypted nonce in transaction metadata +//! - receiver decrypts nonce and derives same shared_secret +//! - spending uses nullifier protocol (view key can spend) +//! +//! # ⚠️ CRITICAL SECURITY WARNING +//! +//! **VIEW KEY HOLDERS CAN SPEND COINS IN NULLIFIER MODE.** +//! +//! this is by design for fast proving (~200x faster than ECDH). serial secrets derive +//! from shared_secret, which view key holders can compute. do NOT share view keys with +//! anyone you wouldn't trust with your funds. for audit-only access, use a different +//! approach (signature-based custody mode is not yet implemented). +//! +//! ## security model +//! +//! - **nullifier mode** (only mode): fast (~10K zkVM cycles). view key holder CAN spend. +//! serial secrets derived from shared_secret. security from nullifier protocol, not puzzle logic. +//! +//! ## migration from ECDH +//! +//! this module now uses hash-based stealth for consistency with settlement proofs. +//! - old: shared_secret = ECDH(ephemeral_priv, view_pub) = ECDH(view_priv, ephemeral_pub) +//! - new: shared_secret = hash("stealth_v1" || view_pubkey || nonce) +//! - nonce transmitted encrypted to receiver + +use once_cell::sync::Lazy; +use sha2::{Digest, Sha256}; + +/// domain separator for stealth derivation +const STEALTH_DOMAIN: &[u8] = b"veil_stealth_v1"; + +/// domain separator for nullifier mode +const STEALTH_NULLIFIER_DOMAIN: &[u8] = b"veil_stealth_nullifier_v1"; + +// ============================================================================ +// nullifier mode puzzle +// ============================================================================ + +/// compile-time constant for nullifier mode puzzle +/// this is a trivial puzzle - security comes from nullifier protocol, not puzzle logic +const NULLIFIER_PUZZLE_SOURCE: &str = "(mod () ())"; + +/// puzzle hash for nullifier-mode stealth coins +/// all nullifier-mode coins share this puzzle hash +pub static STEALTH_NULLIFIER_PUZZLE_HASH: Lazy<[u8; 32]> = Lazy::new(|| { + clvm_zk_core::compile_chialisp_template_hash_default(NULLIFIER_PUZZLE_SOURCE) + .expect("nullifier puzzle compilation failed") +}); + +// ============================================================================ +// types +// ============================================================================ + +/// authorization data for spending a stealth coin (nullifier mode only) +#[derive(Clone, Debug)] +pub struct StealthSpendAuth { + pub serial_number: [u8; 32], + pub serial_randomness: [u8; 32], +} + +impl StealthSpendAuth { + /// convert to CoinSecrets for use with Spender + pub fn to_coin_secrets(&self) -> clvm_zk_core::coin_commitment::CoinSecrets { + clvm_zk_core::coin_commitment::CoinSecrets::new(self.serial_number, self.serial_randomness) + } +} + +/// scanned coin with nullifier mode data +#[derive(Clone, Debug)] +pub struct ScannedStealthCoin { + pub puzzle_hash: [u8; 32], + pub shared_secret: [u8; 32], + pub nonce: [u8; 32], + pub puzzle_source: String, +} + +/// wallet keys for stealth addresses (view + spend separation) +#[derive(Clone)] +pub struct StealthKeys { + pub view_privkey: [u8; 32], + pub spend_privkey: [u8; 32], +} + +/// public stealth address (safe to publish) +/// now uses hash-based pubkey derivation (no EC math) +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StealthAddress { + pub view_pubkey: [u8; 32], // changed from 33 bytes (compressed EC) to 32 bytes (hash) + pub spend_pubkey: [u8; 32], // changed from 33 bytes to 32 bytes +} + +/// view-only capability (can scan, cannot spend) +#[derive(Clone)] +pub struct StealthViewKey { + pub view_privkey: [u8; 32], + pub spend_pubkey: [u8; 32], // changed from 33 bytes to 32 bytes +} + +/// result of creating a stealth payment +#[derive(Clone, Debug)] +pub struct StealthPayment { + pub puzzle_hash: [u8; 32], + pub nonce: [u8; 32], // sender must encrypt and transmit this + pub shared_secret: [u8; 32], + pub puzzle_source: String, +} + +impl StealthKeys { + /// generate new random stealth keys + pub fn generate() -> Self { + use rand::RngCore; + let mut rng = rand::thread_rng(); + + let mut view_privkey = [0u8; 32]; + let mut spend_privkey = [0u8; 32]; + rng.fill_bytes(&mut view_privkey); + rng.fill_bytes(&mut spend_privkey); + + Self { + view_privkey, + spend_privkey, + } + } + + /// derive from master seed using different paths + pub fn from_seed(seed: &[u8]) -> Self { + let view_privkey = derive_key(seed, b"stealth_view"); + let spend_privkey = derive_key(seed, b"stealth_spend"); + + Self { + view_privkey, + spend_privkey, + } + } + + /// derive nonce deterministically for stealth payment + /// + /// uses HD-style derivation: nonce = hash(view_privkey || "nonce" || index) + /// this ensures: + /// - deterministic generation (wallet recovery from seed) + /// - no RNG failure risk + /// - guaranteed uniqueness per index + pub fn derive_nonce(&self, nonce_index: u32) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"veil_stealth_nonce_v1"); + hasher.update(self.view_privkey); + hasher.update(nonce_index.to_le_bytes()); + hasher.finalize().into() + } + + /// get public stealth address + pub fn stealth_address(&self) -> StealthAddress { + StealthAddress { + view_pubkey: privkey_to_pubkey(&self.view_privkey), + spend_pubkey: privkey_to_pubkey(&self.spend_privkey), + } + } + + /// extract view-only key (for watch-only wallets, auditors) + pub fn view_only(&self) -> StealthViewKey { + StealthViewKey { + view_privkey: self.view_privkey, + spend_pubkey: privkey_to_pubkey(&self.spend_privkey), + } + } + + /// get authorization for spending a stealth coin + /// + /// returns serial secrets for nullifier protocol. + /// view key holder CAN spend (fast proving, no signatures). + pub fn get_spend_auth(&self, shared_secret: &[u8; 32]) -> StealthSpendAuth { + let secrets = derive_nullifier_secrets_from_shared_secret(shared_secret); + StealthSpendAuth { + serial_number: secrets.serial_number, + serial_randomness: secrets.serial_randomness, + } + } +} + +impl StealthAddress { + /// encode as 64 bytes (view_pub || spend_pub) + pub fn to_bytes(&self) -> [u8; 64] { + let mut bytes = [0u8; 64]; + bytes[..32].copy_from_slice(&self.view_pubkey); + bytes[32..].copy_from_slice(&self.spend_pubkey); + bytes + } + + /// decode from 64 bytes + pub fn from_bytes(bytes: &[u8; 64]) -> Self { + let mut view_pubkey = [0u8; 32]; + let mut spend_pubkey = [0u8; 32]; + view_pubkey.copy_from_slice(&bytes[..32]); + spend_pubkey.copy_from_slice(&bytes[32..]); + Self { + view_pubkey, + spend_pubkey, + } + } +} + +impl StealthViewKey { + /// derive shared_secret from nonce (for scanning with known nonce) + /// + /// receiver decrypts nonce from transaction metadata, then derives shared_secret + pub fn derive_shared_secret(&self, nonce: &[u8; 32]) -> [u8; 32] { + let view_pubkey = privkey_to_pubkey(&self.view_privkey); + derive_stealth_shared_secret(&view_pubkey, nonce) + } + + /// scan a coin given its nonce (from decrypted transaction metadata) + /// + /// returns Some(ScannedStealthCoin) if coin matches nullifier mode puzzle + pub fn try_scan_with_nonce( + &self, + puzzle_hash: &[u8; 32], + nonce: &[u8; 32], + ) -> Option { + // derive shared secret from nonce + let shared_secret = self.derive_shared_secret(nonce); + + // nullifier mode: puzzle_hash must match the trivial puzzle + if puzzle_hash == STEALTH_NULLIFIER_PUZZLE_HASH.as_ref() { + return Some(ScannedStealthCoin { + puzzle_hash: *puzzle_hash, + shared_secret, + nonce: *nonce, + puzzle_source: NULLIFIER_PUZZLE_SOURCE.to_string(), + }); + } + + None + } + + /// scan a coin with verification tag (prevents false positives) + /// + /// verifies that derived_tag matches expected_tag + pub fn try_scan_with_tag( + &self, + puzzle_hash: &[u8; 32], + nonce: &[u8; 32], + expected_tag: &[u8; 4], + ) -> Option { + // derive shared secret from nonce + let shared_secret = self.derive_shared_secret(nonce); + + // derive verification tag from shared_secret + let derived_tag = derive_stealth_tag(&shared_secret); + + // verify tag matches + if &derived_tag != expected_tag { + return None; // not our coin + } + + // nullifier mode: puzzle_hash must match the trivial puzzle + if puzzle_hash == STEALTH_NULLIFIER_PUZZLE_HASH.as_ref() { + return Some(ScannedStealthCoin { + puzzle_hash: *puzzle_hash, + shared_secret, + nonce: *nonce, + puzzle_source: NULLIFIER_PUZZLE_SOURCE.to_string(), + }); + } + + None + } +} + +/// derive stealth shared_secret from view_pubkey and nonce +/// +/// consistent with settlement proof stealth address derivation +pub fn derive_stealth_shared_secret(view_pubkey: &[u8; 32], nonce: &[u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"stealth_v1"); + hasher.update(view_pubkey); + hasher.update(nonce); + hasher.finalize().into() +} + +/// derive verification tag from shared_secret +/// +/// this 4-byte tag allows receivers to filter false positives when scanning +/// nullifier-mode stealth coins (which all share the same puzzle_hash) +pub fn derive_stealth_tag(shared_secret: &[u8; 32]) -> [u8; 4] { + let mut hasher = Sha256::new(); + hasher.update(b"veil_stealth_tag_v1"); + hasher.update(shared_secret); + let hash: [u8; 32] = hasher.finalize().into(); + [hash[0], hash[1], hash[2], hash[3]] +} + +/// create a stealth payment using HD-derived nonce +/// +/// uses nullifier mode: fast proving (~10K cycles), trivial puzzle, view key can spend. +/// +/// # arguments +/// * `sender_keys` - sender's stealth keys (used to derive nonce) +/// * `nonce_index` - index for nonce derivation (track in wallet) +/// * `recipient` - recipient's stealth address +/// +/// # security +/// nonce derived deterministically: hash(sender_view_key || "nonce" || index) +/// - wallet recovery: regenerate all payments from seed + indices +/// - no RNG failure risk +/// - guaranteed uniqueness per index +/// +/// # important +/// sender MUST encrypt and transmit the nonce to receiver +pub fn create_stealth_payment_hd( + sender_keys: &StealthKeys, + nonce_index: u32, + recipient: &StealthAddress, +) -> StealthPayment { + // derive nonce deterministically + let nonce = sender_keys.derive_nonce(nonce_index); + + // compute shared secret: hash(view_pubkey || nonce) + let shared_secret = derive_stealth_shared_secret(&recipient.view_pubkey, &nonce); + + // nullifier mode: trivial puzzle, security from nullifier protocol + StealthPayment { + puzzle_hash: *STEALTH_NULLIFIER_PUZZLE_HASH, + nonce, + shared_secret, + puzzle_source: NULLIFIER_PUZZLE_SOURCE.to_string(), + } +} + +/// create a stealth payment with explicit nonce +/// +/// use this when you want to provide your own nonce +pub fn create_stealth_payment_with_nonce( + nonce: [u8; 32], + recipient: &StealthAddress, +) -> StealthPayment { + // compute shared secret: hash(view_pubkey || nonce) + let shared_secret = derive_stealth_shared_secret(&recipient.view_pubkey, &nonce); + + StealthPayment { + puzzle_hash: *STEALTH_NULLIFIER_PUZZLE_HASH, + nonce, + shared_secret, + puzzle_source: NULLIFIER_PUZZLE_SOURCE.to_string(), + } +} + +/// derive coin secrets for NULLIFIER mode stealth addresses +/// +/// uses domain-separated derivation: +/// - coin_secret = sha256(STEALTH_NULLIFIER_DOMAIN || shared_secret) +/// - serial_number = sha256(coin_secret || "serial") +/// - serial_randomness = sha256(coin_secret || "rand") +/// +/// both sender and receiver can derive identical secrets from shared_secret. +/// view key holder CAN spend in this mode (fast proving, no signature needed). +pub fn derive_nullifier_secrets_from_shared_secret( + shared_secret: &[u8; 32], +) -> clvm_zk_core::coin_commitment::CoinSecrets { + // derive intermediate coin_secret + let mut hasher = Sha256::new(); + hasher.update(STEALTH_NULLIFIER_DOMAIN); + hasher.update(shared_secret); + let coin_secret: [u8; 32] = hasher.finalize().into(); + + // derive serial_number from coin_secret + let mut hasher = Sha256::new(); + hasher.update(coin_secret); + hasher.update(b"serial"); + let serial_number: [u8; 32] = hasher.finalize().into(); + + // derive serial_randomness from coin_secret + let mut hasher = Sha256::new(); + hasher.update(coin_secret); + hasher.update(b"rand"); + let serial_randomness: [u8; 32] = hasher.finalize().into(); + + clvm_zk_core::coin_commitment::CoinSecrets::new(serial_number, serial_randomness) +} + +// ============================================================================ +// internal helpers +// ============================================================================ + +fn derive_key(seed: &[u8], path: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(STEALTH_DOMAIN); + hasher.update(seed); + hasher.update(path); + hasher.finalize().into() +} + +/// derive pubkey from privkey using hash (no EC math) +fn privkey_to_pubkey(privkey: &[u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"stealth_pubkey_v1"); + hasher.update(privkey); + hasher.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stealth_payment_roundtrip() { + // receiver generates keys + let receiver_keys = StealthKeys::generate(); + let stealth_address = receiver_keys.stealth_address(); + + // sender creates payment + let sender_keys = StealthKeys::generate(); + let payment = create_stealth_payment_hd(&sender_keys, 0, &stealth_address); + + // receiver decrypts nonce (simulated) and scans + let view_key = receiver_keys.view_only(); + let scanned = view_key + .try_scan_with_nonce(&payment.puzzle_hash, &payment.nonce) + .expect("should find coin"); + + assert_eq!(scanned.puzzle_hash, payment.puzzle_hash); + assert_eq!(scanned.shared_secret, payment.shared_secret); + + // derive spending authorization + let auth = receiver_keys.get_spend_auth(&scanned.shared_secret); + let coin_secrets = auth.to_coin_secrets(); + + // verify secrets are derivable + assert_eq!(coin_secrets.serial_number.len(), 32); + assert_eq!(coin_secrets.serial_randomness.len(), 32); + } + + #[test] + fn test_wrong_receiver_filtered_by_tag() { + let receiver_keys = StealthKeys::generate(); + let wrong_keys = StealthKeys::generate(); + + let sender_keys = StealthKeys::generate(); + let payment = create_stealth_payment_hd(&sender_keys, 0, &receiver_keys.stealth_address()); + + // compute correct tag + let payment_tag = derive_stealth_tag(&payment.shared_secret); + + // wrong receiver tries to scan with tag verification + let wrong_view = wrong_keys.view_only(); + let scan_result = + wrong_view.try_scan_with_tag(&payment.puzzle_hash, &payment.nonce, &payment_tag); + + // wrong receiver is filtered out because derived shared_secret differs + assert!(scan_result.is_none()); + + // correct receiver succeeds + let correct_view = receiver_keys.view_only(); + let scan_result = + correct_view.try_scan_with_tag(&payment.puzzle_hash, &payment.nonce, &payment_tag); + assert!(scan_result.is_some()); + } + + #[test] + fn test_multiple_payments_same_puzzle() { + // in nullifier mode, all payments use same puzzle_hash + let receiver_keys = StealthKeys::generate(); + let stealth_address = receiver_keys.stealth_address(); + + let sender_keys = StealthKeys::generate(); + let payment1 = create_stealth_payment_hd(&sender_keys, 0, &stealth_address); + let payment2 = create_stealth_payment_hd(&sender_keys, 1, &stealth_address); + + // SAME puzzle_hash (nullifier mode) + assert_eq!(payment1.puzzle_hash, payment2.puzzle_hash); + assert_eq!(payment1.puzzle_hash, *STEALTH_NULLIFIER_PUZZLE_HASH); + + // different nonces (unlinkability) + assert_ne!(payment1.nonce, payment2.nonce); + + // different shared secrets + assert_ne!(payment1.shared_secret, payment2.shared_secret); + + // receiver can find both (with decrypted nonces) + let view_key = receiver_keys.view_only(); + let scanned1 = view_key.try_scan_with_nonce(&payment1.puzzle_hash, &payment1.nonce); + let scanned2 = view_key.try_scan_with_nonce(&payment2.puzzle_hash, &payment2.nonce); + + assert!(scanned1.is_some()); + assert!(scanned2.is_some()); + } + + #[test] + fn test_from_seed_deterministic() { + let seed = b"test seed for deterministic keys"; + + let keys1 = StealthKeys::from_seed(seed); + let keys2 = StealthKeys::from_seed(seed); + + assert_eq!(keys1.view_privkey, keys2.view_privkey); + assert_eq!(keys1.spend_privkey, keys2.spend_privkey); + } + + #[test] + fn test_nullifier_secrets_deterministic() { + let shared_secret = [42u8; 32]; + + let secrets1 = derive_nullifier_secrets_from_shared_secret(&shared_secret); + let secrets2 = derive_nullifier_secrets_from_shared_secret(&shared_secret); + + assert_eq!(secrets1.serial_number, secrets2.serial_number); + assert_eq!(secrets1.serial_randomness, secrets2.serial_randomness); + } + + #[test] + fn test_hd_nonce_derivation_deterministic() { + // HD nonces must be deterministic for wallet recovery + let sender_keys = StealthKeys::generate(); + + let nonce0_a = sender_keys.derive_nonce(0); + let nonce0_b = sender_keys.derive_nonce(0); + let nonce1 = sender_keys.derive_nonce(1); + + // same index produces same nonce + assert_eq!(nonce0_a, nonce0_b); + + // different indices produce different nonces + assert_ne!(nonce0_a, nonce1); + } + + #[test] + fn test_hd_payment_deterministic_recreation() { + // critical: sender can recreate payment details from nonce_index + let sender_keys = StealthKeys::generate(); + let receiver_address = StealthKeys::generate().stealth_address(); + + let nonce_index = 42; + + // create payment twice with same index + let payment1 = create_stealth_payment_hd(&sender_keys, nonce_index, &receiver_address); + let payment2 = create_stealth_payment_hd(&sender_keys, nonce_index, &receiver_address); + + // must produce identical results (critical for wallet recovery) + assert_eq!(payment1.puzzle_hash, payment2.puzzle_hash); + assert_eq!(payment1.nonce, payment2.nonce); + assert_eq!(payment1.shared_secret, payment2.shared_secret); + } + + #[test] + fn test_address_encoding() { + let keys = StealthKeys::generate(); + let address = keys.stealth_address(); + + let bytes = address.to_bytes(); + let decoded = StealthAddress::from_bytes(&bytes); + + assert_eq!(address, decoded); + } +} diff --git a/src/wallet/tests.rs b/src/wallet/tests.rs index e20c8a4..0a37d66 100644 --- a/src/wallet/tests.rs +++ b/src/wallet/tests.rs @@ -1,8 +1,9 @@ -#![cfg(test)] - +#[cfg(test)] use super::*; +#[cfg(test)] use crate::wallet::hd_wallet::*; +#[cfg(test)] const TEST_SEED: &[u8] = b"test seed must be at least 16 bytes long!!!"; #[test] diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 7736df2..702563a 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -28,6 +28,8 @@ impl Network { } /// Core error type for the wallet system +/// +/// can be converted to `ClvmZkError` for unified error handling #[derive(Debug, thiserror::Error)] pub enum WalletError { #[error("Invalid seed (must be 16-64 bytes)")] @@ -42,3 +44,20 @@ pub enum WalletError { #[error("Cryptographic operation failed")] CryptoError, } + +impl From for clvm_zk_core::ClvmZkError { + fn from(err: WalletError) -> Self { + match err { + WalletError::InvalidSeed => { + clvm_zk_core::ClvmZkError::InvalidInput("invalid seed".to_string()) + } + WalletError::Bip32Error(e) => clvm_zk_core::ClvmZkError::CryptoError(e.to_string()), + WalletError::DerivationFailed => { + clvm_zk_core::ClvmZkError::CryptoError("key derivation failed".to_string()) + } + WalletError::CryptoError => { + clvm_zk_core::ClvmZkError::CryptoError("cryptographic operation failed".to_string()) + } + } + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7076601..b1e9e9b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use once_cell::sync::Lazy; use std::env; use std::sync::Once; diff --git a/tests/expression_tests.rs b/tests/expression_tests.rs index e619a8c..7c73707 100644 --- a/tests/expression_tests.rs +++ b/tests/expression_tests.rs @@ -1,5 +1,5 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use tokio::task; diff --git a/tests/fuzz_tests.rs b/tests/fuzz_tests.rs index 8440130..d3cff84 100644 --- a/tests/fuzz_tests.rs +++ b/tests/fuzz_tests.rs @@ -1,6 +1,6 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; mod common; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use common::{test_expression, TestResult}; use tokio::task; diff --git a/tests/parameter_tests.rs b/tests/parameter_tests.rs index fab9111..8b6a170 100644 --- a/tests/parameter_tests.rs +++ b/tests/parameter_tests.rs @@ -1,6 +1,6 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; mod common; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; /// Test unified API with mixed byte and integer parameters #[test] diff --git a/tests/performance_tests.rs b/tests/performance_tests.rs index 287d36e..b80549c 100644 --- a/tests/performance_tests.rs +++ b/tests/performance_tests.rs @@ -3,7 +3,7 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; use tokio::task; use crate::common::BATCH_SIZE; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use std::collections::HashSet; diff --git a/tests/proof_validation_tests.rs b/tests/proof_validation_tests.rs index 16b7381..52b3469 100644 --- a/tests/proof_validation_tests.rs +++ b/tests/proof_validation_tests.rs @@ -1,7 +1,7 @@ use clvm_zk::{ClvmZkProver, ProgramParameter}; use tokio::task; mod common; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use common::BATCH_SIZE; diff --git a/tests/recursive_aggregation_tests.rs b/tests/recursive_aggregation_tests.rs index fbc2e56..ab9cd26 100644 --- a/tests/recursive_aggregation_tests.rs +++ b/tests/recursive_aggregation_tests.rs @@ -1,6 +1,6 @@ #![cfg(feature = "risc0")] -use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets}; +use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets, XCH_TAIL}; use clvm_zk_core::merkle::SparseMerkleTree; use clvm_zk_core::{Input, ProgramParameter, SerialCommitmentData, ZKClvmResult}; use clvm_zk_risc0::{RecursiveAggregator, Risc0Backend}; @@ -36,8 +36,13 @@ fn generate_test_proof( // compute commitments let serial_commitment = coin_secrets.serial_commitment(hash_data); - let coin_commitment = - CoinCommitment::compute(amount, &program_hash, &serial_commitment, hash_data); + let coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &program_hash, + &serial_commitment, + hash_data, + ); // create merkle tree with single coin let mut merkle_tree = SparseMerkleTree::new(20, hash_data); @@ -60,6 +65,10 @@ fn generate_test_proof( program_hash, amount, }), + tail_hash: None, // XCH by default + additional_coins: None, + mint_data: None, + tail_source: None, }; backend diff --git a/tests/security_tests.rs b/tests/security_tests.rs index 4d1c27d..9b0d069 100644 --- a/tests/security_tests.rs +++ b/tests/security_tests.rs @@ -1,7 +1,7 @@ mod common; use crate::common::BATCH_SIZE; use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use tokio::task; /// Test proof integrity attacks - tampering with proofs should cause verification to fail diff --git a/tests/signature_integration_tests.rs b/tests/signature_integration_tests.rs index ee12e2e..5666550 100644 --- a/tests/signature_integration_tests.rs +++ b/tests/signature_integration_tests.rs @@ -1,7 +1,12 @@ -/// Integration tests for signature verification in the simulator -/// -/// These tests demonstrate that signature verification is properly integrated -/// into the spend authorization process. +#![cfg(feature = "testing")] + +//! Integration tests for signature verification in the simulator +//! +//! These tests demonstrate that signature verification is properly integrated +//! into the spend authorization process. +//! +//! Requires `testing` feature (enabled by default). + use clvm_zk::simulator::{CLVMZkSimulator, CoinMetadata, CoinType, SimulatedTransaction}; use clvm_zk::testing_helpers::CoinFactory; @@ -48,12 +53,14 @@ fn validate_transaction_proofs( return Err(format!("Spend bundle {} has empty proof - invalid", i)); } - // Check nullifier is not all-zeros (common garbage proof indicator) - if bundle.nullifier == [0u8; 32] { - return Err(format!( - "Spend bundle {} has zero nullifier - invalid proof", - i - )); + // Check nullifiers are not all-zeros (common garbage proof indicator) + for nullifier in &bundle.nullifiers { + if nullifier == &[0u8; 32] { + return Err(format!( + "Spend bundle {} has zero nullifier - invalid proof", + i + )); + } } // Check public conditions exist (proof actually computed something) @@ -66,7 +73,11 @@ fn validate_transaction_proofs( } // 5. Additional validation: ensure nullifiers match spend bundles - let bundle_nullifiers: Vec<[u8; 32]> = tx.spend_bundles.iter().map(|b| b.nullifier).collect(); + let bundle_nullifiers: Vec<[u8; 32]> = tx + .spend_bundles + .iter() + .flat_map(|b| b.nullifiers.iter().copied()) + .collect(); for tx_nullifier in &tx.nullifiers { if !bundle_nullifiers.contains(tx_nullifier) { return Err(format!( diff --git a/tests/signature_tests.rs b/tests/signature_tests.rs index 572584e..6a6b3b0 100644 --- a/tests/signature_tests.rs +++ b/tests/signature_tests.rs @@ -1,6 +1,6 @@ mod common; use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; // CLVM condition opcodes const AGG_SIG_UNSAFE: u8 = 49; diff --git a/tests/simulator_tests.rs b/tests/simulator_tests.rs index 3214fb6..b82d98d 100644 --- a/tests/simulator_tests.rs +++ b/tests/simulator_tests.rs @@ -5,7 +5,7 @@ use clvm_zk::protocol::PrivateCoin; use clvm_zk::simulator::*; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; use clvm_zk_core::coin_commitment::SerialCommitment; use sha2::{Digest, Sha256}; diff --git a/tests/test_cat_commitment.rs b/tests/test_cat_commitment.rs new file mode 100644 index 0000000..c8d4838 --- /dev/null +++ b/tests/test_cat_commitment.rs @@ -0,0 +1,63 @@ +// quick test to verify XCH and CAT commitments are different +use clvm_zk_core::{CoinCommitment, SerialCommitment, XCH_TAIL}; +use sha2::{Digest, Sha256}; + +fn hash_data(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +fn main() { + let puzzle_hash = [0x42u8; 32]; + let amount = 1000u64; + let serial_commitment = SerialCommitment([0x99u8; 32]); + + // XCH commitment + let xch_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &puzzle_hash, + &serial_commitment, + hash_data, + ); + + // CAT commitment with tail hash + let cat_tail = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, + 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, + ]; + + let cat_commitment = CoinCommitment::compute( + &cat_tail, + amount, + &puzzle_hash, + &serial_commitment, + hash_data, + ); + + println!( + "XCH commitment: {:02x}{:02x}{:02x}{:02x}...", + xch_commitment.as_bytes()[0], + xch_commitment.as_bytes()[1], + xch_commitment.as_bytes()[2], + xch_commitment.as_bytes()[3] + ); + + println!( + "CAT commitment: {:02x}{:02x}{:02x}{:02x}...", + cat_commitment.as_bytes()[0], + cat_commitment.as_bytes()[1], + cat_commitment.as_bytes()[2], + cat_commitment.as_bytes()[3] + ); + + if xch_commitment == cat_commitment { + println!("\n❌ FAILED: commitments are identical (should differ!)"); + std::process::exit(1); + } else { + println!("\n✓ PASSED: XCH and CAT commitments are different"); + println!(" this proves asset isolation - can't confuse XCH with CATs"); + } +} diff --git a/tests/test_cat_minting.rs b/tests/test_cat_minting.rs new file mode 100644 index 0000000..287ec50 --- /dev/null +++ b/tests/test_cat_minting.rs @@ -0,0 +1,767 @@ +//! CAT minting test +//! +//! demonstrates: +//! 1. defining a TAIL program (token asset issuance limiter) +//! 2. computing tail_hash from the TAIL program bytecode +//! 3. minting CAT coins with that tail_hash +//! 4. spending minted CAT coins +//! 5. ring spends with CAT coins (multi-input) +//! 6. generating ZK mint proofs (risc0/sp1) + +#[cfg(feature = "mock")] +use clvm_zk::protocol::Spender; +use clvm_zk::protocol::PrivateCoin; +use clvm_zk_core::{ + compile_chialisp_template_hash_default, coin_commitment::CoinCommitment, + with_standard_conditions, XCH_TAIL, +}; +#[cfg(feature = "mock")] +use clvm_zk_core::ProgramParameter; +#[cfg(feature = "risc0")] +use clvm_zk_core::{ + coin_commitment::CoinSecrets, merkle::SparseMerkleTree, GenesisSpend, Input, MintData, +}; + +/// compute SHA256 hash +fn hash_data(data: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +/// TAIL program that allows unlimited minting (for testing) +/// in production you'd use signature-based or governance-controlled TAILs +const UNLIMITED_TAIL: &str = "(mod () 1)"; + +/// TAIL program that requires a signature to mint +/// (more realistic but requires BLS setup) +#[allow(dead_code)] +const SIGNATURE_TAIL: &str = "(mod (pubkey signature) (bls_verify pubkey (sha256 1) signature))"; + +/// compute tail_hash from a TAIL program +/// this is how CAT asset types are identified +fn compute_tail_hash(tail_program: &str) -> [u8; 32] { + compile_chialisp_template_hash_default(tail_program) + .expect("TAIL program should compile") +} + +#[test] +fn test_tail_hash_computation() { + println!("\n=== TAIL HASH COMPUTATION TEST ===\n"); + + // compute tail_hash for unlimited TAIL + let unlimited_tail_hash = compute_tail_hash(UNLIMITED_TAIL); + println!("unlimited TAIL program: {}", UNLIMITED_TAIL); + println!("unlimited tail_hash: {}", hex::encode(unlimited_tail_hash)); + + // verify it's not all zeros (that would be XCH) + assert_ne!( + unlimited_tail_hash, XCH_TAIL, + "TAIL hash should not equal XCH_TAIL" + ); + + // verify determinism - same program should produce same hash + let hash2 = compute_tail_hash(UNLIMITED_TAIL); + assert_eq!( + unlimited_tail_hash, hash2, + "TAIL hash should be deterministic" + ); + + // different TAIL program should produce different hash + let different_tail = "(mod () 2)"; + let different_hash = compute_tail_hash(different_tail); + assert_ne!( + unlimited_tail_hash, different_hash, + "different TAIL programs should produce different hashes" + ); + + println!("\n✓ TAIL hash computation works correctly"); +} + +#[test] +fn test_cat_coin_creation() { + println!("\n=== CAT COIN CREATION TEST ===\n"); + + // 1. define our TAIL program + let tail_program = UNLIMITED_TAIL; + let tail_hash = compute_tail_hash(tail_program); + println!("minting CAT with tail_hash: {}", hex::encode(tail_hash)); + + // 2. create a puzzle for our CAT coin + let cat_puzzle = with_standard_conditions( + "(mod (out_puzzle out_amount out_serial out_rand) + (list (list CREATE_COIN out_puzzle out_amount out_serial out_rand)))", + ); + let cat_puzzle_hash = + compile_chialisp_template_hash_default(&cat_puzzle).expect("compile puzzle"); + + // 3. "mint" a CAT coin (create coin with the computed tail_hash) + let mint_amount = 1000u64; + let (cat_coin, cat_secrets) = + PrivateCoin::new_with_secrets_and_tail(cat_puzzle_hash, mint_amount, tail_hash); + + println!("minted CAT coin:"); + println!(" amount: {} mojos", mint_amount); + println!(" tail_hash: {}", hex::encode(cat_coin.tail_hash)); + println!(" is_cat: {}", cat_coin.is_cat()); + println!(" is_xch: {}", cat_coin.is_xch()); + + // verify the coin is a CAT, not XCH + assert!(cat_coin.is_cat(), "minted coin should be a CAT"); + assert!(!cat_coin.is_xch(), "minted coin should not be XCH"); + assert_eq!(cat_coin.tail_hash, tail_hash, "tail_hash should match"); + + // 4. verify commitment includes tail_hash + let commitment = CoinCommitment::compute( + &cat_coin.tail_hash, + cat_coin.amount, + &cat_coin.puzzle_hash, + &cat_coin.serial_commitment, + hash_data, + ); + + // same coin as XCH should have different commitment + let xch_commitment = CoinCommitment::compute( + &XCH_TAIL, + cat_coin.amount, + &cat_coin.puzzle_hash, + &cat_coin.serial_commitment, + hash_data, + ); + + assert_ne!( + commitment, xch_commitment, + "CAT commitment should differ from XCH commitment" + ); + + println!(" CAT commitment: {}...", hex::encode(&commitment.0[..8])); + println!(" XCH commitment: {}...", hex::encode(&xch_commitment.0[..8])); + + // store secrets for later use + let _ = cat_secrets; + + println!("\n✓ CAT coin creation works correctly"); +} + +#[test] +#[cfg(feature = "mock")] +fn test_cat_spend() { + println!("\n=== CAT SPEND TEST (MOCK) ===\n"); + + // 1. mint a CAT coin + let tail_hash = compute_tail_hash(UNLIMITED_TAIL); + + // balanced puzzle that outputs full amount + let spend_puzzle = with_standard_conditions( + "(mod (out_puzzle out_serial out_rand) + (list (list CREATE_COIN out_puzzle 1000 out_serial out_rand)))", + ); + let puzzle_hash = compile_chialisp_template_hash_default(&spend_puzzle).expect("compile"); + + let (cat_coin, cat_secrets) = + PrivateCoin::new_with_secrets_and_tail(puzzle_hash, 1000, tail_hash); + + println!("minted CAT: {} mojos", cat_coin.amount); + println!("tail_hash: {}...", hex::encode(&tail_hash[..8])); + + // 2. create merkle tree (single leaf) + let commitment = CoinCommitment::compute( + &cat_coin.tail_hash, + cat_coin.amount, + &cat_coin.puzzle_hash, + &cat_coin.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + let merkle_root = commitment.0; + + // 3. spend parameters + let out_puzzle = [1u8; 32]; + let out_serial = [2u8; 32]; + let out_rand = [3u8; 32]; + + let solution_params = vec![ + ProgramParameter::Bytes(out_puzzle.to_vec()), + ProgramParameter::Bytes(out_serial.to_vec()), + ProgramParameter::Bytes(out_rand.to_vec()), + ]; + + // 4. create spend proof + let result = Spender::create_spend_with_serial( + &cat_coin, + &spend_puzzle, + &solution_params, + &cat_secrets, + vec![], // empty merkle path for single-leaf tree + merkle_root, + 0, + ); + + match result { + Ok(bundle) => { + println!("\n✓ CAT spend proof generated"); + println!(" proof size: {} bytes", bundle.zk_proof.len()); + println!(" nullifiers: {}", bundle.nullifiers.len()); + + // verify nullifier is present + assert!(!bundle.nullifiers.is_empty(), "should have nullifier"); + assert_ne!( + bundle.nullifiers[0], [0u8; 32], + "nullifier should be non-zero" + ); + + println!("\n✓ CAT SPEND TEST PASSED"); + } + Err(e) => { + panic!("CAT spend failed: {:?}", e); + } + } +} + +#[test] +#[cfg(feature = "mock")] +fn test_cat_ring_spend() { + println!("\n=== CAT RING SPEND TEST (MOCK) ===\n"); + + // 1. mint multiple CAT coins with SAME tail_hash + let tail_hash = compute_tail_hash(UNLIMITED_TAIL); + println!("minting 3 CAT coins with tail_hash: {}...", hex::encode(&tail_hash[..8])); + + // balanced puzzle - each outputs its own amount + let puzzle1 = with_standard_conditions( + "(mod () (list (list CREATE_COIN 0x0101010101010101010101010101010101010101010101010101010101010101 100)))", + ); + let puzzle2 = with_standard_conditions( + "(mod () (list (list CREATE_COIN 0x0202020202020202020202020202020202020202020202020202020202020202 200)))", + ); + let puzzle3 = with_standard_conditions( + "(mod () (list (list CREATE_COIN 0x0303030303030303030303030303030303030303030303030303030303030303 300)))", + ); + + let hash1 = compile_chialisp_template_hash_default(&puzzle1).expect("compile"); + let hash2 = compile_chialisp_template_hash_default(&puzzle2).expect("compile"); + let hash3 = compile_chialisp_template_hash_default(&puzzle3).expect("compile"); + + let (coin1, secrets1) = PrivateCoin::new_with_secrets_and_tail(hash1, 100, tail_hash); + let (coin2, secrets2) = PrivateCoin::new_with_secrets_and_tail(hash2, 200, tail_hash); + let (coin3, secrets3) = PrivateCoin::new_with_secrets_and_tail(hash3, 300, tail_hash); + + println!("coin1: {} mojos, is_cat: {}", coin1.amount, coin1.is_cat()); + println!("coin2: {} mojos, is_cat: {}", coin2.amount, coin2.is_cat()); + println!("coin3: {} mojos, is_cat: {}", coin3.amount, coin3.is_cat()); + println!("total: {} mojos", coin1.amount + coin2.amount + coin3.amount); + + // 2. build merkle tree with all 3 coins + let commit1 = CoinCommitment::compute( + &coin1.tail_hash, + coin1.amount, + &coin1.puzzle_hash, + &coin1.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + let commit2 = CoinCommitment::compute( + &coin2.tail_hash, + coin2.amount, + &coin2.puzzle_hash, + &coin2.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + let commit3 = CoinCommitment::compute( + &coin3.tail_hash, + coin3.amount, + &coin3.puzzle_hash, + &coin3.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + + // simple 4-leaf tree (3 coins + 1 padding) + let padding = [0u8; 32]; + let left_branch = clvm_zk::crypto_utils::hash_data_default( + &[commit1.0.as_slice(), commit2.0.as_slice()].concat(), + ); + let right_branch = clvm_zk::crypto_utils::hash_data_default( + &[commit3.0.as_slice(), padding.as_slice()].concat(), + ); + let merkle_root = + clvm_zk::crypto_utils::hash_data_default(&[left_branch.as_slice(), right_branch.as_slice()].concat()); + + // merkle paths + let path1 = vec![commit2.0, right_branch]; + let path2 = vec![commit1.0, right_branch]; + let path3 = vec![padding, left_branch]; + + // 3. create ring spend + let coins = vec![ + (&coin1, puzzle1.as_str(), &[][..], &secrets1, path1, 0), + (&coin2, puzzle2.as_str(), &[][..], &secrets2, path2, 1), + (&coin3, puzzle3.as_str(), &[][..], &secrets3, path3, 2), + ]; + + let result = Spender::create_ring_spend(coins, merkle_root); + + match result { + Ok(bundle) => { + println!("\n✓ CAT ring spend proof generated"); + println!(" proof size: {} bytes", bundle.zk_proof.len()); + println!(" nullifiers: {}", bundle.nullifiers.len()); + + // should have 3 nullifiers (one per coin) + assert_eq!(bundle.nullifiers.len(), 3, "should have 3 nullifiers"); + + // all nullifiers should be unique + assert_ne!(bundle.nullifiers[0], bundle.nullifiers[1]); + assert_ne!(bundle.nullifiers[1], bundle.nullifiers[2]); + assert_ne!(bundle.nullifiers[0], bundle.nullifiers[2]); + + println!("\n✓ CAT RING SPEND TEST PASSED"); + } + Err(e) => { + panic!("CAT ring spend failed: {:?}", e); + } + } +} + +#[test] +#[cfg(feature = "mock")] +fn test_cat_tail_mismatch_rejected() { + println!("\n=== CAT TAIL MISMATCH TEST (MOCK) ===\n"); + + // try to create ring spend with different tail_hashes - should fail + let tail1 = compute_tail_hash(UNLIMITED_TAIL); + let tail2 = compute_tail_hash("(mod () 2)"); // different TAIL + + let puzzle = with_standard_conditions( + "(mod () (list (list CREATE_COIN 0x0101010101010101010101010101010101010101010101010101010101010101 100)))", + ); + let hash = compile_chialisp_template_hash_default(&puzzle).expect("compile"); + + let (coin1, secrets1) = PrivateCoin::new_with_secrets_and_tail(hash, 100, tail1); + let (coin2, secrets2) = PrivateCoin::new_with_secrets_and_tail(hash, 100, tail2); + + println!("coin1 tail: {}...", hex::encode(&tail1[..8])); + println!("coin2 tail: {}...", hex::encode(&tail2[..8])); + + // build merkle tree + let commit1 = CoinCommitment::compute( + &coin1.tail_hash, + coin1.amount, + &coin1.puzzle_hash, + &coin1.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + let commit2 = CoinCommitment::compute( + &coin2.tail_hash, + coin2.amount, + &coin2.puzzle_hash, + &coin2.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + + let merkle_root = clvm_zk::crypto_utils::hash_data_default( + &[commit1.0.as_slice(), commit2.0.as_slice()].concat(), + ); + + let path1 = vec![commit2.0]; + let path2 = vec![commit1.0]; + + // attempt ring spend with mismatched tails + let coins = vec![ + (&coin1, puzzle.as_str(), &[][..], &secrets1, path1, 0), + (&coin2, puzzle.as_str(), &[][..], &secrets2, path2, 1), + ]; + + let result = Spender::create_ring_spend(coins, merkle_root); + + match result { + Ok(_) => { + panic!("ring spend with mismatched tails should fail!"); + } + Err(e) => { + println!("✓ correctly rejected: {:?}", e); + assert!( + format!("{:?}", e).contains("tail_hash"), + "error should mention tail_hash" + ); + println!("\n✓ CAT TAIL MISMATCH TEST PASSED"); + } + } +} + +/// test the proper ZK mint proof flow with risc0 backend +/// this generates an actual zkvm proof that verifies TAIL authorization +#[test] +#[cfg(feature = "risc0")] +fn test_mint_proof_risc0() { + use clvm_zk_risc0::Risc0Backend; + + println!("\n=== MINT PROOF TEST (RISC0) ===\n"); + + let backend = Risc0Backend::new().expect("risc0 backend should initialize"); + + // prepare mint data + let output_puzzle_hash = [42u8; 32]; + let output_serial = [1u8; 32]; + let output_rand = [2u8; 32]; + let output_amount = 1000u64; + + let mint_data = MintData { + tail_source: UNLIMITED_TAIL.to_string(), + tail_params: vec![], + output_puzzle_hash, + output_amount, + output_serial, + output_rand, + genesis_coin: None, + }; + + // create input with mint mode + let input = Input { + chialisp_source: "".to_string(), // not used in mint mode + program_parameters: vec![], + serial_commitment_data: None, + tail_hash: None, + additional_coins: None, + mint_data: Some(mint_data), + tail_source: None, + }; + + println!("generating mint proof..."); + let result = backend.prove_with_input(input); + + match result { + Ok(zk_result) => { + println!("✓ mint proof generated"); + println!(" proof size: {} bytes", zk_result.proof_bytes.len()); + println!(" program_hash (tail_hash): {}", hex::encode(zk_result.proof_output.program_hash)); + println!(" nullifiers: {} (should be 0 for mint)", zk_result.proof_output.nullifiers.len()); + println!(" proof_type: {}", zk_result.proof_output.proof_type); + + // verify proof type is Mint (3) + assert_eq!(zk_result.proof_output.proof_type, 3, "proof_type should be Mint (3)"); + + // verify no nullifiers (minting doesn't spend coins) + assert!( + zk_result.proof_output.nullifiers.is_empty(), + "mint should have no nullifiers" + ); + + // verify public_values contains tail_hash and coin_commitment + assert_eq!( + zk_result.proof_output.public_values.len(), + 2, + "should have 2 public values" + ); + + let tail_hash = &zk_result.proof_output.public_values[0]; + let coin_commitment = &zk_result.proof_output.public_values[1]; + + println!(" public tail_hash: {}", hex::encode(tail_hash)); + println!(" public coin_commitment: {}", hex::encode(coin_commitment)); + + // verify tail_hash matches expected + let expected_tail_hash = compute_tail_hash(UNLIMITED_TAIL); + assert_eq!( + tail_hash.as_slice(), + expected_tail_hash.as_slice(), + "tail_hash should match UNLIMITED_TAIL" + ); + + // verify coin_commitment is 32 bytes + assert_eq!( + coin_commitment.len(), + 32, + "coin_commitment should be 32 bytes" + ); + + println!("\n✓ MINT PROOF TEST (RISC0) PASSED"); + } + Err(e) => { + panic!("mint proof failed: {:?}", e); + } + } +} + +/// test that mint with failing TAIL is rejected +#[test] +#[cfg(feature = "risc0")] +fn test_mint_failing_tail_rejected() { + use clvm_zk_risc0::Risc0Backend; + + println!("\n=== MINT FAILING TAIL TEST (RISC0) ===\n"); + + let backend = Risc0Backend::new().expect("risc0 backend should initialize"); + + // TAIL that returns nil (fails authorization) + let failing_tail = "(mod () ())"; + + let mint_data = MintData { + tail_source: failing_tail.to_string(), + tail_params: vec![], + output_puzzle_hash: [42u8; 32], + output_amount: 1000, + output_serial: [1u8; 32], + output_rand: [2u8; 32], + genesis_coin: None, + }; + + let input = Input { + chialisp_source: "".to_string(), + program_parameters: vec![], + serial_commitment_data: None, + tail_hash: None, + additional_coins: None, + mint_data: Some(mint_data), + tail_source: None, + }; + + println!("attempting mint with failing TAIL..."); + let result = backend.prove_with_input(input); + + match result { + Ok(_) => { + panic!("mint with failing TAIL should be rejected!"); + } + Err(e) => { + println!("✓ correctly rejected: {:?}", e); + println!("\n✓ MINT FAILING TAIL TEST PASSED"); + } + } +} + +/// test minting multiple different CAT types +/// demonstrates that each TAIL program produces a unique asset type +#[test] +#[cfg(feature = "risc0")] +fn test_mint_multiple_cats() { + use clvm_zk_risc0::Risc0Backend; + + println!("\n=== MINT MULTIPLE CATS TEST (RISC0) ===\n"); + + let backend = Risc0Backend::new().expect("risc0 backend should initialize"); + + // define 3 different CAT types with different TAIL programs + let cats = vec![ + ("GOLD", "(mod () 1)"), // unlimited gold + ("SILVER", "(mod () 2)"), // different constant = different hash + ("BRONZE", "(mod (x) (> x 0))"), // requires positive param + ]; + + let mut minted_cats: Vec<([u8; 32], Vec)> = vec![]; + + for (i, (name, tail_source)) in cats.iter().enumerate() { + println!("minting {}...", name); + + let mint_data = MintData { + tail_source: tail_source.to_string(), + tail_params: if *name == "BRONZE" { + vec![clvm_zk_core::ProgramParameter::Int(100)] // pass positive param + } else { + vec![] + }, + output_puzzle_hash: [(i + 1) as u8; 32], + output_amount: (i + 1) as u64 * 1000, + output_serial: [(i + 10) as u8; 32], + output_rand: [(i + 20) as u8; 32], + genesis_coin: None, + }; + + let input = Input { + chialisp_source: "".to_string(), + program_parameters: vec![], + serial_commitment_data: None, + tail_hash: None, + additional_coins: None, + mint_data: Some(mint_data), + tail_source: None, + }; + + let result = backend.prove_with_input(input).expect("mint should succeed"); + + let tail_hash: [u8; 32] = result.proof_output.public_values[0] + .clone() + .try_into() + .expect("tail_hash should be 32 bytes"); + let coin_commitment = result.proof_output.public_values[1].clone(); + + println!(" {} tail_hash: {}", name, hex::encode(&tail_hash[..8])); + println!(" {} coin_commitment: {}", name, hex::encode(&coin_commitment[..8])); + println!(" {} amount: {} mojos", name, (i + 1) * 1000); + + minted_cats.push((tail_hash, coin_commitment)); + } + + // verify all tail_hashes are unique + println!("\nverifying uniqueness..."); + assert_ne!(minted_cats[0].0, minted_cats[1].0, "GOLD != SILVER"); + assert_ne!(minted_cats[1].0, minted_cats[2].0, "SILVER != BRONZE"); + assert_ne!(minted_cats[0].0, minted_cats[2].0, "GOLD != BRONZE"); + + // verify all coin_commitments are unique + assert_ne!(minted_cats[0].1, minted_cats[1].1, "commitments unique"); + assert_ne!(minted_cats[1].1, minted_cats[2].1, "commitments unique"); + + println!("✓ all 3 CAT types have unique tail_hashes"); + println!("✓ all coin_commitments are unique"); + + // verify none equal XCH + for (i, (tail_hash, _)) in minted_cats.iter().enumerate() { + assert_ne!( + *tail_hash, XCH_TAIL, + "CAT {} should not equal XCH", + ["GOLD", "SILVER", "BRONZE"][i] + ); + } + println!("✓ none equal XCH_TAIL"); + + println!("\n✓ MINT MULTIPLE CATS TEST PASSED"); + println!(" minted: GOLD (1000), SILVER (2000), BRONZE (3000)"); +} + +/// test genesis-linked minting: mint is bound to a genesis coin +/// the genesis coin's nullifier prevents double-minting +#[test] +#[cfg(feature = "risc0")] +fn test_genesis_linked_mint() { + use clvm_zk_risc0::Risc0Backend; + + println!("\n=== GENESIS-LINKED MINT TEST (RISC0) ===\n"); + + let backend = Risc0Backend::new().expect("risc0 backend should initialize"); + + // 1. create a genesis coin (an XCH coin that authorizes one-time minting) + let genesis_serial = [11u8; 32]; + let genesis_rand = [12u8; 32]; + let genesis_puzzle_hash = [13u8; 32]; + let genesis_amount = 0u64; // genesis can be zero-value + let genesis_tail_hash = XCH_TAIL; // genesis is XCH + + // compute genesis commitments + let genesis_secrets = CoinSecrets::new(genesis_serial, genesis_rand); + let genesis_serial_commitment = genesis_secrets.serial_commitment(hash_data); + let genesis_coin_commitment = CoinCommitment::compute( + &genesis_tail_hash, + genesis_amount, + &genesis_puzzle_hash, + &genesis_serial_commitment, + hash_data, + ); + + // put genesis coin in merkle tree + let mut merkle_tree = SparseMerkleTree::new(20, hash_data); + let leaf_index = merkle_tree.insert(*genesis_coin_commitment.as_bytes(), hash_data); + let merkle_root = merkle_tree.root(); + let merkle_proof = merkle_tree.generate_proof(leaf_index, hash_data).unwrap(); + + println!("genesis coin created:"); + println!(" commitment: {}...", hex::encode(&genesis_coin_commitment.as_bytes()[..8])); + println!(" merkle_root: {}...", hex::encode(&merkle_root[..8])); + + // 2. TAIL program that checks genesis_nullifier matches expected value + // for this test, use unlimited TAIL (just verifies genesis mechanism works) + // a production TAIL would: (mod (genesis_nullifier) (= genesis_nullifier EXPECTED)) + let tail_source = "(mod (genesis_nullifier) 1)"; // accepts any genesis nullifier + + // 3. create mint with genesis coin + let mint_data = MintData { + tail_source: tail_source.to_string(), + tail_params: vec![], // genesis_nullifier will be prepended automatically + output_puzzle_hash: [42u8; 32], + output_amount: 5000, + output_serial: [50u8; 32], + output_rand: [51u8; 32], + genesis_coin: Some(GenesisSpend { + serial_number: genesis_serial, + serial_randomness: genesis_rand, + puzzle_hash: genesis_puzzle_hash, + amount: genesis_amount, + tail_hash: genesis_tail_hash, + serial_commitment: *genesis_serial_commitment.as_bytes(), + coin_commitment: *genesis_coin_commitment.as_bytes(), + merkle_path: merkle_proof.path, + merkle_root, + leaf_index, + }), + }; + + let input = Input { + chialisp_source: "".to_string(), + program_parameters: vec![], + serial_commitment_data: None, + tail_hash: None, + additional_coins: None, + mint_data: Some(mint_data), + tail_source: None, + }; + + println!("generating genesis-linked mint proof..."); + let result = backend.prove_with_input(input).expect("genesis mint should succeed"); + + // verify proof includes genesis nullifier + assert_eq!(result.proof_output.proof_type, 3, "should be mint type"); + assert_eq!( + result.proof_output.nullifiers.len(), + 1, + "should have 1 nullifier (genesis)" + ); + + let genesis_nullifier = result.proof_output.nullifiers[0]; + println!(" genesis nullifier: {}", hex::encode(genesis_nullifier)); + assert_ne!(genesis_nullifier, [0u8; 32], "nullifier should be non-zero"); + + // verify determinism: same genesis should produce same nullifier + // this is what prevents double-minting: validators track nullifier set + let expected_nullifier = clvm_zk_core::compute_nullifier( + hash_data, + &genesis_serial, + &genesis_puzzle_hash, + genesis_amount, + ); + assert_eq!( + genesis_nullifier, expected_nullifier, + "nullifier should be deterministic" + ); + + println!(" ✓ genesis nullifier matches expected"); + println!(" ✓ same genesis coin → same nullifier → can't mint twice"); + + println!("\n✓ GENESIS-LINKED MINT TEST PASSED"); +} + +/// verify that genesis nullifier is deterministic across mints +/// (same genesis = same nullifier = validator rejects second mint) +#[test] +#[cfg(feature = "risc0")] +fn test_genesis_nullifier_determinism() { + println!("\n=== GENESIS NULLIFIER DETERMINISM TEST ===\n"); + + // two different genesis coins should produce different nullifiers + let nullifier_a = clvm_zk_core::compute_nullifier( + hash_data, + &[1u8; 32], // serial_number + &[2u8; 32], // puzzle_hash + 100, + ); + let nullifier_b = clvm_zk_core::compute_nullifier( + hash_data, + &[3u8; 32], // different serial + &[2u8; 32], + 100, + ); + + assert_ne!(nullifier_a, nullifier_b, "different genesis = different nullifier"); + println!(" ✓ different genesis coins → different nullifiers"); + + // same genesis coin always produces same nullifier + let nullifier_a2 = clvm_zk_core::compute_nullifier( + hash_data, + &[1u8; 32], + &[2u8; 32], + 100, + ); + assert_eq!(nullifier_a, nullifier_a2, "same genesis = same nullifier"); + println!(" ✓ same genesis coin → same nullifier (deterministic)"); + + println!("\n✓ GENESIS NULLIFIER DETERMINISM TEST PASSED"); + println!(" validators add genesis nullifier to spent set"); + println!(" second mint attempt → nullifier already in set → REJECTED"); +} diff --git a/tests/test_condition_transformation.rs b/tests/test_condition_transformation.rs index 523289e..a36f34b 100644 --- a/tests/test_condition_transformation.rs +++ b/tests/test_condition_transformation.rs @@ -36,13 +36,15 @@ fn test_condition_transformation_logic() { serial_data[51..83].copy_from_slice(serial_rand); let serial_commitment = hash_data(&serial_data); - // Compute coin_commitment - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 89]; + // Compute coin_commitment (v2.0 format with tail_hash) + let coin_domain = b"clvm_zk_coin_v2.0"; + let tail_hash = [0u8; 32]; // XCH default + let mut coin_data = [0u8; 121]; coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(amount_bytes); - coin_data[25..57].copy_from_slice(puzzle_hash_ref); - coin_data[57..89].copy_from_slice(&serial_commitment); + coin_data[17..49].copy_from_slice(&tail_hash); + coin_data[49..57].copy_from_slice(amount_bytes); + coin_data[57..89].copy_from_slice(puzzle_hash_ref); + coin_data[89..121].copy_from_slice(&serial_commitment); let coin_commitment = hash_data(&coin_data); // Transform: replace 4 args with 1 arg (commitment) @@ -149,12 +151,14 @@ fn compute_coin_commitment( serial_data[51..83].copy_from_slice(serial_randomness); let serial_commitment = hash_data(&serial_data); - // Compute coin_commitment - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 89]; + // Compute coin_commitment (v2.0 format with tail_hash) + let coin_domain = b"clvm_zk_coin_v2.0"; + let tail_hash = [0u8; 32]; // XCH default + let mut coin_data = [0u8; 121]; coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(puzzle_hash); - coin_data[57..89].copy_from_slice(&serial_commitment); + coin_data[17..49].copy_from_slice(&tail_hash); + coin_data[49..57].copy_from_slice(&amount.to_be_bytes()); + coin_data[57..89].copy_from_slice(puzzle_hash); + coin_data[89..121].copy_from_slice(&serial_commitment); hash_data(&coin_data) } diff --git a/tests/test_conditional_spend.rs b/tests/test_conditional_spend.rs new file mode 100644 index 0000000..d1d1896 --- /dev/null +++ b/tests/test_conditional_spend.rs @@ -0,0 +1,188 @@ +//! test conditional spend proof creation +//! +//! verifies maker can create locked conditional spend proofs + +#[cfg(feature = "mock")] +use clvm_zk::protocol::{PrivateCoin, ProofType, Spender}; +#[cfg(feature = "mock")] +use clvm_zk_core::{ + compile_chialisp_template_hash_default, with_standard_conditions, ProgramParameter, +}; + +#[test] +#[cfg(feature = "mock")] +fn test_conditional_spend_creation() { + println!("\n=== CONDITIONAL SPEND TEST (MOCK) ===\n"); + + // create a coin for maker + let maker_amount = 1000u64; + let maker_change = 900u64; // change back to maker + + // output coin parameters + let change_puzzle = [3u8; 32]; + let change_serial = [4u8; 32]; + let change_rand = [5u8; 32]; + + // use with_standard_conditions for proper CREATE_COIN macro + // this puzzle creates a single output coin (the change) + let offer_puzzle = with_standard_conditions( + "(mod (change_puzzle change_amount change_serial change_rand) + (list (list CREATE_COIN change_puzzle change_amount change_serial change_rand)))", + ); + + // compute the puzzle hash FIRST, then create coin with it + let maker_puzzle_hash = + compile_chialisp_template_hash_default(&offer_puzzle).expect("compile puzzle"); + + let (maker_coin, maker_secrets) = + PrivateCoin::new_with_secrets(maker_puzzle_hash, maker_amount); + + println!("maker coin: {} mojos", maker_amount); + + // create simple merkle tree (single leaf) + let maker_commitment = clvm_zk_core::coin_commitment::CoinCommitment::compute( + &maker_coin.tail_hash, + maker_coin.amount, + &maker_coin.puzzle_hash, + &maker_coin.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + + let maker_merkle_root = maker_commitment.0; + + // solution parameters for the puzzle + let solution_params = vec![ + ProgramParameter::Bytes(change_puzzle.to_vec()), + ProgramParameter::Int(maker_change), + ProgramParameter::Bytes(change_serial.to_vec()), + ProgramParameter::Bytes(change_rand.to_vec()), + ]; + + println!("creating conditional spend:"); + println!(" change: {} mojos", maker_change); + + // create conditional spend proof + let result = Spender::create_conditional_spend( + &maker_coin, + &offer_puzzle, + &solution_params, + &maker_secrets, + vec![], // empty merkle path for single-leaf tree + maker_merkle_root, + 0, + None, + ); + + match result { + Ok(bundle) => { + println!("\n✓ conditional spend created successfully"); + println!(" proof type: {:?}", bundle.proof_type); + println!(" proof size: {} bytes", bundle.zk_proof.len()); + println!(" nullifiers: {}", bundle.nullifiers.len()); + println!(" output size: {} bytes", bundle.public_conditions.len()); + + // verify proof type is ConditionalSpend + assert_eq!( + bundle.proof_type, + ProofType::ConditionalSpend, + "proof should be ConditionalSpend type" + ); + + // verify we have exactly one nullifier + assert_eq!( + bundle.nullifiers.len(), + 1, + "should have exactly one nullifier" + ); + assert_ne!( + bundle.nullifiers[0], [0u8; 32], + "nullifier should be non-zero" + ); + + // verify output is not empty (contains settlement terms) + assert!( + !bundle.public_conditions.is_empty(), + "output should contain settlement terms" + ); + + println!("\n✓ ALL CHECKS PASSED"); + println!(" - proof type: ConditionalSpend ✓"); + println!(" - nullifier present: ✓"); + println!(" - settlement terms in output: ✓"); + } + Err(e) => { + panic!("conditional spend failed: {:?}", e); + } + } +} + +#[test] +#[cfg(feature = "mock")] +fn test_conditional_vs_regular_spend() { + println!("\n=== CONDITIONAL VS REGULAR SPEND TEST ===\n"); + + let coin_amount = 1000u64; + let out_puzzle_hash = [1u8; 32]; // destination puzzle + + // puzzle that creates balanced output (51 = CREATE_COIN, outputs full amount back) + let balanced_puzzle = format!( + "(mod () (list (list 51 0x{} {})))", + hex::encode(out_puzzle_hash), + coin_amount + ); + + // compute puzzle hash FIRST + let puzzle_hash = + compile_chialisp_template_hash_default(&balanced_puzzle).expect("compile puzzle"); + + let (coin, secrets) = PrivateCoin::new_with_secrets(puzzle_hash, coin_amount); + + let commitment = clvm_zk_core::coin_commitment::CoinCommitment::compute( + &coin.tail_hash, + coin.amount, + &coin.puzzle_hash, + &coin.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + + let merkle_root = commitment.0; + + // create regular spend + let regular_bundle = Spender::create_spend_with_serial( + &coin, + &balanced_puzzle, + &[], + &secrets, + vec![], + merkle_root, + 0, + ) + .expect("regular spend failed"); + + // create conditional spend + let conditional_bundle = Spender::create_conditional_spend( + &coin, + &balanced_puzzle, + &[], + &secrets, + vec![], + merkle_root, + 0, + None, + ) + .expect("conditional spend failed"); + + println!("regular spend:"); + println!(" proof type: {:?}", regular_bundle.proof_type); + println!(" nullifiers: {}", regular_bundle.nullifiers.len()); + + println!("\nconditional spend:"); + println!(" proof type: {:?}", conditional_bundle.proof_type); + println!(" nullifiers: {}", conditional_bundle.nullifiers.len()); + + // verify proof types are different + assert_eq!(regular_bundle.proof_type, ProofType::Transaction); + assert_eq!(conditional_bundle.proof_type, ProofType::ConditionalSpend); + + println!("\n✓ proof types correctly differentiated"); +} diff --git a/tests/test_create_coin_privacy.rs b/tests/test_create_coin_privacy.rs index 25da40c..a534146 100644 --- a/tests/test_create_coin_privacy.rs +++ b/tests/test_create_coin_privacy.rs @@ -1,6 +1,8 @@ -/// Test 51 transformation for output privacy +/// Test CREATE_COIN (opcode 51) transformation for output privacy #[cfg(feature = "mock")] -use clvm_zk_core::{extract_coin_commitments, hash_data, ProgramParameter}; +use clvm_zk_core::{ + extract_coin_commitments, hash_data, with_standard_conditions, ProgramParameter, +}; #[cfg(feature = "mock")] use clvm_zk_mock::MockBackend; @@ -16,11 +18,11 @@ fn test_create_coin_private_mode() { let recipient_puzzle = [0x42u8; 32]; let amount: u64 = 1000; - // Chialisp program with 4-arg 51 (private mode) - let program = r#" - (mod (recipient amount serial_num serial_rand) - (list (list 51 recipient amount serial_num serial_rand))) - "#; + // Chialisp program with 4-arg CREATE_COIN - private mode + let program = with_standard_conditions( + "(mod (recipient amount serial_num serial_rand) + (list (list CREATE_COIN recipient amount serial_num serial_rand)))", + ); // Parameters: recipient, amount, serial_number, serial_randomness let params = vec![ @@ -33,7 +35,7 @@ fn test_create_coin_private_mode() { // Generate proof let backend = MockBackend::new().expect("failed to create backend"); let result = backend - .prove_chialisp_program(program, ¶ms) + .prove_chialisp_program(&program, ¶ms) .expect("proof generation failed"); // Extract coin commitments from proof output @@ -53,7 +55,7 @@ fn test_create_coin_private_mode() { assert_eq!(commitments[0], expected_commitment, "commitment mismatch"); - println!("✓ 51 private mode working"); + println!("✓ CREATE_COIN private mode working"); println!(" coin_commitment: {}", hex::encode(commitments[0])); } @@ -72,12 +74,12 @@ fn test_create_coin_multiple_outputs() { let amount2: u64 = 300; // Chialisp program creating 2 coins - let program = r#" - (mod (r1 a1 s1 sr1 r2 a2 s2 sr2) + let program = with_standard_conditions( + "(mod (r1 a1 s1 sr1 r2 a2 s2 sr2) (list - (list 51 r1 a1 s1 sr1) - (list 51 r2 a2 s2 sr2))) - "#; + (list CREATE_COIN r1 a1 s1 sr1) + (list CREATE_COIN r2 a2 s2 sr2)))", + ); let params = vec![ ProgramParameter::Bytes(recipient1.to_vec()), @@ -92,7 +94,7 @@ fn test_create_coin_multiple_outputs() { let backend = MockBackend::new().expect("failed to create backend"); let result = backend - .prove_chialisp_program(program, ¶ms) + .prove_chialisp_program(&program, ¶ms) .expect("proof generation failed"); let commitments = @@ -108,7 +110,7 @@ fn test_create_coin_multiple_outputs() { assert_eq!(commitments[0], expected1, "commitment 1 mismatch"); assert_eq!(commitments[1], expected2, "commitment 2 mismatch"); - println!("✓ 51 multiple outputs working"); + println!("✓ CREATE_COIN multiple outputs working"); println!(" commitment 1: {}", hex::encode(commitments[0])); println!(" commitment 2: {}", hex::encode(commitments[1])); } @@ -116,14 +118,14 @@ fn test_create_coin_multiple_outputs() { #[test] #[cfg(feature = "mock")] fn test_create_coin_transparent_mode() { - // Test backward compatibility: 2-arg 51 should pass through unchanged + // Test backward compatibility: 2-arg CREATE_COIN should pass through unchanged let recipient = [0x33u8; 32]; let amount: u64 = 777; - let program = r#" - (mod (recipient amount) - (list (list 51 recipient amount))) - "#; + let program = with_standard_conditions( + "(mod (recipient amount) + (list (list CREATE_COIN recipient amount)))", + ); let params = vec![ ProgramParameter::Bytes(recipient.to_vec()), @@ -132,7 +134,7 @@ fn test_create_coin_transparent_mode() { let backend = MockBackend::new().expect("failed to create backend"); let result = backend - .prove_chialisp_program(program, ¶ms) + .prove_chialisp_program(&program, ¶ms) .expect("proof generation failed"); // Parse output to verify 2-arg format preserved @@ -140,7 +142,7 @@ fn test_create_coin_transparent_mode() { .expect("failed to parse conditions"); assert_eq!(conditions.len(), 1, "expected 1 condition"); - assert_eq!(conditions[0].opcode, 51, "expected 51 opcode"); + assert_eq!(conditions[0].opcode, 51, "expected CREATE_COIN opcode"); assert_eq!( conditions[0].args.len(), 2, @@ -148,10 +150,10 @@ fn test_create_coin_transparent_mode() { ); assert_eq!(&conditions[0].args[0], &recipient.to_vec()); - println!("✓ 51 transparent mode (backward compatibility) working"); + println!("✓ CREATE_COIN transparent mode (backward compatibility) working"); } -// Helper: compute coin_commitment +// Helper: compute coin_commitment v2 (with XCH tail_hash) #[cfg(feature = "mock")] fn compute_coin_commitment( puzzle_hash: &[u8; 32], @@ -159,6 +161,8 @@ fn compute_coin_commitment( serial_number: &[u8; 32], serial_randomness: &[u8; 32], ) -> [u8; 32] { + use clvm_zk_core::coin_commitment::{build_coin_commitment_preimage, XCH_TAIL}; + // Compute serial_commitment let serial_domain = b"clvm_zk_serial_v1.0"; let mut serial_data = [0u8; 83]; @@ -167,13 +171,13 @@ fn compute_coin_commitment( serial_data[51..83].copy_from_slice(serial_randomness); let serial_commitment = hash_data(&serial_data); - // Compute coin_commitment - let coin_domain = b"clvm_zk_coin_v1.0"; - let mut coin_data = [0u8; 89]; - coin_data[..17].copy_from_slice(coin_domain); - coin_data[17..25].copy_from_slice(&amount.to_be_bytes()); - coin_data[25..57].copy_from_slice(puzzle_hash); - coin_data[57..89].copy_from_slice(&serial_commitment); + // Compute coin_commitment v2 using shared function + let coin_data = build_coin_commitment_preimage( + &XCH_TAIL, // XCH (native currency) + amount, + puzzle_hash, + &serial_commitment, + ); hash_data(&coin_data) } diff --git a/tests/test_e2e_risc0.rs b/tests/test_e2e_risc0.rs new file mode 100644 index 0000000..d596cb8 --- /dev/null +++ b/tests/test_e2e_risc0.rs @@ -0,0 +1,794 @@ +//! end-to-end tests with real risc0 proofs +//! +//! these tests generate actual ZK proofs via the risc0 backend. +//! run: cargo test-risc0 --test test_e2e_risc0 + +#![cfg(feature = "risc0")] + +use clvm_zk::protocol::settlement::{prove_settlement, SettlementParams}; +use clvm_zk::protocol::{PrivateCoin, ProofType, Spender}; +use clvm_zk::simulator::*; +use clvm_zk_core::coin_commitment::{CoinCommitment, CoinSecrets}; +use clvm_zk_core::merkle::SparseMerkleTree; +use clvm_zk_core::{ + compile_chialisp_template_hash_default, compute_nullifier, with_standard_conditions, + GenesisSpend, Input, MintData, ProgramParameter, XCH_TAIL, +}; +use clvm_zk_risc0::Risc0Backend; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +fn hash_data(data: &[u8]) -> [u8; 32] { + Sha256::digest(data).into() +} + +fn meta(owner: &str, notes: &str) -> CoinMetadata { + CoinMetadata { + owner: owner.to_string(), + coin_type: CoinType::Regular, + notes: notes.to_string(), + } +} + +fn cat_meta(owner: &str, notes: &str) -> CoinMetadata { + CoinMetadata { + owner: owner.to_string(), + coin_type: CoinType::Cat, + notes: notes.to_string(), + } +} + +const UNLIMITED_TAIL: &str = "(mod () 1)"; + +// ============================================================================ +// 1. XCH mint → spend → double-spend rejection +// ============================================================================ + +#[test] +fn test_e2e_xch_mint_spend_verify() { + let mut sim = CLVMZkSimulator::new(); + + // simple identity puzzle: returns its argument + let puzzle = "(mod (x) x)"; + let puzzle_hash = compile_chialisp_template_hash_default(puzzle).unwrap(); + + // mint XCH coin + let (coin, secrets) = PrivateCoin::new_with_secrets(puzzle_hash, 1000); + sim.add_coin(coin.clone(), &secrets, meta("alice", "xch coin")); + + // spend it + let result = sim.spend_coins_with_params(vec![( + coin.clone(), + puzzle.to_string(), + vec![ProgramParameter::Int(42)], + secrets.clone(), + )]); + + let tx = result.expect("first spend should succeed"); + + // verify proof properties + assert!(!tx.spend_bundles.is_empty(), "should have spend bundles"); + assert!(!tx.spend_bundles[0].zk_proof.is_empty(), "proof bytes should be non-empty"); + assert_eq!(tx.nullifiers.len(), 1, "should produce exactly 1 nullifier"); + assert_ne!(tx.nullifiers[0], [0u8; 32], "nullifier should be non-zero"); + + // nullifier tracked in simulator + assert!(sim.has_nullifier(&tx.nullifiers[0])); + + // double-spend must fail + let result2 = sim.spend_coins_with_params(vec![( + coin, + puzzle.to_string(), + vec![ProgramParameter::Int(42)], + secrets, + )]); + + match result2 { + Err(SimulatorError::DoubleSpend(_)) => {} // expected + Err(e) => panic!("expected DoubleSpend, got: {:?}", e), + Ok(_) => panic!("double-spend should have been rejected"), + } +} + +// ============================================================================ +// 2. CAT mint proof → add to sim → spend +// ============================================================================ + +#[test] +fn test_e2e_cat_mint_spend() { + let backend = Risc0Backend::new().expect("risc0 init"); + let mut sim = CLVMZkSimulator::new(); + + let tail_hash = compile_chialisp_template_hash_default(UNLIMITED_TAIL).unwrap(); + + // puzzle for the minted coin - balanced spend that outputs full amount + let spend_puzzle = with_standard_conditions( + "(mod (out_puzzle out_serial out_rand) + (list (list CREATE_COIN out_puzzle 500 out_serial out_rand)))", + ); + let puzzle_hash = compile_chialisp_template_hash_default(&spend_puzzle).unwrap(); + + // generate mint proof via backend + let output_serial = [10u8; 32]; + let output_rand = [11u8; 32]; + + let mint_data = MintData { + tail_source: UNLIMITED_TAIL.to_string(), + tail_params: vec![], + output_puzzle_hash: puzzle_hash, + output_amount: 500, + output_serial, + output_rand, + genesis_coin: None, + }; + + let input = Input { + chialisp_source: "".to_string(), + program_parameters: vec![], + serial_commitment_data: None, + tail_hash: None, + additional_coins: None, + mint_data: Some(mint_data), + tail_source: None, + }; + + let zk_result = backend.prove_with_input(input).expect("mint proof"); + + // verify mint proof structure + assert_eq!(zk_result.proof_output.proof_type, 3, "should be Mint type"); + assert!( + zk_result.proof_output.nullifiers.is_empty(), + "mint should have no nullifiers" + ); + assert_eq!(zk_result.proof_output.public_values.len(), 2); + + let proof_tail_hash: [u8; 32] = zk_result.proof_output.public_values[0] + .clone() + .try_into() + .unwrap(); + assert_eq!(proof_tail_hash, tail_hash); + + let coin_commitment_bytes: [u8; 32] = zk_result.proof_output.public_values[1] + .clone() + .try_into() + .unwrap(); + + // reconstruct the minted coin and add to simulator + let secrets = CoinSecrets::new(output_serial, output_rand); + let serial_commitment = secrets.serial_commitment(hash_data); + // build the coin with the exact serial commitment from the mint + let cat_coin = PrivateCoin::new_with_tail(puzzle_hash, 500, serial_commitment, tail_hash); + + // verify coin commitment matches proof + let expected_commitment = + CoinCommitment::compute(&tail_hash, 500, &puzzle_hash, &cat_coin.serial_commitment, hash_data); + assert_eq!(*expected_commitment.as_bytes(), coin_commitment_bytes); + + sim.add_coin(cat_coin.clone(), &secrets, cat_meta("alice", "minted CAT")); + + // spend the CAT coin + let out_puzzle = [1u8; 32]; + let out_serial = [2u8; 32]; + let out_rand = [3u8; 32]; + + let tx = sim + .spend_coins_with_params(vec![( + cat_coin, + spend_puzzle, + vec![ + ProgramParameter::Bytes(out_puzzle.to_vec()), + ProgramParameter::Bytes(out_serial.to_vec()), + ProgramParameter::Bytes(out_rand.to_vec()), + ], + secrets, + )]) + .expect("CAT spend should succeed"); + + assert_eq!(tx.nullifiers.len(), 1); + assert!(sim.has_nullifier(&tx.nullifiers[0])); +} + +// ============================================================================ +// 3. genesis-linked mint: no double mint +// ============================================================================ + +#[test] +fn test_e2e_genesis_mint_no_double_mint() { + let backend = Risc0Backend::new().expect("risc0 init"); + let mut sim = CLVMZkSimulator::new(); + + // create genesis XCH coin in simulator + let genesis_puzzle_hash = [13u8; 32]; + let (genesis_coin, genesis_secrets) = + PrivateCoin::new_with_secrets(genesis_puzzle_hash, 0); + sim.add_coin( + genesis_coin.clone(), + &genesis_secrets, + meta("issuer", "genesis coin"), + ); + + // compute genesis commitments for mint + let genesis_serial_commitment = genesis_secrets.serial_commitment(hash_data); + let genesis_coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + 0, + &genesis_puzzle_hash, + &genesis_serial_commitment, + hash_data, + ); + + // build merkle tree with genesis coin + let mut merkle_tree = SparseMerkleTree::new(20, hash_data); + let leaf_index = merkle_tree.insert(*genesis_coin_commitment.as_bytes(), hash_data); + let merkle_root = merkle_tree.root(); + let merkle_proof = merkle_tree.generate_proof(leaf_index, hash_data).unwrap(); + + let tail_source = "(mod (genesis_nullifier) 1)"; + + // first mint — should succeed + let mint_data = MintData { + tail_source: tail_source.to_string(), + tail_params: vec![], + output_puzzle_hash: [42u8; 32], + output_amount: 5000, + output_serial: [50u8; 32], + output_rand: [51u8; 32], + genesis_coin: Some(GenesisSpend { + serial_number: genesis_secrets.serial_number, + serial_randomness: genesis_secrets.serial_randomness, + puzzle_hash: genesis_puzzle_hash, + amount: 0, + tail_hash: XCH_TAIL, + serial_commitment: *genesis_serial_commitment.as_bytes(), + coin_commitment: *genesis_coin_commitment.as_bytes(), + merkle_path: merkle_proof.path.clone(), + merkle_root, + leaf_index, + }), + }; + + let input = Input { + chialisp_source: "".to_string(), + program_parameters: vec![], + serial_commitment_data: None, + tail_hash: None, + additional_coins: None, + mint_data: Some(mint_data), + tail_source: None, + }; + + let result = backend.prove_with_input(input).expect("first genesis mint should succeed"); + assert_eq!(result.proof_output.nullifiers.len(), 1); + + let genesis_nullifier = result.proof_output.nullifiers[0]; + + // verify nullifier is deterministic + let expected_nullifier = + compute_nullifier(hash_data, &genesis_secrets.serial_number, &genesis_puzzle_hash, 0); + assert_eq!(genesis_nullifier, expected_nullifier); + + // track nullifier externally (simulating validator behavior) + // the simulator only tracks nullifiers via spend_coins internally, + // so for genesis mints we use a local set + let mut nullifier_set = HashSet::new(); + nullifier_set.insert(genesis_nullifier); + + // second mint with same genesis → nullifier already spent + let mint_data2 = MintData { + tail_source: tail_source.to_string(), + tail_params: vec![], + output_puzzle_hash: [99u8; 32], + output_amount: 9999, + output_serial: [60u8; 32], + output_rand: [61u8; 32], + genesis_coin: Some(GenesisSpend { + serial_number: genesis_secrets.serial_number, + serial_randomness: genesis_secrets.serial_randomness, + puzzle_hash: genesis_puzzle_hash, + amount: 0, + tail_hash: XCH_TAIL, + serial_commitment: *genesis_serial_commitment.as_bytes(), + coin_commitment: *genesis_coin_commitment.as_bytes(), + merkle_path: merkle_proof.path, + merkle_root, + leaf_index, + }), + }; + + let input2 = Input { + chialisp_source: "".to_string(), + program_parameters: vec![], + serial_commitment_data: None, + tail_hash: None, + additional_coins: None, + mint_data: Some(mint_data2), + tail_source: None, + }; + + // the proof itself will succeed (guest doesn't know about global nullifier set) + // but the nullifier will be the SAME → validator rejects + let result2 = backend.prove_with_input(input2).expect("proof generation succeeds"); + let nullifier2 = result2.proof_output.nullifiers[0]; + assert_eq!( + nullifier2, genesis_nullifier, + "same genesis → same nullifier" + ); + assert!( + nullifier_set.contains(&nullifier2), + "validator rejects: nullifier already spent" + ); +} + +// ============================================================================ +// 4. CAT ring spend (2 coins, same tail_hash) +// ============================================================================ + +#[test] +fn test_e2e_cat_ring_spend() { + let mut sim = CLVMZkSimulator::new(); + let tail_hash = compile_chialisp_template_hash_default(UNLIMITED_TAIL).unwrap(); + + // balanced puzzles — total output = 100 + 200 = 300 + let puzzle1 = with_standard_conditions( + "(mod () (list (list CREATE_COIN 0x0101010101010101010101010101010101010101010101010101010101010101 150)))", + ); + let puzzle2 = with_standard_conditions( + "(mod () (list (list CREATE_COIN 0x0202020202020202020202020202020202020202020202020202020202020202 150)))", + ); + + let hash1 = compile_chialisp_template_hash_default(&puzzle1).unwrap(); + let hash2 = compile_chialisp_template_hash_default(&puzzle2).unwrap(); + + let (coin1, secrets1) = PrivateCoin::new_with_secrets_and_tail(hash1, 100, tail_hash); + let (coin2, secrets2) = PrivateCoin::new_with_secrets_and_tail(hash2, 200, tail_hash); + + sim.add_coin(coin1.clone(), &secrets1, cat_meta("alice", "cat1")); + sim.add_coin(coin2.clone(), &secrets2, cat_meta("alice", "cat2")); + + let merkle_root = sim.get_merkle_root().unwrap(); + let (path1, idx1) = sim.get_merkle_path_and_index(&coin1).unwrap(); + let (path2, idx2) = sim.get_merkle_path_and_index(&coin2).unwrap(); + + let coins = vec![ + (&coin1, puzzle1.as_str(), &[][..], &secrets1, path1, idx1), + (&coin2, puzzle2.as_str(), &[][..], &secrets2, path2, idx2), + ]; + + let bundle = Spender::create_ring_spend(coins, merkle_root).expect("ring spend should succeed"); + + // verify 2 nullifiers + assert_eq!(bundle.nullifiers.len(), 2); + assert_ne!(bundle.nullifiers[0], bundle.nullifiers[1]); + assert!(!bundle.zk_proof.is_empty()); + + // verify both nullifiers are unique and non-zero (validator would track these) + let mut nullifier_set = HashSet::new(); + for nul in &bundle.nullifiers { + assert_ne!(*nul, [0u8; 32]); + assert!(nullifier_set.insert(*nul), "nullifiers should be unique"); + } +} + +// ============================================================================ +// 5. offer: alice XCH → bob CAT (conditional spend + settlement) +// ============================================================================ + +#[test] +fn test_e2e_offer_create_take() { + let mut sim = CLVMZkSimulator::new(); + let tail_hash = compile_chialisp_template_hash_default(UNLIMITED_TAIL).unwrap(); + + // alice has XCH coin + // maker puzzle outputs: ((51 change_puzzle change_amount change_serial change_rand) (offered requested maker_pubkey)) + // alice offers 1000 XCH, requests 500 CAT, change = 0 + let maker_pubkey = [77u8; 32]; + let change_puzzle = [88u8; 32]; + let change_serial = [89u8; 32]; + let change_rand = [90u8; 32]; + + let offer_puzzle = with_standard_conditions(&format!( + r#"(mod () + (c + (list CREATE_COIN + 0x{} + 0 + 0x{} + 0x{}) + (c 1000 (c 500 (c 0x{} ())))))"#, + hex::encode(change_puzzle), + hex::encode(change_serial), + hex::encode(change_rand), + hex::encode(maker_pubkey), + )); + + let offer_puzzle_hash = compile_chialisp_template_hash_default(&offer_puzzle).unwrap(); + let (alice_coin, alice_secrets) = PrivateCoin::new_with_secrets(offer_puzzle_hash, 1000); + sim.add_coin( + alice_coin.clone(), + &alice_secrets, + meta("alice", "xch for offer"), + ); + + // bob has CAT coin (puzzle just returns its amount for simplicity) + let bob_puzzle = with_standard_conditions( + "(mod (out_puzzle out_serial out_rand) + (list (list CREATE_COIN out_puzzle 500 out_serial out_rand)))", + ); + let bob_puzzle_hash = compile_chialisp_template_hash_default(&bob_puzzle).unwrap(); + let (bob_coin, bob_secrets) = + PrivateCoin::new_with_secrets_and_tail(bob_puzzle_hash, 500, tail_hash); + sim.add_coin(bob_coin.clone(), &bob_secrets, cat_meta("bob", "cat for offer")); + + // alice creates conditional spend (offer) + let merkle_root = sim.get_merkle_root().unwrap(); + let (alice_path, alice_idx) = sim.get_merkle_path_and_index(&alice_coin).unwrap(); + + let maker_proof = Spender::create_conditional_spend( + &alice_coin, + &offer_puzzle, + &[], + &alice_secrets, + alice_path, + merkle_root, + alice_idx, + None, + ) + .expect("conditional spend should succeed"); + + assert_eq!(maker_proof.proof_type, ProofType::ConditionalSpend); + assert!(!maker_proof.nullifiers.is_empty()); + + // bob takes the offer via settlement proof + let (bob_path, bob_idx) = sim.get_merkle_path_and_index(&bob_coin).unwrap(); + + let settlement_params = SettlementParams { + maker_proof: maker_proof.clone(), + taker_coin: bob_coin.clone(), + taker_secrets: bob_secrets.clone(), + taker_merkle_path: bob_path, + merkle_root, + taker_leaf_index: bob_idx, + payment_nonce: [33u8; 32], + taker_goods_puzzle: [44u8; 32], + taker_change_puzzle: [55u8; 32], + payment_serial: [60u8; 32], + payment_rand: [61u8; 32], + goods_serial: [62u8; 32], + goods_rand: [63u8; 32], + change_serial: [64u8; 32], + change_rand: [65u8; 32], + taker_tail_hash: tail_hash, + goods_tail_hash: XCH_TAIL, // alice's asset is XCH + }; + + let settlement = prove_settlement(settlement_params).expect("settlement should succeed"); + + assert_eq!(settlement.proof_type, ProofType::Settlement); + assert!(!settlement.zk_proof.is_empty()); + + // verify both nullifiers produced + assert_ne!(settlement.output.maker_nullifier, [0u8; 32]); + assert_ne!(settlement.output.taker_nullifier, [0u8; 32]); + + // process settlement in simulator (registers nullifiers + adds commitments to merkle tree) + sim.process_settlement(&settlement.output); + + assert!(sim.has_nullifier(&settlement.output.maker_nullifier)); + assert!(sim.has_nullifier(&settlement.output.taker_nullifier)); + + // verify new coin commitments are non-zero + assert_ne!(settlement.output.payment_commitment, [0u8; 32]); + assert_ne!(settlement.output.taker_goods_commitment, [0u8; 32]); +} + +// ============================================================================ +// 6. TAIL-on-delta melt: burn CAT with permissive TAIL +// ============================================================================ + +#[test] +fn test_e2e_tail_on_delta_melt() { + let mut sim = CLVMZkSimulator::new(); + let tail_hash = compile_chialisp_template_hash_default(UNLIMITED_TAIL).unwrap(); + + // puzzle that outputs less than input (melt/burn) + // input = 1000, output = 500 → delta = -500 + let melt_puzzle = with_standard_conditions( + "(mod (out_puzzle out_serial out_rand) + (list (list CREATE_COIN out_puzzle 500 out_serial out_rand)))", + ); + let puzzle_hash = compile_chialisp_template_hash_default(&melt_puzzle).unwrap(); + + let (coin, secrets) = PrivateCoin::new_with_secrets_and_tail(puzzle_hash, 1000, tail_hash); + sim.add_coin(coin.clone(), &secrets, cat_meta("alice", "cat to melt")); + + let merkle_root = sim.get_merkle_root().unwrap(); + let (path, idx) = sim.get_merkle_path_and_index(&coin).unwrap(); + + let coin_commitment = CoinCommitment::compute( + &tail_hash, + coin.amount, + &coin.puzzle_hash, + &coin.serial_commitment, + hash_data, + ); + + let out_puzzle = [1u8; 32]; + let out_serial = [2u8; 32]; + let out_rand = [3u8; 32]; + + // use prove_with_input directly so we can pass tail_source for delta authorization + let backend = Risc0Backend::new().expect("risc0 init"); + + let input = Input { + chialisp_source: melt_puzzle.clone(), + program_parameters: vec![ + ProgramParameter::Bytes(out_puzzle.to_vec()), + ProgramParameter::Bytes(out_serial.to_vec()), + ProgramParameter::Bytes(out_rand.to_vec()), + ], + serial_commitment_data: Some(clvm_zk_core::SerialCommitmentData { + serial_number: secrets.serial_number, + serial_randomness: secrets.serial_randomness, + merkle_path: path, + coin_commitment: *coin_commitment.as_bytes(), + serial_commitment: *coin.serial_commitment.as_bytes(), + merkle_root, + leaf_index: idx, + program_hash: puzzle_hash, + amount: 1000, + }), + tail_hash: Some(tail_hash), + tail_source: Some(UNLIMITED_TAIL.to_string()), // TAIL authorizes the negative delta + additional_coins: None, + mint_data: None, + }; + + let result = backend.prove_with_input(input).expect("melt proof should succeed with permissive TAIL"); + + // verify proof succeeded + assert!(!result.proof_bytes.is_empty()); + assert_eq!(result.proof_output.nullifiers.len(), 1); + assert_ne!(result.proof_output.nullifiers[0], [0u8; 32]); + + // the TAIL authorized the delta, so proof generation succeeded + // in production, a restrictive TAIL would reject unauthorized burns +} + +// ============================================================================ +// 7. NM-001 regression: spend a settlement goods coin after offer-take +// this test fails if wallet coins have wrong tail_hash or program source +// ============================================================================ + +#[test] +fn test_e2e_settlement_goods_coin_spendable() { + let mut sim = CLVMZkSimulator::new(); + let cat_tail_hash = compile_chialisp_template_hash_default(UNLIMITED_TAIL).unwrap(); + + // alice offers XCH, requests CAT + let maker_pubkey = [77u8; 32]; + let change_puzzle = [88u8; 32]; + let change_serial = [89u8; 32]; + let change_rand = [90u8; 32]; + + let offer_puzzle = with_standard_conditions(&format!( + r#"(mod () + (c + (list CREATE_COIN + 0x{} + 0 + 0x{} + 0x{}) + (c 1000 (c 500 (c 0x{} ())))))"#, + hex::encode(change_puzzle), + hex::encode(change_serial), + hex::encode(change_rand), + hex::encode(maker_pubkey), + )); + + let offer_puzzle_hash = compile_chialisp_template_hash_default(&offer_puzzle).unwrap(); + let (alice_coin, alice_secrets) = PrivateCoin::new_with_secrets(offer_puzzle_hash, 1000); + sim.add_coin(alice_coin.clone(), &alice_secrets, meta("alice", "xch offer")); + + // bob has CAT coin with a balanced spend puzzle + let bob_puzzle = with_standard_conditions( + "(mod (out_puzzle out_serial out_rand) + (list (list CREATE_COIN out_puzzle 500 out_serial out_rand)))", + ); + let bob_puzzle_hash = compile_chialisp_template_hash_default(&bob_puzzle).unwrap(); + let (bob_coin, bob_secrets) = + PrivateCoin::new_with_secrets_and_tail(bob_puzzle_hash, 500, cat_tail_hash); + sim.add_coin(bob_coin.clone(), &bob_secrets, cat_meta("bob", "cat for offer")); + + // alice creates conditional spend + let merkle_root = sim.get_merkle_root().unwrap(); + let (alice_path, alice_idx) = sim.get_merkle_path_and_index(&alice_coin).unwrap(); + + let maker_proof = Spender::create_conditional_spend( + &alice_coin, + &offer_puzzle, + &[], + &alice_secrets, + alice_path, + merkle_root, + alice_idx, + None, + ) + .expect("conditional spend"); + + // bob takes the offer — use REAL puzzle hash for goods (derived from chialisp) + let goods_puzzle_source = "(mod (x) x)"; // identity puzzle + let goods_puzzle_hash = compile_chialisp_template_hash_default(goods_puzzle_source).unwrap(); + + let goods_serial = [62u8; 32]; + let goods_rand = [63u8; 32]; + + let (bob_path, bob_idx) = sim.get_merkle_path_and_index(&bob_coin).unwrap(); + + let settlement_params = SettlementParams { + maker_proof: maker_proof.clone(), + taker_coin: bob_coin.clone(), + taker_secrets: bob_secrets.clone(), + taker_merkle_path: bob_path, + merkle_root, + taker_leaf_index: bob_idx, + payment_nonce: [33u8; 32], + taker_goods_puzzle: goods_puzzle_hash, + taker_change_puzzle: [55u8; 32], + payment_serial: [60u8; 32], + payment_rand: [61u8; 32], + goods_serial, + goods_rand, + change_serial: [64u8; 32], + change_rand: [65u8; 32], + taker_tail_hash: cat_tail_hash, + goods_tail_hash: XCH_TAIL, // alice's asset is XCH + }; + + let settlement = prove_settlement(settlement_params).expect("settlement proof"); + sim.process_settlement(&settlement.output).expect("process settlement"); + + // reconstruct the goods coin with CORRECT fields + // goods = alice's asset (XCH), amount = offered (1000) + let goods_secrets = CoinSecrets::new(goods_serial, goods_rand); + let goods_serial_commitment = + clvm_zk_core::coin_commitment::SerialCommitment::compute(&goods_serial, &goods_rand, hash_data); + + let goods_coin = PrivateCoin::new_with_tail( + goods_puzzle_hash, + 1000, // offered amount + goods_serial_commitment, + XCH_TAIL, // goods_tail = alice's XCH + ); + + // verify coin is findable in merkle tree (commitment matches) + let path_result = sim.get_merkle_path_and_index(&goods_coin); + assert!( + path_result.is_some(), + "goods coin must be in merkle tree after settlement" + ); + + // SPEND IT — this is the actual NM-001 regression test + // before the fix: would fail with program_hash mismatch (placeholder program) + // or merkle lookup failure (wrong tail_hash) + let tx = sim + .spend_coins_with_params(vec![( + goods_coin, + goods_puzzle_source.to_string(), + vec![ProgramParameter::Int(42)], + goods_secrets, + )]) + .expect("settlement goods coin should be spendable"); + + assert_eq!(tx.nullifiers.len(), 1); + assert_ne!(tx.nullifiers[0], [0u8; 32]); + assert!(sim.has_nullifier(&tx.nullifiers[0])); +} + +// ============================================================================ +// 8. NM-001 corollary: wrong tail_hash causes merkle lookup failure +// proves the bug mechanism — coin with wrong tail can't be found +// ============================================================================ + +#[test] +fn test_e2e_settlement_wrong_tail_not_in_tree() { + let mut sim = CLVMZkSimulator::new(); + let cat_tail_hash = compile_chialisp_template_hash_default(UNLIMITED_TAIL).unwrap(); + + // same setup as test 7 + let maker_pubkey = [77u8; 32]; + let change_puzzle = [88u8; 32]; + let change_serial = [89u8; 32]; + let change_rand = [90u8; 32]; + + let offer_puzzle = with_standard_conditions(&format!( + r#"(mod () + (c + (list CREATE_COIN + 0x{} + 0 + 0x{} + 0x{}) + (c 1000 (c 500 (c 0x{} ())))))"#, + hex::encode(change_puzzle), + hex::encode(change_serial), + hex::encode(change_rand), + hex::encode(maker_pubkey), + )); + + let offer_puzzle_hash = compile_chialisp_template_hash_default(&offer_puzzle).unwrap(); + let (alice_coin, alice_secrets) = PrivateCoin::new_with_secrets(offer_puzzle_hash, 1000); + sim.add_coin(alice_coin.clone(), &alice_secrets, meta("alice", "xch")); + + let bob_puzzle = with_standard_conditions( + "(mod (out_puzzle out_serial out_rand) + (list (list CREATE_COIN out_puzzle 500 out_serial out_rand)))", + ); + let bob_puzzle_hash = compile_chialisp_template_hash_default(&bob_puzzle).unwrap(); + let (bob_coin, bob_secrets) = + PrivateCoin::new_with_secrets_and_tail(bob_puzzle_hash, 500, cat_tail_hash); + sim.add_coin(bob_coin.clone(), &bob_secrets, cat_meta("bob", "cat")); + + let merkle_root = sim.get_merkle_root().unwrap(); + let (alice_path, alice_idx) = sim.get_merkle_path_and_index(&alice_coin).unwrap(); + + let maker_proof = Spender::create_conditional_spend( + &alice_coin, &offer_puzzle, &[], &alice_secrets, alice_path, merkle_root, alice_idx, None, + ) + .expect("conditional spend"); + + let goods_puzzle_source = "(mod (x) x)"; + let goods_puzzle_hash = compile_chialisp_template_hash_default(goods_puzzle_source).unwrap(); + let goods_serial = [62u8; 32]; + let goods_rand = [63u8; 32]; + + let (bob_path, bob_idx) = sim.get_merkle_path_and_index(&bob_coin).unwrap(); + let settlement = prove_settlement(SettlementParams { + maker_proof, + taker_coin: bob_coin.clone(), + taker_secrets: bob_secrets.clone(), + taker_merkle_path: bob_path, + merkle_root, + taker_leaf_index: bob_idx, + payment_nonce: [33u8; 32], + taker_goods_puzzle: goods_puzzle_hash, + taker_change_puzzle: [55u8; 32], + payment_serial: [60u8; 32], + payment_rand: [61u8; 32], + goods_serial, + goods_rand, + change_serial: [64u8; 32], + change_rand: [65u8; 32], + taker_tail_hash: cat_tail_hash, + goods_tail_hash: XCH_TAIL, + }) + .expect("settlement"); + sim.process_settlement(&settlement.output).expect("process"); + + // reconstruct goods coin with WRONG tail (the old NM-001 bug) + let goods_serial_commitment = + clvm_zk_core::coin_commitment::SerialCommitment::compute(&goods_serial, &goods_rand, hash_data); + let wrong_coin = PrivateCoin::new_with_tail( + goods_puzzle_hash, + 1000, + goods_serial_commitment, + cat_tail_hash, // WRONG — should be XCH_TAIL, using CAT tail instead + ); + + // wrong tail → different commitment → not in tree + assert!( + sim.get_merkle_path_and_index(&wrong_coin).is_none(), + "coin with wrong tail_hash must NOT be found in merkle tree" + ); + + // correct tail → found + let correct_coin = PrivateCoin::new_with_tail( + goods_puzzle_hash, + 1000, + goods_serial_commitment, + XCH_TAIL, + ); + assert!( + sim.get_merkle_path_and_index(&correct_coin).is_some(), + "coin with correct tail_hash must be found in merkle tree" + ); +} diff --git a/tests/test_large_values.rs b/tests/test_large_values.rs index 67d401a..925a102 100644 --- a/tests/test_large_values.rs +++ b/tests/test_large_values.rs @@ -1,6 +1,6 @@ // Test that values >= 128 get correctly encoded with 2-byte CLVM format use clvm_zk::ProgramParameter; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; #[test] fn test_large_value_encoding() -> Result<(), Box> { println!("Testing CLVM encoding for values >= 128...\n"); diff --git a/tests/test_payment_scanning.rs b/tests/test_payment_scanning.rs index 4f90d39..e44e931 100644 --- a/tests/test_payment_scanning.rs +++ b/tests/test_payment_scanning.rs @@ -1,142 +1,105 @@ -/// test payment scanning for ecdh-based coin discovery +/// test hash-based stealth payment scanning #[cfg(feature = "mock")] mod tests { - use clvm_zk::payment_keys::{derive_ecdh_puzzle_hash_from_sender, PaymentKey}; - use clvm_zk::protocol::PrivateCoin; - use clvm_zk_core::coin_commitment::SerialCommitment; + use clvm_zk::payment_keys::{derive_stealth_puzzle_hash, generate_stealth_nonce, PaymentKey}; #[test] - fn test_scan_finds_ecdh_payment() { - // maker has a payment key - let maker_key = PaymentKey::generate(); - - // taker creates payment to maker via ecdh (real ECDH with private key) - let taker_key = PaymentKey::generate(); - let payment_puzzle_hash = derive_ecdh_puzzle_hash_from_sender( - &maker_key.to_pubkey(), - &taker_key.privkey.unwrap(), - ) - .unwrap(); - - // create payment coin with ecdh puzzle_hash - let payment_coin = PrivateCoin { - puzzle_hash: payment_puzzle_hash, - amount: 1000, - serial_commitment: SerialCommitment::compute( - &[0x11; 32], - &[0x22; 32], - clvm_zk::crypto_utils::hash_data_default, - ), - }; - - // create some other coins with different puzzle hashes - let other_coin1 = PrivateCoin { - puzzle_hash: [0x33; 32], - amount: 500, - serial_commitment: SerialCommitment::compute( - &[0x44; 32], - &[0x55; 32], - clvm_zk::crypto_utils::hash_data_default, - ), - }; - - let other_coin2 = PrivateCoin { - puzzle_hash: [0x66; 32], - amount: 250, - serial_commitment: SerialCommitment::compute( - &[0x77; 32], - &[0x88; 32], - clvm_zk::crypto_utils::hash_data_default, - ), - }; - - // maker scans for payments - let coins = vec![ - (other_coin1, [0x02; 32]), // not ours - (payment_coin, taker_key.to_pubkey()), // THIS ONE - ecdh match - (other_coin2, [0x03; 32]), // not ours - ]; - - let spendable = maker_key.scan_for_payments(&coins); - - // should find exactly coin at index 1 - assert_eq!(spendable.len(), 1, "should find exactly 1 spendable coin"); - assert_eq!(spendable[0], 1, "should find coin at index 1"); + fn test_stealth_puzzle_derivation() { + // receiver has a payment key + let receiver = PaymentKey::generate(); + + // sender creates payment with random nonce + let nonce = generate_stealth_nonce(); + let payment_puzzle = derive_stealth_puzzle_hash(&receiver.to_pubkey(), &nonce); + + // receiver can verify with same nonce + assert!( + receiver.can_spend_stealth_coin(&nonce, &payment_puzzle), + "receiver should be able to spend with correct nonce" + ); + + // wrong nonce doesn't work + let wrong_nonce = generate_stealth_nonce(); + assert!( + !receiver.can_spend_stealth_coin(&wrong_nonce, &payment_puzzle), + "wrong nonce should not work" + ); + + // wrong receiver can't spend + let other_receiver = PaymentKey::generate(); + assert!( + !other_receiver.can_spend_stealth_coin(&nonce, &payment_puzzle), + "other receiver should not be able to spend" + ); } #[test] - fn test_scan_multiple_payments() { + fn test_multiple_payments_different_nonces() { let receiver = PaymentKey::generate(); - // multiple senders create payments - let sender1 = PaymentKey::generate(); - let sender2 = PaymentKey::generate(); - let sender3 = PaymentKey::generate(); - - // create ecdh coins from sender1 and sender3 (real ECDH with private keys) - let payment1_puzzle = - derive_ecdh_puzzle_hash_from_sender(&receiver.to_pubkey(), &sender1.privkey.unwrap()) - .unwrap(); - let payment2_puzzle = - derive_ecdh_puzzle_hash_from_sender(&receiver.to_pubkey(), &sender3.privkey.unwrap()) - .unwrap(); - - let payment1 = PrivateCoin::new_with_secrets(payment1_puzzle, 500).0; - let random_coin = PrivateCoin::new_with_secrets([0x99; 32], 300).0; - let payment2 = PrivateCoin::new_with_secrets(payment2_puzzle, 700).0; - - let coins = vec![ - (payment1, sender1.to_pubkey()), // match - (random_coin, sender2.to_pubkey()), // no match - (payment2, sender3.to_pubkey()), // match - ]; - - let spendable = receiver.scan_for_payments(&coins); - - assert_eq!(spendable.len(), 2, "should find 2 spendable coins"); - assert_eq!(spendable[0], 0, "first payment at index 0"); - assert_eq!(spendable[1], 2, "second payment at index 2"); - } + // multiple payments with different nonces + let nonce1 = generate_stealth_nonce(); + let nonce2 = generate_stealth_nonce(); - #[test] - fn test_scan_with_pubkey_only_returns_empty() { - // observer wallet (pubkey only, no privkey) - let pubkey_only = PaymentKey::from_pubkey([0x02; 32]); + let puzzle1 = derive_stealth_puzzle_hash(&receiver.to_pubkey(), &nonce1); + let puzzle2 = derive_stealth_puzzle_hash(&receiver.to_pubkey(), &nonce2); - let coin = PrivateCoin::new_with_secrets([0x11; 32], 1000).0; - let coins = vec![(coin, [0x03; 32])]; + // different nonces produce different puzzles + assert_ne!( + puzzle1, puzzle2, + "different nonces should produce different puzzles" + ); - let spendable = pubkey_only.scan_for_payments(&coins); + // receiver can spend both with correct nonces + assert!(receiver.can_spend_stealth_coin(&nonce1, &puzzle1)); + assert!(receiver.can_spend_stealth_coin(&nonce2, &puzzle2)); - assert_eq!(spendable.len(), 0, "pubkey-only wallet can't spend"); + // but not with swapped nonces + assert!(!receiver.can_spend_stealth_coin(&nonce1, &puzzle2)); + assert!(!receiver.can_spend_stealth_coin(&nonce2, &puzzle1)); } #[test] - fn test_can_spend_ecdh_coin_verification() { - let receiver = PaymentKey::generate(); - let sender = PaymentKey::generate(); + fn test_pubkey_only_can_verify() { + // pubkey-only receiver (for verification, not spending) + let full_key = PaymentKey::generate(); + let pubkey_only = PaymentKey::from_pubkey(full_key.to_pubkey()); - // correct ecdh derivation (real ECDH with sender's private key) - let correct_puzzle = - derive_ecdh_puzzle_hash_from_sender(&receiver.to_pubkey(), &sender.privkey.unwrap()) - .unwrap(); - assert!( - receiver.can_spend_ecdh_coin(&sender.to_pubkey(), &correct_puzzle), - "should be able to spend correct ecdh coin" - ); + let nonce = generate_stealth_nonce(); + let puzzle = derive_stealth_puzzle_hash(&full_key.to_pubkey(), &nonce); - // wrong puzzle_hash - let wrong_puzzle = [0x99; 32]; + // pubkey-only can still verify (stealth is hash-based) assert!( - !receiver.can_spend_ecdh_coin(&sender.to_pubkey(), &wrong_puzzle), - "should not be able to spend wrong puzzle" + pubkey_only.can_spend_stealth_coin(&nonce, &puzzle), + "pubkey-only should be able to verify stealth puzzle" ); + } - // wrong sender pubkey - let other_sender = PaymentKey::generate(); - assert!( - !receiver.can_spend_ecdh_coin(&other_sender.to_pubkey(), &correct_puzzle), - "should not match with wrong sender pubkey" - ); + #[test] + fn test_deterministic_puzzle_derivation() { + let receiver = PaymentKey::generate(); + let nonce = [42u8; 32]; + + // same inputs should produce same puzzle + let puzzle1 = derive_stealth_puzzle_hash(&receiver.to_pubkey(), &nonce); + let puzzle2 = derive_stealth_puzzle_hash(&receiver.to_pubkey(), &nonce); + + assert_eq!(puzzle1, puzzle2, "same inputs should produce same puzzle"); + } + + #[test] + fn test_offer_key_derivation() { + let master = PaymentKey::generate(); + + // derive keys for different offers + let offer0 = master.derive_offer_key(0).unwrap(); + let offer1 = master.derive_offer_key(1).unwrap(); + + // different indices produce different keys + assert_ne!(offer0.to_pubkey(), offer1.to_pubkey()); + + // same index produces same key + let offer0_again = master.derive_offer_key(0).unwrap(); + assert_eq!(offer0.to_pubkey(), offer0_again.to_pubkey()); } } diff --git a/tests/test_precompiled_bytecode.rs b/tests/test_precompiled_bytecode.rs new file mode 100644 index 0000000..5050563 --- /dev/null +++ b/tests/test_precompiled_bytecode.rs @@ -0,0 +1,92 @@ +//! tests to verify hardcoded precompiled bytecode matches source +//! +//! critical security test: if these fail, the guest programs have stale bytecode +//! that doesn't match the source, which could lead to unexpected behavior + +use sha2::{Digest, Sha256}; + +fn sha2_hash(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +// these must match the constants in backends/risc0/guest/src/main.rs +// and backends/sp1/program/src/main.rs +const DELEGATED_PUZZLE_SOURCE: &str = r#"(mod (offered requested maker_pubkey change_amount change_puzzle change_serial change_rand) + (c + (c 51 (c change_puzzle (c change_amount (c change_serial (c change_rand ()))))) + (c offered (c requested (c maker_pubkey ()))) + ) +)"#; + +const DELEGATED_PUZZLE_BYTECODE: &[u8] = &[ + 0xff, 0x02, 0xff, 0xff, 0x01, 0xff, 0x04, 0xff, 0xff, 0x04, 0xff, 0xff, 0x01, 0x33, 0xff, 0xff, + 0x04, 0xff, 0x5f, 0xff, 0xff, 0x04, 0xff, 0x2f, 0xff, 0xff, 0x04, 0xff, 0x82, 0x00, 0xbf, 0xff, + 0xff, 0x04, 0xff, 0x82, 0x01, 0x7f, 0xff, 0xff, 0x01, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xff, + 0xff, 0x04, 0xff, 0x05, 0xff, 0xff, 0x04, 0xff, 0x0b, 0xff, 0xff, 0x04, 0xff, 0x17, 0xff, 0xff, + 0x01, 0x80, 0x80, 0x80, 0x80, 0x80, 0xff, 0xff, 0x04, 0xff, 0xff, 0x01, 0x80, 0xff, 0x01, 0x80, + 0x80, +]; + +const DELEGATED_PUZZLE_HASH: [u8; 32] = [ + 0x26, 0x24, 0x38, 0x09, 0xc2, 0x14, 0xb0, 0x80, 0x00, 0x4c, 0x48, 0x36, 0x05, 0x75, 0x7a, 0xa7, + 0xb3, 0xfc, 0xd5, 0x24, 0x34, 0xad, 0xd2, 0x4f, 0xe8, 0x20, 0x69, 0x7b, 0xfd, 0x8a, 0x63, 0x81, +]; + +#[test] +fn test_delegated_puzzle_bytecode_matches_source() { + // compile the source and verify it matches the hardcoded bytecode + let (compiled_bytecode, compiled_hash) = + clvm_zk_core::compile_chialisp_to_bytecode(sha2_hash, DELEGATED_PUZZLE_SOURCE) + .expect("delegated puzzle should compile"); + + // verify bytecode matches + assert_eq!( + compiled_bytecode, DELEGATED_PUZZLE_BYTECODE, + "CRITICAL: delegated puzzle bytecode mismatch! \ + the hardcoded bytecode in guest programs is stale. \ + run `cargo run --example precompile_delegated` to regenerate." + ); + + // verify hash matches + assert_eq!( + compiled_hash, DELEGATED_PUZZLE_HASH, + "CRITICAL: delegated puzzle hash mismatch! \ + the hardcoded hash in guest programs is stale. \ + run `cargo run --example precompile_delegated` to regenerate." + ); +} + +#[test] +fn test_delegated_puzzle_hash_matches_bytecode() { + // independently verify the hash is correct for the bytecode + let computed_hash = sha2_hash(DELEGATED_PUZZLE_BYTECODE); + assert_eq!( + computed_hash, DELEGATED_PUZZLE_HASH, + "CRITICAL: hardcoded hash doesn't match hardcoded bytecode! \ + the constants are inconsistent." + ); +} + +#[test] +fn test_precompiled_constants_documentation() { + // this test exists to document what the precompiled puzzle does + // and ensure it's correct + + // the delegated puzzle: + // 1. creates a change coin: (51 change_puzzle change_amount change_serial change_rand) + // 2. returns: (offered requested maker_pubkey) for external verification + + // parse to verify structure + let (bytecode, _) = + clvm_zk_core::compile_chialisp_to_bytecode(sha2_hash, DELEGATED_PUZZLE_SOURCE) + .expect("should compile"); + + assert!(!bytecode.is_empty(), "bytecode should not be empty"); + assert_eq!( + bytecode.len(), + 81, + "bytecode length changed - update guest constants" + ); +} diff --git a/tests/test_ring_balance_enforcement.rs b/tests/test_ring_balance_enforcement.rs new file mode 100644 index 0000000..3cf176d --- /dev/null +++ b/tests/test_ring_balance_enforcement.rs @@ -0,0 +1,291 @@ +// test ring spend balance enforcement +// +// CRITICAL: these tests expose a security vulnerability where ring spends +// don't enforce balance checking. the guest verifies merkle membership but +// doesn't check sum(inputs) == sum(outputs). + +use clvm_zk::protocol::{PrivateCoin, Spender}; +use clvm_zk::simulator::*; +use clvm_zk_core::compile_chialisp_template_hash_default; + +// balance enforcement tests require real ZK verification (mock just passes everything) +#[cfg(not(feature = "mock"))] +#[tokio::test] +async fn test_ring_spend_rejects_inflation_attack() { + // EXPLOIT TEST: spend 300 XCH, create 1000 XCH + // this should FAIL but currently PASSES (exposing the bug) + + let mut sim = CLVMZkSimulator::new(); + + // puzzle that creates 1000 XCH output regardless of input + // using transparent 2-arg CREATE_COIN format for testing + let exploit_puzzle = r#"(mod () + (list + (list 51 + (sha256 1) + 1000 + ) + ) + )"#; + + let puzzle_hash = + compile_chialisp_template_hash_default(&exploit_puzzle).expect("puzzle compilation failed"); + + // create 3 coins: 100 + 200 + 150 = 450 XCH input + let (coin1, secrets1) = PrivateCoin::new_with_secrets(puzzle_hash, 100); + let (coin2, secrets2) = PrivateCoin::new_with_secrets(puzzle_hash, 200); + let (coin3, secrets3) = PrivateCoin::new_with_secrets(puzzle_hash, 150); + + sim.add_coin( + coin1.clone(), + &secrets1, + CoinMetadata { + owner: "attacker".to_string(), + coin_type: CoinType::Regular, + notes: "coin1".to_string(), + }, + ); + sim.add_coin( + coin2.clone(), + &secrets2, + CoinMetadata { + owner: "attacker".to_string(), + coin_type: CoinType::Regular, + notes: "coin2".to_string(), + }, + ); + sim.add_coin( + coin3.clone(), + &secrets3, + CoinMetadata { + owner: "attacker".to_string(), + coin_type: CoinType::Regular, + notes: "coin3".to_string(), + }, + ); + + let merkle_root = sim.get_merkle_root().expect("no merkle root"); + let (path1, idx1) = sim.get_merkle_path_and_index(&coin1).expect("no path"); + let (path2, idx2) = sim.get_merkle_path_and_index(&coin2).expect("no path"); + let (path3, idx3) = sim.get_merkle_path_and_index(&coin3).expect("no path"); + + let coins = vec![ + (&coin1, exploit_puzzle, &[][..], &secrets1, path1, idx1), + (&coin2, exploit_puzzle, &[][..], &secrets2, path2, idx2), + (&coin3, exploit_puzzle, &[][..], &secrets3, path3, idx3), + ]; + + let result = Spender::create_ring_spend(coins, merkle_root); + + // this SHOULD fail with balance error + // but will currently SUCCEED (the bug) + match result { + Ok(_) => { + eprintln!("❌ BUG EXPOSED: inflation attack succeeded!"); + eprintln!(" spent 450 XCH, created 1000 XCH"); + eprintln!(" balance enforcement is MISSING"); + panic!("ring spend should reject unbalanced transaction"); + } + Err(e) => { + eprintln!("✓ correctly rejected: {}", e); + assert!( + e.to_string().contains("balance"), + "should fail with balance error" + ); + } + } +} + +#[cfg(not(feature = "mock"))] +#[tokio::test] +async fn test_ring_spend_rejects_no_outputs() { + // spend 300 XCH, create 0 outputs + // should FAIL + + let mut sim = CLVMZkSimulator::new(); + + // puzzle that creates no outputs + let puzzle = "1000"; // just returns number, no CREATE_COIN + + let puzzle_hash = + compile_chialisp_template_hash_default(puzzle).expect("puzzle compilation failed"); + + let (coin1, secrets1) = PrivateCoin::new_with_secrets(puzzle_hash, 100); + let (coin2, secrets2) = PrivateCoin::new_with_secrets(puzzle_hash, 200); + + sim.add_coin( + coin1.clone(), + &secrets1, + CoinMetadata { + owner: "test".to_string(), + coin_type: CoinType::Regular, + notes: "coin1".to_string(), + }, + ); + sim.add_coin( + coin2.clone(), + &secrets2, + CoinMetadata { + owner: "test".to_string(), + coin_type: CoinType::Regular, + notes: "coin2".to_string(), + }, + ); + + let merkle_root = sim.get_merkle_root().expect("no merkle root"); + let (path1, idx1) = sim.get_merkle_path_and_index(&coin1).expect("no path"); + let (path2, idx2) = sim.get_merkle_path_and_index(&coin2).expect("no path"); + + let coins = vec![ + (&coin1, puzzle, &[][..], &secrets1, path1, idx1), + (&coin2, puzzle, &[][..], &secrets2, path2, idx2), + ]; + + let result = Spender::create_ring_spend(coins, merkle_root); + + match result { + Ok(_) => { + eprintln!("❌ BUG: accepted transaction with no outputs"); + panic!("should reject transaction with 0 outputs"); + } + Err(e) => { + eprintln!("✓ correctly rejected: {}", e); + assert!(e.to_string().contains("balance")); + } + } +} + +#[tokio::test] +async fn test_ring_spend_accepts_balanced() { + // spend 300 XCH, create 300 XCH (balanced) + // should PASS + + let mut sim = CLVMZkSimulator::new(); + + // puzzle that creates exactly 300 XCH output (matching 100+200 input) + // using transparent 2-arg CREATE_COIN format + let balanced_puzzle = r#"(mod () + (list + (list 51 + (sha256 1) + 150 + ) + (list 51 + (sha256 2) + 150 + ) + ) + )"#; + + let puzzle_hash = compile_chialisp_template_hash_default(&balanced_puzzle) + .expect("puzzle compilation failed"); + + let (coin1, secrets1) = PrivateCoin::new_with_secrets(puzzle_hash, 100); + let (coin2, secrets2) = PrivateCoin::new_with_secrets(puzzle_hash, 200); + + sim.add_coin( + coin1.clone(), + &secrets1, + CoinMetadata { + owner: "alice".to_string(), + coin_type: CoinType::Regular, + notes: "coin1".to_string(), + }, + ); + sim.add_coin( + coin2.clone(), + &secrets2, + CoinMetadata { + owner: "alice".to_string(), + coin_type: CoinType::Regular, + notes: "coin2".to_string(), + }, + ); + + let merkle_root = sim.get_merkle_root().expect("no merkle root"); + let (path1, idx1) = sim.get_merkle_path_and_index(&coin1).expect("no path"); + let (path2, idx2) = sim.get_merkle_path_and_index(&coin2).expect("no path"); + + let coins = vec![ + (&coin1, balanced_puzzle, &[][..], &secrets1, path1, idx1), + (&coin2, balanced_puzzle, &[][..], &secrets2, path2, idx2), + ]; + + let result = Spender::create_ring_spend(coins, merkle_root); + + match result { + Ok(bundle) => { + eprintln!("✓ balanced transaction accepted"); + eprintln!(" nullifiers: {}", bundle.nullifiers.len()); + } + Err(e) => { + eprintln!("❌ rejected valid balanced transaction: {}", e); + panic!("should accept balanced transaction"); + } + } +} + +#[cfg(not(feature = "mock"))] +#[tokio::test] +async fn test_ring_spend_rejects_deflation() { + // spend 300 XCH, create 100 XCH + // should FAIL (burning without authorization) + + let mut sim = CLVMZkSimulator::new(); + + let deflation_puzzle = r#"(mod () + (list + (list 51 + (sha256 1) + 100 + ) + ) + )"#; + + let puzzle_hash = compile_chialisp_template_hash_default(&deflation_puzzle) + .expect("puzzle compilation failed"); + + let (coin1, secrets1) = PrivateCoin::new_with_secrets(puzzle_hash, 100); + let (coin2, secrets2) = PrivateCoin::new_with_secrets(puzzle_hash, 200); + + sim.add_coin( + coin1.clone(), + &secrets1, + CoinMetadata { + owner: "test".to_string(), + coin_type: CoinType::Regular, + notes: "coin1".to_string(), + }, + ); + sim.add_coin( + coin2.clone(), + &secrets2, + CoinMetadata { + owner: "test".to_string(), + coin_type: CoinType::Regular, + notes: "coin2".to_string(), + }, + ); + + let merkle_root = sim.get_merkle_root().expect("no merkle root"); + let (path1, idx1) = sim.get_merkle_path_and_index(&coin1).expect("no path"); + let (path2, idx2) = sim.get_merkle_path_and_index(&coin2).expect("no path"); + + let coins = vec![ + (&coin1, deflation_puzzle, &[][..], &secrets1, path1, idx1), + (&coin2, deflation_puzzle, &[][..], &secrets2, path2, idx2), + ]; + + let result = Spender::create_ring_spend(coins, merkle_root); + + match result { + Ok(_) => { + eprintln!("❌ BUG: accepted deflation (300 → 100)"); + panic!("should reject unbalanced transaction"); + } + Err(e) => { + eprintln!("✓ correctly rejected: {}", e); + assert!(e.to_string().contains("balance")); + } + } +} diff --git a/tests/test_ring_spend_debug.rs b/tests/test_ring_spend_debug.rs new file mode 100644 index 0000000..7899fb3 --- /dev/null +++ b/tests/test_ring_spend_debug.rs @@ -0,0 +1,329 @@ +// Ring Spend Debug Test +// Tests the multi-coin ring spend path with detailed debug output + +use clvm_zk::protocol::{PrivateCoin, Spender}; +use clvm_zk::simulator::*; +use clvm_zk_core::coin_commitment::XCH_TAIL; +use clvm_zk_core::compile_chialisp_template_hash_default; + +/// Simple test puzzle that returns a fixed value +fn simple_puzzle() -> &'static str { + "1000" +} + +/// Create a CAT tail hash for testing +fn test_cat_tail_hash() -> [u8; 32] { + use sha2::{Digest, Sha256}; + Sha256::digest(b"test_cat_tail_v1").into() +} + +#[test] +fn test_ring_spend_merkle_debug() { + eprintln!("\n\n========================================"); + eprintln!(" RING SPEND MERKLE DEBUG TEST "); + eprintln!("========================================\n"); + + let mut sim = CLVMZkSimulator::new(); + + // Use CAT coins (non-XCH) so we exercise the ring spend path + let tail_hash = test_cat_tail_hash(); + eprintln!("Using CAT tail_hash: {}", hex::encode(tail_hash)); + + let puzzle_code = simple_puzzle(); + let puzzle_hash = + compile_chialisp_template_hash_default(puzzle_code).expect("failed to compile puzzle"); + eprintln!("Puzzle code: {}", puzzle_code); + eprintln!("Puzzle hash: {}", hex::encode(puzzle_hash)); + + // Create 2 CAT coins + let (coin1, secrets1) = PrivateCoin::new_with_secrets_and_tail(puzzle_hash, 100, tail_hash); + let (coin2, secrets2) = PrivateCoin::new_with_secrets_and_tail(puzzle_hash, 200, tail_hash); + + eprintln!("\n--- COIN 1 ---"); + eprintln!(" amount: {}", coin1.amount); + eprintln!(" puzzle_hash: {}", hex::encode(coin1.puzzle_hash)); + eprintln!( + " serial_commitment: {}", + hex::encode(coin1.serial_commitment.as_bytes()) + ); + eprintln!(" tail_hash: {}", hex::encode(coin1.tail_hash)); + eprintln!( + " secrets1.serial_number: {}", + hex::encode(secrets1.serial_number) + ); + + eprintln!("\n--- COIN 2 ---"); + eprintln!(" amount: {}", coin2.amount); + eprintln!(" puzzle_hash: {}", hex::encode(coin2.puzzle_hash)); + eprintln!( + " serial_commitment: {}", + hex::encode(coin2.serial_commitment.as_bytes()) + ); + eprintln!(" tail_hash: {}", hex::encode(coin2.tail_hash)); + eprintln!( + " secrets2.serial_number: {}", + hex::encode(secrets2.serial_number) + ); + + // Add coins to simulator + eprintln!("\n--- ADDING COINS TO SIMULATOR ---"); + sim.add_coin( + coin1.clone(), + &secrets1, + CoinMetadata { + owner: "alice".to_string(), + coin_type: CoinType::Cat, + notes: "CAT coin 1".to_string(), + }, + ); + + sim.add_coin( + coin2.clone(), + &secrets2, + CoinMetadata { + owner: "alice".to_string(), + coin_type: CoinType::Cat, + notes: "CAT coin 2".to_string(), + }, + ); + + // Dump tree state + sim.debug_dump_tree_state(); + + // Verify merkle proofs for each coin individually + eprintln!("\n--- VERIFYING MERKLE PROOFS INDIVIDUALLY ---"); + + let result1 = sim.debug_verify_merkle_path(&coin1, "COIN 1"); + match result1 { + Ok(()) => eprintln!("Coin 1 merkle proof: OK"), + Err(e) => eprintln!("Coin 1 merkle proof: FAILED - {}", e), + } + + let result2 = sim.debug_verify_merkle_path(&coin2, "COIN 2"); + match result2 { + Ok(()) => eprintln!("Coin 2 merkle proof: OK"), + Err(e) => eprintln!("Coin 2 merkle proof: FAILED - {}", e), + } + + // Get merkle paths and root + let merkle_root = sim.get_merkle_root().expect("no merkle root"); + eprintln!("\n--- PREPARING RING SPEND ---"); + eprintln!("merkle_root: {}", hex::encode(merkle_root)); + + let (path1, idx1) = sim + .get_merkle_path_and_index(&coin1) + .expect("no path for coin1"); + let (path2, idx2) = sim + .get_merkle_path_and_index(&coin2) + .expect("no path for coin2"); + + eprintln!("coin1: leaf_index={}, path_len={}", idx1, path1.len()); + eprintln!("coin2: leaf_index={}, path_len={}", idx2, path2.len()); + + // Now attempt the ring spend + eprintln!("\n--- EXECUTING RING SPEND ---"); + + let coins = vec![ + (&coin1, puzzle_code, &[][..], &secrets1, path1, idx1), + (&coin2, puzzle_code, &[][..], &secrets2, path2, idx2), + ]; + + match Spender::create_ring_spend(coins, merkle_root) { + Ok(bundle) => { + eprintln!("\n✓ RING SPEND SUCCEEDED!"); + eprintln!(" nullifiers: {}", bundle.nullifiers.len()); + for (i, n) in bundle.nullifiers.iter().enumerate() { + eprintln!(" [{}]: {}", i, hex::encode(n)); + } + eprintln!(" proof_size: {} bytes", bundle.proof_size()); + } + Err(e) => { + eprintln!("\n✗ RING SPEND FAILED: {:?}", e); + } + } + + eprintln!("\n========================================"); + eprintln!(" TEST COMPLETE "); + eprintln!("========================================\n"); +} + +#[test] +fn test_ring_spend_xch_debug() { + eprintln!("\n\n========================================"); + eprintln!(" RING SPEND XCH DEBUG TEST "); + eprintln!("========================================\n"); + + let mut sim = CLVMZkSimulator::new(); + + // Use XCH coins (tail_hash = all zeros) + let tail_hash = XCH_TAIL; + eprintln!("Using XCH tail_hash: {}", hex::encode(tail_hash)); + + let puzzle_code = simple_puzzle(); + let puzzle_hash = + compile_chialisp_template_hash_default(puzzle_code).expect("failed to compile puzzle"); + + // Create 2 XCH coins + let (coin1, secrets1) = PrivateCoin::new_with_secrets(puzzle_hash, 100); + let (coin2, secrets2) = PrivateCoin::new_with_secrets(puzzle_hash, 200); + + eprintln!("coin1: amount={}, is_xch={}", coin1.amount, coin1.is_xch()); + eprintln!("coin2: amount={}, is_xch={}", coin2.amount, coin2.is_xch()); + + // Add coins to simulator + sim.add_coin( + coin1.clone(), + &secrets1, + CoinMetadata { + owner: "alice".to_string(), + coin_type: CoinType::Regular, + notes: "XCH coin 1".to_string(), + }, + ); + + sim.add_coin( + coin2.clone(), + &secrets2, + CoinMetadata { + owner: "alice".to_string(), + coin_type: CoinType::Regular, + notes: "XCH coin 2".to_string(), + }, + ); + + // Dump tree state + sim.debug_dump_tree_state(); + + // Verify merkle proofs + let _ = sim.debug_verify_merkle_path(&coin1, "XCH COIN 1"); + let _ = sim.debug_verify_merkle_path(&coin2, "XCH COIN 2"); + + // Get merkle paths and root + let merkle_root = sim.get_merkle_root().expect("no merkle root"); + let (path1, idx1) = sim + .get_merkle_path_and_index(&coin1) + .expect("no path for coin1"); + let (path2, idx2) = sim + .get_merkle_path_and_index(&coin2) + .expect("no path for coin2"); + + // Execute ring spend + eprintln!("\n--- EXECUTING XCH RING SPEND ---"); + + let coins = vec![ + (&coin1, puzzle_code, &[][..], &secrets1, path1, idx1), + (&coin2, puzzle_code, &[][..], &secrets2, path2, idx2), + ]; + + match Spender::create_ring_spend(coins, merkle_root) { + Ok(bundle) => { + eprintln!("\n✓ XCH RING SPEND SUCCEEDED!"); + eprintln!(" nullifiers: {}", bundle.nullifiers.len()); + } + Err(e) => { + eprintln!("\n✗ XCH RING SPEND FAILED: {:?}", e); + } + } +} + +#[test] +fn test_single_coin_vs_ring_merkle() { + eprintln!("\n\n========================================"); + eprintln!(" SINGLE vs RING MERKLE COMPARISON "); + eprintln!("========================================\n"); + + let mut sim = CLVMZkSimulator::new(); + + let puzzle_code = simple_puzzle(); + let puzzle_hash = + compile_chialisp_template_hash_default(puzzle_code).expect("failed to compile puzzle"); + + // Create 3 coins + let (coin1, secrets1) = PrivateCoin::new_with_secrets(puzzle_hash, 100); + let (coin2, secrets2) = PrivateCoin::new_with_secrets(puzzle_hash, 200); + let (coin3, secrets3) = PrivateCoin::new_with_secrets(puzzle_hash, 300); + + // Add all coins + sim.add_coin( + coin1.clone(), + &secrets1, + CoinMetadata { + owner: "test".to_string(), + coin_type: CoinType::Regular, + notes: "coin1".to_string(), + }, + ); + sim.add_coin( + coin2.clone(), + &secrets2, + CoinMetadata { + owner: "test".to_string(), + coin_type: CoinType::Regular, + notes: "coin2".to_string(), + }, + ); + sim.add_coin( + coin3.clone(), + &secrets3, + CoinMetadata { + owner: "test".to_string(), + coin_type: CoinType::Regular, + notes: "coin3".to_string(), + }, + ); + + sim.debug_dump_tree_state(); + + // Test single-coin spend first + eprintln!("\n--- SINGLE COIN SPEND (coin1) ---"); + let single_result = sim.spend_coins(vec![( + coin1.clone(), + puzzle_code.to_string(), + secrets1.clone(), + )]); + + match single_result { + Ok(tx) => { + eprintln!( + "✓ Single spend succeeded, nullifier: {}", + hex::encode(&tx.nullifiers[0]) + ); + } + Err(e) => { + eprintln!("✗ Single spend failed: {:?}", e); + } + } + + // Now test ring spend with remaining coins + eprintln!("\n--- RING SPEND (coin2 + coin3) ---"); + let merkle_root = sim.get_merkle_root().expect("no merkle root"); + let (path2, idx2) = sim + .get_merkle_path_and_index(&coin2) + .expect("no path for coin2"); + let (path3, idx3) = sim + .get_merkle_path_and_index(&coin3) + .expect("no path for coin3"); + + eprintln!("After single spend:"); + eprintln!(" coin2: idx={}, path_len={}", idx2, path2.len()); + eprintln!(" coin3: idx={}, path_len={}", idx3, path3.len()); + + // Debug verify the paths + let _ = sim.debug_verify_merkle_path(&coin2, "COIN 2 (after single spend)"); + let _ = sim.debug_verify_merkle_path(&coin3, "COIN 3 (after single spend)"); + + let coins = vec![ + (&coin2, puzzle_code, &[][..], &secrets2, path2, idx2), + (&coin3, puzzle_code, &[][..], &secrets3, path3, idx3), + ]; + + match Spender::create_ring_spend(coins, merkle_root) { + Ok(bundle) => { + eprintln!("\n✓ Ring spend succeeded!"); + eprintln!(" nullifiers: {}", bundle.nullifiers.len()); + } + Err(e) => { + eprintln!("\n✗ Ring spend failed: {:?}", e); + } + } +} diff --git a/tests/test_settlement_api.rs b/tests/test_settlement_api.rs new file mode 100644 index 0000000..d39370b --- /dev/null +++ b/tests/test_settlement_api.rs @@ -0,0 +1,111 @@ +//! verify settlement API exists and compiles +//! +//! this test checks that all the settlement infrastructure is in place: +//! - conditional spend creation (Spender::create_conditional_spend) +//! - settlement proof types (SettlementParams, SettlementOutput) +//! - recursive verification imports work +//! +//! we don't actually generate proofs here, just verify the API compiles + +use clvm_zk::protocol::settlement::{SettlementOutput, SettlementParams, SettlementProof}; +use clvm_zk::protocol::{ProofType, Spender}; + +#[test] +fn test_api_exists() { + // verify types exist + let _proof_type_check = |pt: ProofType| match pt { + ProofType::Transaction => "transaction", + ProofType::ConditionalSpend => "conditional", + ProofType::Settlement => "settlement", + ProofType::Mint => "mint", + }; + + println!("✓ ProofType enum has all four variants"); + + // verify Spender has create_conditional_spend method + let _has_method = Spender::create_conditional_spend; + println!("✓ Spender::create_conditional_spend exists"); + + // verify settlement types exist + let _settlement_output = |o: SettlementOutput| { + ( + o.maker_nullifier, + o.taker_nullifier, + o.maker_change_commitment, + o.payment_commitment, + o.taker_goods_commitment, + o.taker_change_commitment, + ) + }; + println!("✓ SettlementOutput type exists with all fields"); + + let _settlement_params = |_p: SettlementParams| {}; + println!("✓ SettlementParams type exists"); + + let _settlement_proof = |_p: SettlementProof| {}; + println!("✓ SettlementProof type exists"); + + // verify settlement guest exists (compile-time check) + #[cfg(feature = "risc0")] + { + // this will fail to compile if SETTLEMENT_ELF doesn't exist + use clvm_zk_risc0::SETTLEMENT_ELF; + let _ = SETTLEMENT_ELF; + println!("✓ SETTLEMENT_ELF available (risc0)"); + } + + #[cfg(not(feature = "risc0"))] + { + println!("⚠ risc0 feature not enabled, skipping SETTLEMENT_ELF check"); + } + + println!("\n✓ ALL SETTLEMENT API CHECKS PASSED"); + println!(" recursive settlement infrastructure is complete"); +} + +#[test] +fn test_proof_type_differentiation() { + // verify proof types are correctly differentiated + assert_ne!( + ProofType::Transaction as u8, + ProofType::ConditionalSpend as u8 + ); + assert_ne!(ProofType::Transaction as u8, ProofType::Settlement as u8); + assert_ne!(ProofType::Transaction as u8, ProofType::Mint as u8); + assert_ne!( + ProofType::ConditionalSpend as u8, + ProofType::Settlement as u8 + ); + assert_ne!(ProofType::ConditionalSpend as u8, ProofType::Mint as u8); + assert_ne!(ProofType::Settlement as u8, ProofType::Mint as u8); + + println!("✓ proof types have distinct values:"); + println!(" Transaction: {}", ProofType::Transaction as u8); + println!(" ConditionalSpend: {}", ProofType::ConditionalSpend as u8); + println!(" Settlement: {}", ProofType::Settlement as u8); + println!(" Mint: {}", ProofType::Mint as u8); +} + +#[test] +fn test_settlement_output_size() { + // verify SettlementOutput has expected structure + let output = SettlementOutput { + maker_nullifier: [1u8; 32], + taker_nullifier: [2u8; 32], + maker_change_commitment: [3u8; 32], + payment_commitment: [4u8; 32], + taker_goods_commitment: [5u8; 32], + taker_change_commitment: [6u8; 32], + maker_pubkey: [7u8; 32], // for validator to check against offer + }; + + assert_eq!(output.maker_nullifier, [1u8; 32]); + assert_eq!(output.taker_nullifier, [2u8; 32]); + assert_eq!(output.maker_change_commitment, [3u8; 32]); + assert_eq!(output.payment_commitment, [4u8; 32]); + assert_eq!(output.taker_goods_commitment, [5u8; 32]); + assert_eq!(output.taker_change_commitment, [6u8; 32]); + assert_eq!(output.maker_pubkey, [7u8; 32]); + + println!("✓ SettlementOutput has 7 fields (6 commitments + maker_pubkey = 224 bytes)"); +} diff --git a/tests/test_settlement_recursive.rs b/tests/test_settlement_recursive.rs new file mode 100644 index 0000000..4cea7e7 --- /dev/null +++ b/tests/test_settlement_recursive.rs @@ -0,0 +1,226 @@ +//! integration test for recursive settlement proofs +//! +//! verifies: +//! 1. maker creates conditional spend proof (locked) +//! 2. taker creates settlement proof (recursively verifies maker's proof) +//! 3. settlement proof validates correctly + +#[cfg(feature = "mock")] +use clvm_zk::protocol::settlement::{prove_settlement, SettlementParams}; +#[cfg(feature = "mock")] +use clvm_zk::protocol::{PrivateCoin, ProofType, Spender}; +#[cfg(feature = "mock")] +use clvm_zk_core::{ + compile_chialisp_template_hash_default, with_standard_conditions, ProgramParameter, +}; + +#[test] +#[cfg(feature = "mock")] +fn test_settlement_mock() { + println!("\n=== SETTLEMENT MOCK TEST ===\n"); + println!("testing settlement flow with mock backend (logic only, no ZK)"); + + // setup: create two coins (maker + taker) + let maker_amount = 1000u64; + let taker_amount = 500u64; + + let offered = 100u64; + let maker_change = maker_amount - offered; + + // output coin parameters + let change_puzzle = [3u8; 32]; + let change_serial = [4u8; 32]; + let change_rand = [5u8; 32]; + + // maker offer puzzle using proper parameter approach + let offer_puzzle = with_standard_conditions( + "(mod (change_puzzle change_amount change_serial change_rand) + (list (list CREATE_COIN change_puzzle change_amount change_serial change_rand)))", + ); + + let maker_puzzle_hash = + compile_chialisp_template_hash_default(&offer_puzzle).expect("compile puzzle"); + + // taker puzzle - simple balanced spend + let _taker_out_puzzle = [20u8; 32]; + let _taker_serial = [21u8; 32]; + let _taker_rand = [22u8; 32]; + + let taker_puzzle = with_standard_conditions( + "(mod (out_puzzle out_amount out_serial out_rand) + (list (list CREATE_COIN out_puzzle out_amount out_serial out_rand)))", + ); + + let taker_puzzle_hash = + compile_chialisp_template_hash_default(&taker_puzzle).expect("compile taker puzzle"); + + // create coins with correct puzzle hashes + let (maker_coin, maker_secrets) = + PrivateCoin::new_with_secrets(maker_puzzle_hash, maker_amount); + + let (taker_coin, taker_secrets) = + PrivateCoin::new_with_secrets(taker_puzzle_hash, taker_amount); + + println!("maker coin: {} mojos", maker_amount); + println!("taker coin: {} mojos", taker_amount); + + // create simple merkle tree (single leaf for each coin) + let maker_commitment = clvm_zk_core::coin_commitment::CoinCommitment::compute( + &maker_coin.tail_hash, + maker_coin.amount, + &maker_coin.puzzle_hash, + &maker_coin.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + + let taker_commitment = clvm_zk_core::coin_commitment::CoinCommitment::compute( + &taker_coin.tail_hash, + taker_coin.amount, + &taker_coin.puzzle_hash, + &taker_coin.serial_commitment, + clvm_zk::crypto_utils::hash_data_default, + ); + + // for simplicity, use coin commitments as merkle roots (single-leaf tree) + let maker_merkle_root = maker_commitment.0; + let taker_merkle_root = taker_commitment.0; + + // STEP 1: maker creates conditional spend proof + println!("\n--- STEP 1: maker creates conditional spend ---"); + println!("offer: {} mojos change", maker_change); + + // solution parameters for maker puzzle + let maker_params = vec![ + ProgramParameter::Bytes(change_puzzle.to_vec()), + ProgramParameter::Int(maker_change), + ProgramParameter::Bytes(change_serial.to_vec()), + ProgramParameter::Bytes(change_rand.to_vec()), + ]; + + let maker_bundle = Spender::create_conditional_spend( + &maker_coin, + &offer_puzzle, + &maker_params, + &maker_secrets, + vec![], // empty merkle path for single-leaf tree + maker_merkle_root, + 0, + None, + ) + .expect("maker conditional spend failed"); + + println!("✓ maker created conditional spend proof"); + println!(" proof type: {:?}", maker_bundle.proof_type); + println!(" proof size: {} bytes", maker_bundle.zk_proof.len()); + + assert_eq!(maker_bundle.proof_type, ProofType::ConditionalSpend); + assert!(!maker_bundle.nullifiers.is_empty()); + + // STEP 2: taker creates settlement proof (recursively verifies maker's proof) + println!("\n--- STEP 2: taker creates settlement proof ---"); + + // generate random nonce for hash-based stealth address + let mut payment_nonce = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut payment_nonce); + + // generate new coin secrets for settlement outputs + let payment_serial = [10u8; 32]; + let payment_rand = [11u8; 32]; + let goods_serial = [12u8; 32]; + let goods_rand = [13u8; 32]; + let change_serial_taker = [14u8; 32]; + let change_rand_taker = [15u8; 32]; + + let taker_goods_puzzle = [20u8; 32]; + let taker_change_puzzle = [21u8; 32]; + + let settlement_params = SettlementParams { + maker_proof: maker_bundle, + taker_coin, + taker_secrets, + taker_merkle_path: vec![], // empty for single-leaf tree + merkle_root: taker_merkle_root, + taker_leaf_index: 0, + payment_nonce, + taker_goods_puzzle, + taker_change_puzzle, + payment_serial, + payment_rand, + goods_serial, + goods_rand, + change_serial: change_serial_taker, + change_rand: change_rand_taker, + taker_tail_hash: [0u8; 32], // XCH + goods_tail_hash: [0u8; 32], // XCH + }; + + println!("generating settlement proof (recursive verification)..."); + + // settlement proof requires risc0 backend for recursive proving + // mock backend can only test step 1 (conditional spend) + #[cfg(feature = "mock")] + { + match prove_settlement(settlement_params) { + Err(e) if e.to_string().contains("requires risc0") => { + println!( + "⚠ settlement proof skipped (mock backend doesn't support recursive proving)" + ); + println!("\n✓ CONDITIONAL SPEND TEST PASSED (mock backend)"); + println!(" - conditional spend proof: ✓"); + println!(" - settlement proof: skipped (requires risc0)"); + return; + } + Err(e) => panic!("unexpected error: {:?}", e), + Ok(_) => {} // continue with verification if it somehow works + } + } + + #[cfg(not(feature = "mock"))] + let settlement_proof = prove_settlement(settlement_params).expect("settlement proof failed"); + + println!("✓ settlement proof generated successfully"); + #[cfg(not(feature = "mock"))] + println!(" proof type: {:?}", settlement_proof.proof_type); + #[cfg(not(feature = "mock"))] + println!(" proof size: {} bytes", settlement_proof.zk_proof.len()); + + // STEP 3: verify settlement output (only with real backend) + #[cfg(not(feature = "mock"))] + { + println!("\n--- STEP 3: verify settlement output ---"); + + let output = &settlement_proof.output; + + println!("maker nullifier: {}", hex::encode(output.maker_nullifier)); + println!("taker nullifier: {}", hex::encode(output.taker_nullifier)); + println!( + "maker change: {}", + hex::encode(output.maker_change_commitment) + ); + println!( + "payment (T→M): {}", + hex::encode(output.payment_commitment) + ); + println!( + "goods (M→T): {}", + hex::encode(output.taker_goods_commitment) + ); + println!( + "taker change: {}", + hex::encode(output.taker_change_commitment) + ); + + // verify we got valid commitments (non-zero) + assert_ne!(output.maker_nullifier, [0u8; 32]); + assert_ne!(output.taker_nullifier, [0u8; 32]); + assert_ne!(output.maker_change_commitment, [0u8; 32]); + assert_ne!(output.payment_commitment, [0u8; 32]); + assert_ne!(output.taker_goods_commitment, [0u8; 32]); + assert_ne!(output.taker_change_commitment, [0u8; 32]); + + println!("\n✓ SETTLEMENT RECURSIVE PROOF TEST PASSED"); + println!(" - conditional spend: ✓"); + println!(" - recursive verification: ✓"); + println!(" - settlement outputs: ✓"); + } +} diff --git a/tests/test_simulator_create_coin.rs b/tests/test_simulator_create_coin.rs index 7b56454..5ed7e11 100644 --- a/tests/test_simulator_create_coin.rs +++ b/tests/test_simulator_create_coin.rs @@ -1,12 +1,13 @@ -/// test simulator integration with 4-arg 51 (output privacy) +/// test simulator integration with 4-arg CREATE_COIN (output privacy) #[cfg(feature = "mock")] use clvm_zk::protocol::PrivateCoin; #[cfg(feature = "mock")] -use clvm_zk::simulator::*; +use clvm_zk::simulator::{CLVMZkSimulator, CoinMetadata, CoinType}; #[cfg(feature = "mock")] -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; -#[cfg(feature = "mock")] -use clvm_zk_core::coin_commitment::{CoinSecrets, SerialCommitment}; +use clvm_zk_core::{ + compile_chialisp_template_hash_default, with_standard_conditions, CoinSecrets, + SerialCommitment, XCH_TAIL, +}; #[test] #[cfg(feature = "mock")] @@ -14,15 +15,15 @@ fn test_create_and_spend_coins() { let mut sim = CLVMZkSimulator::new(); // program that creates 2 new coins - let alice_program = r#" - (mod (puzzle1 puzzle2 serial1 rand1 serial2 rand2) + let alice_program = with_standard_conditions( + "(mod (puzzle1 puzzle2 serial1 rand1 serial2 rand2) (list - (list 51 puzzle1 600 serial1 rand1) - (list 51 puzzle2 300 serial2 rand2))) - "#; + (list CREATE_COIN puzzle1 600 serial1 rand1) + (list CREATE_COIN puzzle2 300 serial2 rand2)))", + ); // compute actual puzzle hash for alice's coin - let puzzle_hash = compile_chialisp_template_hash_default(alice_program) + let puzzle_hash = compile_chialisp_template_hash_default(&alice_program) .expect("failed to compile alice program"); let (alice_coin, alice_secrets) = PrivateCoin::new_with_secrets(puzzle_hash, 1000); @@ -59,6 +60,7 @@ fn test_create_and_spend_coins() { puzzle_hash: bob_puzzle, amount: 600, serial_commitment: bob_serial_commitment, + tail_hash: XCH_TAIL, }; let charlie_serial: [u8; 32] = rand::random(); @@ -73,6 +75,7 @@ fn test_create_and_spend_coins() { puzzle_hash: charlie_puzzle, amount: 300, serial_commitment: charlie_serial_commitment, + tail_hash: XCH_TAIL, }; let params = vec![ @@ -172,17 +175,17 @@ fn test_create_and_spend_coins() { fn test_create_coin_adds_to_merkle_tree() { let mut sim = CLVMZkSimulator::new(); - // program that creates 2 new coins using 4-arg 51 - let program = r#" - (mod (puzzle1 puzzle2 serial1 rand1 serial2 rand2) + // program that creates 2 new coins using 4-arg CREATE_COIN + let program = with_standard_conditions( + "(mod (puzzle1 puzzle2 serial1 rand1 serial2 rand2) (list - (list 51 puzzle1 500 serial1 rand1) - (list 51 puzzle2 300 serial2 rand2))) - "#; + (list CREATE_COIN puzzle1 500 serial1 rand1) + (list CREATE_COIN puzzle2 300 serial2 rand2)))", + ); // compute actual puzzle hash for alice's coin let puzzle_hash = - compile_chialisp_template_hash_default(program).expect("failed to compile program"); + compile_chialisp_template_hash_default(&program).expect("failed to compile program"); let (alice_coin, alice_secrets) = PrivateCoin::new_with_secrets(puzzle_hash, 1000); @@ -240,7 +243,7 @@ fn test_create_coin_adds_to_merkle_tree() { "nullifier should be in set" ); - println!("✓ simulator integration with 4-arg 51 working"); + println!("✓ simulator integration with 4-arg CREATE_COIN working"); } #[test] @@ -249,15 +252,15 @@ fn test_create_coin_adds_to_merkle_tree() { fn test_create_coin_transparent_mode() { let mut sim = CLVMZkSimulator::new(); - // program using 2-arg 51 (transparent mode) - let program = r#" - (mod (puzzle) - (list (list 51 puzzle 1000))) - "#; + // program using 2-arg CREATE_COIN (transparent mode) + let program = with_standard_conditions( + "(mod (puzzle) + (list (list CREATE_COIN puzzle 1000)))", + ); // compute actual puzzle hash let puzzle_hash = - compile_chialisp_template_hash_default(program).expect("failed to compile program"); + compile_chialisp_template_hash_default(&program).expect("failed to compile program"); let (coin, secrets) = PrivateCoin::new_with_secrets(puzzle_hash, 2000); @@ -282,5 +285,5 @@ fn test_create_coin_transparent_mode() { result.err() ); - println!("✓ transparent mode (2-arg 51) working"); + println!("✓ transparent mode (2-arg CREATE_COIN) working"); } diff --git a/tests/test_transfers.rs b/tests/test_transfers.rs new file mode 100644 index 0000000..53c6172 --- /dev/null +++ b/tests/test_transfers.rs @@ -0,0 +1,505 @@ +/// comprehensive transfer tests for XCH, CAT, and stealth address functionality +use clvm_zk_core::{CoinCommitment, SerialCommitment, XCH_TAIL}; +use sha2::{Digest, Sha256}; + +fn hash_data(data: &[u8]) -> [u8; 32] { + Sha256::digest(data).into() +} + +// ============================================================================ +// XCH TRANSFER TESTS +// ============================================================================ + +#[test] +fn test_xch_commitment_format() { + // v2.0 format: hash("clvm_zk_coin_v2.0" || tail_hash || amount || puzzle_hash || serial_commitment) + let puzzle_hash = [0x42u8; 32]; + let amount = 1000u64; + let serial = SerialCommitment([0x99u8; 32]); + + let commitment = CoinCommitment::compute(&XCH_TAIL, amount, &puzzle_hash, &serial, hash_data); + + // verify commitment is 32 bytes + assert_eq!(commitment.as_bytes().len(), 32); + + // verify determinism + let commitment2 = CoinCommitment::compute(&XCH_TAIL, amount, &puzzle_hash, &serial, hash_data); + assert_eq!(commitment, commitment2); +} + +#[test] +fn test_xch_different_amounts_different_commitments() { + let puzzle_hash = [0x42u8; 32]; + let serial = SerialCommitment([0x99u8; 32]); + + let c1 = CoinCommitment::compute(&XCH_TAIL, 1000, &puzzle_hash, &serial, hash_data); + let c2 = CoinCommitment::compute(&XCH_TAIL, 1001, &puzzle_hash, &serial, hash_data); + + assert_ne!( + c1, c2, + "different amounts must produce different commitments" + ); +} + +#[test] +fn test_xch_different_puzzles_different_commitments() { + let serial = SerialCommitment([0x99u8; 32]); + + let c1 = CoinCommitment::compute(&XCH_TAIL, 1000, &[0x01u8; 32], &serial, hash_data); + let c2 = CoinCommitment::compute(&XCH_TAIL, 1000, &[0x02u8; 32], &serial, hash_data); + + assert_ne!( + c1, c2, + "different puzzles must produce different commitments" + ); +} + +#[test] +fn test_xch_zero_amount_valid() { + // zero-value outputs are valid (used for announcements, etc) + let puzzle_hash = [0x42u8; 32]; + let serial = SerialCommitment([0x99u8; 32]); + + let commitment = CoinCommitment::compute(&XCH_TAIL, 0, &puzzle_hash, &serial, hash_data); + assert_eq!(commitment.as_bytes().len(), 32); +} + +#[test] +fn test_xch_max_amount_valid() { + // max u64 amount should work + let puzzle_hash = [0x42u8; 32]; + let serial = SerialCommitment([0x99u8; 32]); + + let commitment = CoinCommitment::compute(&XCH_TAIL, u64::MAX, &puzzle_hash, &serial, hash_data); + assert_eq!(commitment.as_bytes().len(), 32); +} + +// ============================================================================ +// CAT (COLORED ASSET TOKEN) TESTS +// ============================================================================ + +#[test] +fn test_cat_commitment_differs_from_xch() { + let puzzle_hash = [0x42u8; 32]; + let amount = 1000u64; + let serial = SerialCommitment([0x99u8; 32]); + + // XCH uses zero tail_hash + let xch_commitment = + CoinCommitment::compute(&XCH_TAIL, amount, &puzzle_hash, &serial, hash_data); + + // CAT uses non-zero tail_hash (hash of TAIL program) + let cat_tail = hash_data(b"my_cat_tail_program"); + let cat_commitment = + CoinCommitment::compute(&cat_tail, amount, &puzzle_hash, &serial, hash_data); + + assert_ne!( + xch_commitment, cat_commitment, + "XCH and CAT with same amount/puzzle must have different commitments" + ); +} + +#[test] +fn test_different_cats_different_commitments() { + let puzzle_hash = [0x42u8; 32]; + let amount = 1000u64; + let serial = SerialCommitment([0x99u8; 32]); + + let cat_a_tail = hash_data(b"cat_token_a"); + let cat_b_tail = hash_data(b"cat_token_b"); + + let cat_a = CoinCommitment::compute(&cat_a_tail, amount, &puzzle_hash, &serial, hash_data); + let cat_b = CoinCommitment::compute(&cat_b_tail, amount, &puzzle_hash, &serial, hash_data); + + assert_ne!( + cat_a, cat_b, + "different CAT types must have different commitments" + ); +} + +#[test] +fn test_cat_tail_hash_format() { + // tail_hash should be 32 bytes (sha256 of TAIL program) + let tail_hash = hash_data(b"(mod () (x))"); // simple TAIL + assert_eq!(tail_hash.len(), 32); + + // commitment with this tail should work + let serial = SerialCommitment([0x99u8; 32]); + let commitment = CoinCommitment::compute(&tail_hash, 1000, &[0x42u8; 32], &serial, hash_data); + assert_eq!(commitment.as_bytes().len(), 32); +} + +#[test] +fn test_cat_asset_isolation() { + // same serial, puzzle, amount but different asset must be different commitment + // this prevents cross-asset attacks + let puzzle = [0x42u8; 32]; + let serial = SerialCommitment([0x11u8; 32]); + let amount = 5000u64; + + let xch = CoinCommitment::compute(&XCH_TAIL, amount, &puzzle, &serial, hash_data); + let cat1 = CoinCommitment::compute(&[0x01u8; 32], amount, &puzzle, &serial, hash_data); + let cat2 = CoinCommitment::compute(&[0x02u8; 32], amount, &puzzle, &serial, hash_data); + + // all three must be different + assert_ne!(xch, cat1); + assert_ne!(xch, cat2); + assert_ne!(cat1, cat2); +} + +// ============================================================================ +// SERIAL COMMITMENT TESTS +// ============================================================================ + +#[test] +fn test_serial_commitment_format() { + // v1.0 format: hash("clvm_zk_serial_v1.0" || serial_number || serial_randomness) + let serial_number = [0x11u8; 32]; + let serial_randomness = [0x22u8; 32]; + + let commitment = SerialCommitment::compute(&serial_number, &serial_randomness, hash_data); + + assert_eq!(commitment.as_bytes().len(), 32); +} + +#[test] +fn test_serial_commitment_determinism() { + let serial_number = [0x11u8; 32]; + let serial_randomness = [0x22u8; 32]; + + let c1 = SerialCommitment::compute(&serial_number, &serial_randomness, hash_data); + let c2 = SerialCommitment::compute(&serial_number, &serial_randomness, hash_data); + + assert_eq!(c1, c2, "same inputs must produce same serial commitment"); +} + +#[test] +fn test_serial_commitment_different_randomness() { + let serial_number = [0x11u8; 32]; + + let c1 = SerialCommitment::compute(&serial_number, &[0x22u8; 32], hash_data); + let c2 = SerialCommitment::compute(&serial_number, &[0x33u8; 32], hash_data); + + assert_ne!( + c1, c2, + "different randomness must produce different commitments" + ); +} + +#[test] +fn test_serial_commitment_different_serial_number() { + let serial_randomness = [0x22u8; 32]; + + let c1 = SerialCommitment::compute(&[0x11u8; 32], &serial_randomness, hash_data); + let c2 = SerialCommitment::compute(&[0x12u8; 32], &serial_randomness, hash_data); + + assert_ne!( + c1, c2, + "different serial numbers must produce different commitments" + ); +} + +// ============================================================================ +// NULLIFIER TESTS +// ============================================================================ + +#[test] +fn test_nullifier_format() { + // nullifier = hash(serial_number || program_hash || amount) + let serial_number = [0x11u8; 32]; + let program_hash = [0x42u8; 32]; + let amount = 1000u64; + + let mut data = Vec::with_capacity(72); + data.extend_from_slice(&serial_number); + data.extend_from_slice(&program_hash); + data.extend_from_slice(&amount.to_be_bytes()); + let nullifier = hash_data(&data); + + assert_eq!(nullifier.len(), 32); +} + +#[test] +fn test_nullifier_determinism() { + let serial_number = [0x11u8; 32]; + let program_hash = [0x42u8; 32]; + let amount = 1000u64; + + let compute_nullifier = || { + let mut data = Vec::with_capacity(72); + data.extend_from_slice(&serial_number); + data.extend_from_slice(&program_hash); + data.extend_from_slice(&amount.to_be_bytes()); + hash_data(&data) + }; + + assert_eq!( + compute_nullifier(), + compute_nullifier(), + "nullifier must be deterministic" + ); +} + +#[test] +fn test_nullifier_excludes_randomness() { + // nullifier intentionally excludes serial_randomness + // this prevents linking nullifier to coin_commitment + let serial_number = [0x11u8; 32]; + let program_hash = [0x42u8; 32]; + let amount = 1000u64; + + let mut data = Vec::with_capacity(72); + data.extend_from_slice(&serial_number); + data.extend_from_slice(&program_hash); + data.extend_from_slice(&amount.to_be_bytes()); + let nullifier = hash_data(&data); + + // coin commitment includes serial_randomness, nullifier doesn't + // this is by design for unlinkability + let serial_commitment = SerialCommitment::compute(&serial_number, &[0x22u8; 32], hash_data); + let coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &program_hash, + &serial_commitment, + hash_data, + ); + + // nullifier should NOT be derivable from coin_commitment + // (they use completely different hash structures) + assert_ne!(nullifier, *coin_commitment.as_bytes()); +} + +// ============================================================================ +// COIN COMMITMENT PREIMAGE TESTS (v2.0 format) +// ============================================================================ + +#[test] +fn test_coin_commitment_preimage_size() { + // v2.0: domain(17) || tail_hash(32) || amount(8) || puzzle_hash(32) || serial_commitment(32) = 121 bytes + use clvm_zk_core::coin_commitment::{ + build_coin_commitment_preimage, COIN_COMMITMENT_PREIMAGE_SIZE, + }; + + assert_eq!(COIN_COMMITMENT_PREIMAGE_SIZE, 121); + + let preimage = build_coin_commitment_preimage(&XCH_TAIL, 1000, &[0x42u8; 32], &[0x99u8; 32]); + + assert_eq!(preimage.len(), 121); +} + +#[test] +fn test_coin_commitment_domain_separation() { + use clvm_zk_core::coin_commitment::COIN_COMMITMENT_DOMAIN; + + assert_eq!(COIN_COMMITMENT_DOMAIN, b"clvm_zk_coin_v2.0"); + assert_eq!(COIN_COMMITMENT_DOMAIN.len(), 17); +} + +// ============================================================================ +// STEALTH ADDRESS TESTS (hash-based stealth) +// ============================================================================ + +#[cfg(feature = "mock")] +mod stealth_tests { + use clvm_zk::wallet::stealth::{create_stealth_payment_hd, StealthKeys}; + + #[test] + fn test_stealth_payment_creation() { + let sender = StealthKeys::generate(); + let receiver = StealthKeys::generate(); + let address = receiver.stealth_address(); + + let payment = create_stealth_payment_hd(&sender, 0, &address); + + // payment should have valid puzzle_hash + assert_eq!(payment.puzzle_hash.len(), 32); + + // nonce should be 32 bytes (hash-based stealth) + assert_eq!(payment.nonce.len(), 32); + + // shared_secret should be 32 bytes + assert_eq!(payment.shared_secret.len(), 32); + + // puzzle_source should be non-empty chialisp + assert!(!payment.puzzle_source.is_empty()); + } + + #[test] + fn test_stealth_unlinkability() { + let sender = StealthKeys::generate(); + let receiver = StealthKeys::generate(); + let address = receiver.stealth_address(); + + // multiple payments to same address with different indices + let p1 = create_stealth_payment_hd(&sender, 0, &address); + let p2 = create_stealth_payment_hd(&sender, 1, &address); + let p3 = create_stealth_payment_hd(&sender, 2, &address); + + // nullifier-based stealth: all payments use same trivial puzzle + // unlinkability comes from unique nonces + serial numbers + assert_eq!( + p1.puzzle_hash, p2.puzzle_hash, + "nullifier mode uses constant puzzle" + ); + + // all nonces must be different (source of unlinkability) + assert_ne!(p1.nonce, p2.nonce); + assert_ne!(p2.nonce, p3.nonce); + assert_ne!(p1.nonce, p3.nonce); + + // all shared_secrets must be different (derive unique serial numbers) + assert_ne!(p1.shared_secret, p2.shared_secret); + assert_ne!(p2.shared_secret, p3.shared_secret); + assert_ne!(p1.shared_secret, p3.shared_secret); + } + + #[test] + fn test_stealth_scanning() { + let sender = StealthKeys::generate(); + let receiver = StealthKeys::generate(); + let wrong_receiver = StealthKeys::generate(); + + let payment = create_stealth_payment_hd(&sender, 0, &receiver.stealth_address()); + + // correct receiver finds it via try_scan_with_nonce + let view_key = receiver.view_only(); + let found = view_key.try_scan_with_nonce(&payment.puzzle_hash, &payment.nonce); + assert!(found.is_some()); + + // wrong receiver doesn't find it (different shared secret) + let wrong_view = wrong_receiver.view_only(); + let found_wrong = wrong_view.try_scan_with_nonce(&payment.puzzle_hash, &payment.nonce); + // hash-based stealth always "finds" (derives a shared secret), but spend auth won't work + assert!(found_wrong.is_some()); // different from ECDH - we derive a secret but it's wrong + } + + #[test] + fn test_stealth_spend_auth_derivation() { + let sender = StealthKeys::generate(); + let receiver = StealthKeys::generate(); + let payment = create_stealth_payment_hd(&sender, 0, &receiver.stealth_address()); + + // scan to get shared_secret + let view_key = receiver.view_only(); + let scanned = view_key + .try_scan_with_nonce(&payment.puzzle_hash, &payment.nonce) + .expect("should find coin"); + + // derive spend authorization (contains serial/randomness for nullifier) + let spend_auth = receiver.get_spend_auth(&scanned.shared_secret); + let coin_secrets = spend_auth.to_coin_secrets(); + + // coin secrets should be 32 bytes each + assert_eq!(coin_secrets.serial_number.len(), 32); + assert_eq!(coin_secrets.serial_randomness.len(), 32); + } + + #[test] + fn test_stealth_view_only_keys() { + let receiver = StealthKeys::generate(); + let view_only = receiver.view_only(); + + // view_only has view_privkey but only spend_PUBKEY (32-byte hash-based) + assert_eq!(view_only.view_privkey.len(), 32); + assert_eq!(view_only.spend_pubkey.len(), 32); // hash-based pubkey, not EC + + // can derive shared secrets but cannot derive spend auth (needs full keys) + } + + #[test] + fn test_stealth_deterministic_from_seed() { + let seed = b"my wallet seed phrase backup"; + + let keys1 = StealthKeys::from_seed(seed); + let keys2 = StealthKeys::from_seed(seed); + + assert_eq!(keys1.view_privkey, keys2.view_privkey); + assert_eq!(keys1.spend_privkey, keys2.spend_privkey); + + // different seed = different keys + let keys3 = StealthKeys::from_seed(b"different seed"); + assert_ne!(keys1.view_privkey, keys3.view_privkey); + } +} + +// ============================================================================ +// INTEGRATION: COMMITMENT CHAIN VERIFICATION +// ============================================================================ + +#[test] +fn test_full_commitment_chain() { + // simulate full coin creation flow: + // 1. generate serial secrets + // 2. compute serial_commitment + // 3. compute coin_commitment + // 4. verify chain is deterministic + + let serial_number = [0x11u8; 32]; + let serial_randomness = [0x22u8; 32]; + let puzzle_hash = [0x42u8; 32]; + let amount = 1000u64; + + // step 1: serial commitment + let serial_commitment = + SerialCommitment::compute(&serial_number, &serial_randomness, hash_data); + + // step 2: coin commitment (XCH) + let coin_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &puzzle_hash, + &serial_commitment, + hash_data, + ); + + // step 3: nullifier (for spending) + let mut nullifier_data = Vec::with_capacity(72); + nullifier_data.extend_from_slice(&serial_number); + nullifier_data.extend_from_slice(&puzzle_hash); + nullifier_data.extend_from_slice(&amount.to_be_bytes()); + let nullifier = hash_data(&nullifier_data); + + // all outputs should be 32 bytes + assert_eq!(serial_commitment.as_bytes().len(), 32); + assert_eq!(coin_commitment.as_bytes().len(), 32); + assert_eq!(nullifier.len(), 32); + + // all should be deterministic + let serial2 = SerialCommitment::compute(&serial_number, &serial_randomness, hash_data); + let coin2 = CoinCommitment::compute(&XCH_TAIL, amount, &puzzle_hash, &serial2, hash_data); + + assert_eq!(serial_commitment, serial2); + assert_eq!(coin_commitment, coin2); +} + +#[test] +fn test_cat_commitment_chain() { + // same as above but for CAT + let serial_number = [0x11u8; 32]; + let serial_randomness = [0x22u8; 32]; + let puzzle_hash = [0x42u8; 32]; + let amount = 1000u64; + let cat_tail = hash_data(b"my_stablecoin_tail"); + + let serial_commitment = + SerialCommitment::compute(&serial_number, &serial_randomness, hash_data); + let coin_commitment = CoinCommitment::compute( + &cat_tail, + amount, + &puzzle_hash, + &serial_commitment, + hash_data, + ); + + // must differ from XCH with same parameters + let xch_commitment = CoinCommitment::compute( + &XCH_TAIL, + amount, + &puzzle_hash, + &serial_commitment, + hash_data, + ); + + assert_ne!(coin_commitment, xch_commitment); +} diff --git a/tests/verification_security_tests.rs b/tests/verification_security_tests.rs index 260fc09..411557c 100644 --- a/tests/verification_security_tests.rs +++ b/tests/verification_security_tests.rs @@ -9,7 +9,7 @@ #![cfg(any(feature = "sp1", feature = "risc0"))] use clvm_zk::{ClvmZkProver, ProgramParameter}; -use clvm_zk_core::chialisp::compile_chialisp_template_hash_default; +use clvm_zk_core::compile_chialisp_template_hash_default; #[cfg(test)] mod verification_security_tests {