From f49b1d79c81a8e0e16896cdc750c612732a725a7 Mon Sep 17 00:00:00 2001 From: Ranjna Ganesh Ram Date: Thu, 18 Jun 2026 18:54:28 +0530 Subject: [PATCH] feat(wasm-privacy-coin): support for merkle tree and java packaging Ticket: CSHLD-1061 --- .github/scripts/detect-changes.mjs | 2 +- .github/workflows/build-and-test.yaml | 42 + package-lock.json | 8 + packages/wasm-privacy-coin/.gitignore | 4 + packages/wasm-privacy-coin/.releaserc.json | 7 + packages/wasm-privacy-coin/Cargo.lock | 1061 +++++++++++++++++ packages/wasm-privacy-coin/Cargo.toml | 33 + packages/wasm-privacy-coin/Makefile | 28 + packages/wasm-privacy-coin/README.md | 381 ++++++ packages/wasm-privacy-coin/build.rs | 7 + packages/wasm-privacy-coin/package.json | 13 + packages/wasm-privacy-coin/pom.xml | 76 ++ .../proto/privacy_coin.proto | 45 + packages/wasm-privacy-coin/src/lib.rs | 265 ++++ .../wasm/privacycoin/MerkleTreeInfo.java | 14 + .../bitgo/wasm/privacycoin/WasmException.java | 16 + .../privacycoin/zcash/ShieldedCommitment.java | 49 + .../privacycoin/zcash/ShieldedMerkleTree.java | 203 ++++ .../wasm/privacycoin/zcash/ShieldedRoot.java | 49 + .../wasm/privacycoin/zcash/TreeState.java | 40 + .../wasm/privacycoin/zcash/WasmBridge.java | 121 ++ .../wasm/privacycoin/MerkleTreeInfoTest.java | 37 + .../wasm/privacycoin/WasmExceptionTest.java | 35 + .../zcash/ShieldedMerkleTreeTest.java | 421 +++++++ packages/wasm-privacy-coin/src/zcash/mod.rs | 1 + packages/wasm-privacy-coin/src/zcash/tree.rs | 516 ++++++++ 26 files changed, 3473 insertions(+), 1 deletion(-) create mode 100644 packages/wasm-privacy-coin/.gitignore create mode 100644 packages/wasm-privacy-coin/.releaserc.json create mode 100644 packages/wasm-privacy-coin/Cargo.lock create mode 100644 packages/wasm-privacy-coin/Cargo.toml create mode 100644 packages/wasm-privacy-coin/Makefile create mode 100644 packages/wasm-privacy-coin/README.md create mode 100644 packages/wasm-privacy-coin/build.rs create mode 100644 packages/wasm-privacy-coin/package.json create mode 100644 packages/wasm-privacy-coin/pom.xml create mode 100644 packages/wasm-privacy-coin/proto/privacy_coin.proto create mode 100644 packages/wasm-privacy-coin/src/lib.rs create mode 100644 packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/MerkleTreeInfo.java create mode 100644 packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/WasmException.java create mode 100644 packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedCommitment.java create mode 100644 packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTree.java create mode 100644 packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedRoot.java create mode 100644 packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/TreeState.java create mode 100644 packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/WasmBridge.java create mode 100644 packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/MerkleTreeInfoTest.java create mode 100644 packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/WasmExceptionTest.java create mode 100644 packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTreeTest.java create mode 100644 packages/wasm-privacy-coin/src/zcash/mod.rs create mode 100644 packages/wasm-privacy-coin/src/zcash/tree.rs diff --git a/.github/scripts/detect-changes.mjs b/.github/scripts/detect-changes.mjs index e3b05504f99..2f27c3c6ffc 100644 --- a/.github/scripts/detect-changes.mjs +++ b/.github/scripts/detect-changes.mjs @@ -2,7 +2,7 @@ import { execSync } from 'child_process'; import { appendFileSync } from 'fs'; -const ALL_PACKAGES = ['wasm-bip32', 'wasm-mps', 'wasm-utxo', 'wasm-solana', 'wasm-dot', 'wasm-ton']; +const ALL_PACKAGES = ['wasm-bip32', 'wasm-mps', 'wasm-utxo', 'wasm-solana', 'wasm-dot', 'wasm-ton', 'wasm-privacy-coin']; function setOutput(packages) { const value = JSON.stringify(packages); diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 593053b467e..dccd92fd442 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -64,6 +64,7 @@ jobs: packages/wasm-solana packages/wasm-dot packages/wasm-ton + packages/wasm-privacy-coin cache-on-failure: true - name: Setup Node @@ -101,6 +102,23 @@ jobs: - name: Build packages run: npm --workspaces run build + - name: Setup JDK 17 (for wasm-privacy-coin JAR) + uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: "17" + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('packages/wasm-privacy-coin/pom.xml') }} + restore-keys: ${{ runner.os }}-maven- + + - name: Build wasm-privacy-coin JAR + working-directory: packages/wasm-privacy-coin + run: make jar + - name: Check Source Code Formatting run: npm run check-fmt @@ -121,6 +139,7 @@ jobs: packages/wasm-dot/js/wasm/ packages/wasm-ton/dist/ packages/wasm-ton/js/wasm/ + packages/wasm-privacy-coin/dist/ retention-days: 1 - name: Upload webui artifact @@ -156,6 +175,9 @@ jobs: - package: wasm-ton needs-wasm-pack: false has-wasm-pack-tests: false + - package: wasm-privacy-coin + needs-wasm-pack: false + has-wasm-pack-tests: false steps: - uses: actions/checkout@v4 with: @@ -203,6 +225,26 @@ jobs: run: cargo test --workspace working-directory: packages/${{ matrix.package }} + - name: Cache Maven dependencies + if: matrix.package == 'wasm-privacy-coin' + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('packages/wasm-privacy-coin/pom.xml') }} + restore-keys: ${{ runner.os }}-maven- + + - name: Setup JDK 17 (wasm-privacy-coin Java tests) + if: matrix.package == 'wasm-privacy-coin' + uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: "17" + + - name: Java Test + if: matrix.package == 'wasm-privacy-coin' + working-directory: packages/wasm-privacy-coin + run: make test-java + - name: Wasm-Pack Test (Node) if: matrix.has-wasm-pack-tests run: npm run test:wasm-pack-node diff --git a/package-lock.json b/package-lock.json index 84fd9b209b3..5ebbe3575ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -248,6 +248,10 @@ "resolved": "packages/wasm-mps", "link": true }, + "node_modules/@bitgo/wasm-privacy-coin": { + "resolved": "packages/wasm-privacy-coin", + "link": true + }, "node_modules/@bitgo/wasm-solana": { "resolved": "packages/wasm-solana", "link": true @@ -22355,6 +22359,10 @@ "node": ">=14.17" } }, + "packages/wasm-privacy-coin": { + "name": "@bitgo/wasm-privacy-coin", + "version": "0.0.0-semantic-release-managed" + }, "packages/wasm-solana": { "name": "@bitgo/wasm-solana", "version": "0.0.1", diff --git a/packages/wasm-privacy-coin/.gitignore b/packages/wasm-privacy-coin/.gitignore new file mode 100644 index 00000000000..514a36c464b --- /dev/null +++ b/packages/wasm-privacy-coin/.gitignore @@ -0,0 +1,4 @@ +src/main/resources/wasm/ +target/ +dist/ +.flattened-pom.xml \ No newline at end of file diff --git a/packages/wasm-privacy-coin/.releaserc.json b/packages/wasm-privacy-coin/.releaserc.json new file mode 100644 index 00000000000..33ae552bf22 --- /dev/null +++ b/packages/wasm-privacy-coin/.releaserc.json @@ -0,0 +1,7 @@ +{ + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/github", { "failComment": false }] + ] +} diff --git a/packages/wasm-privacy-coin/Cargo.lock b/packages/wasm-privacy-coin/Cargo.lock new file mode 100644 index 00000000000..26c420453b8 --- /dev/null +++ b/packages/wasm-privacy-coin/Cargo.lock @@ -0,0 +1,1061 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "corez" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df6f98652d30167eaeea34d77b730e07c8caba6df17bd4551842b9b8da01deb" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core", + "subtle", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fpe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c4b37de5ae15812a764c958297cfc50f5c010438f60c6ce75d11b802abd404" +dependencies = [ + "cbc", + "cipher", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "halo2_poseidon" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa3da60b81f02f9b33ebc6252d766f843291fb4d2247a07ae73d20b791fc56f" +dependencies = [ + "bitvec", + "ff", + "group", + "pasta_curves", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "incrementalmerkletree" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30821f91f0fa8660edca547918dc59812893b497d07c1144f326f07fdd94aba9" +dependencies = [ + "either", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jubjub" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8499f7a74008aafbecb2a2e608a3e13e4dd3e84df198b604451efe93f2de6e61" +dependencies = [ + "bitvec", + "bls12_381", + "ff", + "group", + "rand_core", + "subtle", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memuse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d97bbf43eb4f088f8ca469930cde17fa036207c9a5e02ccc5107c4e8b17c964" + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nonempty" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "orchard" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a54f8d29bfb1e76a9d4e868a1a08cce2e57dd2bdc66232982822ad3114b91ab3" +dependencies = [ + "aes", + "bitvec", + "blake2b_simd", + "corez", + "ff", + "fpe", + "getset", + "group", + "halo2_poseidon", + "hex", + "incrementalmerkletree", + "lazy_static", + "memuse", + "nonempty", + "pasta_curves", + "rand", + "rand_core", + "reddsa", + "serde", + "sinsemilla", + "subtle", + "tracing", + "visibility", + "zcash_note_encryption", + "zcash_spec", + "zip32", +] + +[[package]] +name = "pasta_curves" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "lazy_static", + "rand", + "static_assertions", + "subtle", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "reddsa" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4784b85c8bfd17b36b86e664e6e504ecdb586001086ee23749e4a633bbb84832" +dependencies = [ + "blake2b_simd", + "byteorder", + "group", + "jubjub", + "pasta_curves", + "rand_core", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shardtree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" +dependencies = [ + "bitflags", + "either", + "incrementalmerkletree", + "tracing", +] + +[[package]] +name = "sinsemilla" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d268ae0ea06faafe1662e9967cd4f9022014f5eeb798e0c302c876df8b7af9c" +dependencies = [ + "group", + "pasta_curves", + "subtle", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-privacy-coin" +version = "0.1.0" +dependencies = [ + "hex", + "incrementalmerkletree", + "orchard", + "prost", + "prost-build", + "protoc-bin-vendored", + "serde", + "serde_json", + "shardtree", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zcash_note_encryption" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77efec759c3798b6e4d829fcc762070d9b229b0f13338c40bf993b7b609c2272" +dependencies = [ + "chacha20", + "chacha20poly1305", + "cipher", + "rand_core", + "subtle", +] + +[[package]] +name = "zcash_spec" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded3f58b93486aa79b85acba1001f5298f27a46489859934954d262533ee2915" +dependencies = [ + "blake2b_simd", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zip32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b64bf5186a8916f7a48f2a98ef599bf9c099e2458b36b819e393db1c0e768c4b" +dependencies = [ + "bech32", + "blake2b_simd", + "memuse", + "subtle", + "zcash_spec", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/wasm-privacy-coin/Cargo.toml b/packages/wasm-privacy-coin/Cargo.toml new file mode 100644 index 00000000000..73b1e23bf68 --- /dev/null +++ b/packages/wasm-privacy-coin/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "wasm-privacy-coin" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Core tree crates — pinned to exact versions for deterministic builds. +shardtree = "=0.6.2" +incrementalmerkletree = "=0.8.2" +orchard = { version = "=0.14.0", default-features = false } + +# Protobuf encode/decode for the Java↔WASM wire format. +prost = { version = "0.13", default-features = false, features = ["prost-derive"] } + +# serde + serde_json: used for the persistence wire format (PersistedShardTreeState JSON). +# The Java↔WASM boundary uses protobuf, but save()/from_state() serialise the in-memory +# shardtree to JSON so it can survive JVM restarts. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# hex: used to encode/decode MerkleHashOrchard bytes in the serialised shard/cap nodes. +hex = "0.4" + +[build-dependencies] +prost-build = "0.13" +protoc-bin-vendored = "3" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/packages/wasm-privacy-coin/Makefile b/packages/wasm-privacy-coin/Makefile new file mode 100644 index 00000000000..f651a7ffc57 --- /dev/null +++ b/packages/wasm-privacy-coin/Makefile @@ -0,0 +1,28 @@ +WASM_TARGET = wasm32-unknown-unknown +WASM_DIST = dist/wasm-privacy-coin.wasm +WASM_RES = src/main/resources/wasm/privacy_coin.wasm + +.PHONY: build +build: + cargo build --target $(WASM_TARGET) --release + mkdir -p dist + cp target/$(WASM_TARGET)/release/wasm_privacy_coin.wasm $(WASM_DIST) + +.PHONY: jar +jar: build + mkdir -p src/main/resources/wasm + cp $(WASM_DIST) $(WASM_RES) + mvn package -DskipTests + cp target/wasm-privacy-coin-*.jar dist/wasm-privacy-coin.jar + +.PHONY: test-java +test-java: + mkdir -p src/main/resources/wasm + cp $(WASM_DIST) $(WASM_RES) + mvn test + +.PHONY: clean +clean: + cargo clean + rm -rf dist src/main/resources/wasm + mvn clean 2>/dev/null || true diff --git a/packages/wasm-privacy-coin/README.md b/packages/wasm-privacy-coin/README.md new file mode 100644 index 00000000000..13c94c59b4b --- /dev/null +++ b/packages/wasm-privacy-coin/README.md @@ -0,0 +1,381 @@ +# wasm-privacy-coin + +Shielded commitment tree operations (Zcash Orchard, NU6) compiled to a WebAssembly +**cdylib** binary and wrapped with a typed Java API for use by the `indexer-utxo` service. + +The Rust core implements the Orchard `ShardTree` from the `shardtree` crate. It is +compiled to `wasm32-unknown-unknown` and embedded in a JAR. The Java layer loads it +via [Chicory](https://github.com/dylibso/chicory) (pure-JVM WASM runtime, no +native/JNI). All type marshaling between Java and Rust uses **protobuf** — messages +are defined in `proto/privacy_coin.proto` and code is generated at build time by +`prost` (Rust) and `protobuf-maven-plugin` (Java). + +--- + +## Table of contents + +1. [Prerequisites](#prerequisites) +2. [Project structure](#project-structure) +3. [Build targets](#build-targets) +4. [Architecture](#architecture) +5. [Java API reference](#java-api-reference) +6. [Java usage examples](#java-usage-examples) +7. [Protobuf interface](#protobuf-interface) +8. [Error codes](#error-codes) +9. [Pinned dependencies](#pinned-dependencies) + +--- + +## Prerequisites + +| Tool | Version | Notes | +| ------------------------------- | -------------------- | ------------------------------------------ | +| Rust toolchain | `nightly-2025-10-23` | via `rustup` | +| `wasm32-unknown-unknown` target | — | `rustup target add wasm32-unknown-unknown` | +| Java | 17 | required by the Maven build | +| Maven | 3.9+ | invoked through `make` targets | + +No `cargo-component` or native Wasmtime installation required. + +--- + +## Project structure + +``` +wasm-privacy-coin/ +├── proto/ +│ └── privacy_coin.proto # Single source of truth for the wire format +├── src/ +│ ├── lib.rs # #[no_mangle] WASM exports, protobuf encode/decode +│ └── zcash/ +│ ├── mod.rs # Module declaration +│ └── tree.rs # OwnedTree, ShardTree logic, serialization +├── src/main/java/com/bitgo/wasm/privacycoin/ +│ ├── MerkleTreeInfo.java # Immutable value type for tree metadata +│ ├── WasmException.java # Runtime exception with typed error codes +│ └── zcash/ +│ ├── WasmBridge.java # Low-level Chicory bridge (package-private) +│ ├── ShieldedMerkleTree.java # Public high-level Java API +│ ├── ShieldedCommitment.java # Immutable 32-byte cmx value type +│ ├── ShieldedRoot.java # Immutable 32-byte Merkle root value type +│ └── TreeState.java # Opaque serialized tree state (save/fromState) +├── src/test/java/com/bitgo/wasm/privacycoin/zcash/ +│ └── ShieldedMerkleTreeTest.java # JUnit 5 integration tests +├── build.rs # Runs prost-build to generate Rust proto bindings +├── Cargo.toml +├── pom.xml +└── Makefile +``` + +--- + +## Build targets + +All build steps are driven by `make`. + +| Target | What it does | +| ---------------- | --------------------------------------------------------------------------------- | +| `make build` | Compiles Rust → WASM binary at `dist/wasm-privacy-coin.wasm` | +| `make jar` | Runs `build`, embeds the WASM into the JAR, produces `dist/wasm-privacy-coin.jar` | +| `make test-java` | Copies WASM into resources, runs JUnit 5 tests via Maven | +| `make clean` | Removes `dist/`, `target/`, and the embedded WASM resource | + +### Quick start + +```bash +# 1. Add the WASM compile target (once per machine) +rustup target add wasm32-unknown-unknown + +# 2. Compile and package +make jar + +# 3. Run tests +make test-java +``` + +The compiled WASM binary lands at `dist/wasm-privacy-coin.wasm` and is also copied +to `src/main/resources/wasm/privacy_coin.wasm` so it is embedded in the JAR at +`/wasm/privacy_coin.wasm` on the classpath. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Java (indexer-utxo) │ +│ │ +│ ShieldedMerkleTree ← public API │ +│ uses ShieldedCommitment / ShieldedRoot / TreeState │ +│ │ proto-encoded request bytes │ +│ WasmBridge ← package-private │ +│ holds Chicory Instance (per-instance, pure-JVM) │ +│ │ alloc/write/call/dealloc/read via linear memory │ +└─────────┼────────────────────────────────────────────────────────┘ + │ WASM binary boundary (wasm32-unknown-unknown cdylib) +┌─────────┴────────────────────────────────────────────────────────┐ +│ WASM module │ +│ │ +│ lib.rs (#[no_mangle] exports, prost decode/encode) │ +│ └→ zcash/tree.rs (OwnedTree / ShieldedShardTree) │ +│ shardtree + incrementalmerkletree + orchard crates │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Protobuf wire format.** The contract is declared in `proto/privacy_coin.proto`. +`prost-build` generates Rust bindings at compile time; `protobuf-maven-plugin` +generates Java bindings during `mvn generate-sources` (downloads `protoc` automatically, +no system install needed). Each call writes a proto-encoded request into WASM linear +memory and reads a `Response` proto from the `LAST_RESULT` buffer. + +**Persistence.** `save()` / `fromState()` use serde JSON internally (the +`PersistedShardTreeState` format). This is the on-disk/DB format, not the Java↔WASM +wire format. The Java layer sees it as opaque `TreeState` bytes. + +**One instance = one tree.** Each `ShieldedMerkleTree` owns a dedicated Chicory +`Instance` with its own WASM linear memory. Two instances never share state. + +**Thread safety.** `ShieldedMerkleTree` is not thread-safe. Use one instance per +thread or add external synchronization. + +--- + +## Java API reference + +### `ShieldedMerkleTree` (public) + +`com.bitgo.wasm.privacycoin.zcash.ShieldedMerkleTree` + +Implements `AutoCloseable`. Always use in try-with-resources. + +#### Factory methods + +| Method | Description | +| -------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `static fromFrontier(byte[] frontier, long blockHeight)` | Initialize from a CommitmentTree v0 frontier (raw bytes from `z_gettreestate`) | +| `static fromState(TreeState state)` | Restore from a `TreeState` previously returned by `save()` | + +#### Instance methods + +| Method | Returns | Description | +| ------------------------------------------------------------------------------------------------------ | ---------------- | --------------------------------------------------------------- | +| `ping()` | `void` | Verifies the WASM module is alive | +| `appendCommitments(long blockHeight, List commitments)` | `ShieldedRoot` | Append cmx values, checkpoint the tree, return the new root | +| `appendCommitments(long blockHeight, List commitments, ShieldedRoot expectedRoot)` | `ShieldedRoot` | Same, with optional root verification | +| `truncateToCheckpoint(long blockHeight)` | `ShieldedRoot` | Roll back to a prior checkpoint, return the root at that height | +| `save()` | `TreeState` | Serialize tree state for persistence | +| `getInfo()` | `MerkleTreeInfo` | Return tip height, leaf count, checkpoint count | +| `close()` | `void` | Drop the in-WASM tree and release the Chicory instance | + +**`blockHeight`** must be in the range `[0, 4_294_967_295]` (Rust `u32`). Passing a +negative value or a value above `0xFFFFFFFFL` throws `IllegalArgumentException` +immediately in Java, before any WASM call. + +--- + +### `ShieldedCommitment` (public) + +`com.bitgo.wasm.privacycoin.zcash.ShieldedCommitment` + +Immutable 32-byte Orchard note commitment value (the `cmx` field of an Orchard output +description). A valid Pallas base field element in little-endian byte order. + +```java +ShieldedCommitment cmx = ShieldedCommitment.of(rawBytes); // throws IAE if not 32 bytes +byte[] back = cmx.bytes(); // defensive copy +``` + +--- + +### `ShieldedRoot` (public) + +`com.bitgo.wasm.privacycoin.zcash.ShieldedRoot` + +Immutable 32-byte Orchard Merkle tree root. + +--- + +### `TreeState` (public) + +`com.bitgo.wasm.privacycoin.zcash.TreeState` + +Opaque serialized tree state returned by `save()` and accepted by `fromState()`. The +internal format is UTF-8 JSON (`PersistedShardTreeState`), but callers should treat it +as an opaque blob. + +```java +TreeState state = tree.save(); +byte[] blob = state.bytes(); // store in DB +TreeState restored = TreeState.of(blob); // load from DB +``` + +--- + +### `MerkleTreeInfo` (public) + +`com.bitgo.wasm.privacycoin.MerkleTreeInfo` + +Immutable snapshot returned by `getInfo()`. + +| Field | Type | Description | +| ----------------- | ------ | ------------------------------------------------------------------------ | +| `tipHeight` | `Long` | Most recently checkpointed block height; `null` if no block appended yet | +| `leafCount` | `long` | Total Orchard commitments appended across all blocks | +| `checkpointCount` | `int` | Number of checkpoints currently retained (max 100) | + +--- + +### `WasmException` (public) + +`com.bitgo.wasm.privacycoin.WasmException` + +Unchecked exception thrown by all `ShieldedMerkleTree` methods on WASM-level errors. + +```java +catch (WasmException e) { + String code = e.getErrorCode(); // structured error code (see table below) + String message = e.getMessage(); // human-readable detail +} +``` + +--- + +## Java usage examples + +### 1. Initialize from a frontier (first sync) + +```java +byte[] frontier = HexFormat.of().parseHex(orchardTreeHex); +long blockHeight = 2_500_000L; + +try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(frontier, blockHeight)) { + MerkleTreeInfo info = tree.getInfo(); + System.out.println("tip height : " + info.tipHeight); // 2500000 + System.out.println("leaf count : " + info.leafCount); // 1 + System.out.println("checkpoints: " + info.checkpointCount); // 1 +} +``` + +### 2. Initialize from an empty state + +```java +TreeState emptyState = new TreeState( + "{\"shards\":[],\"cap\":{\"type\":\"Nil\"},\"checkpoints\":[]," + + "\"tip_height\":null,\"leaf_count\":0}"); + +try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(emptyState)) { + tree.ping(); +} +``` + +### 3. Append commitments for a block + +```java +ShieldedCommitment cmx = ShieldedCommitment.of(HexFormat.of().parseHex( + "0100000000000000000000000000000000000000000000000000000000000000")); + +try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(savedState)) { + ShieldedRoot root = tree.appendCommitments(2_500_001L, List.of(cmx)); + // Empty block — still creates a checkpoint + tree.appendCommitments(2_500_002L, Collections.emptyList()); +} +``` + +### 4. Append with root verification + +```java +ShieldedRoot expected = ShieldedRoot.of(expectedRootBytes); + +try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(savedState)) { + ShieldedRoot root = tree.appendCommitments(2_500_001L, cmxList, expected); + // root.equals(expected) is guaranteed +} +``` + +`WasmException` with code `ROOT_MISMATCH` is thrown if the computed root does not match. + +### 5. Save and restore state + +```java +byte[] snapshot; +try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(emptyState)) { + tree.appendCommitments(100L, List.of(cmx)); + snapshot = tree.save().bytes(); +} + +try (ShieldedMerkleTree restored = ShieldedMerkleTree.fromState(TreeState.of(snapshot))) { + MerkleTreeInfo info = restored.getInfo(); + System.out.println("tip height : " + info.tipHeight); // 100 +} +``` + +### 6. Reorg handling + +```java +try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(savedState)) { + ShieldedRoot root100 = tree.appendCommitments(100L, List.of(cmx)); + tree.appendCommitments(101L, List.of(cmx)); + + ShieldedRoot restoredRoot = tree.truncateToCheckpoint(100L); + // restoredRoot.equals(root100) +} +``` + +--- + +## Protobuf interface + +The wire format between Java and Rust is declared in `proto/privacy_coin.proto`. + +**Request messages** (Java → WASM linear memory → Rust): + +| Message | Export | Fields | +| -------------------------- | ------------------------ | -------------------------------------------------------------------------------------- | +| `FromFrontierRequest` | `from_frontier` | `frontier: bytes`, `block_height: uint32` | +| `AppendCommitmentsRequest` | `append_commitments` | `block_height: uint32`, `commitments: repeated bytes`, `expected_root: optional bytes` | +| `TruncateRequest` | `truncate_to_checkpoint` | `block_height: uint32` | + +**Response envelope** (WASM LAST_RESULT → Java): + +Every export writes a `Response` proto with a `oneof result`: + +- `ok: bool` — void success (`ping`, `from_frontier`, `from_state`, `drop_tree`) +- `bytes_value: bytes` — root hash or serialized state +- `info_value: TreeInfo` — from `get_info` +- `error: WasmError { code, message }` — any failure + +`from_state` and `save_state` pass state bytes directly (no proto wrapper needed for +the payload; only the `Response` envelope uses proto). + +--- + +## Error codes + +| Code | Meaning | +| ---------------------- | ----------------------------------------------------------------------------- | +| `ROOT_MISMATCH` | Computed Merkle root differs from the provided `expectedRoot` | +| `CHECKPOINT_NOT_FOUND` | No checkpoint exists for the requested block height | +| `INVALID_FRONTIER` | Frontier bytes could not be parsed | +| `INVALID_STATE` | State bytes could not be deserialized | +| `NO_TREE` | WASM export called before `from_frontier` / `from_state` initialized the tree | +| `DECODE_ERROR` | Incoming proto request could not be decoded | +| `SAVE_ERROR` | Tree serialization failed | +| `GET_INFO_ERROR` | `get_info` failed internally | +| `WASM_ERROR` | Catch-all for errors without a structured code | + +--- + +## Pinned dependencies + +| Crate | Pinned version | Role | +| ----------------------- | -------------- | ---------------------------------------------------------- | +| `shardtree` | `0.6.2` | Incremental Merkle tree with checkpointing | +| `incrementalmerkletree` | `0.8.2` | Core tree primitives (`Position`, `Address`, `Frontier`) | +| `orchard` | `0.14.0` | `MerkleHashOrchard` hash type and empty-root table | +| `prost` | `0.13` | Protobuf encode/decode (Rust) | +| `serde` / `serde_json` | `1` | Persistence serialization (`PersistedShardTreeState` JSON) | +| `hex` | `0.4` | Hex encoding of hash bytes in the persistence format | + +Do not upgrade `shardtree`, `incrementalmerkletree`, or `orchard` without verifying +that the new versions remain compatible with zcashd's serialization format and produce +identical root hashes for the same inputs. diff --git a/packages/wasm-privacy-coin/build.rs b/packages/wasm-privacy-coin/build.rs new file mode 100644 index 00000000000..96299f06bf5 --- /dev/null +++ b/packages/wasm-privacy-coin/build.rs @@ -0,0 +1,7 @@ +fn main() { + let protoc = protoc_bin_vendored::protoc_bin_path().unwrap(); + prost_build::Config::new() + .protoc_executable(protoc) + .compile_protos(&["proto/privacy_coin.proto"], &["proto"]) + .unwrap(); +} diff --git a/packages/wasm-privacy-coin/package.json b/packages/wasm-privacy-coin/package.json new file mode 100644 index 00000000000..1658a103c1a --- /dev/null +++ b/packages/wasm-privacy-coin/package.json @@ -0,0 +1,13 @@ +{ + "name": "@bitgo/wasm-privacy-coin", + "version": "0.0.0-semantic-release-managed", + "scripts": { + "build": "make build", + "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings", + "check-fmt": "cargo fmt -- --check", + "test": "cargo test --workspace" + }, + "files": [ + "dist/wasm-privacy-coin.wasm" + ] +} diff --git a/packages/wasm-privacy-coin/pom.xml b/packages/wasm-privacy-coin/pom.xml new file mode 100644 index 00000000000..be7fbd6edae --- /dev/null +++ b/packages/wasm-privacy-coin/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + com.bitgo + wasm-privacy-coin + 0.1.0 + jar + + WASM module for shielded merkle tree operations + + + 17 + 17 + UTF-8 + + + + + + com.dylibso.chicory + runtime + 1.1.1 + + + + + com.google.protobuf + protobuf-java + 4.29.3 + + + + + org.junit.jupiter + junit-jupiter + 5.10.3 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + + + io.github.ascopes + protobuf-maven-plugin + 2.6.0 + + 4.29.3 + + ${project.basedir}/proto + + + + + generate + + + + + + + diff --git a/packages/wasm-privacy-coin/proto/privacy_coin.proto b/packages/wasm-privacy-coin/proto/privacy_coin.proto new file mode 100644 index 00000000000..8268bfd1ab9 --- /dev/null +++ b/packages/wasm-privacy-coin/proto/privacy_coin.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; +package bitgo.privacy_coin; + +option java_package = "com.bitgo.wasm.privacycoin.proto"; +option java_multiple_files = true; + +// --- Request messages --- + +message FromFrontierRequest { + bytes frontier = 1; // raw CommitmentTree v0 bytes + uint32 block_height = 2; +} + +message AppendCommitmentsRequest { + uint32 block_height = 1; + repeated bytes commitments = 2; // each exactly 32 bytes (cmx) + optional bytes expected_root = 3; // 32 bytes; absent = skip verification +} + +message TruncateRequest { + uint32 block_height = 1; +} + +// --- Response types --- + +message TreeInfo { + optional uint32 tip_height = 1; // absent if no block appended yet + uint64 leaf_count = 2; + uint32 checkpoint_count = 3; +} + +message WasmError { + string code = 1; // SCREAMING_SNAKE_CASE, e.g. ROOT_MISMATCH + string message = 2; +} + +// Generic envelope — every WASM export writes one of these to LAST_RESULT +message Response { + oneof result { + bool ok = 1; // void success (ping, from_frontier, from_state, drop_tree) + bytes bytes_value = 2; // raw bytes success (root hash, persisted state) + TreeInfo info_value = 3; // get_info success + WasmError error = 4; // any failure + } +} diff --git a/packages/wasm-privacy-coin/src/lib.rs b/packages/wasm-privacy-coin/src/lib.rs new file mode 100644 index 00000000000..56467db1377 --- /dev/null +++ b/packages/wasm-privacy-coin/src/lib.rs @@ -0,0 +1,265 @@ +mod zcash; + +use prost::Message; +use std::cell::RefCell; + +// Include protobuf generated code from build.rs / prost-build. +pub mod proto { + include!(concat!(env!("OUT_DIR"), "/bitgo.privacy_coin.rs")); +} + +use proto::{ + response, AppendCommitmentsRequest, FromFrontierRequest, Response, TreeInfo, TruncateRequest, + WasmError, +}; + +// --------------------------------------------------------------------------- +// Per-instance state +// +// `wasm32-unknown-unknown` is single-threaded; `thread_local!` is safe here. +// Each Java `ShieldedMerkleTree` owns its own Chicory `Instance` (separate +// WASM linear memory), so these statics are effectively per-Java-object. +// --------------------------------------------------------------------------- + +thread_local! { + static TREE: RefCell> = const { RefCell::new(None) }; + static LAST_RESULT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +// --------------------------------------------------------------------------- +// Result buffer helpers +// --------------------------------------------------------------------------- + +fn set_last_result(r: Response) { + LAST_RESULT.with(|lr| *lr.borrow_mut() = r.encode_to_vec()); +} + +fn write_ok() { + set_last_result(Response { + result: Some(response::Result::Ok(true)), + }); +} + +fn write_ok_bytes(bytes: Vec) { + set_last_result(Response { + result: Some(response::Result::BytesValue(bytes)), + }); +} + +fn write_error(code: &str, msg: &str) { + set_last_result(Response { + result: Some(response::Result::Error(WasmError { + code: code.into(), + message: msg.into(), + })), + }); +} + +/// Split "CODE: message" into ("CODE", "message"). +/// Falls back to ("WASM_ERROR", whole string) if no valid code prefix is found. +fn split_error(e: &str) -> (&str, &str) { + if let Some(pos) = e.find(':') { + let code = &e[..pos]; + if !code.is_empty() + && code + .chars() + .all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit()) + { + return (code, e[pos + 1..].trim()); + } + } + ("WASM_ERROR", e) +} + +// --------------------------------------------------------------------------- +// Memory management exports (called by WasmBridge before/after each call) +// --------------------------------------------------------------------------- + +#[no_mangle] +pub extern "C" fn alloc(len: u32) -> *mut u8 { + let mut buf = Vec::with_capacity(len as usize); + let ptr = buf.as_mut_ptr(); + std::mem::forget(buf); + ptr +} + +/// # Safety +/// `ptr` must be a pointer previously returned by `alloc` with the same `len`. +#[no_mangle] +pub unsafe extern "C" fn dealloc(ptr: *mut u8, len: u32) { + let _ = Vec::from_raw_parts(ptr, 0, len as usize); +} + +/// Returns a pointer into the LAST_RESULT buffer. Valid until the next WASM call. +#[no_mangle] +pub extern "C" fn last_result_ptr() -> *const u8 { + LAST_RESULT.with(|lr| lr.borrow().as_ptr()) +} + +/// Returns the byte length of the LAST_RESULT buffer. +#[no_mangle] +pub extern "C" fn last_result_len() -> u32 { + LAST_RESULT.with(|lr| lr.borrow().len() as u32) +} + +// --------------------------------------------------------------------------- +// Tree lifecycle exports +// --------------------------------------------------------------------------- + +/// Verifies the WASM module is responding. +#[no_mangle] +pub extern "C" fn ping() -> i32 { + write_ok(); + 0 +} + +/// Initialize the tree from a CommitmentTree v0 frontier. +/// +/// # Safety +/// `ptr` must point to `len` valid bytes in WASM linear memory, as written by `WasmBridge.call`. +#[no_mangle] +pub unsafe extern "C" fn from_frontier(ptr: *const u8, len: u32) -> i32 { + let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }; + match FromFrontierRequest::decode(bytes) { + Err(e) => write_error("DECODE_ERROR", &e.to_string()), + Ok(req) => match zcash::tree::OwnedTree::from_frontier(&req.frontier, req.block_height) { + Ok(tree) => { + TREE.with(|t| *t.borrow_mut() = Some(tree)); + write_ok(); + } + Err(e) => { + let (code, msg) = split_error(&e); + write_error(code, msg); + } + }, + } + 0 +} + +/// Restore the tree from bytes produced by `save_state`. +/// +/// # Safety +/// `ptr` must point to `len` valid bytes in WASM linear memory, as written by `WasmBridge.call`. +#[no_mangle] +pub unsafe extern "C" fn from_state(ptr: *const u8, len: u32) -> i32 { + let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }; + match zcash::tree::OwnedTree::from_state(bytes) { + Ok(tree) => { + TREE.with(|t| *t.borrow_mut() = Some(tree)); + write_ok(); + } + Err(e) => { + let (code, msg) = split_error(&e); + write_error(code, msg); + } + } + 0 +} + +/// Drop the tree, releasing all in-memory state. +#[no_mangle] +pub extern "C" fn drop_tree() -> i32 { + TREE.with(|t| *t.borrow_mut() = None); + write_ok(); + 0 +} + +// --------------------------------------------------------------------------- +// Tree operation exports +// --------------------------------------------------------------------------- + +/// Append note commitments for a block, checkpoint the tree, optionally verify root. +/// +/// # Safety +/// `ptr` must point to `len` valid bytes in WASM linear memory, as written by `WasmBridge.call`. +#[no_mangle] +pub unsafe extern "C" fn append_commitments(ptr: *const u8, len: u32) -> i32 { + let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }; + match AppendCommitmentsRequest::decode(bytes) { + Err(e) => write_error("DECODE_ERROR", &e.to_string()), + Ok(req) => { + let expected_root = req.expected_root; + TREE.with(|t| { + let mut borrow = t.borrow_mut(); + match borrow.as_mut() { + None => write_error("NO_TREE", "tree not initialized"), + Some(tree) => { + let commitments: Vec> = + req.commitments.iter().map(|b| b.to_vec()).collect(); + let exp = expected_root.as_deref(); + match tree.append_commitments(req.block_height, commitments, exp) { + Ok(root) => write_ok_bytes(root), + Err(e) => { + let (code, msg) = split_error(&e); + write_error(code, msg); + } + } + } + } + }); + } + } + 0 +} + +/// Roll back the tree to the checkpoint at `block_height`. +/// +/// # Safety +/// `ptr` must point to `len` valid bytes in WASM linear memory, as written by `WasmBridge.call`. +#[no_mangle] +pub unsafe extern "C" fn truncate_to_checkpoint(ptr: *const u8, len: u32) -> i32 { + let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }; + match TruncateRequest::decode(bytes) { + Err(e) => write_error("DECODE_ERROR", &e.to_string()), + Ok(req) => { + TREE.with(|t| { + let mut borrow = t.borrow_mut(); + match borrow.as_mut() { + None => write_error("NO_TREE", "tree not initialized"), + Some(tree) => match tree.truncate_to_checkpoint(req.block_height) { + Ok(root) => write_ok_bytes(root), + Err(e) => { + let (code, msg) = split_error(&e); + write_error(code, msg); + } + }, + } + }); + } + } + 0 +} + +/// Serialize the tree state to bytes for later restoration via `from_state`. +#[no_mangle] +pub extern "C" fn save_state() -> i32 { + TREE.with(|t| match t.borrow().as_ref() { + None => write_error("NO_TREE", "tree not initialized"), + Some(tree) => match tree.save() { + Ok(bytes) => write_ok_bytes(bytes), + Err(e) => write_error("SAVE_ERROR", &e), + }, + }); + 0 +} + +/// Return metadata about the current tree state. +#[no_mangle] +pub extern "C" fn get_info() -> i32 { + TREE.with(|t| match t.borrow().as_ref() { + None => write_error("NO_TREE", "tree not initialized"), + Some(tree) => match tree.get_info() { + Ok((tip_height, leaf_count, checkpoint_count)) => { + set_last_result(Response { + result: Some(response::Result::InfoValue(TreeInfo { + tip_height, + leaf_count, + checkpoint_count, + })), + }); + } + Err(e) => write_error("GET_INFO_ERROR", &e), + }, + }); + 0 +} diff --git a/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/MerkleTreeInfo.java b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/MerkleTreeInfo.java new file mode 100644 index 00000000000..b4ee6393319 --- /dev/null +++ b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/MerkleTreeInfo.java @@ -0,0 +1,14 @@ +package com.bitgo.wasm.privacycoin; + +public final class MerkleTreeInfo { + /** Null if no block has been appended yet. */ + public final Long tipHeight; + public final long leafCount; + public final int checkpointCount; + + public MerkleTreeInfo(Long tipHeight, long leafCount, int checkpointCount) { + this.tipHeight = tipHeight; + this.leafCount = leafCount; + this.checkpointCount = checkpointCount; + } +} diff --git a/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/WasmException.java b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/WasmException.java new file mode 100644 index 00000000000..80e744de908 --- /dev/null +++ b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/WasmException.java @@ -0,0 +1,16 @@ +package com.bitgo.wasm.privacycoin; + +public final class WasmException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String errorCode; + + public WasmException(String errorCode, String message) { + super("[" + errorCode + "] " + message); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedCommitment.java b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedCommitment.java new file mode 100644 index 00000000000..0bf25bece28 --- /dev/null +++ b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedCommitment.java @@ -0,0 +1,49 @@ +package com.bitgo.wasm.privacycoin.zcash; + +import java.util.Arrays; +import java.util.Objects; + +/** + * A 32-byte shielded note commitment value (cmx). + * + *

Immutable; {@link #bytes()} returns a defensive copy. + */ +public final class ShieldedCommitment { + + public static final int SIZE = 32; + + private final byte[] bytes; + + private ShieldedCommitment(byte[] bytes) { + this.bytes = bytes; + } + + /** + * Wraps a 32-byte commitment value. + * + * @throws IllegalArgumentException if {@code bytes.length != 32} + */ + public static ShieldedCommitment of(byte[] bytes) { + Objects.requireNonNull(bytes, "bytes must not be null"); + if (bytes.length != SIZE) { + throw new IllegalArgumentException( + "ShieldedCommitment must be " + SIZE + " bytes, got " + bytes.length); + } + return new ShieldedCommitment(bytes.clone()); + } + + /** Returns a defensive copy of the 32 raw bytes. */ + public byte[] bytes() { + return bytes.clone(); + } + + @Override + public boolean equals(Object o) { + return o instanceof ShieldedCommitment c && Arrays.equals(bytes, c.bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } +} diff --git a/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTree.java b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTree.java new file mode 100644 index 00000000000..8017c574195 --- /dev/null +++ b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTree.java @@ -0,0 +1,203 @@ +package com.bitgo.wasm.privacycoin.zcash; + +import com.bitgo.wasm.privacycoin.MerkleTreeInfo; +import com.bitgo.wasm.privacycoin.WasmException; +import com.bitgo.wasm.privacycoin.proto.AppendCommitmentsRequest; +import com.bitgo.wasm.privacycoin.proto.FromFrontierRequest; +import com.bitgo.wasm.privacycoin.proto.Response; +import com.bitgo.wasm.privacycoin.proto.TreeInfo; +import com.bitgo.wasm.privacycoin.proto.TruncateRequest; +import com.google.protobuf.ByteString; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * High-level Java wrapper for the shielded commitment tree WASM module. + * + *

Each instance owns a dedicated Chicory WASM runtime; two {@code ShieldedMerkleTree} + * instances share no state. Implements {@link AutoCloseable} for use in try-with-resources. + * + *

All type marshaling uses the protobuf wire format: requests are proto-encoded bytes + * written into WASM linear memory; responses are read from the LAST_RESULT buffer. + * + *

Thread safety: Not thread-safe. Do not share instances across threads. + */ +public final class ShieldedMerkleTree implements AutoCloseable { + + private final WasmBridge bridge; + + private ShieldedMerkleTree(WasmBridge bridge) { + this.bridge = bridge; + } + + // ------------------------------------------------------------------------- + // Factory methods + // ------------------------------------------------------------------------- + + private static ShieldedMerkleTree create(Consumer init) { + var bridge = new WasmBridge(); + boolean ok = false; + try { + init.accept(bridge); + ok = true; + return new ShieldedMerkleTree(bridge); + } finally { + if (!ok) bridge.close(); + } + } + + /** + * Initializes a new tree from a CommitmentTree v0 frontier + * (the {@code orchardTree} value from {@code z_gettreestate}). + * + * @param frontier raw CommitmentTree v0 bytes + * @param blockHeight block height at which the frontier was captured (u32 range) + * @return initialized tree instance + * @throws WasmException if the frontier is invalid + */ + public static ShieldedMerkleTree fromFrontier(byte[] frontier, long blockHeight) { + Objects.requireNonNull(frontier, "frontier must not be null"); + requireU32(blockHeight, "blockHeight"); + return create(bridge -> { + byte[] reqBytes = FromFrontierRequest.newBuilder() + .setFrontier(ByteString.copyFrom(frontier)) + // safe: requireU32 guarantees blockHeight is in [0, 0xFFFF_FFFF] + .setBlockHeight((int) blockHeight) + .build() + .toByteArray(); + unwrapVoid(bridge.call("from_frontier", reqBytes)); + }); + } + + /** + * Restores a tree from a {@link TreeState} previously returned by {@link #save()}. + * + * @param state serialized state from a prior {@code save()} call + * @return restored tree instance + * @throws WasmException if the state is invalid + */ + public static ShieldedMerkleTree fromState(TreeState state) { + Objects.requireNonNull(state, "state must not be null"); + // State bytes are passed directly — no proto wrapper needed for from_state. + return create(bridge -> unwrapVoid(bridge.call("from_state", state.bytes()))); + } + + // ------------------------------------------------------------------------- + // Instance operations + // ------------------------------------------------------------------------- + + /** Verifies the WASM module is responding. */ + public void ping() { + bridge.call("ping"); + } + + /** + * Appends note commitments for a block, checkpoints the tree, and optionally + * verifies the root. + * + * @param blockHeight block height (u32 range) + * @param commitments shielded note commitment values (cmx) for this block + * @param expectedRoot root to verify against; {@code null} to skip + * @return computed root after appending + * @throws WasmException with code {@code ROOT_MISMATCH} if verification fails + */ + public ShieldedRoot appendCommitments( + long blockHeight, List commitments, ShieldedRoot expectedRoot) { + requireU32(blockHeight, "blockHeight"); + Objects.requireNonNull(commitments, "commitments must not be null"); + + AppendCommitmentsRequest.Builder req = AppendCommitmentsRequest.newBuilder() + // safe: requireU32 guarantees blockHeight is in [0, 0xFFFF_FFFF] + .setBlockHeight((int) blockHeight) + .addAllCommitments(commitments.stream() + .map(c -> ByteString.copyFrom(c.bytes())) + .toList()); + if (expectedRoot != null) { + req.setExpectedRoot(ByteString.copyFrom(expectedRoot.bytes())); + } + + Response r = bridge.call("append_commitments", req.build().toByteArray()); + return ShieldedRoot.of(unwrap(r, resp -> resp.getBytesValue().toByteArray())); + } + + /** Convenience overload — appends without root verification. */ + public ShieldedRoot appendCommitments(long blockHeight, List commitments) { + return appendCommitments(blockHeight, commitments, null); + } + + /** + * Rolls the tree back to the checkpoint at the given block height. + * + * @param blockHeight height of the checkpoint to restore (u32 range) + * @return root at the restored checkpoint + * @throws WasmException with code {@code CHECKPOINT_NOT_FOUND} if no checkpoint exists + */ + public ShieldedRoot truncateToCheckpoint(long blockHeight) { + requireU32(blockHeight, "blockHeight"); + byte[] reqBytes = TruncateRequest.newBuilder() + // safe: requireU32 guarantees blockHeight is in [0, 0xFFFF_FFFF] + .setBlockHeight((int) blockHeight) + .build() + .toByteArray(); + Response r = bridge.call("truncate_to_checkpoint", reqBytes); + return ShieldedRoot.of(unwrap(r, resp -> resp.getBytesValue().toByteArray())); + } + + /** + * Serializes the current tree state for later restoration via {@link #fromState(TreeState)}. + * + * @return opaque state snapshot + * @throws WasmException if serialization fails + */ + public TreeState save() { + Response r = bridge.call("save_state"); + return TreeState.of(unwrap(r, resp -> resp.getBytesValue().toByteArray())); + } + + /** + * Returns metadata about the current tree state. + * + * @return tree info snapshot + * @throws WasmException if the call fails + */ + public MerkleTreeInfo getInfo() { + Response r = bridge.call("get_info"); + TreeInfo info = unwrap(r, Response::getInfoValue); + // getTipHeight() returns int; mask to treat as unsigned uint32. + Long tipHeight = info.hasTipHeight() ? (info.getTipHeight() & 0xFFFFFFFFL) : null; + return new MerkleTreeInfo(tipHeight, info.getLeafCount(), info.getCheckpointCount()); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private static void unwrapVoid(Response response) { + if (response.hasError()) throw WasmBridge.toWasmException(response.getError()); + } + + private static T unwrap(Response response, Function extractor) { + if (response.hasError()) throw WasmBridge.toWasmException(response.getError()); + return extractor.apply(response); + } + + private static void requireU32(long value, String name) { + if (value < 0 || value > 0xFFFFFFFFL) { + throw new IllegalArgumentException( + name + " must be in u32 range [0, 4294967295], got: " + value); + } + } + + @Override + public void close() { + try { + bridge.call("drop_tree"); + } catch (Exception ignored) { + // Best-effort: drop the in-WASM tree before the instance goes away. + } + bridge.close(); + } +} diff --git a/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedRoot.java b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedRoot.java new file mode 100644 index 00000000000..5dfcf559f91 --- /dev/null +++ b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedRoot.java @@ -0,0 +1,49 @@ +package com.bitgo.wasm.privacycoin.zcash; + +import java.util.Arrays; +import java.util.Objects; + +/** + * A 32-byte shielded Merkle tree root. + * + *

Immutable; {@link #bytes()} returns a defensive copy. + */ +public final class ShieldedRoot { + + public static final int SIZE = 32; + + private final byte[] bytes; + + private ShieldedRoot(byte[] bytes) { + this.bytes = bytes; + } + + /** + * Wraps a 32-byte root value. + * + * @throws IllegalArgumentException if {@code bytes.length != 32} + */ + public static ShieldedRoot of(byte[] bytes) { + Objects.requireNonNull(bytes, "bytes must not be null"); + if (bytes.length != SIZE) { + throw new IllegalArgumentException( + "ShieldedRoot must be " + SIZE + " bytes, got " + bytes.length); + } + return new ShieldedRoot(bytes.clone()); + } + + /** Returns a defensive copy of the 32 raw bytes. */ + public byte[] bytes() { + return bytes.clone(); + } + + @Override + public boolean equals(Object o) { + return o instanceof ShieldedRoot r && Arrays.equals(bytes, r.bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } +} diff --git a/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/TreeState.java b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/TreeState.java new file mode 100644 index 00000000000..4a219a70bc6 --- /dev/null +++ b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/TreeState.java @@ -0,0 +1,40 @@ +package com.bitgo.wasm.privacycoin.zcash; + +import java.nio.charset.StandardCharsets; + +/** + * An opaque serialized snapshot of a {@link ShieldedMerkleTree}. + * + *

Produced by {@link ShieldedMerkleTree#save()} and consumed by + * {@link ShieldedMerkleTree#fromState(TreeState)}. The internal encoding is an + * implementation detail; do not interpret the bytes directly. + */ +public final class TreeState { + + private final String json; + + /** + * Package-private: constructed by {@link ShieldedMerkleTree#save()} and in tests + * (same package) for bootstrapping from an initial JSON string. + */ + TreeState(String json) { + this.json = json; + } + + /** + * Restores a {@code TreeState} from bytes previously returned by {@link #bytes()}. + * + * @param bytes UTF-8 encoded state bytes from a prior {@code bytes()} call + */ + public static TreeState of(byte[] bytes) { + return new TreeState(new String(bytes, StandardCharsets.UTF_8)); + } + + /** + * Returns the raw bytes of the serialized state (UTF-8 encoded). + * Round-trips through {@link #of(byte[])}. + */ + public byte[] bytes() { + return json.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/WasmBridge.java b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/WasmBridge.java new file mode 100644 index 00000000000..f60fb57713b --- /dev/null +++ b/packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/WasmBridge.java @@ -0,0 +1,121 @@ +package com.bitgo.wasm.privacycoin.zcash; + +import com.bitgo.wasm.privacycoin.WasmException; +import com.bitgo.wasm.privacycoin.proto.Response; +import com.bitgo.wasm.privacycoin.proto.WasmError; +import com.dylibso.chicory.runtime.ExportFunction; +import com.dylibso.chicory.runtime.Instance; +import com.dylibso.chicory.wasm.Parser; +import com.google.protobuf.InvalidProtocolBufferException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Low-level bridge to the shielded-tree WASM instance. + * + *

Package-private: consumers use {@link ShieldedMerkleTree} instead. + * + *

Loads the WASM binary from the classpath, instantiates it via Chicory (pure-JVM, no + * native/JNI), and exposes typed call/read helpers backed by the protobuf wire format. + * Each call writes a proto-encoded request into WASM linear memory, invokes the named + * export, then reads the {@link Response} proto from the LAST_RESULT buffer. + * + *

Thread safety: Not thread-safe. One bridge per {@link ShieldedMerkleTree}. + */ +final class WasmBridge implements AutoCloseable { + + private final Instance instance; + private final ExportFunction fnAlloc; + private final ExportFunction fnDealloc; + private final ExportFunction fnResultPtr; + private final ExportFunction fnResultLen; + + WasmBridge() { + byte[] wasmBytes; + try (InputStream is = WasmBridge.class.getResourceAsStream("/wasm/privacy_coin.wasm")) { + if (is == null) { + throw new IllegalStateException( + "WASM binary not found on classpath: /wasm/privacy_coin.wasm"); + } + wasmBytes = is.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("Failed to load WASM binary", e); + } + + try { + var module = Parser.parse(new ByteArrayInputStream(wasmBytes)); + this.instance = Instance.builder(module).build(); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate WASM module", e); + } + + this.fnAlloc = instance.export("alloc"); + this.fnDealloc = instance.export("dealloc"); + this.fnResultPtr = instance.export("last_result_ptr"); + this.fnResultLen = instance.export("last_result_len"); + } + + private final class WasmBuffer implements AutoCloseable { + final int ptr; + final int len; + + WasmBuffer(byte[] bytes) { + this.len = bytes.length; + this.ptr = (int) fnAlloc.apply(len)[0]; + instance.memory().write(ptr, bytes); + } + + @Override + public void close() { + fnDealloc.apply(ptr, len); + } + } + + /** + * Write {@code requestBytes} into WASM memory, invoke {@code export}, read Response proto. + * + * @param export name of the WASM export to call + * @param requestBytes proto-encoded request bytes + * @return decoded {@link Response} + */ + Response call(String export, byte[] requestBytes) { + try (var buf = new WasmBuffer(requestBytes)) { + instance.export(export).apply(buf.ptr, buf.len); + return readResponse(); + } + } + + /** + * Invoke a no-argument {@code export} and read the Response proto. + * + * @param export name of the WASM export to call + * @return decoded {@link Response} + */ + Response call(String export) { + instance.export(export).apply(); + return readResponse(); + } + + private Response readResponse() { + int ptr = (int) fnResultPtr.apply()[0]; + int len = (int) fnResultLen.apply()[0]; + byte[] bytes = instance.memory().readBytes(ptr, len); + try { + return Response.parseFrom(bytes); + } catch (InvalidProtocolBufferException e) { + throw new IllegalStateException("Failed to decode WASM Response proto", e); + } + } + + /** Wraps a proto {@link WasmError} into a {@link WasmException}. */ + static WasmException toWasmException(WasmError error) { + return new WasmException(error.getCode(), error.getMessage()); + } + + @Override + public void close() { + // Chicory Instance has no explicit close method; nothing to do here. + } +} diff --git a/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/MerkleTreeInfoTest.java b/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/MerkleTreeInfoTest.java new file mode 100644 index 00000000000..d3a7ddfdf33 --- /dev/null +++ b/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/MerkleTreeInfoTest.java @@ -0,0 +1,37 @@ +package com.bitgo.wasm.privacycoin; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class MerkleTreeInfoTest { + + @Test + void constructor_storesAllFields() { + MerkleTreeInfo info = new MerkleTreeInfo(1_500_000L, 42L, 3); + assertEquals(Long.valueOf(1_500_000L), info.tipHeight); + assertEquals(42L, info.leafCount); + assertEquals(3, info.checkpointCount); + } + + @Test + void tipHeight_canBeNull() { + MerkleTreeInfo info = new MerkleTreeInfo(null, 0L, 0); + assertNull(info.tipHeight); + } + + @Test + void zeroValues_areStoredCorrectly() { + MerkleTreeInfo info = new MerkleTreeInfo(0L, 0L, 0); + assertEquals(Long.valueOf(0L), info.tipHeight); + assertEquals(0L, info.leafCount); + assertEquals(0, info.checkpointCount); + } + + @Test + void largeLeafCount_isStoredCorrectly() { + long largeCount = (long) Integer.MAX_VALUE + 1; + MerkleTreeInfo info = new MerkleTreeInfo(null, largeCount, 0); + assertEquals(largeCount, info.leafCount); + } +} diff --git a/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/WasmExceptionTest.java b/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/WasmExceptionTest.java new file mode 100644 index 00000000000..ae283131c38 --- /dev/null +++ b/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/WasmExceptionTest.java @@ -0,0 +1,35 @@ +package com.bitgo.wasm.privacycoin; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class WasmExceptionTest { + + @Test + void getMessage_includesCodeInBrackets() { + WasmException ex = new WasmException("ROOT_MISMATCH", "computed abc but expected def"); + assertEquals("[ROOT_MISMATCH] computed abc but expected def", ex.getMessage()); + } + + @Test + void getErrorCode_returnsOriginalCode() { + WasmException ex = new WasmException("CHECKPOINT_NOT_FOUND", "no checkpoint for height 99"); + assertEquals("CHECKPOINT_NOT_FOUND", ex.getErrorCode()); + } + + @Test + void canBeCaughtAsRuntimeException() { + // Verifies it propagates through a call site that only handles RuntimeException. + assertThrows(RuntimeException.class, () -> { + throw new WasmException("SOME_CODE", "some message"); + }); + } + + @Test + void emptyMessage_formatsCorrectly() { + WasmException ex = new WasmException("CODE", ""); + assertEquals("[CODE] ", ex.getMessage()); + assertEquals("CODE", ex.getErrorCode()); + } +} diff --git a/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTreeTest.java b/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTreeTest.java new file mode 100644 index 00000000000..6257925f15a --- /dev/null +++ b/packages/wasm-privacy-coin/src/test/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTreeTest.java @@ -0,0 +1,421 @@ +package com.bitgo.wasm.privacycoin.zcash; + +import com.bitgo.wasm.privacycoin.MerkleTreeInfo; +import com.bitgo.wasm.privacycoin.WasmException; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HexFormat; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for {@link ShieldedMerkleTree}. + * + *

Requires the WASM binary at {@code src/main/resources/wasm/privacy_coin.wasm}. + * The {@code make test-java} target copies the binary there before running Maven. + */ +class ShieldedMerkleTreeTest { + + /** + * Minimal valid PersistedShardTreeState JSON representing an empty tree. + * Matches {@code tree::PersistedShardTreeState} with serde(tag="type") on TreeNode. + */ + private static final TreeState EMPTY_STATE = new TreeState( + "{\"shards\":[],\"cap\":{\"type\":\"Nil\"},\"checkpoints\":[]," + + "\"tip_height\":null,\"leaf_count\":0}"); + + /** + * CommitmentTree v0 frontier encoding for a single-leaf tree. + * Encoding: 0x01 (left present) | 32-byte hash (value=1 LE, valid Pallas base field element) + * | 0x00 (right absent) | 0x00 (0 parents) = 35 bytes total. + */ + private static final byte[] FRONTIER = HexFormat.of().parseHex( + "0101000000000000000000000000000000000000000000000000000000000000000000"); + + /** + * A valid 32-byte Orchard commitment (Pallas base field element = 1, LE-encoded). + */ + private static final ShieldedCommitment CMX = ShieldedCommitment.of(HexFormat.of().parseHex( + "0100000000000000000000000000000000000000000000000000000000000000")); + + // ------------------------------------------------------------------------- + // ping + // ------------------------------------------------------------------------- + + @Test + void ping_succeeds() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + assertDoesNotThrow(tree::ping); + } + } + + // ------------------------------------------------------------------------- + // fromFrontier + // ------------------------------------------------------------------------- + + @Test + void fromFrontier_setsInitialLeafCountAndTipHeight() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(FRONTIER, 1_000_000L)) { + MerkleTreeInfo info = tree.getInfo(); + assertEquals(Long.valueOf(1_000_000L), info.tipHeight); + assertEquals(1L, info.leafCount); + } + } + + @Test + void fromFrontier_emptyBytes_throwsWasmException() { + assertThrows(WasmException.class, () -> + ShieldedMerkleTree.fromFrontier(new byte[0], 1L)); + } + + @Test + void fromFrontier_emptyTree_throwsWasmException() { + // CommitmentTree v0 with left=absent (0x00): left leaf is required. + byte[] emptyFrontier = HexFormat.of().parseHex("000000"); + assertThrows(WasmException.class, () -> + ShieldedMerkleTree.fromFrontier(emptyFrontier, 1L)); + } + + @Test + void fromFrontier_negativeBlockHeight_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> + ShieldedMerkleTree.fromFrontier(FRONTIER, -1L)); + } + + @Test + void fromFrontier_blockHeightAtU32Max_succeeds() { + // 0xFFFF_FFFF is the maximum valid u32 block height; must not throw. + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(FRONTIER, 0xFFFFFFFFL)) { + MerkleTreeInfo info = tree.getInfo(); + assertEquals(Long.valueOf(0xFFFFFFFFL), info.tipHeight); + } + } + + @Test + void fromFrontier_setsCheckpointCountToOne() { + // fromFrontier inserts a checkpoint at block-height, so checkpoint count must be 1. + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromFrontier(FRONTIER, 1_000_000L)) { + MerkleTreeInfo info = tree.getInfo(); + assertEquals(1, info.checkpointCount); + } + } + + // ------------------------------------------------------------------------- + // fromState + // ------------------------------------------------------------------------- + + @Test + void fromState_emptyState_initializesWithNoTipHeight() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + MerkleTreeInfo info = tree.getInfo(); + assertNull(info.tipHeight); + assertEquals(0L, info.leafCount); + assertEquals(0, info.checkpointCount); + } + } + + @Test + void fromState_invalidJson_throwsWasmException() { + assertThrows(WasmException.class, () -> + ShieldedMerkleTree.fromState(new TreeState("{ not valid json }"))); + } + + // ------------------------------------------------------------------------- + // appendCommitments — empty blocks + // ------------------------------------------------------------------------- + + @Test + void appendCommitments_nullCommitmentsList_throwsNullPointerException() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + assertThrows(NullPointerException.class, () -> + tree.appendCommitments(1L, null)); + } + } + + @Test + void appendCommitments_emptyBlock_returnsRoot() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + ShieldedRoot root = tree.appendCommitments(1L, Collections.emptyList()); + assertNotNull(root); + assertEquals(ShieldedRoot.SIZE, root.bytes().length); + } + } + + @Test + void appendCommitments_emptyBlock_doesNotChangeLeafCount() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, Collections.emptyList()); + MerkleTreeInfo info = tree.getInfo(); + assertEquals(0L, info.leafCount); + assertEquals(Long.valueOf(1L), info.tipHeight); + } + } + + @Test + void appendCommitments_multipleEmptyBlocks_incrementsCheckpointCount() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, Collections.emptyList()); + tree.appendCommitments(2L, Collections.emptyList()); + tree.appendCommitments(3L, Collections.emptyList()); + MerkleTreeInfo info = tree.getInfo(); + assertEquals(Long.valueOf(3L), info.tipHeight); + assertEquals(3, info.checkpointCount); + assertEquals(0L, info.leafCount); + } + } + + // ------------------------------------------------------------------------- + // appendCommitments — with commitments + // ------------------------------------------------------------------------- + + @Test + void appendCommitments_withCommitment_increasesLeafCount() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX)); + MerkleTreeInfo info = tree.getInfo(); + assertEquals(1L, info.leafCount); + assertEquals(Long.valueOf(1L), info.tipHeight); + } + } + + @Test + void appendCommitments_twoBlocks_leafCountAccumulates() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX)); + tree.appendCommitments(2L, List.of(CMX)); + MerkleTreeInfo info = tree.getInfo(); + assertEquals(2L, info.leafCount); + assertEquals(Long.valueOf(2L), info.tipHeight); + assertEquals(2, info.checkpointCount); + } + } + + @Test + void appendCommitments_twoBlocks_rootChanges() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + ShieldedRoot root1 = tree.appendCommitments(1L, List.of(CMX)); + ShieldedRoot root2 = tree.appendCommitments(2L, List.of(CMX)); + assertNotEquals(root1, root2, "Root must change when new leaves are appended"); + } + } + + @Test + void appendCommitments_returnsRoot_isDeterministic() { + // Two fresh trees given the same commitment produce the same root. + ShieldedRoot root1, root2; + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + root1 = tree.appendCommitments(1L, List.of(CMX)); + } + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + root2 = tree.appendCommitments(1L, List.of(CMX)); + } + assertEquals(root1, root2); + } + + @Test + void appendCommitments_correctExpectedRoot_succeeds() { + // Capture actual root first, then confirm passing it as expectedRoot doesn't throw. + ShieldedRoot actualRoot; + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + actualRoot = tree.appendCommitments(1L, List.of(CMX)); + } + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + ShieldedRoot returned = tree.appendCommitments(1L, List.of(CMX), actualRoot); + assertEquals(actualRoot, returned); + } + } + + @Test + void appendCommitments_wrongExpectedRoot_throwsRootMismatch() { + ShieldedRoot wrongRoot = ShieldedRoot.of(new byte[ShieldedRoot.SIZE]); + WasmException ex = assertThrows(WasmException.class, () -> { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX), wrongRoot); + } + }); + assertEquals("ROOT_MISMATCH", ex.getErrorCode()); + } + + @Test + void appendCommitments_multipleCommitmentsInOneBlock_allLeavesCounted() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX, CMX, CMX)); + MerkleTreeInfo info = tree.getInfo(); + assertEquals(3L, info.leafCount); + assertEquals(1, info.checkpointCount); + } + } + + // ------------------------------------------------------------------------- + // truncateToCheckpoint + // ------------------------------------------------------------------------- + + @Test + void truncateToCheckpoint_rollsBackTipHeightAndLeafCount() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX)); + tree.appendCommitments(2L, List.of(CMX)); + + tree.truncateToCheckpoint(1L); + + MerkleTreeInfo info = tree.getInfo(); + assertEquals(Long.valueOf(1L), info.tipHeight); + assertEquals(1L, info.leafCount); + } + } + + @Test + void truncateToCheckpoint_returnsRootMatchingOriginalAppend() { + ShieldedRoot rootAt1; + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + rootAt1 = tree.appendCommitments(1L, List.of(CMX)); + tree.appendCommitments(2L, List.of(CMX)); + + ShieldedRoot restoredRoot = tree.truncateToCheckpoint(1L); + assertEquals(rootAt1, restoredRoot); + } + } + + @Test + void truncateToCheckpoint_unknownHeight_throwsCheckpointNotFound() { + WasmException ex = assertThrows(WasmException.class, () -> { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(100L, Collections.emptyList()); + tree.truncateToCheckpoint(999L); + } + }); + assertEquals("CHECKPOINT_NOT_FOUND", ex.getErrorCode()); + } + + // ------------------------------------------------------------------------- + // save / fromState round-trip + // ------------------------------------------------------------------------- + + @Test + void saveAndLoad_roundTrip_infoFieldsSurvive() { + TreeState savedState; + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(100L, Collections.emptyList()); + savedState = tree.save(); + } + try (ShieldedMerkleTree restored = ShieldedMerkleTree.fromState(savedState)) { + MerkleTreeInfo info = restored.getInfo(); + assertEquals(Long.valueOf(100L), info.tipHeight); + assertEquals(0L, info.leafCount); + assertEquals(1, info.checkpointCount); + } + } + + @Test + void saveAndLoad_withLeaves_preservesLeafCount() { + TreeState savedState; + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX, CMX)); + savedState = tree.save(); + } + try (ShieldedMerkleTree restored = ShieldedMerkleTree.fromState(savedState)) { + MerkleTreeInfo info = restored.getInfo(); + assertEquals(2L, info.leafCount); + assertEquals(Long.valueOf(1L), info.tipHeight); + } + } + + @Test + void saveAndLoad_restoredTreeCanContinueAppending() { + TreeState savedState; + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX)); + savedState = tree.save(); + } + try (ShieldedMerkleTree restored = ShieldedMerkleTree.fromState(savedState)) { + assertDoesNotThrow(() -> restored.appendCommitments(2L, List.of(CMX))); + MerkleTreeInfo info = restored.getInfo(); + assertEquals(2L, info.leafCount); + assertEquals(Long.valueOf(2L), info.tipHeight); + } + } + + @Test + void saveAndLoad_stateBytesRoundTrip() { + // Verify TreeState.bytes() / TreeState.of() round-trips without data loss. + TreeState saved; + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(5L, List.of(CMX)); + saved = tree.save(); + } + TreeState restored = TreeState.of(saved.bytes()); + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(restored)) { + MerkleTreeInfo info = tree.getInfo(); + assertEquals(Long.valueOf(5L), info.tipHeight); + assertEquals(1L, info.leafCount); + } + } + + @Test + void appendCommitments_wrongSizeCommitment_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> + ShieldedCommitment.of(new byte[4])); // must be 32 bytes + } + + @Test + void truncateToCheckpoint_treeIsUsableAfterRollback() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + tree.appendCommitments(1L, List.of(CMX)); + tree.appendCommitments(2L, List.of(CMX)); + tree.truncateToCheckpoint(1L); + + ShieldedRoot root = tree.appendCommitments(3L, List.of(CMX)); + assertEquals(ShieldedRoot.SIZE, root.bytes().length); + MerkleTreeInfo info = tree.getInfo(); + assertEquals(Long.valueOf(3L), info.tipHeight); + assertEquals(2L, info.leafCount); + } + } + + // ------------------------------------------------------------------------- + // blockHeight range validation + // ------------------------------------------------------------------------- + + @Test + void appendCommitments_negativeBlockHeight_throwsIllegalArgumentException() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + assertThrows(IllegalArgumentException.class, () -> + tree.appendCommitments(-1L, Collections.emptyList())); + } + } + + @Test + void appendCommitments_blockHeightAboveU32Max_throwsIllegalArgumentException() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + assertThrows(IllegalArgumentException.class, () -> + tree.appendCommitments(0x1_0000_0000L, Collections.emptyList())); + } + } + + @Test + void truncateToCheckpoint_negativeBlockHeight_throwsIllegalArgumentException() { + try (ShieldedMerkleTree tree = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + assertThrows(IllegalArgumentException.class, () -> + tree.truncateToCheckpoint(-1L)); + } + } + + // ------------------------------------------------------------------------- + // Instance isolation + // ------------------------------------------------------------------------- + + @Test + void twoInstances_shareNoState() { + try (ShieldedMerkleTree tree1 = ShieldedMerkleTree.fromState(EMPTY_STATE); + ShieldedMerkleTree tree2 = ShieldedMerkleTree.fromState(EMPTY_STATE)) { + + tree1.appendCommitments(1L, List.of(CMX)); + + MerkleTreeInfo info2 = tree2.getInfo(); + assertNull(info2.tipHeight); + assertEquals(0L, info2.leafCount); + } + } +} diff --git a/packages/wasm-privacy-coin/src/zcash/mod.rs b/packages/wasm-privacy-coin/src/zcash/mod.rs new file mode 100644 index 00000000000..87821cc7075 --- /dev/null +++ b/packages/wasm-privacy-coin/src/zcash/mod.rs @@ -0,0 +1 @@ +pub mod tree; diff --git a/packages/wasm-privacy-coin/src/zcash/tree.rs b/packages/wasm-privacy-coin/src/zcash/tree.rs new file mode 100644 index 00000000000..2e06f66c366 --- /dev/null +++ b/packages/wasm-privacy-coin/src/zcash/tree.rs @@ -0,0 +1,516 @@ +use incrementalmerkletree::{Address, Hashable, Level, Marking, Position, Retention}; +use orchard::tree::MerkleHashOrchard; +use serde::{Deserialize, Serialize}; +use shardtree::{ + store::memory::MemoryShardStore, + store::{Checkpoint, ShardStore, TreeState}, + LocatedPrunableTree, Node, RetentionFlags, ShardTree, Tree, +}; +use std::collections::BTreeSet; +use std::sync::Arc; + +/// Shielded commitment tree depth +pub const DEPTH: u8 = 32; +/// Number of checkpoints to retain (allows reorgs up to this depth) +pub const MAX_CHECKPOINTS: usize = 100; +/// Shard height for the tree (log2 of shard size) +pub const SHARD_HEIGHT: u8 = 16; + +pub type ShieldedShardTree = + ShardTree, DEPTH, SHARD_HEIGHT>; + +type PrunableT = Tree>, (MerkleHashOrchard, RetentionFlags)>; + +// --------------------------------------------------------------------------- +// Structural serialization types (ported from coins-sandbox/orchard-wasm-shard) +// --------------------------------------------------------------------------- + +/// Serde-tagged enum mirroring `PrunableTree` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum TreeNode { + Nil, + Leaf { + h: String, + f: u8, + }, + Parent { + a: String, + l: Box, + r: Box, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedShard { + pub root_addr_level: u8, + pub root_addr_index: u64, + pub tree: TreeNode, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedCheckpoint { + pub id: u32, + pub position: Option, + pub marks_removed: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedShardTreeState { + pub shards: Vec, + pub cap: TreeNode, + pub checkpoints: Vec, + pub tip_height: Option, + pub leaf_count: u64, +} + +// --------------------------------------------------------------------------- +// Tree <-> TreeNode conversion +// --------------------------------------------------------------------------- + +fn serialize_tree(tree: &PrunableT) -> TreeNode { + match &**tree { + Node::Nil => TreeNode::Nil, + Node::Leaf { value: (h, flags) } => TreeNode::Leaf { + h: hex::encode(h.to_bytes()), + f: flags.bits(), + }, + Node::Parent { ann, left, right } => TreeNode::Parent { + a: ann + .as_ref() + .map(|h| hex::encode(h.to_bytes())) + .unwrap_or_default(), + l: Box::new(serialize_tree(left)), + r: Box::new(serialize_tree(right)), + }, + } +} + +fn deserialize_tree(node: &TreeNode, depth: u8) -> Result { + if depth > DEPTH { + return Err(format!("tree node depth exceeds maximum {}", DEPTH)); + } + match node { + TreeNode::Nil => Ok(Tree::empty()), + TreeNode::Leaf { h, f } => { + let hash = parse_hash_hex(h)?; + let flags = RetentionFlags::from_bits(*f) + .ok_or_else(|| format!("invalid retention flags: {}", f))?; + Ok(Tree::leaf((hash, flags))) + } + TreeNode::Parent { a, l, r } => { + let ann = if a.is_empty() { + None + } else { + Some(Arc::new(parse_hash_hex(a)?)) + }; + Ok(Tree::parent( + ann, + deserialize_tree(l, depth + 1)?, + deserialize_tree(r, depth + 1)?, + )) + } + } +} + +// --------------------------------------------------------------------------- +// ShardTree <-> PersistedShardTreeState extraction / restoration +// --------------------------------------------------------------------------- + +pub fn extract_state( + tree: &ShieldedShardTree, + tip_height: Option, + leaf_count: u64, +) -> Result { + let store = tree.store(); + + let shard_roots = store + .get_shard_roots() + .map_err(|e| format!("get_shard_roots error: {:?}", e))?; + + let mut shards: Vec = Vec::new(); + for addr in shard_roots { + let shard = store + .get_shard(addr) + .map_err(|e| format!("get_shard error: {:?}", e))?; + if let Some(shard) = shard { + shards.push(SerializedShard { + root_addr_level: shard.root_addr().level().into(), + root_addr_index: shard.root_addr().index(), + tree: serialize_tree(shard.root()), + }); + } + } + + let cap = serialize_tree( + &store + .get_cap() + .map_err(|e| format!("get_cap error: {:?}", e))?, + ); + + let count = store + .checkpoint_count() + .map_err(|e| format!("checkpoint_count error: {:?}", e))?; + + let mut checkpoints: Vec = Vec::new(); + if count > 0 { + store + .for_each_checkpoint(count, |id, cp| { + checkpoints.push(SerializedCheckpoint { + id: *id, + position: match cp.tree_state() { + TreeState::Empty => None, + TreeState::AtPosition(pos) => Some(u64::from(pos)), + }, + marks_removed: cp.marks_removed().iter().map(|p| u64::from(*p)).collect(), + }); + Ok(()) + }) + .map_err(|e| format!("for_each_checkpoint error: {:?}", e))?; + } + + Ok(PersistedShardTreeState { + shards, + cap, + checkpoints, + tip_height, + leaf_count, + }) +} + +pub fn restore_state(state: &PersistedShardTreeState) -> Result { + let mut store = MemoryShardStore::empty(); + + for s in &state.shards { + let addr = Address::from_parts(Level::from(s.root_addr_level), s.root_addr_index); + let tree = deserialize_tree(&s.tree, 0)?; + let located = LocatedPrunableTree::from_parts(addr, tree) + .map_err(|a| format!("invalid shard address: {:?}", a))?; + store + .put_shard(located) + .map_err(|e| format!("put_shard error: {:?}", e))?; + } + + let cap_tree = deserialize_tree(&state.cap, 0)?; + store + .put_cap(cap_tree) + .map_err(|e| format!("put_cap error: {:?}", e))?; + + for cp in &state.checkpoints { + let tree_state = match cp.position { + None => TreeState::Empty, + Some(pos) => TreeState::AtPosition(Position::from(pos)), + }; + let marks_removed: BTreeSet = cp + .marks_removed + .iter() + .map(|p| Position::from(*p)) + .collect(); + let checkpoint = Checkpoint::from_parts(tree_state, marks_removed); + store + .add_checkpoint(cp.id, checkpoint) + .map_err(|e| format!("add_checkpoint error: {:?}", e))?; + } + + Ok(ShardTree::new(store, MAX_CHECKPOINTS)) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Parse a hex-encoded hash string into a MerkleHashOrchard. +pub fn parse_hash_hex(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str).map_err(|e| format!("hex decode error: {}", e))?; + parse_hash_bytes(&bytes) +} + +/// Parse a raw 32-byte slice into a MerkleHashOrchard. +pub fn parse_hash_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(format!("expected 32 bytes, got {}", bytes.len())); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(bytes); + Option::from(MerkleHashOrchard::from_bytes(&arr)) + .ok_or_else(|| "invalid MerkleHashOrchard value".to_string()) +} + +/// Get the root hash at the most recent checkpoint as raw bytes. +/// +/// We use `root_at_checkpoint_depth(Some(0))` — the root computed from the last checkpoint +/// — rather than the tree's `frontier()` because after a `save()`/`from_state()` round-trip +/// the in-memory frontier is gone; only checkpoint data survives serialization. +/// This approach produces the correct root in both the live-tree and restored-tree cases. +fn get_checkpoint_root_bytes(tree: &ShieldedShardTree) -> Result, String> { + let root = tree + .root_at_checkpoint_depth(Some(0)) + .map_err(|e| format!("root_at_checkpoint error: {:?}", e))? + .ok_or("no checkpoint available to compute root")?; + Ok(root.to_bytes().to_vec()) +} + +fn read_compact_size(data: &[u8]) -> Result<(u64, usize), String> { + if data.is_empty() { + return Err("unexpected EOF reading compact size".to_string()); + } + match data[0] { + 0..=252 => Ok((data[0] as u64, 1)), + 253 => { + if data.len() < 3 { + return Err("unexpected EOF reading compact size u16".to_string()); + } + Ok((u16::from_le_bytes([data[1], data[2]]) as u64, 3)) + } + 254 => { + if data.len() < 5 { + return Err("unexpected EOF reading compact size u32".to_string()); + } + Ok(( + u32::from_le_bytes([data[1], data[2], data[3], data[4]]) as u64, + 5, + )) + } + 255 => { + if data.len() < 9 { + return Err("unexpected EOF reading compact size u64".to_string()); + } + Ok(( + u64::from_le_bytes([ + data[1], data[2], data[3], data[4], data[5], data[6], data[7], data[8], + ]), + 9, + )) + } + } +} + +fn read_hash(data: &[u8]) -> Result<(MerkleHashOrchard, usize), String> { + if data.len() < 32 { + return Err("unexpected EOF reading hash".to_string()); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&data[..32]); + let hash = Option::from(MerkleHashOrchard::from_bytes(&arr)) + .ok_or_else(|| "invalid MerkleHashOrchard value in frontier".to_string())?; + Ok((hash, 32)) +} + +fn read_optional_hash(data: &[u8]) -> Result<(Option, usize), String> { + if data.is_empty() { + return Err("unexpected EOF reading optional flag".to_string()); + } + match data[0] { + 0x00 => Ok((None, 1)), + 0x01 => { + let (hash, n) = read_hash(&data[1..])?; + Ok((Some(hash), 1 + n)) + } + b => Err(format!("invalid optional flag byte: 0x{:02x}", b)), + } +} + +// --------------------------------------------------------------------------- +// OwnedTree — per-instance tree state (replaces the global TREE mutex) +// --------------------------------------------------------------------------- + +/// Self-contained shielded tree with its own in-memory state. +/// +/// One `OwnedTree` per Chicory WASM instance; each instance owns its own linear memory. +pub struct OwnedTree { + tree: ShieldedShardTree, + tip_height: Option, + leaf_count: u64, +} + +impl OwnedTree { + /// Restore from bytes produced by [`OwnedTree::save`]. + pub fn from_state(state: &[u8]) -> Result { + let json = std::str::from_utf8(state).map_err(|e| format!("invalid UTF-8: {}", e))?; + let persisted: PersistedShardTreeState = + serde_json::from_str(json).map_err(|e| format!("JSON parse error: {}", e))?; + let tree = restore_state(&persisted)?; + Ok(Self { + tree, + tip_height: persisted.tip_height, + leaf_count: persisted.leaf_count, + }) + } + + /// Initialize from a CommitmentTree v0 frontier (raw bytes, not hex-encoded). + pub fn from_frontier(frontier: &[u8], block_height: u32) -> Result { + use incrementalmerkletree::frontier::NonEmptyFrontier; + + let mut offset = 0; + + let (left, n) = read_optional_hash(&frontier[offset..])?; + offset += n; + + let (right, n) = read_optional_hash(&frontier[offset..])?; + offset += n; + + let (parent_count, n) = read_compact_size(&frontier[offset..])?; + offset += n; + + if parent_count > DEPTH as u64 { + return Err(format!( + "parent_count {} exceeds tree depth {}", + parent_count, DEPTH + )); + } + + let mut parents: Vec> = Vec::with_capacity(parent_count as usize); + for _ in 0..parent_count { + let (parent, n) = read_optional_hash(&frontier[offset..])?; + offset += n; + parents.push(parent); + } + + if offset != frontier.len() { + return Err(format!( + "frontier has {} unexpected trailing bytes after offset {}", + frontier.len() - offset, + offset + )); + } + + let left = left.ok_or("commitment tree has no left leaf — tree is empty")?; + + let (leaf, mut ommers, mut position_val) = if let Some(right_hash) = right { + (right_hash, vec![left], 1u64) + } else { + (left, vec![], 0u64) + }; + + for (i, parent) in parents.iter().enumerate() { + if let Some(hash) = parent { + position_val |= 1u64 << (i + 1); + ommers.push(*hash); + } + } + + let position = Position::from(position_val); + let nef = NonEmptyFrontier::from_parts(position, leaf, ommers) + .map_err(|e| format!("frontier construction error: {:?}", e))?; + + let leaf_count = u64::from(nef.position()) + 1; + + let mut tree = ShardTree::new(MemoryShardStore::empty(), MAX_CHECKPOINTS); + tree.insert_frontier_nodes( + nef, + Retention::Checkpoint { + id: block_height, + marking: Marking::None, + }, + ) + .map_err(|e| format!("insert_frontier_nodes error: {}", e))?; + + Ok(Self { + tree, + tip_height: Some(block_height), + leaf_count, + }) + } + + /// Serialize the tree state to bytes (UTF-8 JSON of `PersistedShardTreeState`). + pub fn save(&self) -> Result, String> { + let state = extract_state(&self.tree, self.tip_height, self.leaf_count)?; + serde_json::to_vec(&state).map_err(|e| format!("JSON serialize error: {}", e)) + } + + /// Append raw 32-byte commitments, checkpoint at `block_height`, verify root. + /// + /// Returns the 32-byte root after appending. + pub fn append_commitments( + &mut self, + block_height: u32, + commitments: Vec>, + expected_root: Option<&[u8]>, + ) -> Result, String> { + if commitments.is_empty() { + self.tree + .checkpoint(block_height) + .map_err(|e| format!("checkpoint error: {}", e))?; + self.tip_height = Some(block_height); + if self.leaf_count == 0 { + let empty_root = MerkleHashOrchard::empty_root(Level::from(DEPTH)); + return Ok(empty_root.to_bytes().to_vec()); + } + return get_checkpoint_root_bytes(&self.tree); + } + + // Validate ALL commitments before mutating any tree state. + // This prevents a partial-append scenario where some leaves are inserted and then + // an invalid commitment causes an error, leaving the tree in an inconsistent state + // with orphaned leaf nodes that cannot be rolled back without a checkpoint. + let hashes: Result, String> = + commitments.iter().map(|c| parse_hash_bytes(c)).collect(); + let hashes = hashes?; + + let last_idx = hashes.len() - 1; + for (i, hash) in hashes.into_iter().enumerate() { + let retention = if i == last_idx { + Retention::Checkpoint { + id: block_height, + marking: Marking::None, + } + } else { + Retention::Ephemeral + }; + self.tree + .append(hash, retention) + .map_err(|e| format!("append error: {}", e))?; + self.leaf_count += 1; + } + + self.tip_height = Some(block_height); + + let root = get_checkpoint_root_bytes(&self.tree)?; + + if let Some(expected) = expected_root { + if !expected.is_empty() && root != expected { + return Err(format!( + "ROOT_MISMATCH: computed {} but expected {}", + hex::encode(&root), + hex::encode(expected) + )); + } + } + + Ok(root) + } + + /// Roll back to the checkpoint at `block_height`. + /// + /// Returns the 32-byte root at the restored checkpoint. + pub fn truncate_to_checkpoint(&mut self, block_height: u32) -> Result, String> { + let ok = self + .tree + .truncate_to_checkpoint(&block_height) + .map_err(|e| format!("truncate error: {:?}", e))?; + if !ok { + return Err(format!( + "CHECKPOINT_NOT_FOUND: no checkpoint for block height {}", + block_height + )); + } + self.tip_height = Some(block_height); + self.leaf_count = self + .tree + .max_leaf_position(Some(0)) + .map_err(|e| format!("max_leaf_position error: {:?}", e))? + .map(|p| u64::from(p) + 1) + .unwrap_or(0); + get_checkpoint_root_bytes(&self.tree) + } + + /// Return `(tip_height, leaf_count, checkpoint_count)`. + pub fn get_info(&self) -> Result<(Option, u64, u32), String> { + let checkpoint_count = self + .tree + .store() + .checkpoint_count() + .map_err(|e| format!("checkpoint_count error: {:?}", e))?; + Ok((self.tip_height, self.leaf_count, checkpoint_count as u32)) + } +}