From bbc8079391b9e59170748b62828418dc57fcbd9c Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Tue, 7 Jul 2026 15:53:38 -0400 Subject: [PATCH 01/12] test(privacy): add privacy-preserving test coverage for token and ata programs Validates the Q2 privacy features (shield/deshield, private-to-private transfers, existing-account crediting, group-owned accounts, and private PDAs) against the token and ata program flows, which previously ran almost entirely in public context. Adds the key_protocol dependency for GMS-based group-account tests and introduces a docs/privacy-test-matrix.md tracker mapping each program/instruction/ privacy-dimension combination to pass, fail, or not-expressible, with root-cause findings for each gap (notably that private PDAs are structurally unsupported by any program currently deriving addresses via for_public_pda). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 3740 ++++++++++++++++++++- docs/privacy-test-matrix.md | 337 ++ programs/integration_tests/Cargo.toml | 1 + programs/integration_tests/tests/ata.rs | 540 ++- programs/integration_tests/tests/token.rs | 988 +++++- 5 files changed, 5527 insertions(+), 79 deletions(-) create mode 100644 docs/privacy-test-matrix.md diff --git a/Cargo.lock b/Cargo.lock index a4aec91a..a606a84b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,47 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array 0.14.9", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -54,7 +95,7 @@ dependencies = [ "rustc-hash", "secp256k1", "serde", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -116,6 +157,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "archery" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" +dependencies = [ + "triomphe", +] + [[package]] name = "ark-bn254" version = "0.5.0" @@ -364,7 +414,7 @@ dependencies = [ "ark-ff 0.5.0", "ark-std 0.5.0", "tracing", - "tracing-subscriber", + "tracing-subscriber 0.2.25", ] [[package]] @@ -460,12 +510,160 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[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.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "asn1_der" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156" + +[[package]] +name = "astro-float" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96034cc871c05bb65ad7fb77e6a8bebf45d8b055ed0311769e2f83a1d373c1ec" +dependencies = [ + "astro-float-macro", + "astro-float-num", +] + +[[package]] +name = "astro-float-macro" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cfe0f6df5a74fb25b9e713470ad77e091f35f798730673c8772f26ed438963" +dependencies = [ + "astro-float-num", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "astro-float-num" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86887daca11d02e0b04f37a9cb81888aae881397fb48ff66494e356aea97554a" +dependencies = [ + "itertools 0.10.5", + "lazy_static", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "ata-methods" version = "0.1.0" @@ -500,6 +698,37 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9a9bf8b79a749ee0b911b91b671cc2b6c670bdbc7e3dfd537576ddc94bb2a2" +dependencies = [ + "http 0.2.12", + "log", + "url", +] + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http 1.4.2", + "log", + "url", +] + +[[package]] +name = "authenticated_transfer_core" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +dependencies = [ + "serde", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -517,12 +746,97 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "tokio", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + [[package]] name = "base58" version = "0.2.0" @@ -550,6 +864,17 @@ dependencies = [ "serde", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -647,7 +972,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -696,6 +1021,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "bridge_core" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +dependencies = [ + "lee_core", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -831,10 +1165,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", "cpufeatures 0.3.0", ] +[[package]] +name = "chkstk_stub" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "047f6ab2f3b9bcaf23b593d1580898e4244d27eadf1a1fae99212ee5735d3d1c" +dependencies = [ + "cc", +] + [[package]] name = "chrono" version = "0.4.45" @@ -847,6 +1190,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout 0.1.4", +] + [[package]] name = "cipher" version = "0.5.2" @@ -855,7 +1208,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.2", ] [[package]] @@ -883,17 +1236,49 @@ dependencies = [ ] [[package]] -name = "const-hex" -version = "1.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +name = "common" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "proptest", - "serde_core", -] - + "anyhow", + "authenticated_transfer_core", + "base64", + "borsh", + "clock_core", + "hex", + "lee", + "lee_core", + "log", + "logos-blockchain-common-http-client", + "programs", + "serde", + "serde_with", + "sha2", + "system_accounts", + "thiserror 2.0.18", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -906,6 +1291,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + [[package]] name = "const_format" version = "0.2.36" @@ -927,6 +1318,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -981,6 +1381,39 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crunchy" version = "0.2.4" @@ -993,7 +1426,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.9", "rand_core 0.6.4", "subtle", "zeroize", @@ -1005,7 +1438,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array", + "generic-array 0.14.9", + "rand_core 0.6.4", "typenum", ] @@ -1020,6 +1454,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1029,6 +1472,34 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version 0.4.1", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.20.11" @@ -1098,6 +1569,32 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.118", +] + [[package]] name = "der" version = "0.7.10" @@ -1119,6 +1616,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1185,7 +1696,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version 0.4.1", @@ -1199,7 +1710,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.9", ] [[package]] @@ -1256,6 +1767,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "docker-generate" version = "0.1.3" @@ -1268,6 +1790,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + [[package]] name = "duplicate" version = "2.0.1" @@ -1300,6 +1828,32 @@ dependencies = [ "spki 0.7.3", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "educe" version = "0.6.0" @@ -1334,7 +1888,7 @@ dependencies = [ "crypto-bigint", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.9", "group", "pem-rfc7468", "pkcs8 0.10.2", @@ -1366,6 +1920,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "enum-ordinalize" version = "4.4.1" @@ -1402,6 +1968,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1440,6 +2027,26 @@ dependencies = [ "bytes", ] +[[package]] +name = "faucet_core" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +dependencies = [ + "lee_core", + "serde", +] + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "ff" version = "0.13.1" @@ -1450,6 +2057,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1468,6 +2091,16 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1528,6 +2161,31 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-bounded" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +dependencies = [ + "futures-timer", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1544,12 +2202,33 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1561,6 +2240,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -1573,12 +2263,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1601,15 +2298,26 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "generic-array" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "c2e55f16dcf0e9c00efbe2e655ffe45fc98e7066b52bc92f8a79e64060a79351" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", + "rustversion", + "serde_core", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", "wasm-bindgen", ] @@ -1639,6 +2347,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "group" version = "0.13.0" @@ -1650,12 +2368,40 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1663,6 +2409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -1675,6 +2422,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1690,6 +2446,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1720,6 +2482,68 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "hex_fmt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" + +[[package]] +name = "hickory-proto" +version = "0.25.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d00147af6310f4392a31680db52a3ed45a2e0f68eb18e8c3fe5537ecc96d9e2" +dependencies = [ + "async-recursion", + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "socket2 0.5.10", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5762f69ebdbd4ddb2e975cd24690bf21fe6b2604039189c26acddbc427f12887" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1729,6 +2553,34 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac-sha512" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.2" @@ -1746,7 +2598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.2", ] [[package]] @@ -1757,7 +2609,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -1768,6 +2620,18 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -1788,9 +2652,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", + "h2", + "http 1.4.2", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1804,7 +2670,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.2", "hyper", "hyper-util", "rustls", @@ -1814,6 +2680,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1824,14 +2703,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.2", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -1979,6 +2858,81 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" +dependencies = [ + "async-io", + "core-foundation", + "fnv", + "futures", + "if-addrs", + "ipnet", + "log", + "netlink-packet-core 0.8.1", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "rtnetlink", + "system-configuration 0.7.0", + "tokio", + "windows", +] + +[[package]] +name = "igd-next" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b0d7d4541def58a37bf8efc559683f21edce7c82f0d866c93ac21f7e098f93" +dependencies = [ + "async-trait", + "attohttpc 0.24.1", + "bytes", + "futures", + "http 1.4.2", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.8.6", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc 0.30.1", + "bytes", + "futures", + "http 1.4.2", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.4", + "tokio", + "url", + "xmltree", +] + [[package]] name = "impl-codec" version = "0.6.0" @@ -2034,6 +2988,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.9", +] + [[package]] name = "inout" version = "0.2.2" @@ -2052,6 +3015,7 @@ dependencies = [ "ata-methods", "ata_core", "clock_core", + "key_protocol", "lee", "lee_core", "stablecoin-methods", @@ -2062,6 +3026,19 @@ dependencies = [ "twap_oracle_core", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.4", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2101,6 +3078,31 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jf-crhf" +version = "0.2.0" +source = "git+https://github.com/EspressoSystems/jellyfish?tag=jf-crhf-v0.2.0#f1538793f7f0e391495cb17bbb0c8703ec5f689d" +dependencies = [ + "ark-serialize 0.5.0", + "ark-std 0.5.0", +] + +[[package]] +name = "jf-poseidon2" +version = "0.2.0" +source = "git+https://github.com/EspressoSystems/jellyfish.git?rev=8d80230358e900f8d63765a937f63f4978ca1daa#8d80230358e900f8d63765a937f63f4978ca1daa" +dependencies = [ + "ark-bn254", + "ark-ff 0.5.0", + "ark-std 0.5.0", + "displaydoc", + "hex", + "jf-crhf", + "lazy_static", + "spongefish", + "zeroize", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -2166,6 +3168,28 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "key_protocol" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +dependencies = [ + "aes-gcm", + "anyhow", + "bip39", + "common", + "hex", + "hmac-sha512", + "itertools 0.14.0", + "k256", + "lee", + "lee_core", + "ml-kem", + "rand 0.8.6", + "serde", + "sha2", + "thiserror 2.0.18", +] + [[package]] name = "konst" version = "0.2.20" @@ -2264,32 +3288,1140 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "libredox" -version = "0.1.18" +name = "libp2p" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "b72dc443ddd0254cb49a794ed6b6728400ee446a0f7ab4a07d0209ee98de20e9" dependencies = [ - "libc", + "bytes", + "either", + "futures", + "futures-timer", + "getrandom 0.2.17", + "libp2p-allow-block-list", + "libp2p-autonat", + "libp2p-connection-limits", + "libp2p-core", + "libp2p-dns", + "libp2p-gossipsub", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-mdns", + "libp2p-metrics", + "libp2p-quic", + "libp2p-swarm", + "libp2p-tcp", + "libp2p-upnp", + "multiaddr", + "pin-project", + "rw-stream-sink", + "thiserror 2.0.18", ] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "libp2p-allow-block-list" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "38944b7cb981cc93f2f0fb411ff82d0e983bd226fbcc8d559639a3a73236568b" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] [[package]] -name = "litemap" -version = "0.8.2" +name = "libp2p-autonat" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "e297bfc6cabb70c6180707f8fa05661b77ecb9cb67e8e8e1c469301358fa21d0" +dependencies = [ + "async-trait", + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-request-response", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "rand_core 0.6.4", + "thiserror 2.0.18", + "tracing", + "web-time", +] [[package]] -name = "log" -version = "0.4.33" +name = "libp2p-connection-limits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efe9323175a17caa8a2ed4feaf8a548eeef5e0b72d03840a0eab4bcb0210ce1c" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-core" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249128cd37a2199aff30a7675dffa51caf073b51aa612d2f544b19932b9aebca" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-identity", + "multiaddr", + "multihash", + "multistream-select", + "parking_lot", + "pin-project", + "quick-protobuf", + "rand 0.8.6", + "rw-stream-sink", + "thiserror 2.0.18", + "tracing", + "unsigned-varint 0.8.0", + "web-time", +] + +[[package]] +name = "libp2p-dns" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b780a1150214155b0ed1cdf09fbd2e1b0442604f9146a431d1b21d23eef7bd7" +dependencies = [ + "async-trait", + "futures", + "hickory-resolver", + "libp2p-core", + "libp2p-identity", + "parking_lot", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-gossipsub" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d558548fa3b5a8e9b66392f785921e363c57c05dcadfda4db0d41ae82d313e4a" +dependencies = [ + "async-channel", + "asynchronous-codec", + "base64", + "byteorder", + "bytes", + "either", + "fnv", + "futures", + "futures-timer", + "getrandom 0.2.17", + "hashlink 0.9.1", + "hex_fmt", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "prometheus-client", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "regex", + "serde", + "sha2", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-identify" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c06862544f02d05d62780ff590cc25a75f5c2b9df38ec7a370dcae8bb873cf" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "libp2p-identity" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c" +dependencies = [ + "asn1_der", + "bs58", + "ed25519-dalek", + "hkdf", + "k256", + "multihash", + "prost 0.14.4", + "rand 0.8.6", + "serde", + "sha2", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "libp2p-kad" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bab0466a27ebe955bcbc27328fae5429c5b48c915fd6174931414149802ec23" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "fnv", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "serde", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tracing", + "uint 0.10.0", + "web-time", +] + +[[package]] +name = "libp2p-mdns" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d0ba095e1175d797540e16b62e7576846b883cb5046d4159086837b36846cc" +dependencies = [ + "futures", + "hickory-proto", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-metrics" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce58c64292e87af624fcb86465e7dd8342e46a388d71e8fec0ab37ee789630a" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-gossipsub", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-swarm", + "pin-project", + "prometheus-client", + "web-time", +] + +[[package]] +name = "libp2p-quic" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41432a159b00424a0abaa2c80d786cddff81055ac24aa127e0cf375f7858d880" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-tls", + "quinn", + "rand 0.8.6", + "ring", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-request-response" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548fe44a80ff275d400f1b26b090d441d83ef73efabbeb6415f4ce37e5aed865" +dependencies = [ + "async-trait", + "futures", + "futures-bounded", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-stream" +version = "0.3.0-alpha" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826716f1ee125895f1fb44911413cba023485b552ff96c7a2159bd037ac619bb" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "tracing", +] + +[[package]] +name = "libp2p-swarm" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803399b4b6f68adb85e63ab573ac568154b193e9a640f03e0f2890eabbcb37f8" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm-derive", + "lru", + "multistream-select", + "once_cell", + "rand 0.8.6", + "smallvec", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-swarm-derive" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206e0aa0ebe004d778d79fb0966aa0de996c19894e2c0605ba2f8524dd4443d8" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "libp2p-tcp" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65346fb4d36035b23fec4e7be4c320436ba53537ce9b6be1d1db1f70c905cad0" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libc", + "libp2p-core", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-tls" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080" +dependencies = [ + "futures", + "futures-rustls", + "libp2p-core", + "libp2p-identity", + "rcgen", + "ring", + "rustls", + "rustls-webpki", + "thiserror 2.0.18", + "x509-parser", + "yasna", +] + +[[package]] +name = "libp2p-upnp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d457b9ecceb66e7199f049926fad447f1f17f040e8d29d690c086b4cab8ed14a" +dependencies = [ + "futures", + "futures-timer", + "igd-next 0.15.1", + "libp2p-core", + "libp2p-swarm", + "tokio", + "tracing", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "light-poseidon" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a1ccadd0bb5a32c196da536fd72c59183de24a055f6bf0513bf845fefab862" +dependencies = [ + "ark-bn254", + "ark-ff 0.5.0", + "num-bigint", + "thiserror 1.0.69", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos-blockchain-blend-crypto" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "blake2", + "logos-blockchain-groth16", + "logos-blockchain-poq", + "logos-blockchain-poseidon2", + "logos-blockchain-utils", + "rs-merkle-tree", + "thiserror 2.0.18", +] + +[[package]] +name = "logos-blockchain-blend-message" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "blake2", + "derivative", + "hex", + "itertools 0.14.0", + "logos-blockchain-blend-crypto", + "logos-blockchain-blend-proofs", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-log-targets", + "logos-blockchain-utils", + "serde", + "serde_with", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "logos-blockchain-blend-proofs" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "ed25519-dalek", + "generic-array 1.4.3", + "hex", + "logos-blockchain-blend-crypto", + "logos-blockchain-groth16", + "logos-blockchain-pol", + "logos-blockchain-poq", + "logos-blockchain-poseidon2", + "logos-blockchain-utils", + "num-bigint", + "serde", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "logos-blockchain-chain-broadcast-service" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "derivative", + "logos-blockchain-core", + "overwatch", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "logos-blockchain-chain-service" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "bytes", + "derivative", + "futures", + "logos-blockchain-chain-broadcast-service", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-cryptarchia-sync", + "logos-blockchain-groth16", + "logos-blockchain-ledger", + "logos-blockchain-network-service", + "logos-blockchain-services-utils", + "logos-blockchain-storage-service", + "logos-blockchain-time-service", + "logos-blockchain-tracing", + "logos-blockchain-utils", + "overwatch", + "serde", + "serde_with", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "tracing-futures", +] + +[[package]] +name = "logos-blockchain-circuits-build" +version = "0.5.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +dependencies = [ + "dirs", + "fd-lock", + "flate2", + "tar", + "ureq", +] + +[[package]] +name = "logos-blockchain-circuits-common" +version = "0.5.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +dependencies = [ + "logos-blockchain-circuits-types", +] + +[[package]] +name = "logos-blockchain-circuits-poc-sys" +version = "0.5.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +dependencies = [ + "logos-blockchain-circuits-build", + "logos-blockchain-circuits-common", + "logos-blockchain-circuits-types", +] + +[[package]] +name = "logos-blockchain-circuits-pol-sys" +version = "0.5.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +dependencies = [ + "logos-blockchain-circuits-build", + "logos-blockchain-circuits-common", + "logos-blockchain-circuits-types", +] + +[[package]] +name = "logos-blockchain-circuits-poq-sys" +version = "0.5.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +dependencies = [ + "logos-blockchain-circuits-build", + "logos-blockchain-circuits-common", + "logos-blockchain-circuits-types", +] + +[[package]] +name = "logos-blockchain-circuits-prover" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "rust-rapidsnark", +] + +[[package]] +name = "logos-blockchain-circuits-signature-sys" +version = "0.5.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +dependencies = [ + "logos-blockchain-circuits-build", + "logos-blockchain-circuits-common", + "logos-blockchain-circuits-types", +] + +[[package]] +name = "logos-blockchain-circuits-types" +version = "0.5.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +dependencies = [ + "bytes", + "libc", +] + +[[package]] +name = "logos-blockchain-common-http-client" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "futures", + "hex", + "log", + "logos-blockchain-chain-broadcast-service", + "logos-blockchain-chain-service", + "logos-blockchain-core", + "logos-blockchain-groth16", + "logos-blockchain-http-api-common", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-log-targets", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio-util", + "url", +] + +[[package]] +name = "logos-blockchain-core" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "ark-ff 0.5.0", + "bincode", + "blake2", + "bytes", + "const-hex", + "futures", + "hex", + "logos-blockchain-blend-proofs", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-log-targets", + "logos-blockchain-mmr", + "logos-blockchain-poc", + "logos-blockchain-pol", + "logos-blockchain-poseidon2", + "logos-blockchain-utils", + "logos-blockchain-utxotree", + "multiaddr", + "nom 8.0.0", + "num-bigint", + "rpds", + "serde", + "strum", + "thiserror 2.0.18", + "time", + "tracing", +] + +[[package]] +name = "logos-blockchain-cryptarchia-engine" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "logos-blockchain-pol", + "logos-blockchain-utils", + "serde", + "serde_with", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "logos-blockchain-cryptarchia-sync" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "bytes", + "futures", + "libp2p", + "libp2p-stream", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "rand 0.8.6", + "serde", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "logos-blockchain-groth16" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "ark-bn254", + "ark-ec", + "ark-ff 0.5.0", + "ark-groth16", + "ark-serialize 0.5.0", + "generic-array 1.4.3", + "hex", + "num-bigint", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "logos-blockchain-http-api-common" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "axum", + "logos-blockchain-core", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-log-targets", + "logos-blockchain-tracing", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with", + "time", + "tracing", + "url", + "utoipa", + "validator", +] + +[[package]] +name = "logos-blockchain-key-management-system-keys" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "bytes", + "ed25519-dalek", + "generic-array 1.4.3", + "hex", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-macros", + "logos-blockchain-log-targets", + "logos-blockchain-poseidon2", + "logos-blockchain-utils", + "logos-blockchain-zksign", + "num-bigint", + "rand_core 0.6.4", + "serde", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "logos-blockchain-key-management-system-macros" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "logos-blockchain-ledger" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "derivative", + "logos-blockchain-blend-crypto", + "logos-blockchain-blend-message", + "logos-blockchain-blend-proofs", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-mmr", + "logos-blockchain-pol", + "logos-blockchain-utils", + "logos-blockchain-utxotree", + "num-bigint", + "rand 0.8.6", + "rpds", + "serde", + "serde_arrays", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "logos-blockchain-libp2p" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "backon", + "blake2", + "either", + "futures", + "hex", + "igd-next 0.16.2", + "libp2p", + "logos-blockchain-cryptarchia-sync", + "logos-blockchain-log-targets", + "logos-blockchain-utils", + "multiaddr", + "natpmp", + "netdev", + "num_enum", + "rand 0.8.6", + "serde", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tracing", + "zerocopy", +] + +[[package]] +name = "logos-blockchain-log-targets" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "logos-blockchain-log-targets-macros", +] + +[[package]] +name = "logos-blockchain-log-targets-macros" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "logos-blockchain-mmr" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "ark-ff 0.5.0", + "logos-blockchain-groth16", + "logos-blockchain-poseidon2", + "rpds", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "logos-blockchain-network-service" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "futures", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-sync", + "logos-blockchain-libp2p", + "logos-blockchain-log-targets", + "logos-blockchain-tracing", + "overwatch", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "logos-blockchain-poc" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "logos-blockchain-circuits-poc-sys", + "logos-blockchain-circuits-prover", + "logos-blockchain-circuits-types", + "logos-blockchain-groth16", + "logos-blockchain-log-targets", + "logos-blockchain-proofs-error", + "num-bigint", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "logos-blockchain-pol" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "astro-float", + "logos-blockchain-circuits-pol-sys", + "logos-blockchain-circuits-prover", + "logos-blockchain-circuits-types", + "logos-blockchain-groth16", + "logos-blockchain-log-targets", + "logos-blockchain-proofs-error", + "logos-blockchain-utils", + "num-bigint", + "num-traits", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "logos-blockchain-poq" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "logos-blockchain-circuits-poq-sys", + "logos-blockchain-circuits-prover", + "logos-blockchain-circuits-types", + "logos-blockchain-groth16", + "logos-blockchain-log-targets", + "logos-blockchain-pol", + "logos-blockchain-proofs-error", + "num-bigint", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "logos-blockchain-poseidon2" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "ark-bn254", + "ark-ff 0.5.0", + "jf-poseidon2", + "num-bigint", +] + +[[package]] +name = "logos-blockchain-proofs-error" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "logos-blockchain-circuits-types", + "logos-blockchain-groth16", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "logos-blockchain-services-utils" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "futures", + "log", + "logos-blockchain-log-targets", + "overwatch", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "logos-blockchain-storage-service" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "bytes", + "futures", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-log-targets", + "logos-blockchain-tracing", + "overwatch", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "logos-blockchain-time-service" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "futures", + "log", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-log-targets", + "logos-blockchain-tracing", + "logos-blockchain-utils", + "overwatch", + "serde", + "serde_with", + "sntpc", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "logos-blockchain-tracing" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "flate2", + "logos-blockchain-log-targets", + "opentelemetry", + "opentelemetry-appender-tracing", + "opentelemetry-http", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "rand 0.8.6", + "serde", + "tokio", + "tonic", + "tracing", + "tracing-appender", + "tracing-gelf", + "tracing-loki", + "tracing-opentelemetry", + "tracing-subscriber 0.3.23", + "url", +] + +[[package]] +name = "logos-blockchain-utils" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "async-trait", + "blake2", + "cipher 0.4.4", + "const-hex", + "humantime", + "logos-blockchain-log-targets", + "overwatch", + "rand 0.8.6", + "serde", + "serde_ignored", + "serde_with", + "serde_yaml", + "thiserror 2.0.18", + "time", + "tracing", +] + +[[package]] +name = "logos-blockchain-utxotree" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "ark-ff 0.5.0", + "logos-blockchain-groth16", + "logos-blockchain-poseidon2", + "num-bigint", + "rpds", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "logos-blockchain-zksign" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +dependencies = [ + "logos-blockchain-circuits-prover", + "logos-blockchain-circuits-signature-sys", + "logos-blockchain-circuits-types", + "logos-blockchain-groth16", + "logos-blockchain-log-targets", + "logos-blockchain-poseidon2", + "logos-blockchain-proofs-error", + "num-bigint", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "loki-api" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdc38a304f59a03e6efa3876766a48c70a766a93f88341c3fff4212834b8e327" +dependencies = [ + "prost 0.13.5", + "prost-types", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2305,6 +4437,38 @@ dependencies = [ "libc", ] +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "maybe-async" version = "0.2.11" @@ -2349,6 +4513,28 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -2371,7 +4557,7 @@ dependencies = [ "module-lattice", "pkcs8 0.11.0", "rand_core 0.10.1", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -2385,12 +4571,239 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multiaddr" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6351f60b488e04c1d21bc69e56b89cb3f5e8f5d22557d6e8031bdfd79b6961" +dependencies = [ + "arrayref", + "byteorder", + "data-encoding", + "libp2p-identity", + "multibase", + "multihash", + "percent-encoding", + "serde", + "static_assertions", + "unsigned-varint 0.8.0", + "url", +] + +[[package]] +name = "multibase" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" +dependencies = [ + "base-x", + "base256emoji", + "base45", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "serde", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "multistream-select" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19" +dependencies = [ + "bytes", + "futures", + "log", + "pin-project", + "smallvec", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "natpmp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77366fa8ce34e2e1322dd97da65f11a62f451bd3daae8be6993c00800f61dd07" +dependencies = [ + "async-trait", + "cc", + "netdev", + "tokio", +] + +[[package]] +name = "netdev" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f901362e84cd407be6f8cd9d3a46bccf09136b095792785401ea7d283c79b91d" +dependencies = [ + "dlopen2", + "ipnet", + "libc", + "netlink-packet-core 0.7.0", + "netlink-packet-route 0.17.1", + "netlink-sys", + "once_cell", + "system-configuration 0.6.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053998cea5a306971f88580d0829e90f270f940befd7cf928da179d4187a5a66" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "byteorder", + "libc", + "netlink-packet-core 0.7.0", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core 0.8.1", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror 1.0.69", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core 0.8.1", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_strings" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5b0c77c1b780822bc749a33e39aeb2c07584ab93332303babeabb645298a76e" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2483,6 +4896,15 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2490,10 +4912,135 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", +] + +[[package]] +name = "opentelemetry-appender-tracing" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber 0.3.23", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.2", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +dependencies = [ + "http 1.4.2", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost 0.14.4", + "reqwest", + "thiserror 2.0.18", + "tokio", + "tonic", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost 0.14.4", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.4", + "thiserror 2.0.18", + "tokio", + "tokio-stream", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "overwatch" +version = "0.1.0" +source = "git+https://github.com/logos-co/Overwatch?rev=448c192#448c192895b8311c742b1726a1bb12ee314ad95c" +dependencies = [ + "async-trait", + "futures", + "overwatch-derive", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "overwatch-derive" +version = "0.1.0" +source = "git+https://github.com/logos-co/Overwatch?rev=448c192#448c192895b8311c742b1726a1bb12ee314ad95c" +dependencies = [ + "convert_case 0.8.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "parity-scale-codec" @@ -2523,12 +5070,51 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2554,6 +5140,26 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2591,6 +5197,38 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "postcard" version = "1.1.3" @@ -2635,7 +5273,7 @@ checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" dependencies = [ "fixed-hash", "impl-codec", - "uint", + "uint 0.9.5", ] [[package]] @@ -2647,6 +5285,52 @@ dependencies = [ "toml_edit 0.25.12+spec-1.1.0", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2668,6 +5352,38 @@ dependencies = [ "version_check", ] +[[package]] +name = "programs" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +dependencies = [ + "build_utils", + "lee", +] + +[[package]] +name = "prometheus-client" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504ee9ff529add891127c4827eb481bd69dc0ebc72e9a682e187db4caa60c3ca" +dependencies = [ + "dtoa", + "itoa", + "parking_lot", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2694,7 +5410,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -2710,12 +5436,56 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-protobuf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" +dependencies = [ + "byteorder", +] + +[[package]] +name = "quick-protobuf-codec" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474" +dependencies = [ + "asynchronous-codec", + "bytes", + "quick-protobuf", + "thiserror 1.0.69", + "unsigned-varint 0.8.0", +] + [[package]] name = "quinn" version = "0.11.11" @@ -2724,12 +5494,13 @@ checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", + "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -2766,7 +5537,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -2881,6 +5652,28 @@ dependencies = [ "rustversion", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2952,7 +5745,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", + "h2", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -2983,6 +5777,12 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -3193,7 +5993,7 @@ dependencies = [ "derive_more", "hex", "lazy-regex", - "prost", + "prost 0.13.5", "risc0-binfmt", "risc0-build", "risc0-circuit-keccak", @@ -3240,6 +6040,17 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rpds" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e025feb26210bc196b908e72deb063b1b4000754304341cbc168a1e72c857ebc" +dependencies = [ + "archery", + "serde", + "smallvec", +] + [[package]] name = "rrs-lib" version = "0.1.0" @@ -3250,6 +6061,25 @@ dependencies = [ "paste", ] +[[package]] +name = "rs-merkle-tree" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a3ef170810c387d31b64c0b59734abb0839dac2a8d137909e271bfdec9b1e0" +dependencies = [ + "ark-bn254", + "ark-ff 0.5.0", + "byteorder", + "futures", + "light-poseidon", + "quote", + "rand 0.9.4", + "syn 1.0.109", + "thiserror 2.0.18", + "tiny-keccak", + "tokio", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3270,6 +6100,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core 0.8.1", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "nix", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "ruint" version = "1.17.0" @@ -3305,6 +6153,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +[[package]] +name = "rust-rapidsnark" +version = "0.1.3" +source = "git+https://github.com/logos-blockchain/logos-blockchain-rust-rapidsnark.git?rev=e91187f8ccb5bbfc7bb00dac88169112428da78f#e91187f8ccb5bbfc7bb00dac88169112428da78f" +dependencies = [ + "anyhow", + "cc", + "chkstk_stub", + "num-bigint", + "num-traits", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3335,6 +6195,15 @@ dependencies = [ "semver 1.0.28", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3354,6 +6223,7 @@ version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -3401,6 +6271,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rw-stream-sink" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1" +dependencies = [ + "futures", + "pin-project", + "static_assertions", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3450,6 +6331,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -3458,7 +6345,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der 0.7.10", - "generic-array", + "generic-array 0.14.9", "pkcs8 0.10.2", "serdect", "subtle", @@ -3523,6 +6410,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_arrays" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a16b99c5ea4fe3daccd14853ad260ec00ea043b2708d1fd1da3106dcd8d9df" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3543,6 +6439,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -3557,6 +6463,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3610,6 +6527,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serdect" version = "0.2.0" @@ -3631,6 +6561,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + [[package]] name = "sha3" version = "0.11.0" @@ -3651,12 +6591,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3667,6 +6626,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -3679,6 +6644,32 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + +[[package]] +name = "sntpc" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f778a0f82b3cf5d75f858eceee38e84d5292f1d03415e88cc4ec45ca6ba8a2" +dependencies = [ + "cfg-if", + "tokio", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -3745,6 +6736,24 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "spongefish" +version = "0.2.0" +source = "git+https://github.com/arkworks-rs/spongefish.git?rev=3ded547f7f56d7f8a1fc4c9a5c0ce965310bba5f#3ded547f7f56d7f8a1fc4c9a5c0ce965310bba5f" +dependencies = [ + "ark-ec", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "digest 0.10.7", + "hex", + "keccak 0.1.6", + "rand 0.8.6", + "sha3 0.10.9", + "thiserror 2.0.18", + "zerocopy", + "zeroize", +] + [[package]] name = "stability" version = "0.2.1" @@ -3832,6 +6841,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -3874,12 +6889,73 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system_accounts" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +dependencies = [ + "bridge_core", + "clock_core", + "faucet_core", + "lee_core", + "programs", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3933,6 +7009,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.53" @@ -3963,6 +7048,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4024,11 +7118,25 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", - "socket2", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4039,6 +7147,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4123,6 +7243,43 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -4131,11 +7288,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4147,7 +7308,7 @@ dependencies = [ "bitflags 2.13.0", "bytes", "futures-util", - "http", + "http 1.4.2", "http-body", "pin-project-lite", "tower", @@ -4169,36 +7330,134 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "tracing" -version = "0.1.44" +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber 0.3.23", +] + +[[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 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-gelf" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c0170f1bf67b749d4377c2da1d99d6e722600051ee53870cfb6f618611e29e" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "hostname", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing-core", + "tracing-futures", + "tracing-subscriber 0.3.23", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-loki" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "a8d1ad78bf74c1790b0825ddc35ad1bb9498736c51c8437796b81cadf4916cd8" dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", + "loki-api", + "reqwest", + "serde", + "serde_json", + "snap", + "tokio", + "tracing", "tracing-core", + "tracing-log", + "tracing-serde", + "tracing-subscriber 0.3.23", + "url", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "tracing-opentelemetry" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-subscriber 0.3.23", + "web-time", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tracing-serde" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ - "once_cell", - "valuable", + "serde", + "tracing-core", ] [[package]] @@ -4210,6 +7469,30 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" + [[package]] name = "try-lock" version = "0.2.5" @@ -4272,6 +7555,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unarray" version = "0.1.4" @@ -4284,6 +7579,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4307,12 +7611,69 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http 1.4.2", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4323,14 +7684,86 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling 0.20.11", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4473,6 +7906,55 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4486,6 +7968,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4514,6 +8007,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4592,6 +8106,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4727,6 +8250,60 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -4735,7 +8312,16 @@ checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.10.0", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", ] [[package]] diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md new file mode 100644 index 00000000..da240504 --- /dev/null +++ b/docs/privacy-test-matrix.md @@ -0,0 +1,337 @@ +# Privacy test matrix (Q2 privacy validation) + +Living tracker for the Q2 privacy-feature validation commitment: add privacy-preserving +variants of the existing `token` / `ata` / `amm` / `stablecoin` integration test flows, and +record which combinations work, fail, or cannot be expressed. Every row starting in +**Not started** should end the effort as **Pass** (test merged) or **Fail** / +**Not-expressible** (folded into `docs/privacy-gap-report.md` with root cause). + +This is the tracking scaffold, not the final deliverable — `docs/privacy-gap-report.md` gets +written from the resolved state of this table. + +## Legend + +**Dimension** — which cross-cutting Q2 feature (or baseline coverage gap) a row exercises: + +| Code | Meaning | +|---|---| +| `BASE` | Extends the already-proven single-private-account pattern (shield / private→private / deshield) to an instruction that has no private coverage yet. Not itself one of the four Q2 checkboxes. | +| `PDA` | Private PDAs used as program inputs | +| `GROUP` | Sharing a private account (group-owned) used as a program account | +| `EXIST` | Sending funds to an existing private account (not a fresh one) | +| `CHAIN` | Multiple private accounts in one transaction, and/or a private account carried through a `ChainedCall` | + +**Priority** — `P1` build first, `P2` second wave, `P3` stretch/optional. + +**Status** — `Not started` / `Pass` / `Fail` / `Not-expressible` / `Blocked`. + +**Depends on** — which other deployed program(s) or new crate dependencies the row needs. + +--- + +## Token (`token.rs`) — no program dependencies + +Foundation layer. Has no PDAs of its own and issues no `ChainedCall`s, so `PDA` and `CHAIN` +don't apply here — it's the substrate the other three programs build on. + +### Cross-cutting checkbox audit (end of Token phase, 2026-07-07) + +Checked against the 4 Q2 checkboxes explicitly, not assumed: + +| Checkbox | Status | Basis | +|---|---|---| +| Private PDAs used as program inputs | **N/A at this layer** | `token_core` has no `for_public_pda`/`for_private_pda` calls anywhere — Token holdings are addressed by arbitrary `AccountId`, not program-derived. Only testable once wrapped by another program's PDA (ATA/AMM/Stablecoin) — correctly deferred, not a gap in Token coverage. | +| Sharing a private account (group-owned) | **Covered** | `token_group_owned_holding_shared_control` — see finding below. | +| Sending funds to an existing private account | **Covered** | `token_transfer_into_existing_private_holding` — see finding above. | +| Multiple private accounts in one tx / private accounts through chained calls | **Partially covered** | "Multiple private accounts in one tx" half: covered, but by the *pre-existing* `token_private_transfer` (two private legs, zero public), not by anything added this phase — none of the new tests this phase have more than one private leg. "Carried through chained calls" half: N/A at this layer, Token issues no `ChainedCall`s (only ATA/AMM/Stablecoin do); deferred. | + +Net: of the 4 checkboxes, Token-phase work directly validated 2 (`EXIST`, `GROUP`), leaned on a +pre-existing test for half of a 3rd (`CHAIN`'s multi-account half), and the remaining checkbox +(`PDA`) plus the other half of `CHAIN` are structurally out of reach until ATA/AMM/Stablecoin +phases — not oversights specific to this phase. + +### Existing + +| Instruction | Dimension | Test | Status | +|---|---|---|---| +| Transfer | BASE (shield) | `token_shielded_transfer` | Pass | +| Transfer | BASE (private→private) | `token_private_transfer` | Pass | +| Transfer | BASE (deshield) | `token_deshielded_transfer` | Pass | +| Transfer | BASE (authorized variant) | `token_shielded_transfer_authorized_private_init` — fresh recipient self-initializes via `PrivateAuthorizedInit` instead of being passively credited via `PrivateUnauthorized` | Pass | +| Mint | BASE | `token_mint_shielded` — mint directly to a fresh private recipient (self-authority signer + `PrivateUnauthorized` recipient) | Pass | +| Mint | BASE (authorized variant) | `token_mint_authorized_init` — mint to a fresh recipient that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) instead of being passively credited | Pass | +| Burn | BASE | `token_private_burn` — burn from an existing private holding via a single `PrivateAuthorizedUpdate` | Pass | +| Transfer | `EXIST` | `token_transfer_into_existing_private_holding` — second transfer into an already-shielded recipient | Pass — **with a finding**, see below | +| Transfer | `EXIST` + `CHAIN` (fully private) | `token_private_transfer_into_existing_private_holding` — both legs private, recipient already existing (not fresh); two distinct accounts both via `PrivateAuthorizedUpdate` in one tx | Pass | +| InitializeAccount | BASE | `token_initialize_private_account` — self-init of a private holding via `PrivateAuthorizedInit` | Pass | +| InitializeAccount | new: self-service-only boundary | `token_initialize_private_account_without_nsk_is_not_expressible` | **Not-expressible — confirmed by design, not a gap** | +| Transfer + Burn | `GROUP` | `token_group_owned_holding_shared_control` — shield into a GMS-derived shared holding, spend from it via an independently-derived key | Pass | +| Mint | `EXIST` | `token_mint_into_existing_private_holding` — mint once to establish the holding, mint again into it via `PrivateAuthorizedUpdate` | Pass | + +**Finding (`GROUP`, confirmed 2026-07-07):** sharing a private account genuinely works, and the test +was built to prove *sharing*, not just code reuse: "Alice" creates a `GroupKeyHolder` (fresh GMS) +and derives the shared account's npk/vpk via `derive_keys_for_shared_account`; she shields tokens +into it. The GMS is then distributed to "Bob" through the real `seal_for`/`unseal` ML-KEM-768 +handshake — Bob never touches Alice's `GroupKeyHolder` object, only the sealed bytes. Bob +independently re-derives the identical nsk/npk from the unsealed GMS and successfully burns from +the shared holding using his own derivation. Required adding `key_protocol` as a new git dependency +(same repo/tag as `nssa`/`nssa_core`) to `integration_tests/Cargo.toml` — it wasn't previously a +dependency of `lez-programs`. Passed on the first attempt; no gap found for this dimension at the +Token layer. + +**Finding (`EXIST`, confirmed 2026-07-07):** crediting an *existing* private account works, but only if the +recipient cooperates in the same transaction. Confirmed directly against `InputAccountIdentity`'s +doc comments and `output.rs` in `lee_core`: every variant that touches an existing private account +(`PrivateAuthorizedUpdate`, `PrivatePdaUpdate`) requires that account's own `nsk` + a membership +proof. There is no "blind credit" variant analogous to how any public account can be unilaterally +credited — a sender cannot push funds into an existing private account without the recipient +actively co-signing (supplying their nsk) in that same transaction. This is a real protocol/UX +property, not a bug: worth flagging to the privacy work as the answer to "can you send to an +existing private account" being **yes, but only cooperatively**, which has real wallet-UX +implications (recipient must be online / pre-coordinate, unlike a public transfer or a fresh +shield). + +**Finding (`token_private_transfer_into_existing_private_holding`, confirmed 2026-07-07):** +fills the last open combination for Transfer — every prior private test had at most one +existing-and-private leg (`token_transfer_into_existing_private_holding`'s recipient) or a +fresh second leg (`token_private_transfer`'s recipient), never both legs private *and* the +recipient already existing. Two distinct private accounts, each independently proven via its +own `PrivateAuthorizedUpdate` (one spending, one crediting an existing balance), compose in a +single transaction with no public account anywhere — no signer, no public message ids at all. +Passed on the first attempt; built entirely on direct seeding (`with_private_accounts`) for +both sides, no real setup transactions needed. + +**Finding (`token_mint_into_existing_private_holding`, confirmed 2026-07-07):** the `EXIST` +cooperation requirement generalizes across instructions, not just Transfer. `mint_inner` +already supports crediting an existing holding on the public side (branches on +`user_holding_account.account == Account::default()`); the private side needs the same +`PrivateAuthorizedUpdate` cooperation as Transfer — no instruction-specific escape hatch. +Passed on the first attempt once modeled on `token_transfer_into_existing_private_holding`. + +### Planned + +| Instruction | Dimension | Test | Priority | Depends on | Status | +|---|---|---|---|---|---| +| MintWithAuthority | BASE | `token_mint_with_authority_to_private_holding` | P3 | — | Not started | +| NewFungibleDefinition, NewDefinitionWithMetadata, SetAuthority(WithAuthority), PrintNft | — | **Not planned** — these operate on canonical, publicly-resolvable definitions/authorities; a "private token definition" has no coherent meaning since holders/traders must resolve it | — | — | Out of scope | + +**Correction (`token_initialize_private_account`, resolved 2026-07-07):** originally flagged as a +plausible `Not-expressible` case because `initialize.rs` hard-asserts `is_authorized == true` while +a fresh account created via `PrivateUnauthorized` must be `false`. That flag was based on picking +the wrong identity variant, not a real protocol limit. `InitializeAccount`'s guest requires the +target to be a *signer* (`#[account(init, signer)]`) — i.e. self-initialization, the same shape as +`PrivateAuthorizedInit` (owner supplies their own `nsk` directly, `is_authorized: true` is +legitimate), not `PrivateUnauthorized` (third party credits an account they don't control, `nsk` +withheld, `is_authorized` must be `false`). Matching the identity variant to the instruction's +actual authorization shape resolved it cleanly — passed on the first attempt once corrected. + +**Finding (self-service-only boundary, confirmed 2026-07-07 — prompted by a direct question, +not originally in the matrix):** can a third party initialize a private Token holding for an +`(npk, vpk, identifier)` whose `nsk` they don't possess? No — and this is a deliberate design +boundary, not a gap. Unlike `Transfer`/`Mint`, whose recipient-side host logic never asserts +`is_authorized` (which is exactly why third-party shielding into a fresh recipient works there +via `PrivateUnauthorized`), `InitializeAccount`'s guest declares `account_to_initialize` as +`#[account(init, signer)]`. Attempting it via `PrivateUnauthorized` (`is_authorized: false`, +no `nsk` needed) is rejected — empirically confirmed — at the SPEL macro's own account +validation layer ("`must be a signer`"), before `token_program::initialize::initialize_account`'s +own `is_authorized` assert is even reached. The only variant that can construct a fresh private +account here is `PrivateAuthorizedInit`, which requires supplying `nsk` directly. Net: this +instruction is self-service-only by construction — you can initialize your own private holding, +but not one on someone else's behalf without their key material. Worth carrying into the gap +report as a scoping note on `EXIST`/`BASE`, not a defect. + +**Finding (`token_mint_authorized_init`, confirmed 2026-07-07):** the self-service-only +boundary above is specific to `InitializeAccount`, not a general rule about "authorized" private +identities. `Mint`'s guest marks `user_holding_account` as `#[account(mut)]` only (no +`signer`), and `mint_inner` never asserts `is_authorized` on it — confirmed by reading +`token/src/mint.rs` before writing the test, then verified empirically. So minting to a +recipient that self-initializes via `PrivateAuthorizedInit` (their own `nsk` supplied) works +just as well as `token_mint_shielded`'s passive `PrivateUnauthorized` recipient — passed on the +first attempt. Worth stating plainly in the gap report: whether a "self-authorized fresh +recipient" is accepted is instruction-specific (gated by that instruction's own signer +requirement), not a blanket protocol rule. + +**Finding (`token_shielded_transfer_authorized_private_init`, confirmed 2026-07-07):** the same +`PrivateAuthorizedInit`-instead-of-`PrivateUnauthorized` variant generalizes to `Transfer` too, +closing the last instruction where every fresh-recipient test used only `PrivateUnauthorized` +(`token_shielded_transfer`, `token_private_transfer`'s new recipient, the group test's shield +step). `transfer.rs` asserts `is_authorized` only on the sender, never the recipient — same +shape as `Mint` — so this was expected and passed on the first attempt. Between this and the +`Mint`/`InitializeAccount` results, the picture is now complete: whether a fresh recipient can +choose to self-initialize (`PrivateAuthorizedInit`) instead of being passively credited +(`PrivateUnauthorized`) depends entirely on whether that instruction's guest marks the target +as a signer — true for `InitializeAccount` only (where `PrivateUnauthorized` is actually +rejected), optional for `Transfer`/`Mint` (both variants accepted). + +--- + +## ATA (`ata.rs`) — depends on Token + +### Existing + +| Instruction | Dimension | Test | Status | +|---|---|---|---| +| Create | BASE (private owner only; ATA account + definition public) | `ata_create_from_private_owner` | Pass | +| Create | `PDA` | `ata_create_private_ata_holding_is_not_expressible` | **Not-expressible — confirmed** | + +Verified in `ata/src/create.rs`: the owner account is **not** forwarded into the +`ChainedCall` to Token — only `token_definition` and the ATA holding are. So the existing +`ata_create_from_private_owner` test proves a private account can seed a PDA derivation and +appear as a top-level tx participant, but does **not** prove a private account traveling +through a chained call. That gap is still open despite appearances. + +**Finding (`PDA`, confirmed 2026-07-07 — root cause, not just an observation):** the ATA +holding can never be made a private account as ATA is currently coded, and this is a +structural fact provable from `lee_core`'s circuit source, not empirical friction. Traced +precisely: `Create`'s `ChainedCall.pda_seeds` authorizes Token to mutate +`for_public_pda(ata_program_id, seed)` — a match under the *public* PDA formula. In +`resolve_authorization_and_record_bindings` (`execution_state.rs`), a caller-seed match only +gets recorded into `private_pda_bound_positions` when it matches under `for_private_pda` +(`is_private_form == true`) — a public-form match authorizes the account but never binds it +as a private PDA. Every `PrivatePdaInit`/`PrivatePdaUpdate` identity requires its position to +appear in that binding map (hard `assert!` at `execution_state.rs:211`), and ATA's own +`verify_ata_and_get_seed` independently requires the account id to equal +`for_public_pda(ata_program_id, seed)` — never `for_private_pda`'s output, by construction of +two different hash domains. These two requirements are mutually exclusive for the same +account_id, full stop — confirmed empirically by attempting exactly this and getting the +precise, deterministic rejection (`ata_create_private_ata_holding_is_not_expressible`, which +asserts on the exact panic text). +**This generalizes**: AMM's vault/pool and Stablecoin's position/vault use the identical +`for_public_pda`-only derivation, so they will hit the *same* wall for the *same* reason — no +need to rediscover this per program, just confirm each one uses `for_public_pda` (already +verified for both in `amm_core`/`stablecoin_core`) and cite this finding. **The only fix** is a +source change to `ata_core`/`amm_core`/`stablecoin_core` to derive PDAs via `for_private_pda` +instead — out of scope for this test-writing task, but this is the single clearest, most +actionable item to feed back to the privacy/protocol work. + +All originally-planned ATA rows are now resolved — see updated `Existing` table below. ATA phase +is complete. + +| Instruction | Dimension | Test | Status | +|---|---|---|---| +| Transfer | `CHAIN` + `EXIST` (collapsed — see finding) | `ata_transfer_to_existing_private_recipient` | Pass | +| Burn | new: signer-authorization | `ata_burn_with_private_owner_signing` | Pass | +| Burn | `GROUP` + signer-authorization | `ata_group_owned_owner_signing` | Pass | + +**Finding (`CHAIN` + `EXIST`, confirmed 2026-07-07):** `ata_program::transfer::transfer_from_associated_token_account` +hard-asserts `recipient.account != Account::default()` ("Recipient token holding must be +initialized"). That means a *fresh* private recipient (shield-style, `PrivateUnauthorized`) can +never be created through `ATA::Transfer` — only an already-existing account can be credited. +This collapses what the matrix originally planned as two separate rows (`BASE` and `EXIST`) +into one: `ata_transfer_to_existing_private_recipient` funds a private holding via a direct +(non-ATA) `Token::Transfer` shield first, then sends more into it through ATA's chained call, +with the recipient cooperating via `PrivateAuthorizedUpdate` (consistent with the Token-phase +`EXIST` finding). This is also the first test in the whole exercise where a private account +identity travels through a *nested* `ChainedCall` rather than a top-level instruction call — +and it worked on the first attempt, with no special handling needed. + +**Finding (signer-authorization, confirmed 2026-07-07 — new angle, not in the original matrix):** +`Transfer`/`Burn` require `owner` to be a *signer* (`#[account(signer)]`), unlike `Create` +(merely `mut`). Every existing private-owner test only used owner passively (`Create`, no +signer requirement). `ata_burn_with_private_owner_signing` tests whether a private account can +satisfy a signer requirement by self-initializing *and* signing in the same transaction via +`PrivateAuthorizedInit` — it does, cleanly, on the first attempt. `ata_group_owned_owner_signing` +composes this with `GROUP`: the GMS is distributed through the real seal/unseal handshake (as +in `token_group_owned_holding_shared_control`), and "Bob" — who never touches Alice's +`GroupKeyHolder` object — independently re-derives the matching nsk/npk and signs. Both pass. +Worth feeding back as a positive finding: private/shared accounts can serve as full signing +authorities for instructions that require it, not just as passive recipients. + +--- + +## AMM (`amm.rs`) — depends on Token, TWAP oracle + +33 public tests, 0 private. Confirmed in `amm_core`: all 5 PDAs (config, pool, vault×2, +liquidity-token, lp-lock) use `for_public_pda` exclusively. + +Not every account is an equally meaningful privacy target: Pool/Config are the AMM's public +price surface (reserves must be readable to quote a swap; TWAP needs a continuously +observable tick) — privatizing them fights the AMM's purpose. Vault/LP-lock are the credible +middle case. User-held token/LP balances are the highest-value target. + +### Existing + +0 private tests out of 33 public. + +### Planned + +| Instruction | Dimension | Test | Priority | Depends on | Status | +|---|---|---|---|---|---| +| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | Not started | +| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | Not started | +| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` | P1 | Token, TWAP oracle (public leg) | Not started | +| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | Not started | +| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | Not started | +| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | Not started | +| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | Not started | +| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started | +| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | Not started | +| Pool/Config (any) | `PDA` | `amm_attempt_private_pool_pda` — same predicted not-expressible outcome as above; low priority given the vault row already confirms the root cause for this program | P3 | Token | Not started | +| Initialize, UpdateConfig, CreatePriceObservations, CreateOraclePriceAccount, SyncReserves | — | **Not planned** — admin/infra instructions over public protocol state; a private admin authority is legitimate but low value | — | — | Out of scope (for now) | + +Note: every Swap/AddLiquidity/RemoveLiquidity chains to *both* Token (transfers) and TWAP +oracle (tick refresh) in one instruction — so every `CHAIN` row above is automatically also +a "some legs private, some public" test. Call that out explicitly when the test is written, +not as an incidental detail. + +--- + +## Stablecoin (`stablecoin.rs`) — depends on Token + +Only 2 tests total today (`stablecoin_open_position_then_withdraw_collateral`, +`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt`), 0 private. Both PDAs +(position, position vault) are `for_public_pda` only. + +Arguably the most naturally privacy-motivated program of the four — a CDP's collateral/debt +is exactly what a user would want hidden — despite having the thinnest existing baseline. + +### Existing + +0 private tests out of 2 public. + +### Planned + +| Instruction | Dimension | Test | Priority | Depends on | Status | +|---|---|---|---|---|---| +| OpenPosition | `CHAIN` | `stablecoin_open_position_private_collateral_holding` | P1 | Token | Not started | +| WithdrawCollateral | `CHAIN` | `stablecoin_withdraw_collateral_private_holding` | P1 | Token | Not started | +| RepayDebt | `CHAIN` | `stablecoin_repay_debt_private_holding` | P1 | Token | Not started | +| OpenPosition / Position + Vault | `PDA` | `stablecoin_open_position_private_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `stablecoin_core`); still worth writing as the clearest real-world case (a CDP position is the most natural thing to want private of anything in this whole exercise), but as a confirmation citing the root cause, not a fresh investigation | P1 (high value as *documentation* of the clearest case, even though the outcome is now predicted) | Token | Not started | +| OpenPosition / WithdrawCollateral | `EXIST` | `stablecoin_deposit_into_existing_private_holding` | P2 | Token | Not started | +| OpenPosition (joint CDP) | `GROUP` | `stablecoin_group_owned_position` | P3 | Token, `key_protocol` | Not started | +| (ProtocolParameters, any) | — | **Not planned** — not yet consumed by any instruction (no freeze/admin logic wired up); nothing to test | — | — | Out of scope | + +--- + +## Phase 0 prerequisites (blocking every remaining `GROUP` row) + +- ~~Add `key_protocol` as a git dependency~~ — **done** (2026-07-07), added to + `integration_tests/Cargo.toml` pinned to the same repo/tag as `nssa`/`nssa_core`. Unblocks the + remaining `GROUP` rows in ATA/AMM/Stablecoin; each still needs its own program-specific test + (PDA-based group ownership, not just the regular-account path proven for Token). +- Build the shared privacy test kit in `integration_tests/src/lib.rs` (shield / spend / + private-PDA fund-spend / group-derive helpers) — still not done. Tests so far (Token and ATA + phases) are still hand-rolled per-file; revisit whether to extract shared helpers before AMM. + +**Implementation technique worth carrying into AMM/Stablecoin (found 2026-07-07):** private +account preconditions don't need a real proven transaction to set up. `V03State::with_private_accounts(impl IntoIterator)` +is a genuine, non-test-gated builder method — pair `Commitment::new(&id, &account)` with +`Nullifier::for_account_initialization(&id)` (the same pairing a real `PrivateUnauthorized`/ +`PrivateAuthorizedInit` would have produced) and the seeded state is indistinguishable from a +real one to any subsequent transaction. Confirmed against `lee`'s own test suite pattern before +using it, then applied to refactor `token_private_burn`, `token_transfer_into_existing_private_holding`, +and `token_mint_into_existing_private_holding`'s setup legs — all still pass. Caveat: seeding +skips whatever *public*-side effect the bypassed transaction would have had (sender debit for a +shield, supply increase for a mint) — assertions on public state must account for that, matching +how public fixtures (`Accounts::holder_init()`) already set balances without a real mint ever +having produced them. This will matter more for AMM/Stablecoin, where setup transactions are +heavier (chained calls, multiple accounts) than a single shield. + +## Row count summary + +| Program | Existing private / confirmed | Planned rows | Out-of-scope instructions noted | +|---|---|---|---| +| Token | 13 (3 pre-existing + 10 new: 9 pass + 1 confirmed not-expressible by design) | 1 | 5 | +| ATA | 5 (4 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | +| AMM | 0 (2 rows now predicted not-expressible pending confirmation) | 10 | 5 | +| Stablecoin | 0 | 6 | 1 | diff --git a/programs/integration_tests/Cargo.toml b/programs/integration_tests/Cargo.toml index ce1e5de6..9eb2b101 100644 --- a/programs/integration_tests/Cargo.toml +++ b/programs/integration_tests/Cargo.toml @@ -10,6 +10,7 @@ workspace = true nssa = { workspace = true } nssa_core = { workspace = true, features = ["host", "test_utils"] } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" } +key_protocol = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" } amm_core = { workspace = true } token_core = { workspace = true } ata_core = { workspace = true } diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index 6e5d016a..99df64e1 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -1,6 +1,10 @@ use std::collections::HashMap; use ata_core::{compute_ata_seed, get_associated_token_account_id}; +use key_protocol::key_management::{ + group_key_holder::{GroupKeyHolder, SealingPublicKey}, + secret_holders::SecretSpendingKey, +}; use nssa::{ execute_and_prove, privacy_preserving_transaction::{ @@ -13,7 +17,7 @@ use nssa::{ use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, encryption::{EphemeralPublicKey, ViewingPublicKey}, - EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, NullifierSecretKey, + Commitment, EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, NullifierSecretKey, }; use token_core::{TokenDefinition, TokenHolding}; @@ -592,3 +596,537 @@ fn ata_create_from_private_owner() { } ); } + +// Marvin-todo +/// Documents a confirmed protocol gap (`PDA` Q2 dimension): the ATA holding can never be made +/// a private account as ATA is currently coded. `Create`'s `ChainedCall.pda_seeds` authorizes +/// Token to mutate `for_public_pda(ata_program_id, seed)` — a *public*-form PDA match. Per +/// `resolve_authorization_and_record_bindings` in `lee_core`'s `execution_state.rs`, a +/// caller-seed match only gets recorded in `private_pda_bound_positions` when it matches under +/// `for_private_pda` (`is_private_form == true`); a public-form match authorizes the account +/// but never binds it as a private PDA. Since `PrivatePdaInit`/`PrivatePdaUpdate` require their +/// position to appear in that binding map (`execution_state.rs:211`), and ATA's own +/// `verify_ata_and_get_seed` independently requires the account id to equal +/// `for_public_pda(ata_program_id, seed)` (never `for_private_pda`'s output, by construction), +/// these two requirements can never both hold for the same account_id. This is not +/// program-specific friction — it's structural: fixing it would require `ata_core` (and +/// equally amm_core / stablecoin_core) to derive their PDAs via `for_private_pda` instead, +/// which is a source change to the program, not a test workaround. +#[test] +fn ata_create_private_ata_holding_is_not_expressible() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + + let owner_id = Ids::owner(); + let owner_account = state.get_account_by_id(owner_id); + + // Fresh personal npk/vpk for the ATA holding's privacy identity — distinct from the + // owner's plain public keypair, which only supplies the seed input. + let ata_nsk: NullifierSecretKey = [21u8; 32]; + let ata_npk = NullifierPublicKey::from(&ata_nsk); + let ata_vpk = ViewingPublicKey::from_seed(&[41u8; 32], &[42u8; 32]); + + // Address stays the *standard* public-PDA formula ATA always uses — only its state + // privacy is under test, not its address derivation. + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + + let owner_pre = AccountWithMetadata::new(owner_account, true, owner_id); + let def_pre = AccountWithMetadata::new( + Accounts::token_definition_init(), + false, + Ids::token_definition(), + ); + let ata_pre = AccountWithMetadata::new(Account::default(), false, ata_id); + + let instruction = ata_core::Instruction::Create { + token_program_id: Ids::token_program(), + }; + let instruction_data = Program::serialize_instruction(instruction).unwrap(); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&ata_vpk, &[0u8; 32], 0).0; + + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let result = execute_and_prove( + vec![owner_pre, def_pre, ata_pre], + instruction_data, + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&ata_npk, &ata_vpk), + npk: ata_npk, + ssk: shared_secret, + identifier: 0, + seed: None, + }, + ], + &program_with_deps, + ); + + let err = result.expect_err( + "a private-PDA ATA holding must be rejected: its account id can never satisfy both \ + ATA's own for_public_pda address check and PrivatePdaInit's for_private_pda binding \ + requirement simultaneously", + ); + let message = format!("{err:?}"); + assert!( + message.contains("has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds"), + "expected the private-PDA binding rejection, got a different error: {message}" + ); +} + +// Marvin-todo +/// Credits an *already-existing* private holding through ATA's chained call to Token, and +/// documents a structural finding along the way: +/// `ata_program::transfer::transfer_from_associated_token_account` hard-asserts `recipient.account +/// != Account::default()` ("Recipient token holding must be initialized"), so a *fresh* private +/// recipient (shield-style, `PrivateUnauthorized`) can never be created through `ATA::Transfer` — +/// only an existing account can be credited. That collapses what would otherwise be separate `BASE` +/// and `EXIST` tests into one: this test necessarily exercises both "private account through a +/// chained call" (`CHAIN`) and "sending to an existing private account" (`EXIST`, requiring the +/// recipient's cooperation via `PrivateAuthorizedUpdate`, per the finding already confirmed in +/// `token.rs`). +/// +/// The private holding is funded beforehand via a direct (non-ATA) `Token::Transfer` shield +/// from a throwaway public holder, since neither `ATA::Transfer` (blocked by the assert above) +/// nor `Token::Mint` (this test fixture's definition has `authority: None`, fixed supply) can +/// create it. +#[test] +fn ata_transfer_to_existing_private_recipient() { + let mut state = state_for_ata_tests(); + + // A throwaway public holder to shield from directly via Token — bypassing ATA entirely, + // since ATA::Transfer cannot originate a fresh private recipient (see doc comment above). + let shield_source_key = PrivateKey::try_new([77u8; 32]).expect("valid private key"); + let shield_source_id = AccountId::from(&PublicKey::new_from_private_key(&shield_source_key)); + state.force_insert_account( + shield_source_id, + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128, + }), + nonce: Nonce(0), + }, + ); + + let recipient_nsk: NullifierSecretKey = [51u8; 32]; + let recipient_npk = NullifierPublicKey::from(&recipient_nsk); + let recipient_vpk = ViewingPublicKey::from_seed(&[61u8; 32], &[62u8; 32]); + let recipient_id = AccountId::for_regular_private_account(&recipient_npk, 0); + + let shield_amount = 500_000_u128; + let source_pre = AccountWithMetadata::new( + state.get_account_by_id(shield_source_id), + true, + shield_source_id, + ); + let fresh_recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + let shield_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let token_program_for_shield = Program::new(token_methods::TOKEN_ELF.to_vec().into()) + .expect("valid token ELF") + .into(); + let (shield_output, shield_proof) = execute_and_prove( + vec![source_pre, fresh_recipient_pre], + Program::serialize_instruction(token_core::Instruction::Transfer { + amount_to_transfer: shield_amount, + }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + npk: recipient_npk, + ssk: shield_secret, + identifier: 0, + }, + ], + &token_program_for_shield, + ) + .unwrap(); + let shield_message = + Message::try_from_circuit_output(vec![shield_source_id], vec![Nonce(0)], shield_output) + .unwrap(); + let shield_witness = + WitnessSet::for_message(&shield_message, shield_proof, &[&shield_source_key]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(shield_message, shield_witness), + 0, + 0, + ) + .unwrap(); + + let recipient_after_shield = Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: shield_amount, + }), + nonce: Nonce::private_account_nonce_init(&recipient_id), + }; + assert!( + state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_after_shield)) + .is_some(), + "shield setup must land before the ATA transfer under test" + ); + + // Now the actual test: owner's ATA sends more into that now-existing private recipient. + let owner_id = Ids::owner(); + let owner_account = state.get_account_by_id(owner_id); + let sender_ata_id = Ids::owner_ata(); + let sender_ata_account = state.get_account_by_id(sender_ata_id); + + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_after_shield)) + .expect("recipient's commitment must be in the set"); + let transfer_secret = + SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let owner_pre = AccountWithMetadata::new(owner_account, true, owner_id); + let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id); + let recipient_pre = + AccountWithMetadata::new(recipient_after_shield.clone(), true, recipient_id); + + let ata_transfer_amount = 200_000_u128; + let instruction = ata_core::Instruction::Transfer { + token_program_id: Ids::token_program(), + amount: ata_transfer_amount, + }; + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let (output, proof) = execute_and_prove( + vec![owner_pre, sender_ata_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + ssk: transfer_secret, + nsk: recipient_nsk, + membership_proof, + identifier: 0, + }, + ], + &program_with_deps, + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![owner_id, sender_ata_id], vec![Nonce(0)], output) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner_key()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(sender_ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128 - ata_transfer_amount, + }), + nonce: Nonce(0), + } + ); + + let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) + .private_account_nonce_increment(&recipient_nsk); + let recipient_after_ata_transfer = Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: shield_amount + ata_transfer_amount, + }), + nonce: recipient_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new( + &recipient_id, + &recipient_after_ata_transfer + )) + .is_some()); +} + +// Marvin-todo +/// Tests a previously-untried combination: `Burn`'s guest requires `owner` to be a *signer* +/// (`#[account(signer)]`) — every existing private-owner test so far +/// (`ata_create_from_private_owner`) only used owner as a passive `PrivateUnauthorized` recipient +/// in `Create`, which doesn't need signer authorization at all. Here, owner self-initializes *and* +/// signs in the same transaction via `PrivateAuthorizedInit` (proving control by supplying their +/// own nsk directly) — the ATA holding itself stays public, per the confirmed `PDA` finding above; +/// only the signing identity is private. +#[test] +fn ata_burn_with_private_owner_signing() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + + let owner_nsk: NullifierSecretKey = [91u8; 32]; + let owner_npk = NullifierPublicKey::from(&owner_nsk); + let owner_vpk = ViewingPublicKey::from_seed(&[93u8; 32], &[94u8; 32]); + let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); + + // The ATA holding must stay public (per the confirmed PDA finding), so it's seeded + // directly rather than via a real `Create` transaction. + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + let ata_account = Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128, + }), + nonce: Nonce(0), + }; + state.force_insert_account(ata_id, ata_account.clone()); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let ata_pre = AccountWithMetadata::new(ata_account, false, ata_id); + let def_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + + let burn_amount = 300_000_u128; + let instruction = ata_core::Instruction::Burn { + token_program_id: Ids::token_program(), + amount: burn_amount, + }; + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&owner_vpk, &[0u8; 32], 0).0; + + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let (output, proof) = execute_and_prove( + vec![owner_pre, ata_pre, def_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk), + ssk: shared_secret, + nsk: owner_nsk, + identifier: 0, + }, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![ata_id, Ids::token_definition()], vec![], output) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128 - burn_amount, + }), + nonce: Nonce(0), + } + ); + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 - burn_amount, + metadata_id: None, + authority: None, + }), + nonce: Nonce(0), + } + ); + + let owner_expected = Account { + nonce: Nonce::private_account_nonce_init(&owner_id), + ..Account::default() + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) + .is_some()); +} + +// Marvin-todo +/// Composes the `GROUP` dimension with the signer-authorization finding just proven above: a +/// group-owned owner (GMS distributed through the real seal/unseal handshake, exactly as in +/// `token_group_owned_holding_shared_control`) signs an `ATA::Burn` via `PrivateAuthorizedInit`. +/// "Bob" — who only ever receives the sealed GMS, never Alice's `GroupKeyHolder` object — +/// independently re-derives the identical nsk/npk and successfully signs for the shared ATA +/// owner identity. +#[test] +fn ata_group_owned_owner_signing() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + + // Alice creates the group and derives the shared owner identity's keys. + let alice_holder = GroupKeyHolder::new(); + let derivation_seed = [13_u8; 32]; + let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); + let owner_npk = alice_keys.generate_nullifier_public_key(); + let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); + + // Alice distributes the GMS to Bob via the real seal/unseal handshake. + let bob_sealing_keys = SecretSpendingKey([17_u8; 32]).produce_private_key_holder(None); + let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); + let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; + let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( + bob_sealing_vpk.to_bytes().to_vec(), + )); + let bob_holder = + GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); + + // Bob independently re-derives the same shared owner keys and is the one who signs below. + let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); + let bob_nsk = bob_keys.nullifier_secret_key; + let bob_vpk = bob_keys.generate_viewing_public_key(); + assert_eq!( + bob_keys.generate_nullifier_public_key(), + owner_npk, + "Bob must derive the identical npk as Alice from the shared GMS" + ); + + // The ATA holding must stay public (per the confirmed PDA finding), so it's seeded + // directly rather than via a real `Create` transaction. + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + let ata_account = Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128, + }), + nonce: Nonce(0), + }; + state.force_insert_account(ata_id, ata_account.clone()); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let ata_pre = AccountWithMetadata::new(ata_account, false, ata_id); + let def_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + + let burn_amount = 300_000_u128; + let instruction = ata_core::Instruction::Burn { + token_program_id: Ids::token_program(), + amount: burn_amount, + }; + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0; + + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let (output, proof) = execute_and_prove( + vec![owner_pre, ata_pre, def_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk), + ssk: shared_secret, + nsk: bob_nsk, + identifier: 0, + }, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![ata_id, Ids::token_definition()], vec![], output) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128 - burn_amount, + }), + nonce: Nonce(0), + } + ); + + let owner_expected = Account { + nonce: Nonce::private_account_nonce_init(&owner_id), + ..Account::default() + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) + .is_some()); +} diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index bb276d21..fe0dc5f9 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -1,3 +1,7 @@ +use key_protocol::key_management::{ + group_key_holder::{GroupKeyHolder, SealingPublicKey}, + secret_holders::SecretSpendingKey, +}; use nssa::{ execute_and_prove, privacy_preserving_transaction::{Message, PrivacyPreservingTransaction, WitnessSet}, @@ -8,7 +12,8 @@ use nssa::{ use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, encryption::{EphemeralPublicKey, ViewingPublicKey}, - Commitment, EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, NullifierSecretKey, + Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, + NullifierSecretKey, }; use token_core::{TokenDefinition, TokenHolding}; @@ -758,6 +763,83 @@ fn token_shielded_transfer() { .is_some()); } +/// Shielded transaction to a private account using the account's `nsk`. +/// `token_shielded_transfer` only uses the account's `npk`; thus, `PrivateUnauthorized` private. +#[test] +fn token_shielded_transfer_authorized_private_init() { + let mut state = state_for_token_tests(); + let amount = 500_000_u128; + + let sender_id = Ids::holder(); + let sender_account = state.get_account_by_id(sender_id); + let sender_nonce = sender_account.nonce; + + let recipient_nsk = PrivateKeys::recipient_nsk(); + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); + let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let instruction = token_core::Instruction::Transfer { + amount_to_transfer: amount, + }; + let (output, proof) = execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + ssk: shared_secret, + nsk: recipient_nsk, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![sender_id], vec![sender_nonce], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::holder_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(sender_id), + Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000 - amount, + }), + nonce: Nonce(1), + } + ); + + let recipient_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: amount, + }), + nonce: Nonce::private_account_nonce_init(&recipient_id), + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .is_some()); +} + #[test] fn token_private_transfer() { let mut state = state_for_token_tests(); @@ -942,6 +1024,910 @@ fn token_deshielded_transfer() { .is_some()); } +/// Mints directly to a new recipient private holding (`PrivateUnauthorized`). +/// The recipient's cooperation is unnecessary; only known of the recipient's `npk`, `vpk`. +#[test] +fn token_mint_shielded_to_private_unauthorized() { + let mut state = state_for_token_tests_without_recipient(); + let amount_to_mint = 500_000_u128; + + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + let definition_account = state.get_account_by_id(Ids::token_definition()); + let definition_nonce = definition_account.nonce; + let definition_pre = + AccountWithMetadata::new(definition_account, true, Ids::token_definition()); + let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let instruction = token_core::Instruction::Mint { amount_to_mint }; + let (output, proof) = execute_and_prove( + vec![definition_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + npk: recipient_npk, + ssk: shared_secret, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![Ids::token_definition()], + vec![definition_nonce], + output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::def_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 + amount_to_mint, + metadata_id: None, + authority: Some(Ids::token_definition()), + }), + nonce: Nonce(1), + } + ); + + let recipient_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: amount_to_mint, + }), + nonce: Nonce::private_account_nonce_init(&recipient_id), + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .is_some()); +} + +/// Mints directly to a new recipient private holding (`PrivateAuthorizedInit`). +/// This requires the recipient's secret key `nsk`. +#[test] +fn token_mint_authorized_private_init() { + let mut state = state_for_token_tests_without_recipient(); + let amount_to_mint = 500_000_u128; + + let recipient_nsk = PrivateKeys::recipient_nsk(); + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + let definition_account = state.get_account_by_id(Ids::token_definition()); + let definition_nonce = definition_account.nonce; + let definition_pre = + AccountWithMetadata::new(definition_account, true, Ids::token_definition()); + let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let instruction = token_core::Instruction::Mint { amount_to_mint }; + let (output, proof) = execute_and_prove( + vec![definition_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + ssk: shared_secret, + nsk: recipient_nsk, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![Ids::token_definition()], + vec![definition_nonce], + output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::def_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 + amount_to_mint, + metadata_id: None, + authority: Some(Ids::token_definition()), + }), + nonce: Nonce(1), + } + ); + + let recipient_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: amount_to_mint, + }), + nonce: Nonce::private_account_nonce_init(&recipient_id), + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .is_some()); +} + +/// Mints directly to a pre-existing recipient private holding +/// This requires the recipient's secret key `nsk`. +#[test] +fn token_mint_into_existing_private_holding() { + let mut state = state_for_token_tests_without_recipient(); + let pre_balance = 500_000_u128; + let amount_to_mint = 250_000_u128; + + let recipient_nsk = PrivateKeys::recipient_nsk(); + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + let recipient_pre = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: pre_balance, + }), + nonce: Nonce::private_account_nonce_init(&recipient_id), + }; + state = state.with_private_accounts([( + Commitment::new(&recipient_id, &recipient_pre), + Nullifier::for_account_initialization(&recipient_id), + )]); + assert!( + state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_pre)) + .is_some(), + "seeded balance must land before the existing-holding mint under test" + ); + + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_pre)) + .expect("recipient's commitment must be in the set"); + + let definition_account = state.get_account_by_id(Ids::token_definition()); + let definition_nonce = definition_account.nonce; + let definition_pre = + AccountWithMetadata::new(definition_account, true, Ids::token_definition()); + let existing_recipient_pre = + AccountWithMetadata::new(recipient_pre.clone(), true, recipient_id); + + let shared_secret = + SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let (output, second_proof) = execute_and_prove( + vec![definition_pre, existing_recipient_pre], + Program::serialize_instruction(token_core::Instruction::Mint { + amount_to_mint, + }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + ssk: shared_secret, + nsk: recipient_nsk, + membership_proof, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![Ids::token_definition()], + vec![definition_nonce], + output, + ) + .unwrap(); + let witness = + WitnessSet::for_message(&message, second_proof, &[&Keys::def_key()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 + amount_to_mint, + metadata_id: None, + authority: Some(Ids::token_definition()), + }), + nonce: Nonce(1), + } + ); + + let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) + .private_account_nonce_increment(&recipient_nsk); + let recipient_after_second_mint = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: pre_balance + amount_to_mint, + }), + nonce: recipient_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new( + &recipient_id, + &recipient_after_second_mint + )) + .is_some()); +} + +/// Burns from an existing private holding (`PrivateAuthorizedUpdate`). +#[test] +fn token_private_burn() { + let mut state = state_for_token_tests(); + let holding_balance = 500_000_u128; + let burn_amount = 200_000_u128; + + let holder_npk = PrivateKeys::recipient_npk(); + let holder_nsk = PrivateKeys::recipient_nsk(); + let holder_vpk = PrivateKeys::recipient_vpk(); + let holder_id = PrivateKeys::recipient_id(); + + // Predefined holding account to burn from. + let holder_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: holding_balance, + }), + nonce: Nonce::private_account_nonce_init(&holder_id), + }; + let holder_commitment = Commitment::new(&holder_id, &holder_account); + state = state.with_private_accounts([( + holder_commitment.clone(), + Nullifier::for_account_initialization(&holder_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&holder_commitment) + .expect("holder's commitment must be in the set"); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&holder_vpk, &[0u8; 32], 0).0; + + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let holder_pre = AccountWithMetadata::new(holder_account.clone(), true, holder_id); + + let instruction = token_core::Instruction::Burn { + amount_to_burn: burn_amount, + }; + let (output, proof) = execute_and_prove( + vec![definition_pre, holder_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&holder_npk, &holder_vpk), + ssk: shared_secret, + nsk: holder_nsk, + membership_proof, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![Ids::token_definition()], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 - burn_amount, + metadata_id: None, + authority: Some(Ids::token_definition()), + }), + nonce: Nonce(0), + } + ); + + let new_holder_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: holding_balance - burn_amount, + }), + nonce: Nonce::private_account_nonce_init(&holder_id) + .private_account_nonce_increment(&holder_nsk), + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&holder_id, &new_holder_account)) + .is_some()); +} + +/// Token transfer into a pre-existing Token holding account. This requires +/// the account's `nsk`; `PrivateAuthorizedUpdate`. +#[test] +fn token_transfer_into_existing_private_holding() { + let mut state = state_for_token_tests(); + let init_balance = 500_000_u128; + let second_amount = 100_000_u128; + + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_nsk = PrivateKeys::recipient_nsk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + let recipient_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: init_balance, + }), + nonce: Nonce::private_account_nonce_init(&recipient_id), + }; + let recipient_commitment = Commitment::new(&recipient_id, &recipient_account); + state = state.with_private_accounts([( + recipient_commitment.clone(), + Nullifier::for_account_initialization(&recipient_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&recipient_commitment) + .expect("recipient's commitment must be in the set after seeding"); + + let sender_id = Ids::holder(); + let sender_account = state.get_account_by_id(sender_id); + let sender_nonce = sender_account.nonce; + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); + let recipient_pre = AccountWithMetadata::new(recipient_account.clone(), true, recipient_id); + + let instruction = token_core::Instruction::Transfer { + amount_to_transfer: second_amount, + }; + let (output, proof) = execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + ssk: shared_secret, + nsk: recipient_nsk, + membership_proof, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![sender_id], vec![sender_nonce], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::holder_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(sender_id), + Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + // `first_amount` was seeded directly into the recipient, never debited from + // the sender — only the real transfer (`second_amount`) actually happened. + balance: 1_000_000 - second_amount, + }), + nonce: Nonce(1), + } + ); + + let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) + .private_account_nonce_increment(&recipient_nsk); + let new_recipient_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: init_balance + second_amount, + }), + nonce: recipient_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) + .is_some()); +} + +// Marvin-todo +/// Fully private counterpart to `token_transfer_into_existing_private_holding`: instead of a +/// *public* sender crediting an existing private recipient, both legs are private and the +/// recipient already exists (not fresh, unlike `token_private_transfer`'s new recipient). This +/// is a new combination — two distinct private accounts, both driven by +/// `PrivateAuthorizedUpdate` (spend + credit-existing) in the same transaction — that neither +/// existing test covers. `Token::Transfer` has no definition-account parameter at all, so with +/// both legs private there is no public account anywhere in this transaction: no signer, no +/// public message ids. +#[test] +fn token_private_transfer_into_existing_private_holding() { + let mut state = state_for_token_tests(); + let sender_initial_balance = 500_000_u128; + let recipient_initial_balance = 300_000_u128; + let transfer_amount = 200_000_u128; + + let sender_npk = PrivateKeys::recipient_npk(); + let sender_nsk = PrivateKeys::recipient_nsk(); + let sender_vpk = PrivateKeys::recipient_vpk(); + let sender_id = PrivateKeys::recipient_id(); + + let recipient_npk = PrivateKeys::holder_npk(); + let recipient_nsk = PrivateKeys::holder_nsk(); + let recipient_vpk = PrivateKeys::holder_vpk(); + let recipient_id = PrivateKeys::holder_id(); + + // Seed both sides directly — neither needs a real prior transaction to exist. + let sender_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: sender_initial_balance, + }), + nonce: Nonce::private_account_nonce_init(&sender_id), + }; + let recipient_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: recipient_initial_balance, + }), + nonce: Nonce::private_account_nonce_init(&recipient_id), + }; + state = state.with_private_accounts([ + ( + Commitment::new(&sender_id, &sender_account), + Nullifier::for_account_initialization(&sender_id), + ), + ( + Commitment::new(&recipient_id, &recipient_account), + Nullifier::for_account_initialization(&recipient_id), + ), + ]); + + let sender_membership_proof = state + .get_proof_for_commitment(&Commitment::new(&sender_id, &sender_account)) + .expect("sender's commitment must be in the set"); + let recipient_membership_proof = state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .expect("recipient's commitment must be in the set"); + + let sender_shared_secret = + SharedSecretKey::encapsulate_deterministic(&sender_vpk, &[0u8; 32], 0).0; + let recipient_shared_secret = + SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 1).0; + + let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender_id); + let recipient_pre = AccountWithMetadata::new(recipient_account.clone(), true, recipient_id); + + let instruction = token_core::Instruction::Transfer { + amount_to_transfer: transfer_amount, + }; + let (output, proof) = execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), + ssk: sender_shared_secret, + nsk: sender_nsk, + membership_proof: sender_membership_proof, + identifier: 0, + }, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + ssk: recipient_shared_secret, + nsk: recipient_nsk, + membership_proof: recipient_membership_proof, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let sender_nonce_after = + Nonce::private_account_nonce_init(&sender_id).private_account_nonce_increment(&sender_nsk); + let new_sender_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: sender_initial_balance - transfer_amount, + }), + nonce: sender_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) + .is_some()); + + let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) + .private_account_nonce_increment(&recipient_nsk); + let new_recipient_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: recipient_initial_balance + transfer_amount, + }), + nonce: recipient_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) + .is_some()); +} + +/// Initializes a private holding account directly (private account secret keys are known). +/// `InitializeAccount` requires `account_to_initialize` to be authorized. E.g., for private +/// accounts must be `PrivateAuthorizedInit` and not `PrivateUnauthorized`; the account owner +/// must supply their own `nsk`. +#[test] +fn token_initialize_private_account_succeeds_for_canonical_definition() { + let mut state = state_for_token_tests_without_recipient(); + + let owner_nsk = PrivateKeys::recipient_nsk(); + let owner_npk = PrivateKeys::recipient_npk(); + let owner_vpk = PrivateKeys::recipient_vpk(); + let owner_id = PrivateKeys::recipient_id(); + + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let account_to_init_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&owner_vpk, &[0u8; 32], 0).0; + + let instruction = token_core::Instruction::InitializeAccount; + let (output, proof) = execute_and_prove( + vec![definition_pre, account_to_init_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk), + ssk: shared_secret, + nsk: owner_nsk, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![Ids::token_definition()], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + let expected_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 0, + }), + nonce: Nonce::private_account_nonce_init(&owner_id), + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&owner_id, &expected_account)) + .is_some()); +} + +// TODO: think this is unnecessary; double check. +/// Confirms `InitializeAccount` is self-service-only: unlike `Transfer`/`Mint`, whose recipient +/// host logic never asserts `is_authorized`, the guest's `#[account(init, signer)]` on +/// `account_to_initialize` requires `is_authorized == true` — enforced by the SPEL macro's own +/// account validation before `token_program::initialize::initialize_account`'s host logic +/// (which carries the same assert as defense in depth) ever runs. The only private identity +/// variant satisfying that for a fresh account is `PrivateAuthorizedInit`, which requires +/// supplying `nsk` directly — so a third party cannot initialize a private holding on behalf of +/// an `(npk, vpk, identifier)` whose `nsk` they don't possess. Attempting it via +/// `PrivateUnauthorized` (the variant that *would* allow third-party setup elsewhere) is +/// rejected at the framework's signer check, since that variant forces `is_authorized: false`. +#[test] +fn token_initialize_private_account_without_nsk_is_not_expressible() { + let state = state_for_token_tests_without_recipient(); + + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let account_to_init_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let result = execute_and_prove( + vec![definition_pre, account_to_init_pre], + Program::serialize_instruction(token_core::Instruction::InitializeAccount).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + npk: recipient_npk, + ssk: shared_secret, + identifier: 0, + }, + ], + &token_program().into(), + ); + + let err = result.expect_err( + "initializing a private holding without its nsk must be rejected: InitializeAccount \ + requires is_authorized == true, but PrivateUnauthorized forces is_authorized == false", + ); + let message = format!("{err:?}"); + assert!( + message.contains("must be a signer"), + "expected the self-service-only rejection, got a different error: {message}" + ); +} + +/// Two independent parties share control of one private Token holding via a `GroupKeyHolder` +/// Group Master Secret (GMS), distributed through the real seal/unseal handshake — not by +/// reusing key material directly — so the test proves actual sharing, not code reuse. "Alice" +/// creates the group and shields tokens into the shared holding; "Bob" only ever receives the +/// *sealed* GMS, independently re-derives the identical nsk/npk from it, and successfully +/// burns from the same holding neither of them personally owns. Validates the `GROUP` Q2 +/// dimension: sharing a private account (group-owned) used as a program account. +/// TODO: add a function for spending +#[test] +fn token_group_owned_holding_shared_control_burn() { + let mut state = state_for_token_tests(); + let shield_amount = 500_000_u128; + let burn_amount = 200_000_u128; + + // Alice creates the group and derives the shared account's keys. + let alice_holder = GroupKeyHolder::new(); + let derivation_seed = [7_u8; 32]; + let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); + let group_npk = alice_keys.generate_nullifier_public_key(); + let group_vpk = alice_keys.generate_viewing_public_key(); + let group_id = AccountId::for_regular_private_account(&group_npk, 0); + + // Alice distributes the GMS to Bob via the real seal/unseal handshake, not by handing + // over key material directly. + let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); + let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); + let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; + let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( + bob_sealing_vpk.to_bytes().to_vec(), + )); + let bob_holder = + GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); + + // Bob independently re-derives the same shared-account keys from the unsealed GMS. + let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); + let bob_nsk = bob_keys.nullifier_secret_key; + assert_eq!( + bob_keys.generate_nullifier_public_key(), + group_npk, + "Bob must derive the identical npk as Alice from the shared GMS" + ); + + // Alice shields tokens into the group-owned holding (mirrors `shielded_token_transfer`, + // parameterized by the group's npk/vpk instead of a personal one). + let sender_id = Ids::holder(); + let sender_account = state.get_account_by_id(sender_id); + let sender_nonce = sender_account.nonce; + let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); + let group_pre_shield = AccountWithMetadata::new(Account::default(), false, group_id); + + let shield_secret = SharedSecretKey::encapsulate_deterministic(&group_vpk, &[0u8; 32], 0).0; + let shield_instruction = token_core::Instruction::Transfer { + amount_to_transfer: shield_amount, + }; + let (shield_output, shield_proof) = execute_and_prove( + vec![sender_pre, group_pre_shield], + Program::serialize_instruction(shield_instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&group_npk, &group_vpk), + npk: group_npk, + ssk: shield_secret, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + let shield_message = + Message::try_from_circuit_output(vec![sender_id], vec![sender_nonce], shield_output) + .unwrap(); + let shield_witness = + WitnessSet::for_message(&shield_message, shield_proof, &[&Keys::holder_key()]); + let shield_tx = PrivacyPreservingTransaction::new(shield_message, shield_witness); + state + .transition_from_privacy_preserving_transaction(&shield_tx, 0, 0) + .unwrap(); + + let group_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: shield_amount, + }), + nonce: Nonce::private_account_nonce_init(&group_id), + }; + let group_commitment = Commitment::new(&group_id, &group_account); + assert!(state.get_proof_for_commitment(&group_commitment).is_some()); + + // Bob — who never touched Alice's `GroupKeyHolder` object, only the sealed GMS — burns + // from the group-owned holding using his independently derived nsk. + let membership_proof = state + .get_proof_for_commitment(&group_commitment) + .expect("group holding's commitment must be in the set"); + let burn_shared_secret = + SharedSecretKey::encapsulate_deterministic(&group_vpk, &[0u8; 32], 0).0; + + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let group_pre_burn = AccountWithMetadata::new(group_account, true, group_id); + + let burn_instruction = token_core::Instruction::Burn { + amount_to_burn: burn_amount, + }; + let (burn_output, burn_proof) = execute_and_prove( + vec![definition_pre, group_pre_burn], + Program::serialize_instruction(burn_instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&group_npk, &group_vpk), + ssk: burn_shared_secret, + nsk: bob_nsk, + membership_proof, + identifier: 0, + }, + ], + &token_program().into(), + ) + .unwrap(); + + let burn_message = + Message::try_from_circuit_output(vec![Ids::token_definition()], vec![], burn_output) + .unwrap(); + let burn_witness = WitnessSet::for_message(&burn_message, burn_proof, &[]); + let burn_tx = PrivacyPreservingTransaction::new(burn_message, burn_witness); + state + .transition_from_privacy_preserving_transaction(&burn_tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 - burn_amount, + metadata_id: None, + authority: Some(Ids::token_definition()), + }), + nonce: Nonce(0), + } + ); + + let group_nonce_after = + Nonce::private_account_nonce_init(&group_id).private_account_nonce_increment(&bob_nsk); + let new_group_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: shield_amount - burn_amount, + }), + nonce: group_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&group_id, &new_group_account)) + .is_some()); +} + #[test] fn token_new_fungible_definition_with_authority() { let mut state = V03State::new(); From 3eeb5059bb47021fd1c50206e44651d4dc4c8d7a Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Wed, 8 Jul 2026 16:54:31 -0400 Subject: [PATCH 02/12] test(privacy): extend Stablecoin/ATA privacy coverage and close Token/ATA gaps Add Stablecoin privacy-preserving tests for WithdrawCollateral and RepayDebt (personal and group-owned variants), plus a regression test confirming OpenPosition is incompatible with the privacy circuit (chained-call re-authorization). Close the last planned Token row (MintWithAuthority to a private holding) and the ATA owner-signer gap for Transfer (personal and group-owned), plus a defensive Create/group-owner test. Extract shared privacy-test helpers (identity builders, GroupOwner seal/unseal handshake) into integration_tests/src/lib.rs and use them throughout token.rs, collapsing duplicated InputAccountIdentity/account construction. Update docs/privacy-test-matrix.md with all new findings. --- docs/privacy-test-matrix.md | 317 +++++- programs/integration_tests/src/lib.rs | 120 +++ programs/integration_tests/tests/ata.rs | 348 ++++++- .../integration_tests/tests/stablecoin.rs | 964 +++++++++++++++++- programs/integration_tests/tests/token.rs | 944 +++++++---------- 5 files changed, 2030 insertions(+), 663 deletions(-) diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index da240504..c5d2fafb 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -9,6 +9,42 @@ record which combinations work, fail, or cannot be expressed. Every row starting This is the tracking scaffold, not the final deliverable — `docs/privacy-gap-report.md` gets written from the resolved state of this table. +## Key findings so far (highest priority — read this before anything else) + +1. **`OpenPosition` cannot be called via a `PrivacyPreservingTransaction` at all**, for any + reason related to privacy — confirmed with an all-public control case (zero private + accounts, still fails identically). `open_position.rs` issues two chained calls that both + reuse `vault`: `Token::InitializeAccount` authorizes it via `pda_seeds`, then + `Token::Transfer` re-declares it `is_authorized: false` on its second occurrence (a + legitimate choice on the public-transaction path, per that file's own comment). The privacy + circuit's `authorized_accounts` bookkeeping is monotonic — once authorized, an account must + stay declared `is_authorized: true` on every later occurrence — so this is rejected with + `"Inconsistent authorization for account {id}"` (`lee_core`'s `execution_state.rs:301`). + Likely fixable by not re-declaring `vault` unauthorized on its second occurrence. See + `stablecoin_open_position_via_privacy_transaction_is_not_expressible` and the Stablecoin + section below for the full writeup. **Single most actionable item for the protocol team.** +2. **Private PDAs are structurally impossible under every program's current derivation** — ATA, + AMM, and Stablecoin all derive PDAs via `for_public_pda` only, which can never satisfy + `PrivatePdaInit`/`PrivatePdaUpdate`'s binding requirement (traced precisely in + `execution_state.rs`; see the ATA section). Fixable only by a source change to + `for_private_pda` in each `*_core` crate. +3. **Sending to an existing private account requires the recipient's cooperation** — no + "blind credit" path exists; confirmed across Token/ATA/Stablecoin instructions. Real + wallet-UX implication, not a bug. +4. **Group-owned (shared) accounts work identically to personal ones** wherever tried — + Transfer, Burn, InitializeAccount, and as the signing `owner` behind a PDA-locked resource + (ATA, Stablecoin) — using the real seal/unseal GMS distribution, not just key reuse. +5. **AMM cannot be privacy-tested at all yet** — a *second*, distinct circuit-level issue + blocks every pool-mutating AMM instruction (`Swap*`, `AddLiquidity`, `RemoveLiquidity`, + `SyncReserves`) from the privacy-preserving transaction type, confirmed with all-public + control tests (zero private accounts, still fails): `"Invalid account_identities length"` + inside `execute_and_prove` itself. Ruled out "two different callee programs" as the cause + (a TWAP-only instruction fails identically to a Token+TWAP one); leading unconfirmed + suspect is AMM's pattern of passing an already-mutated `pool` copy into its chained TWAP + call. Root-causing further requires the Docker-based guest rebuild pipeline (`make + build-programs`), not plain `cargo test` — parked pending that investment. See the AMM + section below for the full bisection trail. + ## Legend **Dimension** — which cross-cutting Q2 feature (or baseline coverage gap) a row exercises: @@ -41,7 +77,7 @@ Checked against the 4 Q2 checkboxes explicitly, not assumed: | Checkbox | Status | Basis | |---|---|---| | Private PDAs used as program inputs | **N/A at this layer** | `token_core` has no `for_public_pda`/`for_private_pda` calls anywhere — Token holdings are addressed by arbitrary `AccountId`, not program-derived. Only testable once wrapped by another program's PDA (ATA/AMM/Stablecoin) — correctly deferred, not a gap in Token coverage. | -| Sharing a private account (group-owned) | **Covered** | `token_group_owned_holding_shared_control` — see finding below. | +| Sharing a private account (group-owned) | **Covered** | `token_group_owned_holding_shared_control_burn`/`_transfer`/`_initialize` — see finding below. | | Sending funds to an existing private account | **Covered** | `token_transfer_into_existing_private_holding` — see finding above. | | Multiple private accounts in one tx / private accounts through chained calls | **Partially covered** | "Multiple private accounts in one tx" half: covered, but by the *pre-existing* `token_private_transfer` (two private legs, zero public), not by anything added this phase — none of the new tests this phase have more than one private leg. "Carried through chained calls" half: N/A at this layer, Token issues no `ChainedCall`s (only ATA/AMM/Stablecoin do); deferred. | @@ -50,6 +86,11 @@ pre-existing test for half of a 3rd (`CHAIN`'s multi-account half), and the rema (`PDA`) plus the other half of `CHAIN` are structurally out of reach until ATA/AMM/Stablecoin phases — not oversights specific to this phase. +**Update (2026-07-08):** the one remaining planned row, `token_mint_with_authority_to_private_holding` +(`BASE`, P3), passed — see the finding under Planned below. It doesn't move any of the 4 +checkboxes above (it's `BASE`, not `PDA`/`GROUP`/`EXIST`/`CHAIN`), but it closes the last open +instruction/private-recipient combination at this layer. **Token phase is now complete.** + ### Existing | Instruction | Dimension | Test | Status | @@ -65,8 +106,11 @@ phases — not oversights specific to this phase. | Transfer | `EXIST` + `CHAIN` (fully private) | `token_private_transfer_into_existing_private_holding` — both legs private, recipient already existing (not fresh); two distinct accounts both via `PrivateAuthorizedUpdate` in one tx | Pass | | InitializeAccount | BASE | `token_initialize_private_account` — self-init of a private holding via `PrivateAuthorizedInit` | Pass | | InitializeAccount | new: self-service-only boundary | `token_initialize_private_account_without_nsk_is_not_expressible` | **Not-expressible — confirmed by design, not a gap** | -| Transfer + Burn | `GROUP` | `token_group_owned_holding_shared_control` — shield into a GMS-derived shared holding, spend from it via an independently-derived key | Pass | +| Burn | `GROUP` | `token_group_owned_holding_shared_control_burn` — shield into a GMS-derived shared holding, burn from it via an independently-derived key | Pass | +| Transfer | `GROUP` | `token_group_owned_holding_shared_control_transfer` — group-owned sender spends outward via Transfer to a fresh private recipient, instead of destroying the funds via Burn | Pass | +| InitializeAccount | `GROUP` | `token_group_owned_holding_shared_control_initialize` — a group member (not the group's creator) self-initializes the shared holding directly via `PrivateAuthorizedInit` | Pass | | Mint | `EXIST` | `token_mint_into_existing_private_holding` — mint once to establish the holding, mint again into it via `PrivateAuthorizedUpdate` | Pass | +| MintWithAuthority | BASE | `token_mint_with_authority_to_private_holding` — external-authority mint (distinct signer from the definition) directly to a fresh private recipient | Pass | **Finding (`GROUP`, confirmed 2026-07-07):** sharing a private account genuinely works, and the test was built to prove *sharing*, not just code reuse: "Alice" creates a `GroupKeyHolder` (fresh GMS) @@ -79,6 +123,17 @@ the shared holding using his own derivation. Required adding `key_protocol` as a dependency of `lez-programs`. Passed on the first attempt; no gap found for this dimension at the Token layer. +**Finding (group-owned spend + self-init, confirmed 2026-07-07):** the `_burn` test only proved +group funds could be *destroyed*; `token_group_owned_holding_shared_control_transfer` closes +that gap by having Bob spend outward via `Transfer` to a fresh private recipient instead — +same seal/unseal rigor, both legs private (group sender via `PrivateAuthorizedUpdate`, fresh +recipient via `PrivateUnauthorized`), no public account anywhere in the transaction. +`token_group_owned_holding_shared_control_initialize` closes the other gap: a group *member* +(not the party who created the group) self-initializing the shared holding directly via +`InitializeAccount`/`PrivateAuthorizedInit`, rather than the holding only ever coming into +existence as a side effect of a shield. Both passed on the first attempt — group-owned +accounts behave identically to personal ones across every instruction tried so far. + **Finding (`EXIST`, confirmed 2026-07-07):** crediting an *existing* private account works, but only if the recipient cooperates in the same transaction. Confirmed directly against `InputAccountIdentity`'s doc comments and `output.rs` in `lee_core`: every variant that touches an existing private account @@ -110,11 +165,29 @@ Passed on the first attempt once modeled on `token_transfer_into_existing_privat ### Planned +All originally-planned Token rows are now resolved (`token_mint_with_authority_to_private_holding` +passed — moved into the `Existing` table above) — Token phase is complete. + | Instruction | Dimension | Test | Priority | Depends on | Status | |---|---|---|---|---|---| -| MintWithAuthority | BASE | `token_mint_with_authority_to_private_holding` | P3 | — | Not started | | NewFungibleDefinition, NewDefinitionWithMetadata, SetAuthority(WithAuthority), PrintNft | — | **Not planned** — these operate on canonical, publicly-resolvable definitions/authorities; a "private token definition" has no coherent meaning since holders/traders must resolve it | — | — | Out of scope | +**Finding (`token_mint_with_authority_to_private_holding`, confirmed 2026-07-08):** closes the +last open Token combination — external-authority minting (`MintWithAuthority`, distinct signer +from the definition account) composed with a private recipient. Every prior `MintWithAuthority` +coverage minted to a public holder; every prior private-recipient mint test used self/PDA +authority (plain `Mint`). `mint_inner` never asserts `is_authorized` on `user_holding_account` +regardless of authority mode, so a passive `PrivateUnauthorized` recipient works here exactly as +it does under plain `Mint`. Passed on the first attempt after correcting the `Message` +construction: with two public accounts in the same privacy transaction (`definition`, not a +signer, plus `authority`, the signer), `public_account_ids` must list *both* — in their +`execute_and_prove` input order — for the circuit's public post-states to zip correctly, while +`nonces` lists *only* the signer(s), positionally matched to the witness keys (`signer_account_ids` +is derived from the witness set's public keys, not from `public_account_ids`). This is the first +test in the file with more than one public account alongside a private one, so it's worth +carrying forward: `public_account_ids` (post-state zipping) and `nonces` (signature/nonce +verification) are two independently-sized lists, not one shared list. + **Correction (`token_initialize_private_account`, resolved 2026-07-07):** originally flagged as a plausible `Not-expressible` case because `initialize.rs` hard-asserts `is_authorized == true` while a fresh account created via `PrivateUnauthorized` must be `false`. That flag was based on picking @@ -180,6 +253,19 @@ Verified in `ata/src/create.rs`: the owner account is **not** forwarded into the appear as a top-level tx participant, but does **not** prove a private account traveling through a chained call. That gap is still open despite appearances. +**Finding (third-party bootstrap, confirmed 2026-07-07 — positive finding, not a gap):** +`Create` never asserts `owner.is_authorized`, and the only private identity variant compatible +with an unauthorized owner (`PrivateUnauthorized`) structurally has no `nsk` field at all — it's +built from `npk`/`vpk` alone. So `ata_create_from_private_owner` demonstrates something worth +stating plainly rather than leaving implicit: **any third party can bootstrap another owner's +ATA using only that owner's public key material, without the owner ever exposing (or even +needing to possess yet) their `nsk`.** This mirrors Token's finding that anyone can shield funds +into a fresh private recipient who has never been online — here a wallet provider, faucet, or +counterparty program can pre-create a user's per-token account the same way, purely from public +inputs. The boundary is exactly where signing starts: the moment an instruction needs to *move* +value or prove ongoing control (`Transfer`, `Burn`), `nsk` becomes mandatory — see the +signer-authorization finding below. + **Finding (`PDA`, confirmed 2026-07-07 — root cause, not just an observation):** the ATA holding can never be made a private account as ATA is currently coded, and this is a structural fact provable from `lee_core`'s circuit source, not empirical friction. Traced @@ -212,6 +298,9 @@ is complete. | Transfer | `CHAIN` + `EXIST` (collapsed — see finding) | `ata_transfer_to_existing_private_recipient` | Pass | | Burn | new: signer-authorization | `ata_burn_with_private_owner_signing` | Pass | | Burn | `GROUP` + signer-authorization | `ata_group_owned_owner_signing` | Pass | +| Transfer | new: signer-authorization | `ata_transfer_with_private_owner_signing` | Pass | +| Transfer | `GROUP` + signer-authorization | `ata_transfer_with_group_owned_owner_signing` | Pass | +| Create | `GROUP` (defensive/symmetry only — see finding) | `ata_create_from_group_owned_owner` | Pass | **Finding (`CHAIN` + `EXIST`, confirmed 2026-07-07):** `ata_program::transfer::transfer_from_associated_token_account` hard-asserts `recipient.account != Account::default()` ("Recipient token holding must be @@ -232,11 +321,46 @@ signer requirement). `ata_burn_with_private_owner_signing` tests whether a priva satisfy a signer requirement by self-initializing *and* signing in the same transaction via `PrivateAuthorizedInit` — it does, cleanly, on the first attempt. `ata_group_owned_owner_signing` composes this with `GROUP`: the GMS is distributed through the real seal/unseal handshake (as -in `token_group_owned_holding_shared_control`), and "Bob" — who never touches Alice's +in `token_group_owned_holding_shared_control_burn`), and "Bob" — who never touches Alice's `GroupKeyHolder` object — independently re-derives the matching nsk/npk and signs. Both pass. Worth feeding back as a positive finding: private/shared accounts can serve as full signing authorities for instructions that require it, not just as passive recipients. +**Follow-up (confirmed 2026-07-08 — closing a coverage review gap, not a new dimension):** a +review pass noticed `Burn` had both personal and group-owned signer coverage but `Transfer` +(identical `#[account(signer)]` requirement on `owner`) only had the pre-existing public-owner +test — a private owner had never actually been tried signing `ATA::Transfer`. +`ata_transfer_with_private_owner_signing` / `ata_transfer_with_group_owned_owner_signing` close +that gap directly, mirroring the `Burn` pair exactly (self-init + sign via `PrivateAuthorizedInit`, +personal and group-owned). Both passed on the first attempt, as expected given `Burn`'s identical +shape. Also added `ata_create_from_group_owned_owner` for symmetry — but **this one is a weaker +test by construction, not a gap closure**: `Create` places no signer requirement on `owner` at +all, and its only compatible private identity (`PrivateUnauthorized`) never touches `nsk`, so a +group-derived `owner` is indistinguishable from a personal one at this instruction. The test +confirms that empirically (nothing in `Create` secretly assumes anything about where `npk`/`vpk` +came from) but does **not** demonstrate genuine shared control the way the `Transfer`/`Burn` +group tests do — there is nothing for `Create` to prove sharing over, since it never asks anyone +to prove control of `owner` in the first place. Net: `Create`'s "group ownership" question isn't +an open gap, it's a category mismatch — worth stating that plainly in the gap report rather than +implying it was untested. + +**Finding (ATA cannot originate a fresh private holding, confirmed 2026-07-08 — synthesizes two +separate facts above into one conclusion worth stating plainly): no ATA instruction can bring a +new private token holding into existence, for two independent reasons covering the two accounts +involved.** (1) The ATA's own holding can never be private at all — the confirmed `PDA` finding: +`Create` authorizes it via `for_public_pda` only, which can never satisfy +`PrivatePdaInit`/`PrivatePdaUpdate`'s binding requirement. (2) Even a separate, non-ATA private +recipient can't be freshly created through `ATA::Transfer` — `transfer_from_associated_token_account` +hard-asserts `recipient.account != Account::default()`, rejecting a shield-style fresh +`PrivateUnauthorized` recipient outright; only an *already-existing* recipient can be credited +(per the `CHAIN` + `EXIST` finding above). So ATA can send value *toward* a private destination, +but only one that already exists via some other path — every private holding that appears in +these tests was originated by a direct, non-ATA `Token` call +(`ata_transfer_to_existing_private_recipient`'s setup shields the recipient via `Token::Transfer` +before the ATA transfer under test ever runs). Worth stating as its own line in the gap report: +"ATA cannot emit private token holdings" is a real, structural limitation, not a coverage gap +in the tests written here. + --- ## AMM (`amm.rs`) — depends on Token, TWAP oracle @@ -249,57 +373,166 @@ price surface (reserves must be readable to quote a swap; TWAP needs a continuou observable tick) — privatizing them fights the AMM's purpose. Vault/LP-lock are the credible middle case. User-held token/LP balances are the highest-value target. +### ⚠ Blocked pending investigation (2026-07-08) — read before starting AMM test-writing + +Before writing any private AMM test, an all-public control test through `execute_and_prove` +(the same discipline that found Stablecoin's `OpenPosition` bug) turned up a **second, +distinct circuit-level issue specific to AMM**, unrelated to any privacy dimension. No AMM +privacy tests have been written yet — this needs resolving (or explicitly working around) +first. + +**Symptom**: `SwapExactInput` (8 top-level accounts, 3 chained calls: 2×`Token::Transfer` + +1×`TWAP::UpdateCurrentTick`) fails *inside* `execute_and_prove`, before any private account is +even involved, with `"Invalid account_identities length"` (`lee_core`'s `output.rs:27`) — +`account_identities.len()` (8, what we supply) vs `states_iter.len()` (7, what the circuit +computes). Confirmed with every account `Public`. + +**Bisection done so far**: +- **Ruled out "two different callee programs"**: `SyncReserves` (6 accounts, *one* chained + call, into TWAP oracle only — zero Token calls) fails with the identical pattern (6 vs 5). + So it's not about chaining into two different programs. +- **Ruled out "any multi-account reuse in one chained call"**: Stablecoin's + `WithdrawCollateral` reuses *two* accounts (`vault`, `destination`) inside its single chained + call and works fine — so plain reuse-of-multiple-accounts isn't sufficient on its own to + trigger this. +- **Simplest AMM instruction works**: `UpdateConfig` (2 accounts, zero chained calls) gets + *past* `execute_and_prove` cleanly — it fails later, at `transition_from_privacy_preserving_transaction`, + with `InvalidInput("Empty commitments and empty nullifiers found in message")`. This looks + like an unrelated, general protocol rule (a `PrivacyPreservingTransaction` needs at least one + actual private account, or use `PublicTransaction` instead) — not a bug, but worth noting: + **the "all-public control" methodology needs at least one trivial private leg to get past + this check for future control tests**, not just all-`Public` identities. +- **Leading structural lead, not yet confirmed**: every AMM instruction that hits the length + mismatch passes a *post-update* copy of `pool` (`pool_price_source`, holding `pool_post` — + the already-mutated state, not the original pre-state) into its chained TWAP call. This + "pass what's about to become the post-state as the next call's own pre-state" pattern is + proven correct on the public-transaction path (33 passing tests) but nothing in + Token/ATA/Stablecoin ever exercised it under the privacy circuit. Not yet confirmed as *the* + cause — only the clearest outlier found. + +**Why this wasn't root-caused further**: attempted source-level instrumentation +(`eprintln!` tracing added directly to the pinned `lee_core` checkout's `execution_state.rs`) +to watch the exact bookkeeping live. Confirmed `lee`/`lee_core` genuinely recompiled +(`cargo clean -p lee -p lee_core` + fresh compile logs), but the added prints never +surfaced, while the original panic still fired from the same file/line. This means the actual +executed code path isn't rebuilt by a normal `cargo clean`/`cargo test` cycle — almost +certainly because real guest execution runs a separately cross-compiled RISC-V ELF +(`risc0_build::embed_methods!`), which per this repo's own `CLAUDE.md` needs the Docker-based +`make build-programs` pipeline to rebuild, not plain cargo. Instrumentation was cleanly +reverted (`git status` clean in the checkout; all 52 other tests reconfirmed passing +afterward) rather than sunk further into standing up that Docker toolchain just for tracing. + +**Next step when this is picked back up**: either (a) stand up the guest-rebuild pipeline to +finish the trace, or (b) construct a minimal synthetic instruction (not part of the real AMM +program) that isolates the "post-state passed as next call's pre-state" pattern alone, without +needing to modify any pinned dependency. + ### Existing -0 private tests out of 33 public. +0 private tests out of 33 public. (No private test-writing attempted yet — blocked above.) ### Planned | Instruction | Dimension | Test | Priority | Depends on | Status | |---|---|---|---|---|---| -| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | Not started | -| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | Not started | -| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` | P1 | Token, TWAP oracle (public leg) | Not started | -| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | Not started | -| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | Not started | -| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | Not started | -| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | Not started | -| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started | -| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | Not started | +| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | **Blocked** — see above | +| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | **Blocked** — see above | +| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | **Blocked** — see above (also issues chained calls reusing `pool`-derived accounts; check on resolution) | +| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started (also behind the blocker above) | +| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | **Blocked** — see above | | Pool/Config (any) | `PDA` | `amm_attempt_private_pool_pda` — same predicted not-expressible outcome as above; low priority given the vault row already confirms the root cause for this program | P3 | Token | Not started | | Initialize, UpdateConfig, CreatePriceObservations, CreateOraclePriceAccount, SyncReserves | — | **Not planned** — admin/infra instructions over public protocol state; a private admin authority is legitimate but low value | — | — | Out of scope (for now) | Note: every Swap/AddLiquidity/RemoveLiquidity chains to *both* Token (transfers) and TWAP oracle (tick refresh) in one instruction — so every `CHAIN` row above is automatically also a "some legs private, some public" test. Call that out explicitly when the test is written, -not as an incidental detail. +not as an incidental detail. **All of these are currently blocked by the circuit-level issue +above, since it fires with zero private accounts involved — no privacy dimension can be tested +on any pool-mutating AMM instruction until it's resolved.** --- ## Stablecoin (`stablecoin.rs`) — depends on Token -Only 2 tests total today (`stablecoin_open_position_then_withdraw_collateral`, -`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt`), 0 private. Both PDAs -(position, position vault) are `for_public_pda` only. - -Arguably the most naturally privacy-motivated program of the four — a CDP's collateral/debt -is exactly what a user would want hidden — despite having the thinnest existing baseline. +2 pre-existing public tests (`stablecoin_open_position_then_withdraw_collateral`, +`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt`). Both PDAs (position, position +vault) are `for_public_pda` only, per the ATA `PDA` finding. ### Existing -0 private tests out of 2 public. - -### Planned - -| Instruction | Dimension | Test | Priority | Depends on | Status | -|---|---|---|---|---|---| -| OpenPosition | `CHAIN` | `stablecoin_open_position_private_collateral_holding` | P1 | Token | Not started | -| WithdrawCollateral | `CHAIN` | `stablecoin_withdraw_collateral_private_holding` | P1 | Token | Not started | -| RepayDebt | `CHAIN` | `stablecoin_repay_debt_private_holding` | P1 | Token | Not started | -| OpenPosition / Position + Vault | `PDA` | `stablecoin_open_position_private_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `stablecoin_core`); still worth writing as the clearest real-world case (a CDP position is the most natural thing to want private of anything in this whole exercise), but as a confirmation citing the root cause, not a fresh investigation | P1 (high value as *documentation* of the clearest case, even though the outcome is now predicted) | Token | Not started | -| OpenPosition / WithdrawCollateral | `EXIST` | `stablecoin_deposit_into_existing_private_holding` | P2 | Token | Not started | -| OpenPosition (joint CDP) | `GROUP` | `stablecoin_group_owned_position` | P3 | Token, `key_protocol` | Not started | -| (ProtocolParameters, any) | — | **Not planned** — not yet consumed by any instruction (no freeze/admin logic wired up); nothing to test | — | — | Out of scope | +| Instruction | Dimension | Test | Status | +|---|---|---|---| +| OpenPosition | new: chained-call re-authorization | `stablecoin_open_position_via_privacy_transaction_is_not_expressible` | **Not-expressible — confirmed, root cause traced** | +| WithdrawCollateral | `CHAIN` + `EXIST` | `stablecoin_withdraw_collateral_private_destination` | Pass | +| WithdrawCollateral | `CHAIN` + `EXIST` + `GROUP` | `stablecoin_withdraw_collateral_group_owned_destination` | Pass | +| RepayDebt | `CHAIN` | `stablecoin_repay_debt_private_stablecoin_holding` | Pass | +| RepayDebt | `CHAIN` + `GROUP` | `stablecoin_repay_debt_group_owned_stablecoin_holding` | Pass | +| WithdrawCollateral (owner identity) | `GROUP` | `stablecoin_group_owned_position_owner` | Pass | + +**Finding (`OpenPosition`, confirmed 2026-07-08 — the headline finding for this program, and +arguably the whole exercise): `OpenPosition` cannot be executed through the privacy-preserving +transaction type at all, for any reason related to privacy.** Confirmed with an all-public +control test (every account `Public`, zero private accounts) that fails with the *identical* +error as the private attempt. Root cause traced precisely in `lee_core`'s +`execution_state.rs`: `authorized_accounts` is a monotonic/sticky set — once an account is +authorized via one chained call's `pda_seeds` match, every later occurrence of that same +account must *also* declare `is_authorized: true`, or +`assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")` +fails. `open_position.rs` issues two chained calls that both reuse `vault`: the first +(`Token::InitializeAccount`) authorizes it via `pda_seeds`, sticking it as authorized; the +second (`Token::Transfer`) then deliberately constructs `post_init_vault` with +`is_authorized: false` — a legitimate choice on the public-transaction path (the file's own +comment: "the recipient is already initialized, so no second PDA claim is needed here") — but +the privacy circuit rejects that as inconsistent. **This means no privacy-preserving test can +ever open a position** — not because of anything about privacy, but because the instruction +itself is incompatible with the privacy transaction machinery as currently coded. Every test +below routes around it by seeding position/vault directly via `force_insert_account` (public +accounts, no real `OpenPosition` call), matching how the pre-existing public +`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt` test already worked before this +phase. This is the single most actionable, most severe finding to feed back to the protocol +team — it blocks privacy for `OpenPosition` categorically, independent of the four Q2 +dimensions, and is likely fixable by having `open_position.rs` mark `post_init_vault` as +authorized (or otherwise not re-declare it unauthorized) on its second occurrence. + +**Consequence for the `PDA` dimension**: the originally-planned +`stablecoin_open_position_private_pda` confirmation test was dropped as redundant. Position and +vault are *only* ever claimed (via `Claim::Pda` and chained `pda_seeds` respectively) inside +`OpenPosition` — and since that instruction can't reach the privacy circuit at all, the `PDA` +question for Stablecoin can't even be isolated independently; it's subsumed by the finding +above. No separate test needed — the ATA `PDA` finding (same `for_public_pda`-only root cause) +still stands as the citable reference. + +**Finding (`stablecoin_withdraw_collateral_private_destination` / `..._group_owned_destination`, +confirmed 2026-07-08):** unlike `OpenPosition`, `WithdrawCollateral` issues only *one* chained +call (`Token::Transfer`, reusing `vault` exactly once) — it doesn't hit the re-authorization +bug, and passed on the first attempt with a private, pre-existing destination (`EXIST`, +requiring the destination's `PrivateAuthorizedUpdate` cooperation per the Token/ATA-phase +finding) and again with a group-owned destination (real seal/unseal distribution, `GROUP`). + +**Finding (`stablecoin_repay_debt_private_stablecoin_holding` / `..._group_owned_...`, confirmed +2026-07-08):** `RepayDebt` also has only one chained call (`Token::Burn`) and isn't affected by +the `OpenPosition` bug. `user_stablecoin_holding` is notably *not* PDA-locked (unlike ATA's own +holdings) — it's an ordinary user-controlled token holding — so it's free to be private with no +structural obstacle at all. Passed personal and group-owned variants on the first attempt. + +**Finding (`stablecoin_group_owned_position_owner`, confirmed 2026-07-08 — reframes what +"group-owned position" means):** the position/vault themselves can never be private or +group-owned (the `PDA` finding), and can't even be *opened* through the privacy machinery (the +finding above) — but `owner` is just an `AccountId` used for PDA seed derivation and signer +verification, so it doesn't need to be a plain public keypair. Directly mirroring +`ata_group_owned_owner_signing`'s precedent: position/vault are seeded directly (bypassing the +blocked `OpenPosition`), keyed to a group-derived `owner` identity; "Bob" — who only ever +receives the sealed GMS — self-initializes *and* signs that owner identity in one transaction +via `PrivateAuthorizedInit`, then withdraws collateral through it. Passed on the first attempt. +This is the correct, expressible version of "joint control over a CDP": shared control of the +*authority* over a PDA-locked resource, not shared privacy of the resource itself. + +`ProtocolParameters` remains out of scope — not yet consumed by any instruction (no +freeze/admin logic wired up), nothing to test. --- @@ -309,9 +542,13 @@ is exactly what a user would want hidden — despite having the thinnest existin `integration_tests/Cargo.toml` pinned to the same repo/tag as `nssa`/`nssa_core`. Unblocks the remaining `GROUP` rows in ATA/AMM/Stablecoin; each still needs its own program-specific test (PDA-based group ownership, not just the regular-account path proven for Token). -- Build the shared privacy test kit in `integration_tests/src/lib.rs` (shield / spend / - private-PDA fund-spend / group-derive helpers) — still not done. Tests so far (Token and ATA - phases) are still hand-rolled per-file; revisit whether to extract shared helpers before AMM. +- Build the shared privacy test kit in `integration_tests/src/lib.rs` — **partially done** + (2026-07-08): `private_unauthorized_identity`/`private_authorized_init_identity`/ + `private_authorized_update_identity` (build an `InputAccountIdentity` from just the key + material) and `setup_group_shared_account` (the Alice-creates/Bob-unseals GMS handshake) now + live there and are used throughout `token.rs`. `ata.rs`/`stablecoin.rs` still have their own + independent copies of the same patterns — not yet migrated, since that was out of scope for + the token.rs-focused cleanup pass. Revisit migrating them before/during AMM. **Implementation technique worth carrying into AMM/Stablecoin (found 2026-07-07):** private account preconditions don't need a real proven transaction to set up. `V03State::with_private_accounts(impl IntoIterator)` @@ -331,7 +568,7 @@ heavier (chained calls, multiple accounts) than a single shield. | Program | Existing private / confirmed | Planned rows | Out-of-scope instructions noted | |---|---|---|---| -| Token | 13 (3 pre-existing + 10 new: 9 pass + 1 confirmed not-expressible by design) | 1 | 5 | -| ATA | 5 (4 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | +| Token | 16 (3 pre-existing + 13 new: 12 pass + 1 confirmed not-expressible by design) — phase complete | 0 | 5 | +| ATA | 8 (7 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | | AMM | 0 (2 rows now predicted not-expressible pending confirmation) | 10 | 5 | -| Stablecoin | 0 | 6 | 1 | +| Stablecoin | 6 (5 pass + 1 confirmed not-expressible — phase complete) | 0 | 1 | diff --git a/programs/integration_tests/src/lib.rs b/programs/integration_tests/src/lib.rs index 8b137891..a96269ae 100644 --- a/programs/integration_tests/src/lib.rs +++ b/programs/integration_tests/src/lib.rs @@ -1 +1,121 @@ +//! Shared account/key setup helpers for privacy-preserving integration tests. +use key_protocol::key_management::{ + group_key_holder::{GroupKeyHolder, SealingPublicKey}, + secret_holders::SecretSpendingKey, +}; +use nssa::SharedSecretKey; +use nssa_core::{ + account::AccountId, + encryption::{EphemeralPublicKey, ViewingPublicKey}, + EncryptedAccountData, InputAccountIdentity, MembershipProof, NullifierPublicKey, + NullifierSecretKey, +}; + +/// Builds a `PrivateUnauthorized` identity: a third party credits a fresh private account it +/// does not control (no `nsk`, `is_authorized` must be `false` on the paired pre-state). +pub fn private_unauthorized_identity( + npk: NullifierPublicKey, + vpk: &ViewingPublicKey, + output_index: u32, +) -> InputAccountIdentity { + InputAccountIdentity::PrivateUnauthorized { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk), + npk, + ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0, + identifier: 0, + } +} + +/// Builds a `PrivateAuthorizedInit` identity: the owner self-initializes a fresh private +/// account by supplying its own `nsk` directly (`is_authorized` must be `true`). +pub fn private_authorized_init_identity( + nsk: NullifierSecretKey, + vpk: &ViewingPublicKey, + output_index: u32, +) -> InputAccountIdentity { + let npk = NullifierPublicKey::from(&nsk); + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk), + ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0, + nsk, + identifier: 0, + } +} + +/// Builds a `PrivateAuthorizedUpdate` identity: spends/credits an *existing* private account, +/// requiring its own `nsk` and a membership proof of its current committed state. +pub fn private_authorized_update_identity( + nsk: NullifierSecretKey, + vpk: &ViewingPublicKey, + membership_proof: MembershipProof, + output_index: u32, +) -> InputAccountIdentity { + let npk = NullifierPublicKey::from(&nsk); + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk), + ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0, + nsk, + membership_proof, + identifier: 0, + } +} + +/// "Alice": creates a shared private account's `GroupKeyHolder` (Group Master Secret) and +/// derives its public identity. The GMS itself never leaves this struct — other parties only +/// ever receive it through [`GroupOwner::admit_member`]'s real seal/unseal ML-KEM-768 handshake, +/// never by handing over key material directly. +pub struct GroupOwner { + holder: GroupKeyHolder, + derivation_seed: [u8; 32], + pub npk: NullifierPublicKey, + pub vpk: ViewingPublicKey, + pub id: AccountId, +} + +impl GroupOwner { + /// Creates the group and derives the shared account's public identity from + /// `derivation_seed`. + #[must_use] + pub fn new(derivation_seed: [u8; 32]) -> Self { + let holder = GroupKeyHolder::new(); + let keys = holder.derive_keys_for_shared_account(&derivation_seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let id = AccountId::for_regular_private_account(&npk, 0); + Self { + holder, + derivation_seed, + npk, + vpk, + id, + } + } + + /// "Bob": distributes the GMS to a new member via the real seal/unseal handshake and + /// returns that member's independently re-derived secret key — the member never touches + /// this `GroupOwner`'s `GroupKeyHolder`, only the sealed bytes. + #[must_use] + pub fn admit_member(&self) -> NullifierSecretKey { + let member_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); + let member_sealing_vpk = member_sealing_keys.generate_viewing_public_key(); + let member_sealing_vsk = member_sealing_keys.viewing_secret_key; + let sealed_gms = self.holder.seal_for(&SealingPublicKey::from_bytes( + member_sealing_vpk.to_bytes().to_vec(), + )); + let member_holder = GroupKeyHolder::unseal(&sealed_gms, &member_sealing_vsk) + .expect("member must unseal the GMS"); + + let member_keys = member_holder.derive_keys_for_shared_account(&self.derivation_seed); + let member_nsk = member_keys.nullifier_secret_key; + assert_eq!( + member_keys.generate_nullifier_public_key(), + self.npk, + "member must derive the identical npk as the group owner from the shared GMS" + ); + member_nsk + } +} diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index 99df64e1..3d35c216 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; use ata_core::{compute_ata_seed, get_associated_token_account_id}; +use integration_tests::{ + private_authorized_init_identity, private_unauthorized_identity, GroupOwner, +}; use key_protocol::key_management::{ group_key_holder::{GroupKeyHolder, SealingPublicKey}, secret_holders::SecretSpendingKey, @@ -597,21 +600,7 @@ fn ata_create_from_private_owner() { ); } -// Marvin-todo -/// Documents a confirmed protocol gap (`PDA` Q2 dimension): the ATA holding can never be made -/// a private account as ATA is currently coded. `Create`'s `ChainedCall.pda_seeds` authorizes -/// Token to mutate `for_public_pda(ata_program_id, seed)` — a *public*-form PDA match. Per -/// `resolve_authorization_and_record_bindings` in `lee_core`'s `execution_state.rs`, a -/// caller-seed match only gets recorded in `private_pda_bound_positions` when it matches under -/// `for_private_pda` (`is_private_form == true`); a public-form match authorizes the account -/// but never binds it as a private PDA. Since `PrivatePdaInit`/`PrivatePdaUpdate` require their -/// position to appear in that binding map (`execution_state.rs:211`), and ATA's own -/// `verify_ata_and_get_seed` independently requires the account id to equal -/// `for_public_pda(ata_program_id, seed)` (never `for_private_pda`'s output, by construction), -/// these two requirements can never both hold for the same account_id. This is not -/// program-specific friction — it's structural: fixing it would require `ata_core` (and -/// equally amm_core / stablecoin_core) to derive their PDAs via `for_private_pda` instead, -/// which is a source change to the program, not a test workaround. +/// ATA cannot be created as a private account. #[test] fn ata_create_private_ata_holding_is_not_expressible() { let mut state = V03State::new(); @@ -684,22 +673,7 @@ fn ata_create_private_ata_holding_is_not_expressible() { ); } -// Marvin-todo -/// Credits an *already-existing* private holding through ATA's chained call to Token, and -/// documents a structural finding along the way: -/// `ata_program::transfer::transfer_from_associated_token_account` hard-asserts `recipient.account -/// != Account::default()` ("Recipient token holding must be initialized"), so a *fresh* private -/// recipient (shield-style, `PrivateUnauthorized`) can never be created through `ATA::Transfer` — -/// only an existing account can be credited. That collapses what would otherwise be separate `BASE` -/// and `EXIST` tests into one: this test necessarily exercises both "private account through a -/// chained call" (`CHAIN`) and "sending to an existing private account" (`EXIST`, requiring the -/// recipient's cooperation via `PrivateAuthorizedUpdate`, per the finding already confirmed in -/// `token.rs`). -/// -/// The private holding is funded beforehand via a direct (non-ATA) `Token::Transfer` shield -/// from a throwaway public holder, since neither `ATA::Transfer` (blocked by the assert above) -/// nor `Token::Mint` (this test fixture's definition has `authority: None`, fixed supply) can -/// create it. +/// Verifies ATA account can be used to transfer to a private account. #[test] fn ata_transfer_to_existing_private_recipient() { let mut state = state_for_ata_tests(); @@ -878,14 +852,7 @@ fn ata_transfer_to_existing_private_recipient() { .is_some()); } -// Marvin-todo -/// Tests a previously-untried combination: `Burn`'s guest requires `owner` to be a *signer* -/// (`#[account(signer)]`) — every existing private-owner test so far -/// (`ata_create_from_private_owner`) only used owner as a passive `PrivateUnauthorized` recipient -/// in `Create`, which doesn't need signer authorization at all. Here, owner self-initializes *and* -/// signs in the same transaction via `PrivateAuthorizedInit` (proving control by supplying their -/// own nsk directly) — the ATA holding itself stays public, per the confirmed `PDA` finding above; -/// only the signing identity is private. +/// Private account owner can sign transactions. #[test] fn ata_burn_with_private_owner_signing() { let mut state = V03State::new(); @@ -1001,13 +968,8 @@ fn ata_burn_with_private_owner_signing() { .is_some()); } -// Marvin-todo -/// Composes the `GROUP` dimension with the signer-authorization finding just proven above: a -/// group-owned owner (GMS distributed through the real seal/unseal handshake, exactly as in -/// `token_group_owned_holding_shared_control`) signs an `ATA::Burn` via `PrivateAuthorizedInit`. -/// "Bob" — who only ever receives the sealed GMS, never Alice's `GroupKeyHolder` object — -/// independently re-derives the identical nsk/npk and successfully signs for the shared ATA -/// owner identity. +/// TODO: remove, this is essentially same as burn test. Worth noting though that +/// any member can sign. #[test] fn ata_group_owned_owner_signing() { let mut state = V03State::new(); @@ -1130,3 +1092,297 @@ fn ata_group_owned_owner_signing() { .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) .is_some()); } + +/// Private owner +#[test] +fn ata_transfer_with_private_owner_signing() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init()); + + let owner_nsk: NullifierSecretKey = [95u8; 32]; + let owner_npk = NullifierPublicKey::from(&owner_nsk); + let owner_vpk = ViewingPublicKey::from_seed(&[96u8; 32], &[97u8; 32]); + let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); + + // The ATA holding must stay public (per the confirmed PDA finding), so it's seeded + // directly rather than via a real `Create` transaction. + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let sender_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + let sender_ata_account = Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128, + }), + nonce: Nonce(0), + }; + state.force_insert_account(sender_ata_id, sender_ata_account.clone()); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id); + let recipient_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::recipient_ata()), + false, + Ids::recipient_ata(), + ); + + let transfer_amount = 400_000_u128; + let instruction = ata_core::Instruction::Transfer { + token_program_id: Ids::token_program(), + amount: transfer_amount, + }; + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&owner_vpk, &[0u8; 32], 0).0; + + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let (output, proof) = execute_and_prove( + vec![owner_pre, sender_ata_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk), + ssk: shared_secret, + nsk: owner_nsk, + identifier: 0, + }, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![sender_ata_id, Ids::recipient_ata()], vec![], output) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(sender_ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128 - transfer_amount, + }), + nonce: Nonce(0), + } + ); + assert_eq!( + state.get_account_by_id(Ids::recipient_ata()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: transfer_amount, + }), + nonce: Nonce(0), + } + ); + + let owner_expected = Account { + nonce: Nonce::private_account_nonce_init(&owner_id), + ..Account::default() + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) + .is_some()); +} + +/// Group transfer is possible with group members added after the ATA is initialized. +#[test] +fn ata_transfer_with_group_owned_owner_signing() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init()); + + let alice = GroupOwner::new([19_u8; 32]); + let owner_id = alice.id; + + // The ATA holding must stay public (per the confirmed PDA finding), so it's seeded + // directly rather than via a real `Create` transaction. + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let sender_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + let sender_ata_account = Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128, + }), + nonce: Nonce(0), + }; + state.force_insert_account(sender_ata_id, sender_ata_account.clone()); + + let bob_nsk = alice.admit_member(); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id); + let recipient_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::recipient_ata()), + false, + Ids::recipient_ata(), + ); + + let transfer_amount = 400_000_u128; + let instruction = ata_core::Instruction::Transfer { + token_program_id: Ids::token_program(), + amount: transfer_amount, + }; + + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let (output, proof) = execute_and_prove( + vec![owner_pre, sender_ata_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + private_authorized_init_identity(bob_nsk, &alice.vpk, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![sender_ata_id, Ids::recipient_ata()], vec![], output) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(sender_ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128 - transfer_amount, + }), + nonce: Nonce(0), + } + ); + assert_eq!( + state.get_account_by_id(Ids::recipient_ata()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: transfer_amount, + }), + nonce: Nonce(0), + } + ); + + let owner_expected = Account { + nonce: Nonce::private_account_nonce_init(&owner_id), + ..Account::default() + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) + .is_some()); +} + +#[test] +fn ata_create_from_group_owned_owner() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + + let alice = GroupOwner::new([23_u8; 32]); + let owner_id = alice.id; + + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let owner_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + + let owner_pre = AccountWithMetadata::new(Account::default(), false, owner_id); + let def_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let ata_pre = AccountWithMetadata::new(Account::default(), false, owner_ata_id); + + let instruction = ata_core::Instruction::Create { + token_program_id: Ids::token_program(), + }; + + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let (output, proof) = execute_and_prove( + vec![owner_pre, def_pre, ata_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + private_unauthorized_identity(alice.npk, &alice.vpk, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![Ids::token_definition(), owner_ata_id], + vec![], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(owner_ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 0_u128, + }), + nonce: Nonce(0), + } + ); +} diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index d72ad671..165214bd 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -1,8 +1,24 @@ +use std::collections::HashMap; + +use key_protocol::key_management::{ + group_key_holder::{GroupKeyHolder, SealingPublicKey}, + secret_holders::SecretSpendingKey, +}; use nssa::{ + execute_and_prove, + privacy_preserving_transaction::{ + circuit::ProgramWithDependencies, Message, PrivacyPreservingTransaction, WitnessSet, + }, + program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, - public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, + public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State, +}; +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + encryption::{EphemeralPublicKey, ViewingPublicKey}, + Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, + NullifierSecretKey, }; -use nssa_core::account::{Account, AccountId, Data, Nonce}; use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; use token_core::{TokenDefinition, TokenHolding}; @@ -10,6 +26,41 @@ struct Keys; struct Ids; struct Balances; struct Accounts; +struct PrivateKeys; + +impl PrivateKeys { + fn destination_nsk() -> NullifierSecretKey { + [111; 32] + } + + fn destination_npk() -> NullifierPublicKey { + NullifierPublicKey::from(&Self::destination_nsk()) + } + + fn destination_vpk() -> ViewingPublicKey { + ViewingPublicKey::from_seed(&[141; 32], &[142; 32]) + } + + fn destination_id() -> AccountId { + AccountId::for_regular_private_account(&Self::destination_npk(), 0) + } + + fn stablecoin_holding_nsk() -> NullifierSecretKey { + [121; 32] + } + + fn stablecoin_holding_npk() -> NullifierPublicKey { + NullifierPublicKey::from(&Self::stablecoin_holding_nsk()) + } + + fn stablecoin_holding_vpk() -> ViewingPublicKey { + ViewingPublicKey::from_seed(&[151; 32], &[152; 32]) + } + + fn stablecoin_holding_id() -> AccountId { + AccountId::for_regular_private_account(&Self::stablecoin_holding_npk(), 0) + } +} impl Keys { fn owner() -> PrivateKey { @@ -398,3 +449,912 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { } } } + +fn stablecoin_program() -> Program { + Program::new(stablecoin_methods::STABLECOIN_ELF.to_vec().into()).expect("valid stablecoin ELF") +} + +fn token_program_instance() -> Program { + Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF") +} + +fn stablecoin_with_token_deps() -> ProgramWithDependencies { + ProgramWithDependencies::new( + stablecoin_program(), + HashMap::from([(Ids::token_program(), token_program_instance())]), + ) +} + +// Marvin-todo +/// `OpenPosition` cannot execute through the privacy-preserving transaction type *at all* — +/// confirmed here with every single account `Public` and zero private accounts involved. Root +/// cause traced in `lee_core`'s `execution_state.rs`: `authorized_accounts` is a monotonic/sticky +/// set — once an account is authorized via one chained call's `pda_seeds` match, every later +/// occurrence of that same account must also declare `is_authorized: true`, or +/// `assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")` +/// fails. `open_position.rs` issues two chained calls that both reuse `vault`: the first +/// (`Token::InitializeAccount`) authorizes it via `pda_seeds`, sticking `vault` as authorized; +/// the second (`Token::Transfer`) then deliberately constructs `post_init_vault` with +/// `is_authorized: false` (a legitimate choice on the public-transaction path — "the recipient +/// is already initialized, so no second PDA claim is needed" per that file's own comment) — but +/// the privacy circuit rejects that as inconsistent. This is not a privacy-dimension gap; it +/// blocks `OpenPosition` from ever being expressed as a `PrivacyPreservingTransaction`, so every +/// other instruction that depends on having *opened* a position privately is affected too (see +/// `stablecoin_group_owned_position_owner`, which routes around it by seeding the position/vault +/// directly instead of calling `OpenPosition`). +#[test] +fn stablecoin_open_position_via_privacy_transaction_is_not_expressible() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init()); + + let owner_id = Ids::owner(); + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), + ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = AccountWithMetadata::new(Account::default(), false, position_id); + let vault_pre = AccountWithMetadata::new(Account::default(), false, vault_id); + let user_holding_pre = + AccountWithMetadata::new(Accounts::user_holding_init(), true, Ids::user_holding()); + let definition_pre = AccountWithMetadata::new( + Accounts::collateral_definition_init(), + false, + Ids::collateral_definition(), + ); + + let collateral_amount = Balances::collateral_deposit(); + let instruction = stablecoin_core::Instruction::OpenPosition { collateral_amount }; + + let result = execute_and_prove( + vec![ + owner_pre, + position_pre, + vault_pre, + user_holding_pre, + definition_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &stablecoin_with_token_deps(), + ); + + let err = result.expect_err( + "OpenPosition must be rejected by the privacy-preserving circuit: vault's second \ + chained-call occurrence declares is_authorized: false after already being marked \ + authorized by the first chained call's pda_seeds match", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Inconsistent authorization for account"), + "expected the authorization-consistency rejection, got a different error: {message}" + ); +} + +// Marvin-todo +/// `WithdrawCollateral` has only *one* chained call (`Token::Transfer`, reusing `vault` exactly +/// once), unlike `OpenPosition`'s two — so it should avoid the authorization-consistency +/// blocker confirmed above. Position/vault are seeded directly via `force_insert_account` +/// (public accounts, no real `OpenPosition` call needed, and none is possible per the finding +/// above). `withdraw_collateral.rs` hard-asserts `destination.account != Account::default()`, +/// so `destination` must already exist — same `EXIST` shape as ATA's Transfer, requiring the +/// destination's cooperation via `PrivateAuthorizedUpdate`. +#[test] +fn stablecoin_withdraw_collateral_private_destination() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + + let owner_id = Ids::owner(); + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), + ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let position_collateral = 500_000_u128; + let withdraw_amount = 200_000_u128; + + let position_account = Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id, + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: position_collateral, + debt_amount: 0, + }), + nonce: Nonce(0), + }; + let vault_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: position_collateral, + }), + nonce: Nonce(0), + }; + state.force_insert_account(position_id, position_account); + state.force_insert_account(vault_id, vault_account); + + let destination_nsk = PrivateKeys::destination_nsk(); + let destination_npk = PrivateKeys::destination_npk(); + let destination_vpk = PrivateKeys::destination_vpk(); + let destination_id = PrivateKeys::destination_id(); + let destination_initial_balance = 100_000_u128; + let destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance, + }), + nonce: Nonce::private_account_nonce_init(&destination_id), + }; + state = state.with_private_accounts([( + Commitment::new(&destination_id, &destination_account), + Nullifier::for_account_initialization(&destination_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&destination_id, &destination_account)) + .expect("destination's commitment must be in the set"); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = + AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id); + let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id); + let destination_pre = + AccountWithMetadata::new(destination_account.clone(), true, destination_id); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + amount: withdraw_amount, + }; + + let shared_secret = + SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0; + + let (output, proof) = execute_and_prove( + vec![owner_pre, position_pre, vault_pre, destination_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag( + &destination_npk, + &destination_vpk, + ), + ssk: shared_secret, + nsk: destination_nsk, + membership_proof, + identifier: 0, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, vault_id], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!( + position.collateral_amount, + position_collateral - withdraw_amount + ); + assert_eq!(position.debt_amount, 0); + + match TokenHolding::try_from(&state.get_account_by_id(vault_id).data).expect("valid holding") { + TokenHolding::Fungible { balance, .. } => { + assert_eq!(balance, position_collateral - withdraw_amount); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible vault holding") + } + } + + let destination_nonce_after = Nonce::private_account_nonce_init(&destination_id) + .private_account_nonce_increment(&destination_nsk); + let new_destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance + withdraw_amount, + }), + nonce: destination_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&destination_id, &new_destination_account)) + .is_some()); +} + +// Marvin-todo +/// `GROUP` variance on `stablecoin_withdraw_collateral_private_destination`: the destination is +/// group-owned instead of personal. The GMS is distributed through the real seal/unseal +/// handshake (as in `token_group_owned_holding_shared_control_burn`); "Bob" — who only ever +/// receives the sealed GMS — independently re-derives the shared destination's keys and +/// supplies its `PrivateAuthorizedUpdate` cooperation to receive the withdrawn collateral. +#[test] +fn stablecoin_withdraw_collateral_group_owned_destination() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + + let owner_id = Ids::owner(); + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), + ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let position_collateral = 500_000_u128; + let withdraw_amount = 200_000_u128; + + let position_account = Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id, + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: position_collateral, + debt_amount: 0, + }), + nonce: Nonce(0), + }; + let vault_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: position_collateral, + }), + nonce: Nonce(0), + }; + state.force_insert_account(position_id, position_account); + state.force_insert_account(vault_id, vault_account); + + // Alice creates the group and derives the shared destination's keys. + let alice_holder = GroupKeyHolder::new(); + let derivation_seed = [7_u8; 32]; + let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); + let destination_npk = alice_keys.generate_nullifier_public_key(); + let destination_vpk = alice_keys.generate_viewing_public_key(); + let destination_id = AccountId::for_regular_private_account(&destination_npk, 0); + + // Alice distributes the GMS to Bob via the real seal/unseal handshake. + let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); + let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); + let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; + let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( + bob_sealing_vpk.to_bytes().to_vec(), + )); + let bob_holder = + GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); + let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); + let bob_nsk = bob_keys.nullifier_secret_key; + assert_eq!( + bob_keys.generate_nullifier_public_key(), + destination_npk, + "Bob must derive the identical npk as Alice from the shared GMS" + ); + + let destination_initial_balance = 100_000_u128; + let destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance, + }), + nonce: Nonce::private_account_nonce_init(&destination_id), + }; + state = state.with_private_accounts([( + Commitment::new(&destination_id, &destination_account), + Nullifier::for_account_initialization(&destination_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&destination_id, &destination_account)) + .expect("destination's commitment must be in the set"); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = + AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id); + let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id); + let destination_pre = + AccountWithMetadata::new(destination_account.clone(), true, destination_id); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + amount: withdraw_amount, + }; + + let shared_secret = + SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0; + + let (output, proof) = execute_and_prove( + vec![owner_pre, position_pre, vault_pre, destination_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag( + &destination_npk, + &destination_vpk, + ), + ssk: shared_secret, + nsk: bob_nsk, + membership_proof, + identifier: 0, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, vault_id], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!( + position.collateral_amount, + position_collateral - withdraw_amount + ); + + let destination_nonce_after = Nonce::private_account_nonce_init(&destination_id) + .private_account_nonce_increment(&bob_nsk); + let new_destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance + withdraw_amount, + }), + nonce: destination_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&destination_id, &new_destination_account)) + .is_some()); +} + +// Marvin-todo +/// `user_stablecoin_holding` is private, burned via `RepayDebt`'s single chained `Token::Burn`. +/// Unlike ATA's own holdings (structurally locked to public PDAs), Stablecoin's stablecoin +/// holding is a regular user-controlled token holding with no PDA involved at all, so it's free +/// to be private with no structural obstacle. Position/stablecoin-definition are seeded +/// directly, matching the pre-existing public +/// `stablecoin_repay_debt_burns_stablecoins_and_decreases_debt` test's fixture approach (no real +/// `OpenPosition` call, consistent with the finding above). +#[test] +fn stablecoin_repay_debt_private_stablecoin_holding() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account( + Ids::stablecoin_definition(), + Accounts::stablecoin_definition_init(), + ); + + let owner_id = Ids::owner(); + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), + ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let position_collateral = Balances::collateral_deposit(); + let initial_debt = Balances::initial_debt(); + let repay_amount = Balances::debt_repay_amount(); + + let position_account = Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id, + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: position_collateral, + debt_amount: initial_debt, + }), + nonce: Nonce(0), + }; + state.force_insert_account(position_id, position_account); + + let stablecoin_holding_nsk = PrivateKeys::stablecoin_holding_nsk(); + let stablecoin_holding_npk = PrivateKeys::stablecoin_holding_npk(); + let stablecoin_holding_vpk = PrivateKeys::stablecoin_holding_vpk(); + let stablecoin_holding_id = PrivateKeys::stablecoin_holding_id(); + let initial_stablecoin_balance = Balances::user_stablecoin_holding_init(); + let stablecoin_holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance, + }), + nonce: Nonce::private_account_nonce_init(&stablecoin_holding_id), + }; + state = state.with_private_accounts([( + Commitment::new(&stablecoin_holding_id, &stablecoin_holding_account), + Nullifier::for_account_initialization(&stablecoin_holding_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new( + &stablecoin_holding_id, + &stablecoin_holding_account, + )) + .expect("stablecoin holding's commitment must be in the set"); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = + AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id); + let definition_pre = AccountWithMetadata::new( + Accounts::stablecoin_definition_init(), + false, + Ids::stablecoin_definition(), + ); + let stablecoin_holding_pre = AccountWithMetadata::new( + stablecoin_holding_account.clone(), + true, + stablecoin_holding_id, + ); + + let instruction = stablecoin_core::Instruction::RepayDebt { + amount: repay_amount, + }; + + let shared_secret = + SharedSecretKey::encapsulate_deterministic(&stablecoin_holding_vpk, &[0u8; 32], 0).0; + + let (output, proof) = execute_and_prove( + vec![ + owner_pre, + position_pre, + definition_pre, + stablecoin_holding_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag( + &stablecoin_holding_npk, + &stablecoin_holding_vpk, + ), + ssk: shared_secret, + nsk: stablecoin_holding_nsk, + membership_proof, + identifier: 0, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, Ids::stablecoin_definition()], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!(position.debt_amount, initial_debt - repay_amount); + assert_eq!(position.collateral_amount, position_collateral); + + match TokenDefinition::try_from(&state.get_account_by_id(Ids::stablecoin_definition()).data) + .expect("valid TokenDefinition") + { + TokenDefinition::Fungible { total_supply, .. } => { + assert_eq!( + total_supply, + Balances::stablecoin_supply_init() - repay_amount + ); + } + _ => panic!("expected Fungible definition"), + } + + let stablecoin_holding_nonce_after = Nonce::private_account_nonce_init(&stablecoin_holding_id) + .private_account_nonce_increment(&stablecoin_holding_nsk); + let new_stablecoin_holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance - repay_amount, + }), + nonce: stablecoin_holding_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new( + &stablecoin_holding_id, + &new_stablecoin_holding_account + )) + .is_some()); +} + +// Marvin-todo +/// `GROUP` variance on `stablecoin_repay_debt_private_stablecoin_holding`: the stablecoin +/// holding being burned from is group-owned instead of personal. Same real seal/unseal +/// distribution as every other group test in this exercise; Bob independently re-derives the +/// shared holding's keys and supplies `PrivateAuthorizedUpdate` cooperation for the burn. +#[test] +fn stablecoin_repay_debt_group_owned_stablecoin_holding() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account( + Ids::stablecoin_definition(), + Accounts::stablecoin_definition_init(), + ); + + let owner_id = Ids::owner(); + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), + ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let position_collateral = Balances::collateral_deposit(); + let initial_debt = Balances::initial_debt(); + let repay_amount = Balances::debt_repay_amount(); + + let position_account = Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id, + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: position_collateral, + debt_amount: initial_debt, + }), + nonce: Nonce(0), + }; + state.force_insert_account(position_id, position_account); + + // Alice creates the group and derives the shared stablecoin holding's keys. + let alice_holder = GroupKeyHolder::new(); + let derivation_seed = [7_u8; 32]; + let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); + let holding_npk = alice_keys.generate_nullifier_public_key(); + let holding_vpk = alice_keys.generate_viewing_public_key(); + let holding_id = AccountId::for_regular_private_account(&holding_npk, 0); + + // Alice distributes the GMS to Bob via the real seal/unseal handshake. + let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); + let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); + let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; + let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( + bob_sealing_vpk.to_bytes().to_vec(), + )); + let bob_holder = + GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); + let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); + let bob_nsk = bob_keys.nullifier_secret_key; + assert_eq!( + bob_keys.generate_nullifier_public_key(), + holding_npk, + "Bob must derive the identical npk as Alice from the shared GMS" + ); + + let initial_stablecoin_balance = Balances::user_stablecoin_holding_init(); + let holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance, + }), + nonce: Nonce::private_account_nonce_init(&holding_id), + }; + state = state.with_private_accounts([( + Commitment::new(&holding_id, &holding_account), + Nullifier::for_account_initialization(&holding_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&holding_id, &holding_account)) + .expect("stablecoin holding's commitment must be in the set"); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = + AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id); + let definition_pre = AccountWithMetadata::new( + Accounts::stablecoin_definition_init(), + false, + Ids::stablecoin_definition(), + ); + let holding_pre = AccountWithMetadata::new(holding_account.clone(), true, holding_id); + + let instruction = stablecoin_core::Instruction::RepayDebt { + amount: repay_amount, + }; + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&holding_vpk, &[0u8; 32], 0).0; + + let (output, proof) = execute_and_prove( + vec![owner_pre, position_pre, definition_pre, holding_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&holding_npk, &holding_vpk), + ssk: shared_secret, + nsk: bob_nsk, + membership_proof, + identifier: 0, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, Ids::stablecoin_definition()], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!(position.debt_amount, initial_debt - repay_amount); + + let holding_nonce_after = + Nonce::private_account_nonce_init(&holding_id).private_account_nonce_increment(&bob_nsk); + let new_holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance - repay_amount, + }), + nonce: holding_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&holding_id, &new_holding_account)) + .is_some()); +} + +// Marvin-todo +/// Reframes what "group-owned position" actually means, given the findings above: the +/// *position/vault themselves* can never be private or group-owned (the `PDA` finding), and +/// they can't even be opened through a privacy-preserving transaction at all (the +/// authorization-consistency finding above). But `owner` is just an `AccountId` used for PDA +/// seed derivation and signer verification — it doesn't need to be a plain public keypair. So +/// the real, well-motivated test is: a group-derived `owner` identity controls a PDA-locked +/// position, even though the position/vault stay public. Position/vault are seeded directly +/// (bypassing the blocked `OpenPosition`); "Bob" — who only ever receives the sealed GMS — +/// self-initializes *and* signs the owner identity in one transaction via `PrivateAuthorizedInit` +/// (since this owner has never proven control before), then withdraws collateral through it. +/// Directly mirrors `ata_group_owned_owner_signing`'s precedent for a PDA-locked resource. +#[test] +fn stablecoin_group_owned_position_owner() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init()); + + // Alice creates the group and derives the shared owner identity's keys. + let alice_holder = GroupKeyHolder::new(); + let derivation_seed = [7_u8; 32]; + let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); + let owner_npk = alice_keys.generate_nullifier_public_key(); + let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); + + // Alice distributes the GMS to Bob via the real seal/unseal handshake. + let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); + let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); + let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; + let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( + bob_sealing_vpk.to_bytes().to_vec(), + )); + let bob_holder = + GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); + + // Bob independently re-derives the same shared owner keys. + let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); + let bob_nsk = bob_keys.nullifier_secret_key; + let bob_vpk = bob_keys.generate_viewing_public_key(); + assert_eq!( + bob_keys.generate_nullifier_public_key(), + owner_npk, + "Bob must derive the identical npk as Alice from the shared GMS" + ); + + // Position/vault addresses are derived from the group-owned owner_id — still ordinary + // public PDAs (the seed formula doesn't care whether owner_id is public or private), seeded + // directly since OpenPosition can't be routed through the privacy circuit at all. + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), + ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let position_collateral = 500_000_u128; + let withdraw_amount = 200_000_u128; + let position_account = Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id, + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: position_collateral, + debt_amount: 0, + }), + nonce: Nonce(0), + }; + let vault_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: position_collateral, + }), + nonce: Nonce(0), + }; + state.force_insert_account(position_id, position_account); + state.force_insert_account(vault_id, vault_account); + + // Bob self-initializes and signs the owner identity in the same transaction, then + // withdraws collateral through it. Destination stays public to isolate what's under test: + // only the owner identity's privacy/sharing, nothing else. + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = + AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id); + let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id); + let destination_pre = + AccountWithMetadata::new(Accounts::user_holding_init(), false, Ids::user_holding()); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + amount: withdraw_amount, + }; + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0; + + let (output, proof) = execute_and_prove( + vec![owner_pre, position_pre, vault_pre, destination_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk), + ssk: shared_secret, + nsk: bob_nsk, + identifier: 0, + }, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![position_id, vault_id, Ids::user_holding()], + vec![], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!( + position.collateral_amount, + position_collateral - withdraw_amount + ); + + match TokenHolding::try_from(&state.get_account_by_id(Ids::user_holding()).data) + .expect("valid holding") + { + TokenHolding::Fungible { balance, .. } => { + assert_eq!(balance, Balances::user_holding_init() + withdraw_amount); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible destination holding") + } + } + + let owner_expected = Account { + nonce: Nonce::private_account_nonce_init(&owner_id), + ..Account::default() + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) + .is_some()); +} diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index fe0dc5f9..3a9231ac 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -1,19 +1,18 @@ -use key_protocol::key_management::{ - group_key_holder::{GroupKeyHolder, SealingPublicKey}, - secret_holders::SecretSpendingKey, +use integration_tests::{ + private_authorized_init_identity, private_authorized_update_identity, + private_unauthorized_identity, GroupOwner, }; use nssa::{ execute_and_prove, privacy_preserving_transaction::{Message, PrivacyPreservingTransaction, WitnessSet}, program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, - public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State, + public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, }; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - encryption::{EphemeralPublicKey, ViewingPublicKey}, - Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, - NullifierSecretKey, + encryption::ViewingPublicKey, + Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, }; use token_core::{TokenDefinition, TokenHolding}; @@ -95,35 +94,34 @@ impl Accounts { } fn holder_init() -> Account { - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000_u128, - }), - nonce: Nonce(0), - } + Self::token_holding(1_000_000_u128, Nonce(0)) } fn recipient_init() -> Account { + Self::token_holding(0_u128, Nonce(0)) + } + + fn authority_init() -> Account { Account { program_owner: Ids::token_program(), balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 0_u128, - }), + data: Data::default(), nonce: Nonce(0), } } - fn authority_init() -> Account { + /// A token holding account for the canonical `Ids::token_definition()`, at the given + /// balance and nonce. Covers every private and public token-holding shape in this file — + /// the `program_owner`/`definition_id` are fixed for this test module. + fn token_holding(balance: u128, nonce: Nonce) -> Account { Account { program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::default(), - nonce: Nonce(0), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance, + }), + nonce, } } } @@ -199,15 +197,7 @@ fn token_new_fungible_definition() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(1_000_000_u128, Nonce(1)) ); } @@ -237,15 +227,7 @@ fn token_initialize_account_succeeds_for_canonical_definition() { ); assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 0_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(0_u128, Nonce(1)) ); } @@ -306,28 +288,12 @@ fn token_transfer() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(0), - } + Accounts::token_holding(500_000_u128, Nonce(0)) ); } @@ -388,28 +354,12 @@ fn token_transfer_fresh_authorized_public_recipient() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); } @@ -451,15 +401,7 @@ fn token_burn() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 800_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(800_000_u128, Nonce(1)) ); } @@ -501,15 +443,7 @@ fn token_mint() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_500_000_u128, - }), - nonce: Nonce(0), - } + Accounts::token_holding(1_500_000_u128, Nonce(0)) ); } @@ -623,15 +557,7 @@ fn token_mint_fresh_authorized_public_recipient() { assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); } @@ -693,10 +619,6 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { let sender = AccountWithMetadata::new(sender_account, true, sender_id); let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); - // Sender encapsulates a shared secret against the recipient's viewing key. The - // circuit fills the real EPK, so we pass an empty placeholder in the identity. - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::Transfer { amount_to_transfer: amount, }; @@ -705,13 +627,7 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - npk: recipient_npk, - ssk: shared_secret, - identifier: 0, - }, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -726,15 +642,7 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { .transition_from_privacy_preserving_transaction(&tx, 0, 0) .unwrap(); - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - } + Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id)) } #[test] @@ -746,15 +654,7 @@ fn token_shielded_transfer() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000 - amount, - }), - nonce: Nonce(1), - } + Accounts::token_holding(1_000_000 - amount, Nonce(1)) ); let recipient_commitment = Commitment::new(&PrivateKeys::recipient_id(), &recipient_account); @@ -775,15 +675,12 @@ fn token_shielded_transfer_authorized_private_init() { let sender_nonce = sender_account.nonce; let recipient_nsk = PrivateKeys::recipient_nsk(); - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::Transfer { amount_to_transfer: amount, }; @@ -792,13 +689,7 @@ fn token_shielded_transfer_authorized_private_init() { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - identifier: 0, - }, + private_authorized_init_identity(recipient_nsk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -815,26 +706,11 @@ fn token_shielded_transfer_authorized_private_init() { assert_eq!( state.get_account_by_id(sender_id), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000 - amount, - }), - nonce: Nonce(1), - } + Accounts::token_holding(1_000_000 - amount, Nonce(1)) ); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_account = + Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id)); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) .is_some()); @@ -848,7 +724,6 @@ fn token_private_transfer() { // Shield tokens into a private account (becomes the sender for the private transfer). let sender_account = shielded_token_transfer(shielded_amount, &mut state); - let sender_npk = PrivateKeys::recipient_npk(); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); @@ -862,11 +737,6 @@ fn token_private_transfer() { .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in the set"); - // Distinct `output_index` per private output keeps the encapsulated secrets reproducible. - let shared_secret_1 = SharedSecretKey::encapsulate_deterministic(&sender_vpk, &[0u8; 32], 0).0; - let shared_secret_2 = - SharedSecretKey::encapsulate_deterministic(&new_recipient_vpk, &[0u8; 32], 1).0; - let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender_id); let new_recipient_pre = AccountWithMetadata::new(Account::default(), false, new_recipient_id); @@ -877,24 +747,10 @@ fn token_private_transfer() { vec![sender_pre, new_recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), - ssk: shared_secret_1, - nsk: sender_nsk, - membership_proof, - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &new_recipient_npk, - &new_recipient_vpk, - ), - npk: new_recipient_npk, - ssk: shared_secret_2, - identifier: 0, - }, + // Distinct `output_index` per private output keeps the encapsulated secrets + // reproducible. + private_authorized_update_identity(sender_nsk, &sender_vpk, membership_proof, 0), + private_unauthorized_identity(new_recipient_npk, &new_recipient_vpk, 1), ], &token_program().into(), ) @@ -910,28 +766,16 @@ fn token_private_transfer() { let sender_nonce_after = Nonce::private_account_nonce_init(&sender_id).private_account_nonce_increment(&sender_nsk); - let new_sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shielded_amount - transfer_amount, - }), - nonce: sender_nonce_after, - }; + let new_sender_account = + Accounts::token_holding(shielded_amount - transfer_amount, sender_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) .is_some()); - let new_recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: transfer_amount, - }), - nonce: Nonce::private_account_nonce_init(&new_recipient_id), - }; + let new_recipient_account = Accounts::token_holding( + transfer_amount, + Nonce::private_account_nonce_init(&new_recipient_id), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&new_recipient_id, &new_recipient_account)) .is_some()); @@ -945,7 +789,6 @@ fn token_deshielded_transfer() { // Shield tokens into a private account, then deshield some back to a public account. let sender_account = shielded_token_transfer(shielded_amount, &mut state); - let sender_npk = PrivateKeys::recipient_npk(); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); @@ -956,8 +799,6 @@ fn token_deshielded_transfer() { .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in the set"); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&sender_vpk, &[0u8; 32], 0).0; - let public_recipient_pre = AccountWithMetadata::new( state.get_account_by_id(public_recipient_id), false, @@ -972,14 +813,7 @@ fn token_deshielded_transfer() { vec![sender_pre, public_recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), - ssk: shared_secret, - nsk: sender_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(sender_nsk, &sender_vpk, membership_proof, 0), InputAccountIdentity::Public, ], &token_program().into(), @@ -997,28 +831,13 @@ fn token_deshielded_transfer() { assert_eq!( state.get_account_by_id(public_recipient_id), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: deshield_amount, - }), - nonce: Nonce(0), - } + Accounts::token_holding(deshield_amount, Nonce(0)) ); let sender_nonce_after = Nonce::private_account_nonce_init(&sender_id).private_account_nonce_increment(&sender_nsk); - let new_sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shielded_amount - deshield_amount, - }), - nonce: sender_nonce_after, - }; + let new_sender_account = + Accounts::token_holding(shielded_amount - deshield_amount, sender_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) .is_some()); @@ -1041,21 +860,13 @@ fn token_mint_shielded_to_private_unauthorized() { AccountWithMetadata::new(definition_account, true, Ids::token_definition()); let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::Mint { amount_to_mint }; let (output, proof) = execute_and_prove( vec![definition_pre, recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - npk: recipient_npk, - ssk: shared_secret, - identifier: 0, - }, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -1089,15 +900,10 @@ fn token_mint_shielded_to_private_unauthorized() { } ); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount_to_mint, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_account = Accounts::token_holding( + amount_to_mint, + Nonce::private_account_nonce_init(&recipient_id), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) .is_some()); @@ -1111,7 +917,6 @@ fn token_mint_authorized_private_init() { let amount_to_mint = 500_000_u128; let recipient_nsk = PrivateKeys::recipient_nsk(); - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); @@ -1121,21 +926,13 @@ fn token_mint_authorized_private_init() { AccountWithMetadata::new(definition_account, true, Ids::token_definition()); let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::Mint { amount_to_mint }; let (output, proof) = execute_and_prove( vec![definition_pre, recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - identifier: 0, - }, + private_authorized_init_identity(recipient_nsk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -1169,15 +966,10 @@ fn token_mint_authorized_private_init() { } ); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount_to_mint, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_account = Accounts::token_holding( + amount_to_mint, + Nonce::private_account_nonce_init(&recipient_id), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) .is_some()); @@ -1192,33 +984,22 @@ fn token_mint_into_existing_private_holding() { let amount_to_mint = 250_000_u128; let recipient_nsk = PrivateKeys::recipient_nsk(); - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); - let recipient_pre = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: pre_balance, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_pre = Accounts::token_holding( + pre_balance, + Nonce::private_account_nonce_init(&recipient_id), + ); + let recipient_commitment = Commitment::new(&recipient_id, &recipient_pre); state = state.with_private_accounts([( - Commitment::new(&recipient_id, &recipient_pre), + recipient_commitment.clone(), Nullifier::for_account_initialization(&recipient_id), )]); - assert!( - state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_pre)) - .is_some(), - "seeded balance must land before the existing-holding mint under test" - ); let membership_proof = state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_pre)) - .expect("recipient's commitment must be in the set"); + .get_proof_for_commitment(&recipient_commitment) + .expect("seeded recipient's commitment must be in the set"); let definition_account = state.get_account_by_id(Ids::token_definition()); let definition_nonce = definition_account.nonce; @@ -1227,25 +1008,12 @@ fn token_mint_into_existing_private_holding() { let existing_recipient_pre = AccountWithMetadata::new(recipient_pre.clone(), true, recipient_id); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let (output, second_proof) = execute_and_prove( vec![definition_pre, existing_recipient_pre], - Program::serialize_instruction(token_core::Instruction::Mint { - amount_to_mint, - }) - .unwrap(), + Program::serialize_instruction(token_core::Instruction::Mint { amount_to_mint }).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(recipient_nsk, &recipient_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1257,8 +1025,7 @@ fn token_mint_into_existing_private_holding() { output, ) .unwrap(); - let witness = - WitnessSet::for_message(&message, second_proof, &[&Keys::def_key()]); + let witness = WitnessSet::for_message(&message, second_proof, &[&Keys::def_key()]); state .transition_from_privacy_preserving_transaction( &PrivacyPreservingTransaction::new(message, witness), @@ -1284,15 +1051,8 @@ fn token_mint_into_existing_private_holding() { let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) .private_account_nonce_increment(&recipient_nsk); - let recipient_after_second_mint = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: pre_balance + amount_to_mint, - }), - nonce: recipient_nonce_after, - }; + let recipient_after_second_mint = + Accounts::token_holding(pre_balance + amount_to_mint, recipient_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new( &recipient_id, @@ -1308,21 +1068,15 @@ fn token_private_burn() { let holding_balance = 500_000_u128; let burn_amount = 200_000_u128; - let holder_npk = PrivateKeys::recipient_npk(); let holder_nsk = PrivateKeys::recipient_nsk(); let holder_vpk = PrivateKeys::recipient_vpk(); let holder_id = PrivateKeys::recipient_id(); // Predefined holding account to burn from. - let holder_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: holding_balance, - }), - nonce: Nonce::private_account_nonce_init(&holder_id), - }; + let holder_account = Accounts::token_holding( + holding_balance, + Nonce::private_account_nonce_init(&holder_id), + ); let holder_commitment = Commitment::new(&holder_id, &holder_account); state = state.with_private_accounts([( holder_commitment.clone(), @@ -1332,8 +1086,6 @@ fn token_private_burn() { .get_proof_for_commitment(&holder_commitment) .expect("holder's commitment must be in the set"); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&holder_vpk, &[0u8; 32], 0).0; - let definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_definition()), false, @@ -1349,14 +1101,7 @@ fn token_private_burn() { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&holder_npk, &holder_vpk), - ssk: shared_secret, - nsk: holder_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(holder_nsk, &holder_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1386,16 +1131,10 @@ fn token_private_burn() { } ); - let new_holder_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: holding_balance - burn_amount, - }), - nonce: Nonce::private_account_nonce_init(&holder_id) - .private_account_nonce_increment(&holder_nsk), - }; + let new_holder_account = Accounts::token_holding( + holding_balance - burn_amount, + Nonce::private_account_nonce_init(&holder_id).private_account_nonce_increment(&holder_nsk), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&holder_id, &new_holder_account)) .is_some()); @@ -1409,20 +1148,14 @@ fn token_transfer_into_existing_private_holding() { let init_balance = 500_000_u128; let second_amount = 100_000_u128; - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_nsk = PrivateKeys::recipient_nsk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: init_balance, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_account = Accounts::token_holding( + init_balance, + Nonce::private_account_nonce_init(&recipient_id), + ); let recipient_commitment = Commitment::new(&recipient_id, &recipient_account); state = state.with_private_accounts([( recipient_commitment.clone(), @@ -1436,8 +1169,6 @@ fn token_transfer_into_existing_private_holding() { let sender_account = state.get_account_by_id(sender_id); let sender_nonce = sender_account.nonce; - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); let recipient_pre = AccountWithMetadata::new(recipient_account.clone(), true, recipient_id); @@ -1449,14 +1180,7 @@ fn token_transfer_into_existing_private_holding() { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(recipient_nsk, &recipient_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1473,44 +1197,22 @@ fn token_transfer_into_existing_private_holding() { assert_eq!( state.get_account_by_id(sender_id), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - // `first_amount` was seeded directly into the recipient, never debited from - // the sender — only the real transfer (`second_amount`) actually happened. - balance: 1_000_000 - second_amount, - }), - nonce: Nonce(1), - } + // `first_amount` was seeded directly into the recipient, never debited from the + // sender — only the real transfer (`second_amount`) actually happened. + Accounts::token_holding(1_000_000 - second_amount, Nonce(1)) ); let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) .private_account_nonce_increment(&recipient_nsk); - let new_recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: init_balance + second_amount, - }), - nonce: recipient_nonce_after, - }; + let new_recipient_account = + Accounts::token_holding(init_balance + second_amount, recipient_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) .is_some()); } -// Marvin-todo -/// Fully private counterpart to `token_transfer_into_existing_private_holding`: instead of a -/// *public* sender crediting an existing private recipient, both legs are private and the -/// recipient already exists (not fresh, unlike `token_private_transfer`'s new recipient). This -/// is a new combination — two distinct private accounts, both driven by -/// `PrivateAuthorizedUpdate` (spend + credit-existing) in the same transaction — that neither -/// existing test covers. `Token::Transfer` has no definition-account parameter at all, so with -/// both legs private there is no public account anywhere in this transaction: no signer, no -/// public message ids. +/// Private Token transfer into a pre-existing Token holding account. This requires +/// the account's `nsk`; `PrivateAuthorizedUpdate`. #[test] fn token_private_transfer_into_existing_private_holding() { let mut state = state_for_token_tests(); @@ -1518,58 +1220,43 @@ fn token_private_transfer_into_existing_private_holding() { let recipient_initial_balance = 300_000_u128; let transfer_amount = 200_000_u128; - let sender_npk = PrivateKeys::recipient_npk(); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); - let recipient_npk = PrivateKeys::holder_npk(); let recipient_nsk = PrivateKeys::holder_nsk(); let recipient_vpk = PrivateKeys::holder_vpk(); let recipient_id = PrivateKeys::holder_id(); // Seed both sides directly — neither needs a real prior transaction to exist. - let sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: sender_initial_balance, - }), - nonce: Nonce::private_account_nonce_init(&sender_id), - }; - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: recipient_initial_balance, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let sender_account = Accounts::token_holding( + sender_initial_balance, + Nonce::private_account_nonce_init(&sender_id), + ); + let recipient_account = Accounts::token_holding( + recipient_initial_balance, + Nonce::private_account_nonce_init(&recipient_id), + ); + let sender_commitment = Commitment::new(&sender_id, &sender_account); + let recipient_commitment = Commitment::new(&recipient_id, &recipient_account); state = state.with_private_accounts([ ( - Commitment::new(&sender_id, &sender_account), + sender_commitment.clone(), Nullifier::for_account_initialization(&sender_id), ), ( - Commitment::new(&recipient_id, &recipient_account), + recipient_commitment.clone(), Nullifier::for_account_initialization(&recipient_id), ), ]); let sender_membership_proof = state - .get_proof_for_commitment(&Commitment::new(&sender_id, &sender_account)) + .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in the set"); let recipient_membership_proof = state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .get_proof_for_commitment(&recipient_commitment) .expect("recipient's commitment must be in the set"); - let sender_shared_secret = - SharedSecretKey::encapsulate_deterministic(&sender_vpk, &[0u8; 32], 0).0; - let recipient_shared_secret = - SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 1).0; - let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender_id); let recipient_pre = AccountWithMetadata::new(recipient_account.clone(), true, recipient_id); @@ -1580,22 +1267,13 @@ fn token_private_transfer_into_existing_private_holding() { vec![sender_pre, recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), - ssk: sender_shared_secret, - nsk: sender_nsk, - membership_proof: sender_membership_proof, - identifier: 0, - }, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: recipient_shared_secret, - nsk: recipient_nsk, - membership_proof: recipient_membership_proof, - identifier: 0, - }, + private_authorized_update_identity(sender_nsk, &sender_vpk, sender_membership_proof, 0), + private_authorized_update_identity( + recipient_nsk, + &recipient_vpk, + recipient_membership_proof, + 1, + ), ], &token_program().into(), ) @@ -1613,30 +1291,18 @@ fn token_private_transfer_into_existing_private_holding() { let sender_nonce_after = Nonce::private_account_nonce_init(&sender_id).private_account_nonce_increment(&sender_nsk); - let new_sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: sender_initial_balance - transfer_amount, - }), - nonce: sender_nonce_after, - }; + let new_sender_account = + Accounts::token_holding(sender_initial_balance - transfer_amount, sender_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) .is_some()); let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) .private_account_nonce_increment(&recipient_nsk); - let new_recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: recipient_initial_balance + transfer_amount, - }), - nonce: recipient_nonce_after, - }; + let new_recipient_account = Accounts::token_holding( + recipient_initial_balance + transfer_amount, + recipient_nonce_after, + ); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) .is_some()); @@ -1651,7 +1317,6 @@ fn token_initialize_private_account_succeeds_for_canonical_definition() { let mut state = state_for_token_tests_without_recipient(); let owner_nsk = PrivateKeys::recipient_nsk(); - let owner_npk = PrivateKeys::recipient_npk(); let owner_vpk = PrivateKeys::recipient_vpk(); let owner_id = PrivateKeys::recipient_id(); @@ -1662,21 +1327,13 @@ fn token_initialize_private_account_succeeds_for_canonical_definition() { ); let account_to_init_pre = AccountWithMetadata::new(Account::default(), true, owner_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&owner_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::InitializeAccount; let (output, proof) = execute_and_prove( vec![definition_pre, account_to_init_pre], Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk), - ssk: shared_secret, - nsk: owner_nsk, - identifier: 0, - }, + private_authorized_init_identity(owner_nsk, &owner_vpk, 0), ], &token_program().into(), ) @@ -1691,31 +1348,14 @@ fn token_initialize_private_account_succeeds_for_canonical_definition() { .transition_from_privacy_preserving_transaction(&tx, 0, 0) .unwrap(); - let expected_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 0, - }), - nonce: Nonce::private_account_nonce_init(&owner_id), - }; + let expected_account = Accounts::token_holding(0, Nonce::private_account_nonce_init(&owner_id)); assert!(state .get_proof_for_commitment(&Commitment::new(&owner_id, &expected_account)) .is_some()); } -// TODO: think this is unnecessary; double check. -/// Confirms `InitializeAccount` is self-service-only: unlike `Transfer`/`Mint`, whose recipient -/// host logic never asserts `is_authorized`, the guest's `#[account(init, signer)]` on -/// `account_to_initialize` requires `is_authorized == true` — enforced by the SPEL macro's own -/// account validation before `token_program::initialize::initialize_account`'s host logic -/// (which carries the same assert as defense in depth) ever runs. The only private identity -/// variant satisfying that for a fresh account is `PrivateAuthorizedInit`, which requires -/// supplying `nsk` directly — so a third party cannot initialize a private holding on behalf of -/// an `(npk, vpk, identifier)` whose `nsk` they don't possess. Attempting it via -/// `PrivateUnauthorized` (the variant that *would* allow third-party setup elsewhere) is -/// rejected at the framework's signer check, since that variant forces `is_authorized: false`. +/// Confirms that `InitializeAccount` cannot be performed without private account's `nsk`. +/// E.g., account must be `PrivateAuthorizedInit` and not `PrivateUnauthorized`. #[test] fn token_initialize_private_account_without_nsk_is_not_expressible() { let state = state_for_token_tests_without_recipient(); @@ -1731,20 +1371,12 @@ fn token_initialize_private_account_without_nsk_is_not_expressible() { ); let account_to_init_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let result = execute_and_prove( vec![definition_pre, account_to_init_pre], Program::serialize_instruction(token_core::Instruction::InitializeAccount).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - npk: recipient_npk, - ssk: shared_secret, - identifier: 0, - }, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), ], &token_program().into(), ); @@ -1760,47 +1392,21 @@ fn token_initialize_private_account_without_nsk_is_not_expressible() { ); } -/// Two independent parties share control of one private Token holding via a `GroupKeyHolder` -/// Group Master Secret (GMS), distributed through the real seal/unseal handshake — not by -/// reusing key material directly — so the test proves actual sharing, not code reuse. "Alice" -/// creates the group and shields tokens into the shared holding; "Bob" only ever receives the -/// *sealed* GMS, independently re-derives the identical nsk/npk from it, and successfully -/// burns from the same holding neither of them personally owns. Validates the `GROUP` Q2 -/// dimension: sharing a private account (group-owned) used as a program account. -/// TODO: add a function for spending +/// Two independent parties (Alice and Bob) control a private Token holding (via `GroupKeyHolder`). +/// Alice initializes the private Token account, and Bob burns tokens from the shared account. #[test] fn token_group_owned_holding_shared_control_burn() { let mut state = state_for_token_tests(); let shield_amount = 500_000_u128; let burn_amount = 200_000_u128; - // Alice creates the group and derives the shared account's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [7_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let group_npk = alice_keys.generate_nullifier_public_key(); - let group_vpk = alice_keys.generate_viewing_public_key(); - let group_id = AccountId::for_regular_private_account(&group_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake, not by handing - // over key material directly. - let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - - // Bob independently re-derives the same shared-account keys from the unsealed GMS. - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - assert_eq!( - bob_keys.generate_nullifier_public_key(), - group_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); + // Alice creates the group and derives the shared account's keys; Bob is admitted via the + // real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let group_npk = alice.npk; + let group_vpk = alice.vpk; + let group_id = alice.id; // Alice shields tokens into the group-owned holding (mirrors `shielded_token_transfer`, // parameterized by the group's npk/vpk instead of a personal one). @@ -1810,7 +1416,6 @@ fn token_group_owned_holding_shared_control_burn() { let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); let group_pre_shield = AccountWithMetadata::new(Account::default(), false, group_id); - let shield_secret = SharedSecretKey::encapsulate_deterministic(&group_vpk, &[0u8; 32], 0).0; let shield_instruction = token_core::Instruction::Transfer { amount_to_transfer: shield_amount, }; @@ -1819,13 +1424,7 @@ fn token_group_owned_holding_shared_control_burn() { Program::serialize_instruction(shield_instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&group_npk, &group_vpk), - npk: group_npk, - ssk: shield_secret, - identifier: 0, - }, + private_unauthorized_identity(group_npk, &group_vpk, 0), ], &token_program().into(), ) @@ -1840,15 +1439,8 @@ fn token_group_owned_holding_shared_control_burn() { .transition_from_privacy_preserving_transaction(&shield_tx, 0, 0) .unwrap(); - let group_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shield_amount, - }), - nonce: Nonce::private_account_nonce_init(&group_id), - }; + let group_account = + Accounts::token_holding(shield_amount, Nonce::private_account_nonce_init(&group_id)); let group_commitment = Commitment::new(&group_id, &group_account); assert!(state.get_proof_for_commitment(&group_commitment).is_some()); @@ -1857,8 +1449,6 @@ fn token_group_owned_holding_shared_control_burn() { let membership_proof = state .get_proof_for_commitment(&group_commitment) .expect("group holding's commitment must be in the set"); - let burn_shared_secret = - SharedSecretKey::encapsulate_deterministic(&group_vpk, &[0u8; 32], 0).0; let definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_definition()), @@ -1875,14 +1465,7 @@ fn token_group_owned_holding_shared_control_burn() { Program::serialize_instruction(burn_instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&group_npk, &group_vpk), - ssk: burn_shared_secret, - nsk: bob_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(bob_nsk, &group_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1914,18 +1497,135 @@ fn token_group_owned_holding_shared_control_burn() { let group_nonce_after = Nonce::private_account_nonce_init(&group_id).private_account_nonce_increment(&bob_nsk); - let new_group_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shield_amount - burn_amount, - }), - nonce: group_nonce_after, + let new_group_account = Accounts::token_holding(shield_amount - burn_amount, group_nonce_after); + assert!(state + .get_proof_for_commitment(&Commitment::new(&group_id, &new_group_account)) + .is_some()); +} + +/// Two independent parties (Alice and Bob) control a private Token holding (via `GroupKeyHolder`). +/// Alice initializes the private Token account, and Bob transfers tokens from the shared account. +#[test] +fn token_group_owned_holding_shared_control_transfer() { + let mut state = state_for_token_tests(); + let shield_amount = 500_000_u128; + let transfer_amount = 200_000_u128; + + // Alice creates the group and derives the shared account's keys; Bob is admitted via the + // real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let group_vpk = alice.vpk; + let group_id = alice.id; + + let group_account = + Accounts::token_holding(shield_amount, Nonce::private_account_nonce_init(&group_id)); + let group_commitment = Commitment::new(&group_id, &group_account); + state = state.with_private_accounts([( + group_commitment.clone(), + Nullifier::for_account_initialization(&group_id), + )]); + + // Bob spends via Transfer — not Burn — sending to a fresh private recipient. + let membership_proof = state + .get_proof_for_commitment(&group_commitment) + .expect("group holding's commitment must be in the set"); + + let recipient_npk = PrivateKeys::holder_npk(); + let recipient_vpk = PrivateKeys::holder_vpk(); + let recipient_id = PrivateKeys::holder_id(); + + let group_pre = AccountWithMetadata::new(group_account, true, group_id); + let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + + let instruction = token_core::Instruction::Transfer { + amount_to_transfer: transfer_amount, }; + let (output, proof) = execute_and_prove( + vec![group_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + private_authorized_update_identity(bob_nsk, &group_vpk, membership_proof, 0), + private_unauthorized_identity(recipient_npk, &recipient_vpk, 1), + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let group_nonce_after = + Nonce::private_account_nonce_init(&group_id).private_account_nonce_increment(&bob_nsk); + let new_group_account = + Accounts::token_holding(shield_amount - transfer_amount, group_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&group_id, &new_group_account)) .is_some()); + + let new_recipient_account = Accounts::token_holding( + transfer_amount, + Nonce::private_account_nonce_init(&recipient_id), + ); + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) + .is_some()); +} + +/// Two independent parties (Alice and Bob) control a private Token holding (via `GroupKeyHolder`). +/// Alice initializes the private Token account (`InitializeAccount` with `PrivateAuthorizedInit`)/ +#[test] +fn token_group_owned_holding_shared_control_initialize() { + let mut state = state_for_token_tests_without_recipient(); + + // Alice creates the group and derives the shared account's keys; Bob is admitted via the + // real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let group_id = alice.id; + let bob_nsk = alice.admit_member(); + + // Bob — who never created the group — self-initializes the shared holding directly. + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let group_pre = AccountWithMetadata::new(Account::default(), true, group_id); + + let instruction = token_core::Instruction::InitializeAccount; + let (output, proof) = execute_and_prove( + vec![definition_pre, group_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + private_authorized_init_identity(bob_nsk, &alice.vpk, 0), + ], + &token_program().into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![Ids::token_definition()], vec![], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let expected_account = Accounts::token_holding(0, Nonce::private_account_nonce_init(&group_id)); + assert!(state + .get_proof_for_commitment(&Commitment::new(&group_id, &expected_account)) + .is_some()); } #[test] @@ -2213,15 +1913,7 @@ fn token_rotate_authority_then_new_authority_can_mint() { ); assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_500_000_u128, - }), - nonce: Nonce(0), - } + Accounts::token_holding(1_500_000_u128, Nonce(0)) ); // Step 4: OLD authority (def_key self-authority path) must be rejected after rotation. @@ -2243,3 +1935,105 @@ fn token_rotate_authority_then_new_authority_can_mint() { "Old authority must be rejected after rotation" ); } + +#[test] +fn token_mint_with_authority_to_private_holding() { + let mut state = V03State::new(); + deploy_token(&mut state); + + let authority_key: [u8; 32] = Ids::authority() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + + // Create the definition with an external mint authority from the start — the rotation + // dance itself is already covered by `token_rotate_authority_then_new_authority_can_mint`. + let instruction = token_core::Instruction::NewFungibleDefinition { + name: String::from("Gold"), + total_supply: 1_000_000_u128, + mint_authority: Some(AccountId::new(authority_key)), + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::holder()], + vec![Nonce(0), Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::def_key(), &Keys::holder_key()], + ); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + state.force_insert_account(Ids::authority(), Accounts::authority_init()); + + let amount_to_mint = 500_000_u128; + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + // Definition is `#[account(mut)]` only under external authority — it does not itself + // authorize the mint, so it goes in as an ordinary (unauthorized) public account. + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + let authority_account = state.get_account_by_id(Ids::authority()); + let authority_nonce = authority_account.nonce; + let authority_pre = AccountWithMetadata::new(authority_account, true, Ids::authority()); + + let instruction = token_core::Instruction::MintWithAuthority { amount_to_mint }; + let (output, proof) = execute_and_prove( + vec![definition_pre, recipient_pre, authority_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), + InputAccountIdentity::Public, + ], + &token_program().into(), + ) + .unwrap(); + + // `public_account_ids` carries every public account for post-state zipping (definition, + // then authority — their `execute_and_prove` input order); `nonces` carries only the + // signer(s), positionally matched to the witness keys below (just `authority` here). + let message = Message::try_from_circuit_output( + vec![Ids::token_definition(), Ids::authority()], + vec![authority_nonce], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::authority_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 + amount_to_mint, + metadata_id: None, + authority: Some(AccountId::new(authority_key)), + }), + nonce: Nonce(1), + } + ); + + let recipient_account = Accounts::token_holding( + amount_to_mint, + Nonce::private_account_nonce_init(&recipient_id), + ); + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .is_some()); +} From ce0a8fe324884c1e88ec9d2bddee47b76fe6e811 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Thu, 9 Jul 2026 17:12:18 -0400 Subject: [PATCH 03/12] test(privacy): add findings doc, stablecoin negative-destination test, refine ATA/token coverage Documents the private-account primitives (private PDA vs public PDA, group-shared accounts) and per-program privacy test results in docs/findings.md. Adds a stablecoin test confirming WithdrawCollateral can't pay out to a brand-new private destination, and folds in further ATA/token privacy test refinements. --- docs/findings.md | 236 +++++++++++++ docs/privacy-test-matrix.md | 27 +- programs/integration_tests/tests/ata.rs | 125 ------- .../integration_tests/tests/stablecoin.rs | 321 +++++++----------- programs/integration_tests/tests/token.rs | 170 +++++++++- 5 files changed, 547 insertions(+), 332 deletions(-) create mode 100644 docs/findings.md diff --git a/docs/findings.md b/docs/findings.md new file mode 100644 index 00000000..c6e15fd8 --- /dev/null +++ b/docs/findings.md @@ -0,0 +1,236 @@ + +# LEE privacy + +Similar to public accounts, private accounts can be regular (generated using based on user generated keys) and PDA. Additionally, private accounts can be shared by a group. + +## Overview of (regular) private accounts + +### Private account initialization +Regular private accounts initialization with or without knowledge of the account's nullifier secret key `nsk`. + +- `PrivateUnauthorized` + A special case for initializing private accounts using only `npk` and `vpk`. + + Use cases; + Private donations. A user publishes public keys (`npk`, `vpk`) associated to a set of private account keys. A third party initializes a fresh private account using these keys (and some `identifier`). This initialization transaction does not require the corresponding `nsk`. Any future transactions with this private account must be performed by the account owner (using the `nsk`). +- `PrivateAuthorizedInit` + Private account initialized using the account's `nsk` (and some `identifier`). + +### Private account update (`PrivateAuthorizedUpdate`) +Regular private accounts are updated the same way. Knowledge of the account's `nsk` and other data that is used for the + +### Summary + +|type | authorized | who can use | +|----|----|----| +| `PrivateUnauthorized`| ❌ | anyone | +| `PrivateAuthorizedInit` | ✅ | owner | +| `PrivateAuthorizedUpdate` | ✅ | owner | + +Only the account owner can (1) update their initialized account, and (2) use functions that require authorization with their account. + +## Private PDA + +### Private PDA vs public PDA +- `AccountId` formulas are different: + - Public: `hash(prefix || program_id || seed)` + - Private: `hash(prefix || program_id || seed || npk || identifier)` + +The difference in these PDA `AccountId` formulas prevents programs from being privacy agnostic for PDAs. + +## Group-shared (multi-party) private accounts (TODO) + +A single private account can be jointly controlled by two or more parties without either one +handing over their actual secret key. The mechanism is a **Group Master Secret (GMS)**, +distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse: + +1. One party ("Alice") creates a `GroupKeyHolder` and derives the shared account's `npk`/`vpk` + from it (`derive_keys_for_shared_account(seed)`). +2. Alice **seals** the GMS against a second party's ("Bob's") own sealing public key + (`seal_for`) and hands over only the sealed bytes. +3. Bob **unseals** it with his own sealing secret key (`GroupKeyHolder::unseal`), then + independently re-derives the *identical* `nsk`/`npk` from the same seed — without ever + touching Alice's `GroupKeyHolder` object directly. + +Bob's re-derived `nsk` then works in `PrivateAuthorizedInit`/`PrivateAuthorizedUpdate` exactly +like a personally-held key — confirmed indistinguishable from a personal account for every +instruction tried (spend, sign, self-initialize), across Token, ATA, and Stablecoin. + +# Privacy testing objectives for LEZ programs (TODO) + +- [ ] **Private PDAs used as program inputs across the above flows.** + **Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA, + AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private + formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an + `npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions + accept an `npk` today, so it's never reachable through these programs as coded. Confirmed + empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`); + the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only + pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at + that layer, not a gap. + *Re: "could we compose a test program that uses private PDAs with these pre-existing?"* — + no. None of the four existing programs can be made to produce a `for_private_pda` address + through a test alone, since the formula choice is hardcoded in their source. Demonstrating + the mechanism at all would require either changing one of the `*_core` crates to derive via + `for_private_pda`, or standing up a small purpose-built program whose only job is to + exercise it — both are source changes, not test-writing. **This is the single most + actionable item to feed back to the protocol team.** + +- Group owned shared private account as input to programs. + +- [x] **Sending funds to an existing private account.** + **Achieved, with one real condition: cooperation is required.** Confirmed across Token + (`Transfer`, `Mint`), ATA (`Transfer`, including through a nested chained call into + Token), and Stablecoin (`WithdrawCollateral`). Every path that touches an *existing* + private account (`PrivateAuthorizedUpdate`) requires that account's own `nsk` plus a + membership proof, supplied in the same transaction — there is no blind-credit analog to + `PrivateUnauthorized` for existing accounts (only *fresh* accounts can be credited by a + stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable, + real-world condition: the recipient must be reachable to supply their `nsk` (online or + pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an + untested edge. + +- [~] **Multiple private accounts in one transaction, and private accounts carried through + chained calls.** This is two separate sub-objectives with different status — worth + splitting: + - **Multiple private accounts in one tx — Achieved.** `token_private_transfer` (sender + + recipient, both private, zero public accounts anywhere) and + `token_private_transfer_into_existing_private_holding` (same, recipient already + existing). + - **Carried through a chained call — Achieved, but only single-hop so far.** + `ata_transfer_to_existing_private_recipient` proves a private identity survives one + chained call (ATA → Token) — the first test in the whole exercise to prove this works + at all. Every private Stablecoin `WithdrawCollateral`/`RepayDebt` test also carries a + private account through exactly one chained call (Stablecoin → Token). **Not yet + tested:** deeper, multi-hop chaining — an instruction issuing more than one chained + call with a private account threaded through it (e.g. AMM's `SwapExactInput` chains + into *both* Token and the TWAP oracle in one instruction). That case is currently + unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see + the AMM section) before any chaining depth can even be exercised. So: not unclear — + genuinely proven for the single-hop case, with the deeper case blocked pending AMM. + + +# LEZ programs (TODO) + +## AMM program +TODO + +## ATA program + +ATA program offers limited usage with private accounts. Private accounts can be used as the `owner` (or as a recipient to transactions). But, ATA program can only generate public PDAs. The `owner` account can be public/private/shared and have any `program_owner`. + +| Function tested | Test name | Category | Description of objective | Result | +|---|---|---|---|---| +| Create | `ata_create_private_ata_holding_is_not_expressible` | PDA | Attempts to make the ATA holding itself a private account via `PrivatePdaInit`/`PrivatePdaUpdate` — confirms the public-form PDA match ATA authorizes with and the private-form binding those variants require are mutually exclusive for the same account id | ❌ (confirmed not-expressible) | +| Create | `ata_create_from_group_owned_owner` | GROUP | Group-derived owner identity used to create an ATA — **weaker than the other `GROUP` rows**: `Create` never requires `owner` to prove control, so this can't demonstrate genuine shared control the way the `Transfer`/`Burn` rows below do; it only confirms `Create` doesn't secretly care where `npk`/`vpk` came from | ✅ (defensive/symmetry coverage only) | +| Transfer | `ata_transfer_to_existing_private_recipient` | EXIST, CHAIN | Sends more into an already-shielded private recipient through ATA's *nested* chained call into Token — the first test in the whole exercise proving a private identity survives a chained call at all | ✅ | +| Transfer | `ata_transfer_with_group_owned_owner_signing` | GROUP | Group-owned owner (real GMS seal/unseal handshake) signs `ATA::Transfer` as the required authorizing party | ✅ | +| Burn | `ata_group_owned_owner_signing` | GROUP | Group-owned owner signs `ATA::Burn` as the required authorizing party | ✅ | + +**`PDA`** is confirmed not-expressible for every ATA instruction, not just `Create` — `Transfer` +and `Burn` call the same `ata_core::verify_ata_and_get_seed` function, so the identical +public-form/private-form conflict applies to them too, even though only `Create` has a dedicated +test asserting it. + +Two tests exist outside this table's four categories and are worth noting separately: +`ata_burn_with_private_owner_signing` and `ata_transfer_with_private_owner_signing` (a +*personal*, non-group private owner signing `Burn`/`Transfer`). They were the key discovery that +`owner` must be a *signer* for these two instructions (unlike `Create`) — a real finding, just +not one of the four Q2 checkboxes, so it's omitted here the same way Token's `BASE` rows were. + +## Stablecoin program + +| Function tested | Test name | Category | Description of objective | Result | +|---|---|---|---|---| +| WithdrawCollateral | `stablecoin_withdraw_collateral_private_destination` | CHAIN, EXIST | Withdraws collateral through the single `Token::Transfer` chained call into an already-existing private destination holding | ✅ | +| WithdrawCollateral | `stablecoin_withdraw_collateral_group_owned_destination` | CHAIN, EXIST, GROUP | Same, but the destination holding is group-owned (real GMS seal/unseal handshake) | ✅ | +| WithdrawCollateral | `stablecoin_group_owned_position_owner` | GROUP | The position's `owner` identity itself (not the destination) is group-derived — proves shared authority over a CDP by withdrawing collateral through it | ✅ | +| RepayDebt | `stablecoin_repay_debt_private_stablecoin_holding` | CHAIN | Burns from a private stablecoin holding through the single `Token::Burn` chained call | ✅ | +| RepayDebt | `stablecoin_repay_debt_group_owned_stablecoin_holding` | CHAIN, GROUP | Same, group-owned holding | ✅ | + +**`PDA`** has no rows, and can't even be isolated as its own question for this program: position +and vault are only ever PDA-claimed *inside* `OpenPosition`, and — see below — that instruction +can't reach the privacy circuit at all. The `PDA` question is subsumed by that finding rather +than independently testable; the ATA `PDA` finding (same `for_public_pda`-only root cause, +confirmed in `stablecoin_core`) stands as the citable reference. + +One test sits outside this table's four categories but is the headline finding for the whole +program, worth stating plainly rather than omitting silently: +**`stablecoin_open_position_via_privacy_transaction_is_not_expressible`** — `OpenPosition` +cannot be executed through a privacy-preserving transaction *at all*, for any reason connected +to privacy. Confirmed with an all-public control case (every account `InputAccountIdentity::Public`, +zero private accounts) that fails identically, proving it's a protocol incompatibility in the +`PrivacyPreservingTransaction` code path itself, not a privacy bug — `owner`'s identity type is +irrelevant. Every test above routes around it by seeding position/vault directly rather than +calling `OpenPosition` for real. + +**Root cause, precisely traced:** `open_position.rs` returns two *sibling* chained calls in one +shot (`vec![initialize_call, transfer_call]` — both discovered at once from a single execution of +`open_position`, neither nested inside the other) that both touch `vault`: `InitializeAccount` +declares it `is_authorized: true` (claimed via its PDA seed), `Transfer` then declares the *same* +account_id `is_authorized: false` (a hand-predicted post-`InitializeAccount` state, not a value +threaded through by the framework — the program author is predicting what call 1 will produce, +not observing it). This reuse of one account across two sibling calls with differing declared +authorization is the *only* thing that matters here — contrast with AMM's `remove_liquidity`, +which also returns multiple sibling chained calls at once (4: token A/B withdraw, LP burn, TWAP +tick update) but never reuses one account across two of them, so it never exercises this code +path at all. + +Both transaction-type validators re-derive `is_authorized` per occurrence and assert it matches +the declared value — but they scope that derivation differently. `validated_state_diff.rs` (the +plain `PublicTransaction` validator) computes a fresh `authorized_accounts` set once per parent +call and clones it independently for each sibling *before* any sibling runs — so `Transfer`'s +view of `vault` never sees `InitializeAccount`'s PDA-based authorization, re-derives `false`, +matches. This is why the pre-existing public `stablecoin_open_position_then_withdraw_collateral` +test works. `execution_state.rs` (the `PrivacyPreservingTransaction`/circuit validator) instead +keeps one mutable `authorized_accounts: HashSet` on `self`, threaded with no +per-branch scoping through the entire flat call queue — `InitializeAccount` processing inserts +`vault` into it, and when `Transfer` is processed next, `resolve_authorization_and_record_bindings` +short-circuits via `if authorized_accounts.contains(&pre_account_id) { return true; }`, re-deriving +`true` — which conflicts with the declared `false` and fails +`assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")`. + +**This means `OpenPosition` is fixable two ways**: either scope `execution_state.rs`'s +`authorized_accounts` per sibling branch to match `validated_state_diff.rs`'s behavior (a circuit +fix, benefits every program with this pattern), or change `open_position.rs` to not re-declare +`vault` unauthorized on its second occurrence (a one-line fix local to this program, routing +around the bug rather than fixing it). + +A second, unrelated negative result: +**`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`** — +`WithdrawCollateral` cannot pay out to a brand-new private destination (`PrivateUnauthorized`, +only `npk` known, no `nsk`). `withdraw_collateral.rs` hard-asserts +`destination.account != Account::default()` before the chained `Token::Transfer` is even +constructed, so the destination must already exist — this is a plain program precondition, not a +privacy-circuit artifact, and would equally reject a withdraw to a brand-new *public* +destination. It's why every `WithdrawCollateral` test above uses `PrivateAuthorizedUpdate` +(`nsk` known) rather than `PrivateUnauthorized` for the destination. + +## Token program + +| Function tested | Test name | Category | Description of objective | Result | +|---|---|---|---|---| +| Transfer | `token_transfer_into_existing_private_holding` | EXIST | Second transfer into an already-shielded recipient — confirms crediting an existing private account requires the recipient's own cooperation (`nsk`), not just their public key | ✅ | +| Transfer | `token_private_transfer_into_existing_private_holding` | EXIST, CHAIN | Both legs private (sender + recipient) in one transaction, and the recipient is already existing rather than fresh | ✅ | +| Transfer | `token_group_owned_holding_shared_control_transfer` | GROUP | Group-owned sender (real GMS seal/unseal handshake) spends outward via `Transfer` to a fresh private recipient | ✅ | +| Transfer | `token_private_transfer` | CHAIN | Pre-existing test; two private accounts (sender + fresh recipient) compose in a single transaction with no public account at all — fulfills the "multiple private accounts in one tx" half of `CHAIN` | ✅ | +| Mint | `token_mint_into_existing_private_holding` | EXIST | Mint once to establish a private holding, mint again into it via `PrivateAuthorizedUpdate` — crediting an existing private account | ✅ | +| Burn | `token_group_owned_holding_shared_control_burn` | GROUP | Shield tokens into a GMS-derived shared holding, then burn from it using an independently re-derived key | ✅ | +| InitializeAccount | `token_group_owned_holding_shared_control_initialize` | GROUP | A group member — not the party who created the group — self-initializes the shared holding directly via `PrivateAuthorizedInit` | ✅ | + +**`PDA`** has no Token-layer rows: Token holdings are addressed by an arbitrary `AccountId`, not +a program-derived one — there's no PDA to make private at this layer. Only testable once a +holding is wrapped by another program's PDA (ATA/AMM/Stablecoin). + +**`CHAIN`**'s "carried through chained calls" half also has no Token-layer rows: Token issues no +`ChainedCall`s of its own (only ATA/AMM/Stablecoin do) — that half is exercised for the first +time in the ATA section instead. + +# Conclusions + +## Group shared private accounts +- Group shared accounts are authorized + +# Observations +- Programs can be made privacy agnostic for PDAs by adjusting private PDA `AccountId` formula to match the public variant. Unclear how to precisely handle this to ensure `AMM program` generates unique pools for token pairs (in public PDA case). +- A private PDA can be initialized and used for a program without using traditional PDA lifecycle. E.g., TODO(provide example from `token.rs`) \ No newline at end of file diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index c5d2fafb..64d49364 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -472,6 +472,7 @@ vault) are `for_public_pda` only, per the ATA `PDA` finding. | RepayDebt | `CHAIN` | `stablecoin_repay_debt_private_stablecoin_holding` | Pass | | RepayDebt | `CHAIN` + `GROUP` | `stablecoin_repay_debt_group_owned_stablecoin_holding` | Pass | | WithdrawCollateral (owner identity) | `GROUP` | `stablecoin_group_owned_position_owner` | Pass | +| WithdrawCollateral | new: destination must pre-exist | `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible` | **Not-expressible — confirmed** | **Finding (`OpenPosition`, confirmed 2026-07-08 — the headline finding for this program, and arguably the whole exercise): `OpenPosition` cannot be executed through the privacy-preserving @@ -531,6 +532,19 @@ via `PrivateAuthorizedInit`, then withdraws collateral through it. Passed on the This is the correct, expressible version of "joint control over a CDP": shared control of the *authority* over a PDA-locked resource, not shared privacy of the resource itself. +**Finding (`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`, +confirmed 2026-07-09 — second, unrelated not-expressible result for this program):** +`WithdrawCollateral` cannot pay out to a brand-new private destination. `withdraw_collateral.rs` +hard-asserts `destination.account != Account::default()` before the chained `Token::Transfer` is +even constructed — a plain host-side program precondition, unrelated to the `OpenPosition` +authorization-bookkeeping bug above. It fires regardless of privacy: a brand-new *public* +destination would be rejected identically. Confirmed by attempting `WithdrawCollateral` with a +`PrivateUnauthorized` destination (fresh `Account::default()` pre-state, only `npk` known) and +observing the exact `"Destination must be initialized"` panic surface as the circuit-execution +error. Consequence: every `WithdrawCollateral` test in this phase necessarily uses +`PrivateAuthorizedUpdate` (`nsk` known) for the destination — a pre-existing private destination +is the *only* expressible shape, not a coverage choice. + `ProtocolParameters` remains out of scope — not yet consumed by any instruction (no freeze/admin logic wired up), nothing to test. @@ -542,13 +556,14 @@ freeze/admin logic wired up), nothing to test. `integration_tests/Cargo.toml` pinned to the same repo/tag as `nssa`/`nssa_core`. Unblocks the remaining `GROUP` rows in ATA/AMM/Stablecoin; each still needs its own program-specific test (PDA-based group ownership, not just the regular-account path proven for Token). -- Build the shared privacy test kit in `integration_tests/src/lib.rs` — **partially done** +- Build the shared privacy test kit in `integration_tests/src/lib.rs` — **mostly done** (2026-07-08): `private_unauthorized_identity`/`private_authorized_init_identity`/ `private_authorized_update_identity` (build an `InputAccountIdentity` from just the key - material) and `setup_group_shared_account` (the Alice-creates/Bob-unseals GMS handshake) now - live there and are used throughout `token.rs`. `ata.rs`/`stablecoin.rs` still have their own - independent copies of the same patterns — not yet migrated, since that was out of scope for - the token.rs-focused cleanup pass. Revisit migrating them before/during AMM. + material) and `GroupOwner` (the Alice-creates/Bob-admitted GMS handshake, via `::new(seed)` + + `.admit_member()`) now live there and are used throughout `token.rs`, `stablecoin.rs` (fully + migrated), and the newer `ata.rs` group tests. Only the original `ata_group_owned_owner_signing` + still has its own independent inline copy — not yet migrated. Low priority; revisit + before/during AMM if it's still outstanding then. **Implementation technique worth carrying into AMM/Stablecoin (found 2026-07-07):** private account preconditions don't need a real proven transaction to set up. `V03State::with_private_accounts(impl IntoIterator)` @@ -571,4 +586,4 @@ heavier (chained calls, multiple accounts) than a single shield. | Token | 16 (3 pre-existing + 13 new: 12 pass + 1 confirmed not-expressible by design) — phase complete | 0 | 5 | | ATA | 8 (7 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | | AMM | 0 (2 rows now predicted not-expressible pending confirmation) | 10 | 5 | -| Stablecoin | 6 (5 pass + 1 confirmed not-expressible — phase complete) | 0 | 1 | +| Stablecoin | 7 (5 pass + 2 confirmed not-expressible — phase complete) | 0 | 1 | diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index 3d35c216..ebffe476 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -968,131 +968,6 @@ fn ata_burn_with_private_owner_signing() { .is_some()); } -/// TODO: remove, this is essentially same as burn test. Worth noting though that -/// any member can sign. -#[test] -fn ata_group_owned_owner_signing() { - let mut state = V03State::new(); - deploy_programs(&mut state); - state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); - - // Alice creates the group and derives the shared owner identity's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [13_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let owner_npk = alice_keys.generate_nullifier_public_key(); - let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([17_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - - // Bob independently re-derives the same shared owner keys and is the one who signs below. - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - let bob_vpk = bob_keys.generate_viewing_public_key(); - assert_eq!( - bob_keys.generate_nullifier_public_key(), - owner_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); - - // The ATA holding must stay public (per the confirmed PDA finding), so it's seeded - // directly rather than via a real `Create` transaction. - let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); - let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); - let ata_account = Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000_u128, - }), - nonce: Nonce(0), - }; - state.force_insert_account(ata_id, ata_account.clone()); - - let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); - let ata_pre = AccountWithMetadata::new(ata_account, false, ata_id); - let def_pre = AccountWithMetadata::new( - state.get_account_by_id(Ids::token_definition()), - false, - Ids::token_definition(), - ); - - let burn_amount = 300_000_u128; - let instruction = ata_core::Instruction::Burn { - token_program_id: Ids::token_program(), - amount: burn_amount, - }; - - let shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0; - - let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); - let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); - let program_with_deps = ProgramWithDependencies::new( - ata_program, - HashMap::from([(Ids::token_program(), token_program)]), - ); - - let (output, proof) = execute_and_prove( - vec![owner_pre, ata_pre, def_pre], - Program::serialize_instruction(instruction).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk), - ssk: shared_secret, - nsk: bob_nsk, - identifier: 0, - }, - InputAccountIdentity::Public, - InputAccountIdentity::Public, - ], - &program_with_deps, - ) - .unwrap(); - - let message = - Message::try_from_circuit_output(vec![ata_id, Ids::token_definition()], vec![], output) - .unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[]); - state - .transition_from_privacy_preserving_transaction( - &PrivacyPreservingTransaction::new(message, witness_set), - 0, - 0, - ) - .unwrap(); - - assert_eq!( - state.get_account_by_id(ata_id), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000_u128 - burn_amount, - }), - nonce: Nonce(0), - } - ); - - let owner_expected = Account { - nonce: Nonce::private_account_nonce_init(&owner_id), - ..Account::default() - }; - assert!(state - .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) - .is_some()); -} - /// Private owner #[test] fn ata_transfer_with_private_owner_signing() { diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 165214bd..0d166482 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use key_protocol::key_management::{ - group_key_holder::{GroupKeyHolder, SealingPublicKey}, - secret_holders::SecretSpendingKey, +use integration_tests::{ + private_authorized_init_identity, private_authorized_update_identity, + private_unauthorized_identity, GroupOwner, }; use nssa::{ execute_and_prove, @@ -11,13 +11,12 @@ use nssa::{ }, program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, - public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State, + public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, }; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - encryption::{EphemeralPublicKey, ViewingPublicKey}, - Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, - NullifierSecretKey, + encryption::ViewingPublicKey, + Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, }; use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; use token_core::{TokenDefinition, TokenHolding}; @@ -465,23 +464,9 @@ fn stablecoin_with_token_deps() -> ProgramWithDependencies { ) } -// Marvin-todo -/// `OpenPosition` cannot execute through the privacy-preserving transaction type *at all* — -/// confirmed here with every single account `Public` and zero private accounts involved. Root -/// cause traced in `lee_core`'s `execution_state.rs`: `authorized_accounts` is a monotonic/sticky -/// set — once an account is authorized via one chained call's `pda_seeds` match, every later -/// occurrence of that same account must also declare `is_authorized: true`, or -/// `assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")` -/// fails. `open_position.rs` issues two chained calls that both reuse `vault`: the first -/// (`Token::InitializeAccount`) authorizes it via `pda_seeds`, sticking `vault` as authorized; -/// the second (`Token::Transfer`) then deliberately constructs `post_init_vault` with -/// `is_authorized: false` (a legitimate choice on the public-transaction path — "the recipient -/// is already initialized, so no second PDA claim is needed" per that file's own comment) — but -/// the privacy circuit rejects that as inconsistent. This is not a privacy-dimension gap; it -/// blocks `OpenPosition` from ever being expressed as a `PrivacyPreservingTransaction`, so every -/// other instruction that depends on having *opened* a position privately is affected too (see -/// `stablecoin_group_owned_position_owner`, which routes around it by seeding the position/vault -/// directly instead of calling `OpenPosition`). + +/// `OpenPosition` is blocked by the `privacy_preserving_circuit` due to the handling of +/// sibling chain calls of (uninitialized) private accounts. #[test] fn stablecoin_open_position_via_privacy_transaction_is_not_expressible() { let mut state = V03State::new(); @@ -545,14 +530,7 @@ fn stablecoin_open_position_via_privacy_transaction_is_not_expressible() { ); } -// Marvin-todo -/// `WithdrawCollateral` has only *one* chained call (`Token::Transfer`, reusing `vault` exactly -/// once), unlike `OpenPosition`'s two — so it should avoid the authorization-consistency -/// blocker confirmed above. Position/vault are seeded directly via `force_insert_account` -/// (public accounts, no real `OpenPosition` call needed, and none is possible per the finding -/// above). `withdraw_collateral.rs` hard-asserts `destination.account != Account::default()`, -/// so `destination` must already exist — same `EXIST` shape as ATA's Transfer, requiring the -/// destination's cooperation via `PrivateAuthorizedUpdate`. +/// `WithdrawCollateral` to private account (`PrivateAuthorized`; `nsk` is known). #[test] fn stablecoin_withdraw_collateral_private_destination() { let mut state = V03State::new(); @@ -597,7 +575,6 @@ fn stablecoin_withdraw_collateral_private_destination() { state.force_insert_account(vault_id, vault_account); let destination_nsk = PrivateKeys::destination_nsk(); - let destination_npk = PrivateKeys::destination_npk(); let destination_vpk = PrivateKeys::destination_vpk(); let destination_id = PrivateKeys::destination_id(); let destination_initial_balance = 100_000_u128; @@ -629,9 +606,6 @@ fn stablecoin_withdraw_collateral_private_destination() { amount: withdraw_amount, }; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, vault_pre, destination_pre], Program::serialize_instruction(instruction).unwrap(), @@ -639,17 +613,12 @@ fn stablecoin_withdraw_collateral_private_destination() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &destination_npk, - &destination_vpk, - ), - ssk: shared_secret, - nsk: destination_nsk, + private_authorized_update_identity( + destination_nsk, + &destination_vpk, membership_proof, - identifier: 0, - }, + 0, + ), ], &stablecoin_with_token_deps(), ) @@ -703,14 +672,11 @@ fn stablecoin_withdraw_collateral_private_destination() { .is_some()); } -// Marvin-todo -/// `GROUP` variance on `stablecoin_withdraw_collateral_private_destination`: the destination is -/// group-owned instead of personal. The GMS is distributed through the real seal/unseal -/// handshake (as in `token_group_owned_holding_shared_control_burn`); "Bob" — who only ever -/// receives the sealed GMS — independently re-derives the shared destination's keys and -/// supplies its `PrivateAuthorizedUpdate` cooperation to receive the withdrawn collateral. +/// `WithdrawCollateral` blocks withdraws to private accounts (via private donations); +/// `PrivateUnauthorized` account initialization (e.g., `nsk` is not known) is not permitted +/// due to the assertion in `withdraw_collateral.rs` asserts `destination.account != Account::default()` #[test] -fn stablecoin_withdraw_collateral_group_owned_destination() { +fn stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible() { let mut state = V03State::new(); deploy_programs(&mut state); state.force_insert_account( @@ -752,30 +718,92 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { state.force_insert_account(position_id, position_account); state.force_insert_account(vault_id, vault_account); - // Alice creates the group and derives the shared destination's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [7_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let destination_npk = alice_keys.generate_nullifier_public_key(); - let destination_vpk = alice_keys.generate_viewing_public_key(); - let destination_id = AccountId::for_regular_private_account(&destination_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - assert_eq!( - bob_keys.generate_nullifier_public_key(), - destination_npk, - "Bob must derive the identical npk as Alice from the shared GMS" + let destination_npk = PrivateKeys::destination_npk(); + let destination_vpk = PrivateKeys::destination_vpk(); + let destination_id = PrivateKeys::destination_id(); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = + AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id); + let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id); + let destination_pre = AccountWithMetadata::new(Account::default(), false, destination_id); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + amount: withdraw_amount, + }; + + let result = execute_and_prove( + vec![owner_pre, position_pre, vault_pre, destination_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_unauthorized_identity(destination_npk, &destination_vpk, 0), + ], + &stablecoin_with_token_deps(), + ); + + let err = result.expect_err( + "WithdrawCollateral must be rejected: destination is a brand-new (default) private \ + account, but withdraw_collateral.rs requires the destination to already be initialized", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Destination must be initialized"), + "expected the destination-must-be-initialized rejection, got a different error: {message}" + ); +} + +#[test] +fn stablecoin_withdraw_collateral_group_owned_destination() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + + let owner_id = Ids::owner(); + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let position_collateral = 500_000_u128; + let withdraw_amount = 200_000_u128; + + let position_account = Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id, + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: position_collateral, + debt_amount: 0, + }), + nonce: Nonce(0), + }; + let vault_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: position_collateral, + }), + nonce: Nonce(0), + }; + state.force_insert_account(position_id, position_account); + state.force_insert_account(vault_id, vault_account); + + // Alice creates the group and derives the shared destination's keys; Bob is admitted via + // the real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let destination_vpk = alice.vpk; + let destination_id = alice.id; let destination_initial_balance = 100_000_u128; let destination_account = Account { @@ -806,9 +834,6 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { amount: withdraw_amount, }; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, vault_pre, destination_pre], Program::serialize_instruction(instruction).unwrap(), @@ -816,17 +841,7 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &destination_npk, - &destination_vpk, - ), - ssk: shared_secret, - nsk: bob_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(bob_nsk, &destination_vpk, membership_proof, 0), ], &stablecoin_with_token_deps(), ) @@ -870,14 +885,6 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { .is_some()); } -// Marvin-todo -/// `user_stablecoin_holding` is private, burned via `RepayDebt`'s single chained `Token::Burn`. -/// Unlike ATA's own holdings (structurally locked to public PDAs), Stablecoin's stablecoin -/// holding is a regular user-controlled token holding with no PDA involved at all, so it's free -/// to be private with no structural obstacle. Position/stablecoin-definition are seeded -/// directly, matching the pre-existing public -/// `stablecoin_repay_debt_burns_stablecoins_and_decreases_debt` test's fixture approach (no real -/// `OpenPosition` call, consistent with the finding above). #[test] fn stablecoin_repay_debt_private_stablecoin_holding() { let mut state = V03State::new(); @@ -917,7 +924,6 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { state.force_insert_account(position_id, position_account); let stablecoin_holding_nsk = PrivateKeys::stablecoin_holding_nsk(); - let stablecoin_holding_npk = PrivateKeys::stablecoin_holding_npk(); let stablecoin_holding_vpk = PrivateKeys::stablecoin_holding_vpk(); let stablecoin_holding_id = PrivateKeys::stablecoin_holding_id(); let initial_stablecoin_balance = Balances::user_stablecoin_holding_init(); @@ -959,9 +965,6 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { amount: repay_amount, }; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&stablecoin_holding_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![ owner_pre, @@ -974,17 +977,12 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &stablecoin_holding_npk, - &stablecoin_holding_vpk, - ), - ssk: shared_secret, - nsk: stablecoin_holding_nsk, + private_authorized_update_identity( + stablecoin_holding_nsk, + &stablecoin_holding_vpk, membership_proof, - identifier: 0, - }, + 0, + ), ], &stablecoin_with_token_deps(), ) @@ -1041,11 +1039,6 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { .is_some()); } -// Marvin-todo -/// `GROUP` variance on `stablecoin_repay_debt_private_stablecoin_holding`: the stablecoin -/// holding being burned from is group-owned instead of personal. Same real seal/unseal -/// distribution as every other group test in this exercise; Bob independently re-derives the -/// shared holding's keys and supplies `PrivateAuthorizedUpdate` cooperation for the burn. #[test] fn stablecoin_repay_debt_group_owned_stablecoin_holding() { let mut state = V03State::new(); @@ -1084,30 +1077,12 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { }; state.force_insert_account(position_id, position_account); - // Alice creates the group and derives the shared stablecoin holding's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [7_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let holding_npk = alice_keys.generate_nullifier_public_key(); - let holding_vpk = alice_keys.generate_viewing_public_key(); - let holding_id = AccountId::for_regular_private_account(&holding_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - assert_eq!( - bob_keys.generate_nullifier_public_key(), - holding_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); + // Alice creates the group and derives the shared stablecoin holding's keys; Bob is + // admitted via the real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let holding_vpk = alice.vpk; + let holding_id = alice.id; let initial_stablecoin_balance = Balances::user_stablecoin_holding_init(); let holding_account = Account { @@ -1141,8 +1116,6 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { amount: repay_amount, }; - let shared_secret = SharedSecretKey::encapsulate_deterministic(&holding_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, definition_pre, holding_pre], Program::serialize_instruction(instruction).unwrap(), @@ -1150,14 +1123,7 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&holding_npk, &holding_vpk), - ssk: shared_secret, - nsk: bob_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(bob_nsk, &holding_vpk, membership_proof, 0), ], &stablecoin_with_token_deps(), ) @@ -1198,18 +1164,6 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { .is_some()); } -// Marvin-todo -/// Reframes what "group-owned position" actually means, given the findings above: the -/// *position/vault themselves* can never be private or group-owned (the `PDA` finding), and -/// they can't even be opened through a privacy-preserving transaction at all (the -/// authorization-consistency finding above). But `owner` is just an `AccountId` used for PDA -/// seed derivation and signer verification — it doesn't need to be a plain public keypair. So -/// the real, well-motivated test is: a group-derived `owner` identity controls a PDA-locked -/// position, even though the position/vault stay public. Position/vault are seeded directly -/// (bypassing the blocked `OpenPosition`); "Bob" — who only ever receives the sealed GMS — -/// self-initializes *and* signs the owner identity in one transaction via `PrivateAuthorizedInit` -/// (since this owner has never proven control before), then withdraws collateral through it. -/// Directly mirrors `ata_group_owned_owner_signing`'s precedent for a PDA-locked resource. #[test] fn stablecoin_group_owned_position_owner() { let mut state = V03State::new(); @@ -1220,32 +1174,11 @@ fn stablecoin_group_owned_position_owner() { ); state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init()); - // Alice creates the group and derives the shared owner identity's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [7_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let owner_npk = alice_keys.generate_nullifier_public_key(); - let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - - // Bob independently re-derives the same shared owner keys. - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - let bob_vpk = bob_keys.generate_viewing_public_key(); - assert_eq!( - bob_keys.generate_nullifier_public_key(), - owner_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); + // Alice creates the group and derives the shared owner identity's keys; Bob is admitted + // via the real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let owner_id = alice.id; // Position/vault addresses are derived from the group-owned owner_id — still ordinary // public PDAs (the seed formula doesn't care whether owner_id is public or private), seeded @@ -1296,19 +1229,11 @@ fn stablecoin_group_owned_position_owner() { amount: withdraw_amount, }; - let shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, vault_pre, destination_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk), - ssk: shared_secret, - nsk: bob_nsk, - identifier: 0, - }, + private_authorized_init_identity(bob_nsk, &alice.vpk, 0), InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index 3a9231ac..c39c0cfe 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -7,12 +7,14 @@ use nssa::{ privacy_preserving_transaction::{Message, PrivacyPreservingTransaction, WitnessSet}, program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, - public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, + public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State, }; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - encryption::ViewingPublicKey, - Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, + encryption::{EphemeralPublicKey, ViewingPublicKey}, + program::PdaSeed, + Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, + NullifierSecretKey, }; use token_core::{TokenDefinition, TokenHolding}; @@ -603,6 +605,75 @@ fn token_program() -> Program { Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF") } +/// TODO +/// EXPERIMENTAL — investigating whether `PrivatePdaInit`'s `seed: Some((seed, +/// authority_program_id))` external-derivation-check path lets a private-PDA account be used as an +/// input to an *existing* program's flow (Token) without any chained call, `Claim::Pda`, or +/// awareness from the `authority_program_id` itself. Per `lee_core`'s +/// `circuit_io.rs`/`execution_state.rs`, this path binds the position purely via +/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == +/// pre_state.account_id`, checked directly against the top-level `account_identities` — no chained +/// call needed. Using `Ids::token_program()` as the `authority_program_id` here, but per the +/// circuit source this is not required to correspond to anything Token itself is aware of; it's +/// purely a hash input. +#[test] +fn token_shield_into_private_pda_via_external_seed() { + let mut state = state_for_token_tests(); + let amount = 500_000_u128; + + let sender_id = Ids::holder(); + let sender_account = state.get_account_by_id(sender_id); + let sender_nonce = sender_account.nonce; + let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); + + let authority_program_id = Ids::token_program(); + let pda_seed = PdaSeed::new([77u8; 32]); + let recipient_nsk: NullifierSecretKey = [123u8; 32]; + let recipient_npk = NullifierPublicKey::from(&recipient_nsk); + let recipient_vpk = ViewingPublicKey::from_seed(&[124u8; 32], &[125u8; 32]); + let recipient_id = + AccountId::for_private_pda(&authority_program_id, &pda_seed, &recipient_npk, 0); + + let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; + + let instruction = token_core::Instruction::Transfer { + amount_to_transfer: amount, + }; + let (output, proof) = execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + npk: recipient_npk, + ssk: shared_secret, + identifier: 0, + seed: Some((pda_seed, authority_program_id)), + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![sender_id], vec![sender_nonce], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::holder_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + let recipient_account = + Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id)); + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .is_some()); +} + /// Performs a shielded transfer (public → private) of `amount` tokens from /// `Ids::holder()` to a new private account keyed by `PrivateKeys::recipient_*`. /// Returns the resulting private recipient account. @@ -2037,3 +2108,96 @@ fn token_mint_with_authority_to_private_holding() { .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) .is_some()); } + +/// TODO +/// EXPERIMENTAL — follow-up to `token_shield_into_private_pda_via_external_seed`: proves the +/// *update* half of the same mechanism (crediting an *existing* private PDA, not just creating +/// one), completing a genuine round trip rather than a one-shot creation. `PrivatePdaUpdate`'s +/// external seed path has a different pre-condition than `Init`: `execution_state.rs` asserts +/// `pre_state.is_authorized ^ external_seed.is_some()` — with an external seed supplied, the +/// pre-state must be *unauthorized*, even though we're touching it with a real `nsk` + +/// `membership_proof`. That's incompatible with `Transfer`'s sender role, which requires a +/// framework-level `#[account(signer)]` (`is_authorized: true`) — confirmed empirically: using +/// the private-PDA holder as `Transfer`'s sender fails at the SPEL macro's own validation +/// ("must be a signer"), before Token's own logic is ever reached. `Mint`'s +/// `user_holding_account` has no such requirement (`mint_inner` never asserts `is_authorized` on +/// it, crediting an existing holding or not), so it's used here instead — mirroring the `EXIST` +/// dimension's existing-account-crediting pattern (`token_mint_into_existing_private_holding`), +/// just with a private-PDA holder instead of a regular private account. +#[test] +fn token_mint_into_existing_private_pda_via_external_seed() { + let mut state = state_for_token_tests_without_recipient(); + let holding_balance = 500_000_u128; + let amount_to_mint = 200_000_u128; + + let authority_program_id = Ids::token_program(); + let pda_seed = PdaSeed::new([88u8; 32]); + let holder_nsk: NullifierSecretKey = [131u8; 32]; + let holder_npk = NullifierPublicKey::from(&holder_nsk); + let holder_vpk = ViewingPublicKey::from_seed(&[132u8; 32], &[133u8; 32]); + let holder_id = AccountId::for_private_pda(&authority_program_id, &pda_seed, &holder_npk, 0); + + // Seed the private-PDA holding directly (established technique — no real transaction + // needed). Its eligibility as a private PDA is re-derived independently by the update-side + // check below; nothing about how it was seeded matters to that check. + let holder_account = Accounts::token_holding( + holding_balance, + Nonce::private_account_nonce_init(&holder_id), + ); + let holder_commitment = Commitment::new(&holder_id, &holder_account); + state = state.with_private_accounts([( + holder_commitment.clone(), + Nullifier::for_account_initialization(&holder_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&holder_commitment) + .expect("seeded holder's commitment must be in the set"); + + let definition_account = state.get_account_by_id(Ids::token_definition()); + let definition_nonce = definition_account.nonce; + let definition_pre = + AccountWithMetadata::new(definition_account, true, Ids::token_definition()); + let holder_pre = AccountWithMetadata::new(holder_account, false, holder_id); + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&holder_vpk, &[0u8; 32], 0).0; + + let instruction = token_core::Instruction::Mint { amount_to_mint }; + let (output, proof) = execute_and_prove( + vec![definition_pre, holder_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&holder_npk, &holder_vpk), + ssk: shared_secret, + nsk: holder_nsk, + membership_proof, + identifier: 0, + seed: Some((pda_seed, authority_program_id)), + }, + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![Ids::token_definition()], + vec![definition_nonce], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::def_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + let holder_nonce_after = + Nonce::private_account_nonce_init(&holder_id).private_account_nonce_increment(&holder_nsk); + let new_holder_account = + Accounts::token_holding(holding_balance + amount_to_mint, holder_nonce_after); + assert!(state + .get_proof_for_commitment(&Commitment::new(&holder_id, &new_holder_account)) + .is_some()); +} From 1c65011264295041bd04a92e524a64dc8c199757 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Mon, 13 Jul 2026 17:30:13 -0400 Subject: [PATCH 04/12] test(privacy): confirm AMM circuit bug blocks Swap/AddLiquidity/RemoveLiquidity privacy tests Adds private-account tests for AMM's SwapExactInput/SwapExactOutput, AddLiquidity, and RemoveLiquidity confirming the "Invalid account_identities length" circuit bug also fires with real private accounts, not just the all-public control case, plus a distinct RemoveLiquidity finding (destination must already exist). Also deduplicates the shielded_token_transfer test helper and updates findings/matrix docs accordingly. --- docs/findings.md | 261 +++++--- docs/privacy-test-matrix.md | 105 +++- programs/integration_tests/tests/amm.rs | 729 +++++++++++++++++++++- programs/integration_tests/tests/token.rs | 92 ++- 4 files changed, 1027 insertions(+), 160 deletions(-) diff --git a/docs/findings.md b/docs/findings.md index c6e15fd8..cfd280c7 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -1,23 +1,27 @@ -# LEE privacy +# Privacy coverage in LEZ programs -Similar to public accounts, private accounts can be regular (generated using based on user generated keys) and PDA. Additionally, private accounts can be shared by a group. +LEZ programs, ideally, are privacy agnostic. E.g., a program should work the same for public and private accounts. Currently, LEZ program integration tests only cover public accounts. This task, we expand the tests for LEZ programs to determine how adaptable (TODO-probably wrong word) LEZ programs are to selective privacy. + + +# Private account variants in LEE + +LEE's private state supports (regular) accounts, PDAs and group owned accounts. ## Overview of (regular) private accounts ### Private account initialization -Regular private accounts initialization with or without knowledge of the account's nullifier secret key `nsk`. +Regular private accounts can be initialized with or without knowledge of the account's nullifier secret key `nsk`. This results in two initialization "types": `PrivateUnauthorized` and `PrivateAuthorizedInit`. - `PrivateUnauthorized` - A special case for initializing private accounts using only `npk` and `vpk`. - Use cases; - Private donations. A user publishes public keys (`npk`, `vpk`) associated to a set of private account keys. A third party initializes a fresh private account using these keys (and some `identifier`). This initialization transaction does not require the corresponding `nsk`. Any future transactions with this private account must be performed by the account owner (using the `nsk`). + A special case for private accounts initialization that uses only public keys `npk` and `vpk`. Example: Alice can use Bob's keys (`npk`, `vpk`) and an `identifier` to send Bob a private transaction. Since Alice does not know the corresponding `nsk`, she is spend the resulting private account. E.g., Alice cannot authorize the transaction. + - `PrivateAuthorizedInit` - Private account initialized using the account's `nsk` (and some `identifier`). + Private account initialized using the account's `nsk` (and some `identifier`). This operation cannot be done by the a third-party (an entity that does not possess spending authority of the account). ### Private account update (`PrivateAuthorizedUpdate`) -Regular private accounts are updated the same way. Knowledge of the account's `nsk` and other data that is used for the +Private account updates require knowledge of the account's `nsk`. E.g., Alice cannot update the private account that she initialized for Bob. ### Summary @@ -29,91 +33,104 @@ Regular private accounts are updated the same way. Knowledge of the account's `n Only the account owner can (1) update their initialized account, and (2) use functions that require authorization with their account. +### Remark +- `PrivateUnauthorized` initialization is used for account initialization. `is_authorized = false` is a protection that does not seem crucial. Artifically, blocks some functions. (TODO: return to and shift to conclusions) + ## Private PDA -### Private PDA vs public PDA -- `AccountId` formulas are different: +Private PDAs spending is restrict by a specific program. E.g., an AMM pool has PDAs for liquidity definition and vaults (for Token A and Token B). A program sets `is_authorized = true` for an account (purported PDA) by checking the correctness of its `AccountId`. + +- `AccountId` formulas: - Public: `hash(prefix || program_id || seed)` - Private: `hash(prefix || program_id || seed || npk || identifier)` The difference in these PDA `AccountId` formulas prevents programs from being privacy agnostic for PDAs. -## Group-shared (multi-party) private accounts (TODO) +## Group-shared (multi-party) private accounts A single private account can be jointly controlled by two or more parties without either one handing over their actual secret key. The mechanism is a **Group Master Secret (GMS)**, distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse: -1. One party ("Alice") creates a `GroupKeyHolder` and derives the shared account's `npk`/`vpk` - from it (`derive_keys_for_shared_account(seed)`). -2. Alice **seals** the GMS against a second party's ("Bob's") own sealing public key - (`seal_for`) and hands over only the sealed bytes. -3. Bob **unseals** it with his own sealing secret key (`GroupKeyHolder::unseal`), then - independently re-derives the *identical* `nsk`/`npk` from the same seed — without ever - touching Alice's `GroupKeyHolder` object directly. +1. Alice creates a `GroupKeyHolder` and derives the shared account's keys (`nsk`, `vsk`) + from it. +2. Alice **seals** the GMS against Bob's sealing public key and hands over only the sealed bytes. +3. Bob **unseals** it with his own sealing secret key, then + independently re-derives the account's keys from the same seed. -Bob's re-derived `nsk` then works in `PrivateAuthorizedInit`/`PrivateAuthorizedUpdate` exactly -like a personally-held key — confirmed indistinguishable from a personal account for every -instruction tried (spend, sign, self-initialize), across Token, ATA, and Stablecoin. +This ensures that any member of the group can execute programs on shared accounts using either `PrivateAuthorizedInit` or `PrivateAuthorizedUpdate`. From a program's perspective, shared accounts should behave the same as regular public accounts. -# Privacy testing objectives for LEZ programs (TODO) +# Privacy coverage for LEZ programs objectives (TODO) -- [ ] **Private PDAs used as program inputs across the above flows.** - **Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA, - AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private - formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an - `npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions - accept an `npk` today, so it's never reachable through these programs as coded. Confirmed - empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`); - the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only - pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at - that layer, not a gap. - *Re: "could we compose a test program that uses private PDAs with these pre-existing?"* — - no. None of the four existing programs can be made to produce a `for_private_pda` address - through a test alone, since the formula choice is hardcoded in their source. Demonstrating - the mechanism at all would require either changing one of the `*_core` crates to derive via - `for_private_pda`, or standing up a small purpose-built program whose only job is to - exercise it — both are source changes, not test-writing. **This is the single most - actionable item to feed back to the protocol team.** - -- Group owned shared private account as input to programs. - -- [x] **Sending funds to an existing private account.** - **Achieved, with one real condition: cooperation is required.** Confirmed across Token - (`Transfer`, `Mint`), ATA (`Transfer`, including through a nested chained call into - Token), and Stablecoin (`WithdrawCollateral`). Every path that touches an *existing* - private account (`PrivateAuthorizedUpdate`) requires that account's own `nsk` plus a - membership proof, supplied in the same transaction — there is no blind-credit analog to - `PrivateUnauthorized` for existing accounts (only *fresh* accounts can be credited by a - stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable, - real-world condition: the recipient must be reachable to supply their `nsk` (online or - pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an - untested edge. +In this task, we plan to add tests for e -- [~] **Multiple private accounts in one transaction, and private accounts carried through - chained calls.** This is two separate sub-objectives with different status — worth - splitting: - - **Multiple private accounts in one tx — Achieved.** `token_private_transfer` (sender + - recipient, both private, zero public accounts anywhere) and - `token_private_transfer_into_existing_private_holding` (same, recipient already - existing). - - **Carried through a chained call — Achieved, but only single-hop so far.** - `ata_transfer_to_existing_private_recipient` proves a private identity survives one - chained call (ATA → Token) — the first test in the whole exercise to prove this works - at all. Every private Stablecoin `WithdrawCollateral`/`RepayDebt` test also carries a - private account through exactly one chained call (Stablecoin → Token). **Not yet - tested:** deeper, multi-hop chaining — an instruction issuing more than one chained - call with a private account threaded through it (e.g. AMM's `SwapExactInput` chains - into *both* Token and the TWAP oracle in one instruction). That case is currently - unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see - the AMM section) before any chaining depth can even be exercised. So: not unclear — - genuinely proven for the single-hop case, with the deeper case blocked pending AMM. +| | description | | +|---------|----|----| +| PDA | +| REGULAR | +| EXIST | +| GROUP | +| CHAIN | +- Regular private accounts +- `PrivateUnauthorized` accounts; e.g., "transfer to existing accounts". +- Group shared private accounts +- Private PDAs. # LEZ programs (TODO) ## AMM program -TODO + +**Headline finding: no privacy-preserving test can be written for AMM's pool-mutating +instructions at all right now — not because of privacy, but a distinct circuit-level bug.** + +Before any private-account test, an all-public control test through `execute_and_prove` (same +discipline that caught Stablecoin's `OpenPosition` bug) turned up a second, unrelated +circuit-level issue specific to AMM: `SwapExactInput` fails inside `execute_and_prove` with +`"Invalid account_identities length"` — we supply 8 account identities, the circuit's +`states_iter` only computes 7 — with every account `Public` and zero private accounts involved. +The same pattern reproduces on `SyncReserves` (6 vs 5). The account that silently vanishes from +the circuit trace is `CLOCK_01_PROGRAM_ACCOUNT_ID` — present in the top-level input and in the +AMM program's own returned `post_states` (confirmed in `sync.rs`/`swap.rs` source), but never +seen by the circuit at any call depth. Root cause not yet found. + +Five tests confirm this **also blocks real private-account attempts**, not just the all-public +control case — `amm_swap_a_to_b_private_user_holding_is_not_expressible` and +`amm_swap_exact_output_private_user_holding_is_not_expressible` (private `user_holding_a`, 8 vs +7), `amm_add_liquidity_private_lp_holding_is_not_expressible` (private `user_holding_lp`, 10 vs +9), `amm_add_liquidity_private_user_holdings_is_not_expressible` (private `user_holding_a` + +`user_holding_b` deposit legs, 10 vs 9), `amm_remove_liquidity_private_lp_holding_is_not_expressible` +(private `user_holding_lp`, 10 vs 9) — all five fail with the identical +`"Invalid account_identities length"` panic, always exactly one account short. **Consequence**: +Swap (both variants), AddLiquidity, and RemoveLiquidity cannot be tested for any Q2 privacy +dimension until this circuit bug is fixed — every planned AMM privacy test is blocked on it. See +`docs/privacy-test-matrix.md`'s AMM section for the full bisection log. + +**⚠ To track down later — confirmed `clock` is the account that vanishes, root cause still +open**: instrumented tracing (`eprintln!`s in the pinned `lee_core` checkout's +`execution_state.rs`, exact `Display`-string matching against `CLOCK_01_PROGRAM_ACCOUNT_ID`) +confirmed the circuit's internal per-account processing (`states_iter`) never contains an entry +for `clock`, at any call depth — not the top-level AMM call, not even inside the TWAP +`UpdateCurrentTick` chained call, which itself explicitly re-passes `clock.clone()`. Ruled out a +coincidental `AccountId` collision. **Still unknown**: whether the entry is dropped inside the +AMM guest's own execution, inside the SPEL-macro-generated `#[lez_program]` wrapper code, or +inside the circuit's own bookkeeping before `validate_and_sync_states`'s per-account loop even +runs. **Next concrete step**: check whether `pre_states.len()`/`post_states.len()` already +differ from N/N *before* that loop runs — that single check localizes the bug to one side or the +other and was never executed before this investigation was paused. + +**A second, distinct finding for `RemoveLiquidity`, unrelated to the circuit bug above:** +`remove_liquidity` requires `user_holding_a`/`user_holding_b` to already exist and already be +owned by the configured Token Program (`remove.rs`'s +`assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`) — unlike +`token::transfer`'s recipient handling, which tolerates `Account::default()` and self-initializes +it. So `RemoveLiquidity` can never pay out to a brand-new private destination +(`PrivateUnauthorized` — only `npk` known, no `nsk`): the attempt +(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) fails inside the AMM +program's own precondition check, *before* any chained call or the privacy-preserving circuit is +ever reached — and would equally reject a brand-new *public* destination. Same shape of finding +as Stablecoin's `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`: +a plain program-level precondition that predates privacy entirely, not a circuit artifact. ## ATA program @@ -121,6 +138,7 @@ ATA program offers limited usage with private accounts. Private accounts can be | Function tested | Test name | Category | Description of objective | Result | |---|---|---|---|---| +| Create | `ata_create_from_private_owner` | BASE (private owner only; ATA account + definition public) | Any third party can bootstrap another owner's ATA using only that owner's public key material (`PrivateUnauthorized` — `npk`/`vpk` only, no `nsk`) — `Create` never asserts `owner.is_authorized` | ✅ | | Create | `ata_create_private_ata_holding_is_not_expressible` | PDA | Attempts to make the ATA holding itself a private account via `PrivatePdaInit`/`PrivatePdaUpdate` — confirms the public-form PDA match ATA authorizes with and the private-form binding those variants require are mutually exclusive for the same account id | ❌ (confirmed not-expressible) | | Create | `ata_create_from_group_owned_owner` | GROUP | Group-derived owner identity used to create an ATA — **weaker than the other `GROUP` rows**: `Create` never requires `owner` to prove control, so this can't demonstrate genuine shared control the way the `Transfer`/`Burn` rows below do; it only confirms `Create` doesn't secretly care where `npk`/`vpk` came from | ✅ (defensive/symmetry coverage only) | | Transfer | `ata_transfer_to_existing_private_recipient` | EXIST, CHAIN | Sends more into an already-shielded private recipient through ATA's *nested* chained call into Token — the first test in the whole exercise proving a private identity survives a chained call at all | ✅ | @@ -132,11 +150,13 @@ and `Burn` call the same `ata_core::verify_ata_and_get_seed` function, so the id public-form/private-form conflict applies to them too, even though only `Create` has a dedicated test asserting it. -Two tests exist outside this table's four categories and are worth noting separately: -`ata_burn_with_private_owner_signing` and `ata_transfer_with_private_owner_signing` (a -*personal*, non-group private owner signing `Burn`/`Transfer`). They were the key discovery that -`owner` must be a *signer* for these two instructions (unlike `Create`) — a real finding, just -not one of the four Q2 checkboxes, so it's omitted here the same way Token's `BASE` rows were. +Two tests exist outside this table's categories (not `PDA`/`GROUP`/`EXIST`/`CHAIN`, and not +`BASE` either — tagged `new: signer-authorization` in `docs/privacy-test-matrix.md`) and are +worth noting separately: `ata_burn_with_private_owner_signing` and +`ata_transfer_with_private_owner_signing` (a *personal*, non-group private owner signing +`Burn`/`Transfer`). They were the key discovery that `owner` must be a *signer* for these two +instructions (unlike `Create`) — a real finding, just a distinct dimension from any tag used +elsewhere in this table. ## Stablecoin program @@ -210,13 +230,21 @@ destination. It's why every `WithdrawCollateral` test above uses `PrivateAuthori | Function tested | Test name | Category | Description of objective | Result | |---|---|---|---|---| -| Transfer | `token_transfer_into_existing_private_holding` | EXIST | Second transfer into an already-shielded recipient — confirms crediting an existing private account requires the recipient's own cooperation (`nsk`), not just their public key | ✅ | -| Transfer | `token_private_transfer_into_existing_private_holding` | EXIST, CHAIN | Both legs private (sender + recipient) in one transaction, and the recipient is already existing rather than fresh | ✅ | -| Transfer | `token_group_owned_holding_shared_control_transfer` | GROUP | Group-owned sender (real GMS seal/unseal handshake) spends outward via `Transfer` to a fresh private recipient | ✅ | -| Transfer | `token_private_transfer` | CHAIN | Pre-existing test; two private accounts (sender + fresh recipient) compose in a single transaction with no public account at all — fulfills the "multiple private accounts in one tx" half of `CHAIN` | ✅ | -| Mint | `token_mint_into_existing_private_holding` | EXIST | Mint once to establish a private holding, mint again into it via `PrivateAuthorizedUpdate` — crediting an existing private account | ✅ | +| Transfer | `token_shielded_transfer` | EXIST | A public sender shields tokens into a fresh private recipient (`PrivateUnauthorized` — only `npk`/`vpk` known, no `nsk`) | ✅ | +| Transfer | `token_private_transfer` | REGULAR -> EXIST | Two private accounts (sender via `PrivateAuthorizedUpdate` + fresh recipient via `PrivateUnauthorized`) compose in a single transaction with no public account at all — fulfills the "multiple private accounts in one tx" | ✅ | +| Transfer | `token_deshielded_transfer` | REGULAR | A private sender (`PrivateAuthorizedUpdate`) transfers out to a public recipient | ✅ | +| Transfer | `token_shielded_transfer_authorized_private_init` | REGULAR | Fresh recipient self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) instead of being passively credited via `PrivateUnauthorized` | ✅ | +| Transfer | `token_transfer_into_existing_private_holding` | REGULAR | Similar to `token_shielded_transfer_authorized_private_init`, but this shielded transaction does not initialize the private account. Second transfer into an already-shielded recipient — confirms crediting an existing private account requires the recipient's own cooperation (`nsk`), not just their public key | ✅ | +| Transfer | `token_private_transfer_into_existing_private_holding` | REGULAR -> REGULAR | Both legs private (sender + recipient) in one transaction, and the recipient is already existing rather than fresh | ✅ | +| Transfer | `token_group_owned_holding_shared_control_transfer` | GROUP -> EXIST | Group-owned sender (real GMS seal/unseal handshake) spends outward via `Transfer` to a fresh private recipient (`PrivateUnauthorized`) | ✅ | +| Mint | `token_mint_private_unauthorized` | EXIST | Mint directly to a fresh private recipient (self-authority signer + `PrivateUnauthorized` recipient) | ✅ | +| Mint | `token_mint_authorized_private_init` | REGULAR (authorized variant) | Mint to a fresh recipient that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) instead of being passively credited | ✅ | +| Mint | `token_mint_into_existing_private_holding` | REGULAR | Mint once to establish a private holding, mint again into it via `PrivateAuthorizedUpdate` — crediting an existing private account | ✅ | +| Burn | `token_private_burn` | REGULAR | Burn from an existing private holding via a single `PrivateAuthorizedUpdate` | ✅ | | Burn | `token_group_owned_holding_shared_control_burn` | GROUP | Shield tokens into a GMS-derived shared holding, then burn from it using an independently re-derived key | ✅ | +| InitializeAccount | `token_initialize_private_account_succeeds_for_canonical_definition` | REGULAR | Self-init of a private holding via `PrivateAuthorizedInit` | ✅ | | InitializeAccount | `token_group_owned_holding_shared_control_initialize` | GROUP | A group member — not the party who created the group — self-initializes the shared holding directly via `PrivateAuthorizedInit` | ✅ | +| MintWithAuthority | `token_mint_with_authority_to_private_holding` | EXIST | External-authority mint (distinct signer from the definition) directly to a fresh private recipient | ✅ | **`PDA`** has no Token-layer rows: Token holdings are addressed by an arbitrary `AccountId`, not a program-derived one — there's no PDA to make private at this layer. Only testable once a @@ -226,6 +254,18 @@ holding is wrapped by another program's PDA (ATA/AMM/Stablecoin). `ChainedCall`s of its own (only ATA/AMM/Stablecoin do) — that half is exercised for the first time in the ATA section instead. +| | coverage? | explanation | +|----|---------|----------------| +| REGULAR | full | REGULAR private accounts are used as sender/recipient for initialize, transfer, mint and burn | +| GROUP | full | Tested with initialize, transfer, mint and burn | +| EXIST | partial | EXIST (`PrivateUnauthorized`) cannot be used with initialize due to `is_authorize = false` | +| PDA | N/A | Token program does not use PDAs | + + + + + + # Conclusions ## Group shared private accounts @@ -233,4 +273,59 @@ time in the ATA section instead. # Observations - Programs can be made privacy agnostic for PDAs by adjusting private PDA `AccountId` formula to match the public variant. Unclear how to precisely handle this to ensure `AMM program` generates unique pools for token pairs (in public PDA case). -- A private PDA can be initialized and used for a program without using traditional PDA lifecycle. E.g., TODO(provide example from `token.rs`) \ No newline at end of file +- A private PDA can be initialized and used for a program without using traditional PDA lifecycle. E.g., TODO(provide example from `token.rs`) + +# TODO + +- [ ] **Private PDAs used as program inputs across the above flows.** + + **Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA, + AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private + formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an + `npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions + accept an `npk` today, so it's never reachable through these programs as coded. Confirmed + empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`); + the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only + pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at + that layer, not a gap. + *Re: "could we compose a test program that uses private PDAs with these pre-existing?"* — + no. None of the four existing programs can be made to produce a `for_private_pda` address + through a test alone, since the formula choice is hardcoded in their source. Demonstrating + the mechanism at all would require either changing one of the `*_core` crates to derive via + `for_private_pda`, or standing up a small purpose-built program whose only job is to + exercise it — both are source changes, not test-writing. **This is the single most + actionable item to feed back to the protocol team.** + +- Group owned shared private account as input to programs. + +- [x] **Sending funds to an existing private account.** + **Achieved, with one real condition: cooperation is required.** Confirmed across Token + (`Transfer`, `Mint`), ATA (`Transfer`, including through a nested chained call into + Token), and Stablecoin (`WithdrawCollateral`). Every path that touches an *existing* + private account (`PrivateAuthorizedUpdate`) requires that account's own `nsk` plus a + membership proof, supplied in the same transaction — there is no blind-credit analog to + `PrivateUnauthorized` for existing accounts (only *fresh* accounts can be credited by a + stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable, + real-world condition: the recipient must be reachable to supply their `nsk` (online or + pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an + untested edge. + +- [~] **Multiple private accounts in one transaction, and private accounts carried through + chained calls.** This is two separate sub-objectives with different status — worth + splitting: + - **Multiple private accounts in one tx — Achieved.** `token_private_transfer` (sender + + recipient, both private, zero public accounts anywhere) and + `token_private_transfer_into_existing_private_holding` (same, recipient already + existing). + - **Carried through a chained call — Achieved, but only single-hop so far.** + `ata_transfer_to_existing_private_recipient` proves a private identity survives one + chained call (ATA → Token) — the first test in the whole exercise to prove this works + at all. Every private Stablecoin `WithdrawCollateral`/`RepayDebt` test also carries a + private account through exactly one chained call (Stablecoin → Token). **Not yet + tested:** deeper, multi-hop chaining — an instruction issuing more than one chained + call with a private account threaded through it (e.g. AMM's `SwapExactInput` chains + into *both* Token and the TWAP oracle in one instruction). That case is currently + unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see + the AMM section) before any chaining depth can even be exercised. So: not unclear — + genuinely proven for the single-hop case, with the deeper case blocked pending AMM. + diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index 64d49364..a3e369a3 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -402,44 +402,95 @@ computes). Confirmed with every account `Public`. actual private account, or use `PublicTransaction` instead) — not a bug, but worth noting: **the "all-public control" methodology needs at least one trivial private leg to get past this check for future control tests**, not just all-`Public` identities. -- **Leading structural lead, not yet confirmed**: every AMM instruction that hits the length +- **Leading structural lead, superseded below**: every AMM instruction that hits the length mismatch passes a *post-update* copy of `pool` (`pool_price_source`, holding `pool_post` — the already-mutated state, not the original pre-state) into its chained TWAP call. This "pass what's about to become the post-state as the next call's own pre-state" pattern is proven correct on the public-transaction path (33 passing tests) but nothing in - Token/ATA/Stablecoin ever exercised it under the privacy circuit. Not yet confirmed as *the* - cause — only the clearest outlier found. - -**Why this wasn't root-caused further**: attempted source-level instrumentation -(`eprintln!` tracing added directly to the pinned `lee_core` checkout's `execution_state.rs`) -to watch the exact bookkeeping live. Confirmed `lee`/`lee_core` genuinely recompiled -(`cargo clean -p lee -p lee_core` + fresh compile logs), but the added prints never -surfaced, while the original panic still fired from the same file/line. This means the actual -executed code path isn't rebuilt by a normal `cargo clean`/`cargo test` cycle — almost -certainly because real guest execution runs a separately cross-compiled RISC-V ELF -(`risc0_build::embed_methods!`), which per this repo's own `CLAUDE.md` needs the Docker-based -`make build-programs` pipeline to rebuild, not plain cargo. Instrumentation was cleanly -reverted (`git status` clean in the checkout; all 52 other tests reconfirmed passing -afterward) rather than sunk further into standing up that Docker toolchain just for tracing. - -**Next step when this is picked back up**: either (a) stand up the guest-rebuild pipeline to -finish the trace, or (b) construct a minimal synthetic instruction (not part of the real AMM -program) that isolates the "post-state passed as next call's pre-state" pattern alone, without -needing to modify any pinned dependency. + Token/ATA/Stablecoin ever exercised it under the privacy circuit. This was the leading lead + at the time, but is likely **not** the real cause — see the more precise finding below, which + identifies the specific missing account directly. +- **Precisely identified the missing account (2026-07-13)**: instrumented `execution_state.rs`'s + per-account loop in `validate_and_sync_states` with `eprintln!` tracing (see below for how this + was made to actually take effect) and confirmed via exact string-level `AccountId` matching + that `CLOCK_01_PROGRAM_ACCOUNT_ID` is the account that vanishes — it's supplied as a top-level + input and is clearly present in the AMM program's own returned `post_states` (confirmed + directly in `sync.rs`'s `sync_reserves` and `swap.rs`'s `finalize_swap`, both of which + explicitly include `AccountPostState::new(clock.account...)`), yet it never appears in the + circuit-level trace at any call depth, not even inside the TWAP chained call which also + explicitly passes `clock.clone()`. Root cause of *why* it's dropped is still not found — the + next diagnostic step (checking whether `pre_states.len()`/`post_states.len()` already differ + from 8/8 before the per-account validation loop runs, which would localize the drop to either + the AMM guest/SPEL-macro layer or the circuit's own processing) was planned but not executed. + Instrumentation was fully reverted afterward (verified byte-identical to the original checkout + and original artifact) rather than left in place. +- **Confirmed this also blocks real private-account attempts, not just the all-public control + case (2026-07-13)**: three tests — `amm_swap_a_to_b_private_user_holding_is_not_expressible` + (private `user_holding_a`, 8 vs 7 accounts), `amm_add_liquidity_private_lp_holding_is_not_expressible` + (private `user_holding_lp`, 10 vs 9), `amm_remove_liquidity_private_lp_holding_is_not_expressible` + (private `user_holding_lp`, 10 vs 9) — all fail with the identical + `"Invalid account_identities length"` panic, always exactly one account short. This rules out + "the bug only manifests because there are zero private accounts" as an explanation; it's a + structural property of these instructions' account/chained-call shape, independent of privacy + entirely. + +**How the instrumentation was made to actually take effect (2026-07-08 attempt failed, 2026-07-13 +attempt succeeded)**: `eprintln!` tracing added directly to the pinned `lee_core` checkout's +`execution_state.rs` first appeared to have no effect — prints never surfaced, and the original +panic kept firing from the same file/line even after `cargo clean -p lee -p lee_core` and a fresh +compile. Root cause: real guest execution runs a separately cross-compiled RISC-V ELF +(`risc0_build::embed_methods!`), and the pinned `PRIVACY_PRESERVING_CIRCUIT_ELF` artifact is a +**pre-built, checked-in binary** (`artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin` +in the checkout) embedded via `build_utils::include_artifacts` — editing the `.rs` source alone +never touches that binary. Fix: rebuild the guest ELF directly with +`cargo risczero build -p privacy_preserving_circuit_program --manifest-path /Cargo.toml` +(matching the checkout's own `Justfile` `build-artifacts` recipe) and copy the result over the +checked-in `.bin` — **plus** `cargo clean -p lee -p lee_core` again afterward, since +`cargo:rerun-if-changed` was scoped to the artifacts *directory*, and overwriting a file's +content in place doesn't change the directory's own mtime, so cargo's incremental build silently +kept using the old compiled rlib (with the old bytes baked in via `include_bytes!`) even after +the file swap. Once both steps were done, the `eprintln!` output finally appeared and led +directly to the `CLOCK_01_PROGRAM_ACCOUNT_ID` finding above. All instrumentation (source edits, +rebuilt artifact) was fully reverted afterward and verified byte-identical to the original. + +**Next step when this is picked back up**: check whether `output_pre_states.len()`/ +`output_post_states.len()` already differ from 8/8 (or 6/6, etc.) *before* the per-account +validation loop in `validate_and_sync_states` runs — that would localize the drop to either the +AMM guest/SPEL-macro layer or the circuit's own processing, and is the next concrete step now +that instrumentation is confirmed to work end-to-end. ### Existing -0 private tests out of 33 public. (No private test-writing attempted yet — blocked above.) +6 private tests out of 33 pre-existing public + 6 = 39. No test can yet demonstrate an +actually-working AMM privacy path — five exist purely to confirm the circuit bug also blocks +real private accounts (not just the all-public control case), and one +(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) found a second, distinct, +earlier blocker specific to `RemoveLiquidity`. + +**Second finding, unrelated to the circuit bug (2026-07-13)**: `remove_liquidity` requires +`user_holding_a`/`user_holding_b` to already exist and already be owned by the configured Token +Program (`remove.rs`'s `assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`) +— unlike `token::transfer`'s recipient handling, which tolerates `Account::default()` and +self-initializes it. So `RemoveLiquidity` can never pay out to a brand-new private destination +(`PrivateUnauthorized` — only `npk` known, no `nsk`, the pattern +`token_mint_shielded_to_private_unauthorized` uses): the attempt +(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) fails inside the AMM +program's own precondition check (`"User Token A holding must be owned by the configured Token +Program"`), *before* any chained call or the privacy-preserving circuit is ever reached — and +would equally reject a brand-new *public* destination. Same shape of finding as Stablecoin's +`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`: a plain +program-level precondition that predates privacy entirely, not a circuit artifact. ### Planned | Instruction | Dimension | Test | Priority | Depends on | Status | |---|---|---|---|---|---| -| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | -| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | -| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | -| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | **Blocked** — see above | -| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — private `user_holding_a`, fails identically to the all-public control (8 vs 7 accounts) | +| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — identical 8-account/chained-call shape to `SwapExactInput`, fails identically (8 vs 7 accounts) | +| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings_is_not_expressible` — private deposit legs (`user_holding_a`/`user_holding_b`) | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — fails identically (10 vs 9 accounts) | +| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding_is_not_expressible` — private LP output holding | P1 | Token | **Confirmed not-expressible** — private `user_holding_lp`, fails identically (10 vs 9 accounts) | +| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — private `user_holding_lp`, fails identically (10 vs 9 accounts) | +| RemoveLiquidity | `EXIST` (negative) | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` — brand-new `PrivateUnauthorized` token A/B destinations | P1 | Token | **Confirmed not-expressible for a different reason** — AMM's own precondition requires the destination to already be owned by the Token Program; fails before the circuit bug is even reached | | Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | **Blocked** — see above | | NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | **Blocked** — see above (also issues chained calls reusing `pool`-derived accounts; check on resolution) | | Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started (also behind the blocker above) | @@ -585,5 +636,5 @@ heavier (chained calls, multiple accounts) than a single shield. |---|---|---|---| | Token | 16 (3 pre-existing + 13 new: 12 pass + 1 confirmed not-expressible by design) — phase complete | 0 | 5 | | ATA | 8 (7 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | -| AMM | 0 (2 rows now predicted not-expressible pending confirmation) | 10 | 5 | +| AMM | 6 (all confirmed not-expressible: Swap/SwapExactOutput/AddLiquidity (both LP and deposit legs)/RemoveLiquidity blocked by the same circuit bug, plus RemoveLiquidity's separate new-destination precondition; 2 further rows predicted not-expressible pending confirmation via the `PDA` finding) | 5 | 5 | | Stablecoin | 7 (5 pass + 2 confirmed not-expressible — phase complete) | 0 | 1 | diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index af711aa7..1110caf6 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -3,23 +3,84 @@ reason = "integration fixtures use fixed balances to assert AMM state transitions" )] +use std::collections::HashMap; + use amm_core::{ PoolDefinition, FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use integration_tests::{private_authorized_update_identity, private_unauthorized_identity}; use nssa::{ error::LeeError, + execute_and_prove, + privacy_preserving_transaction::circuit::ProgramWithDependencies, + program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, }; -use nssa_core::account::{Account, AccountId, Data, Nonce}; +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + encryption::ViewingPublicKey, + Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, +}; use token_core::{TokenDefinition, TokenHolding}; struct Keys; struct Ids; struct Balances; struct Accounts; +struct PrivateKeys; + +impl PrivateKeys { + fn user_a_nsk() -> NullifierSecretKey { + [161; 32] + } + + fn user_a_npk() -> NullifierPublicKey { + NullifierPublicKey::from(&Self::user_a_nsk()) + } + + fn user_a_vpk() -> ViewingPublicKey { + ViewingPublicKey::from_seed(&[171; 32], &[172; 32]) + } + + fn user_a_id() -> AccountId { + AccountId::for_regular_private_account(&Self::user_a_npk(), 0) + } + + fn user_lp_nsk() -> NullifierSecretKey { + [162; 32] + } + + fn user_lp_npk() -> NullifierPublicKey { + NullifierPublicKey::from(&Self::user_lp_nsk()) + } + + fn user_lp_vpk() -> ViewingPublicKey { + ViewingPublicKey::from_seed(&[173; 32], &[174; 32]) + } + + fn user_lp_id() -> AccountId { + AccountId::for_regular_private_account(&Self::user_lp_npk(), 0) + } + + fn user_b_nsk() -> NullifierSecretKey { + [163; 32] + } + + fn user_b_npk() -> NullifierPublicKey { + NullifierPublicKey::from(&Self::user_b_nsk()) + } + + fn user_b_vpk() -> ViewingPublicKey { + ViewingPublicKey::from_seed(&[175; 32], &[176; 32]) + } + + fn user_b_id() -> AccountId { + AccountId::for_regular_private_account(&Self::user_b_npk(), 0) + } +} impl Keys { fn user_a() -> PrivateKey { @@ -3038,3 +3099,669 @@ fn amm_add_liquidity_after_fee_accrual() { 6_437 ); } + +fn amm_program_instance() -> Program { + Program::new(amm_methods::AMM_ELF.to_vec().into()).expect("valid amm ELF") +} + +fn token_program_instance() -> Program { + Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF") +} + +fn twap_oracle_program_instance() -> Program { + Program::new(twap_oracle_methods::TWAP_ORACLE_ELF.to_vec().into()).expect("valid twap oracle ELF") +} + +fn amm_with_deps() -> ProgramWithDependencies { + ProgramWithDependencies::new( + amm_program_instance(), + HashMap::from([ + (Ids::token_program(), token_program_instance()), + (Ids::twap_oracle_program(), twap_oracle_program_instance()), + ]), + ) +} + +// Marvin-todo +/// Confirms the AMM circuit-level `"Invalid account_identities length"` bug (bisected with an +/// all-`Public` control case in `docs/privacy-test-matrix.md`'s AMM section) also fires when a +/// real private account is involved in `SwapExactInput`, not just the all-public control shape — +/// i.e. this isn't an artifact of using zero private accounts, the bug blocks a genuine private +/// swap identically. +#[test] +fn amm_swap_a_to_b_private_user_holding_is_not_expressible() { + let mut state = state_for_amm_tests(); + + let user_a_nsk = PrivateKeys::user_a_nsk(); + let user_a_vpk = PrivateKeys::user_a_vpk(); + let user_a_id = PrivateKeys::user_a_id(); + let user_a_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_a_definition(), + balance: Balances::user_a_init(), + }), + nonce: Nonce::private_account_nonce_init(&user_a_id), + }; + state = state.with_private_accounts([( + Commitment::new(&user_a_id, &user_a_account), + Nullifier::for_account_initialization(&user_a_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account)) + .expect("user_a's commitment must be in the set"); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); + let vault_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::SwapExactInput { + swap_amount_in: Balances::swap_amount_in(), + min_amount_out: Balances::swap_min_out(), + token_definition_id_in: Ids::token_a_definition(), + deadline: u64::MAX, + }; + + let result = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + user_a_pre, + user_b_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_a_nsk, &user_a_vpk, membership_proof, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ); + + let err = result.expect_err( + "SwapExactInput must be rejected by the privacy-preserving circuit: the same \ + 'Invalid account_identities length' bug confirmed with an all-public control case \ + also fires with a real private user holding", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Invalid account_identities length"), + "expected the known circuit-level length-mismatch bug, got a different error: {message}" + ); +} + +// Marvin-todo +/// Same confirmation as `amm_swap_a_to_b_private_user_holding_is_not_expressible`, for +/// `SwapExactOutput` — identical 8-account/chained-call shape to `SwapExactInput`, so the same +/// circuit-level bug is expected to fire identically. +#[test] +fn amm_swap_exact_output_private_user_holding_is_not_expressible() { + let mut state = state_for_amm_tests(); + + let user_a_nsk = PrivateKeys::user_a_nsk(); + let user_a_vpk = PrivateKeys::user_a_vpk(); + let user_a_id = PrivateKeys::user_a_id(); + let user_a_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_a_definition(), + balance: Balances::user_a_init(), + }), + nonce: Nonce::private_account_nonce_init(&user_a_id), + }; + state = state.with_private_accounts([( + Commitment::new(&user_a_id, &user_a_account), + Nullifier::for_account_initialization(&user_a_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account)) + .expect("user_a's commitment must be in the set"); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); + let vault_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::SwapExactOutput { + exact_amount_out: Balances::swap_min_out(), + max_amount_in: Balances::swap_amount_in(), + token_definition_id_in: Ids::token_a_definition(), + deadline: u64::MAX, + }; + + let result = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + user_a_pre, + user_b_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_a_nsk, &user_a_vpk, membership_proof, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ); + + let err = result.expect_err( + "SwapExactOutput must be rejected by the privacy-preserving circuit: the same \ + 'Invalid account_identities length' bug also fires with a real private user holding", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Invalid account_identities length"), + "expected the known circuit-level length-mismatch bug, got a different error: {message}" + ); +} + +// Marvin-todo +/// Same confirmation as `amm_swap_a_to_b_private_user_holding_is_not_expressible`, for +/// `AddLiquidity` with a private LP-output holding. +#[test] +fn amm_add_liquidity_private_lp_holding_is_not_expressible() { + let mut state = state_for_amm_tests(); + + let user_lp_nsk = PrivateKeys::user_lp_nsk(); + let user_lp_vpk = PrivateKeys::user_lp_vpk(); + let user_lp_id = PrivateKeys::user_lp_id(); + let user_lp_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance: 500, + }), + nonce: Nonce::private_account_nonce_init(&user_lp_id), + }; + state = state.with_private_accounts([( + Commitment::new(&user_lp_id, &user_lp_account), + Nullifier::for_account_initialization(&user_lp_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account)) + .expect("user_lp's commitment must be in the set"); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); + let vault_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let user_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), true, Ids::user_a()); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); + let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::AddLiquidity { + min_amount_liquidity: Balances::add_min_lp(), + max_amount_to_add_token_a: Balances::add_max_a(), + max_amount_to_add_token_b: Balances::add_max_b(), + deadline: u64::MAX, + }; + + let result = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ); + + let err = result.expect_err( + "AddLiquidity must be rejected by the privacy-preserving circuit: the same \ + 'Invalid account_identities length' bug also fires with a real private LP holding", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Invalid account_identities length"), + "expected the known circuit-level length-mismatch bug, got a different error: {message}" + ); +} + +// Marvin-todo +/// Same confirmation as the two tests above, for `RemoveLiquidity` with a private LP holding +/// (the account that signs/burns to remove liquidity). +#[test] +fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { + let mut state = state_for_amm_tests(); + + let user_lp_nsk = PrivateKeys::user_lp_nsk(); + let user_lp_vpk = PrivateKeys::user_lp_vpk(); + let user_lp_id = PrivateKeys::user_lp_id(); + let user_lp_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance: Balances::remove_lp(), + }), + nonce: Nonce::private_account_nonce_init(&user_lp_id), + }; + state = state.with_private_accounts([( + Commitment::new(&user_lp_id, &user_lp_account), + Nullifier::for_account_initialization(&user_lp_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account)) + .expect("user_lp's commitment must be in the set"); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); + let vault_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let user_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), false, Ids::user_a()); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), false, Ids::user_b()); + let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::RemoveLiquidity { + remove_liquidity_amount: Balances::remove_lp(), + min_amount_to_remove_token_a: Balances::remove_min_a(), + min_amount_to_remove_token_b: Balances::remove_min_b(), + deadline: u64::MAX, + }; + + let result = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ); + + let err = result.expect_err( + "RemoveLiquidity must be rejected by the privacy-preserving circuit: the same \ + 'Invalid account_identities length' bug also fires with a real private LP holding", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Invalid account_identities length"), + "expected the known circuit-level length-mismatch bug, got a different error: {message}" + ); +} + +// Marvin-todo +/// A distinct, earlier finding from `amm_remove_liquidity_private_lp_holding_is_not_expressible`: +/// `remove_liquidity` requires `user_holding_a`/`user_holding_b` to already exist and already be +/// owned by the configured Token Program (`remove.rs`'s +/// `assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`), unlike +/// `token::transfer`'s recipient handling (which tolerates `Account::default()` and +/// self-initializes it). So `RemoveLiquidity` can never pay out to a brand-new private +/// destination (`PrivateUnauthorized` — only `npk`/identifier known, no `nsk`, matching how +/// `token_mint_shielded_to_private_unauthorized` credits a fresh private account it doesn't +/// control) — this fails inside the AMM guest's own precondition check, before any chained call +/// or the privacy-preserving circuit is ever reached, and would equally reject a brand-new +/// *public* destination. Same shape of finding as Stablecoin's +/// `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. +#[test] +fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { + let state = state_for_amm_tests(); + + let user_a_npk = PrivateKeys::user_a_npk(); + let user_a_vpk = PrivateKeys::user_a_vpk(); + let user_a_id = PrivateKeys::user_a_id(); + let user_b_npk = PrivateKeys::user_b_npk(); + let user_b_vpk = PrivateKeys::user_b_vpk(); + let user_b_id = PrivateKeys::user_b_id(); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); + let vault_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let user_a_pre = AccountWithMetadata::new(Account::default(), false, user_a_id); + let user_b_pre = AccountWithMetadata::new(Account::default(), false, user_b_id); + let user_lp_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_lp()), true, Ids::user_lp()); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::RemoveLiquidity { + remove_liquidity_amount: Balances::remove_lp(), + min_amount_to_remove_token_a: Balances::remove_min_a(), + min_amount_to_remove_token_b: Balances::remove_min_b(), + deadline: u64::MAX, + }; + + let result = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_unauthorized_identity(user_a_npk, &user_a_vpk, 0), + private_unauthorized_identity(user_b_npk, &user_b_vpk, 1), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ); + + let err = result.expect_err( + "RemoveLiquidity must be rejected by the AMM program itself: user_holding_a/b must \ + already be initialized and owned by the configured Token Program before any chained \ + call or the privacy-preserving circuit is ever reached", + ); + let message = format!("{err:?}"); + assert!( + message.contains("User Token A holding must be owned by the configured Token Program"), + "expected the AMM program's own initialized-destination precondition, got a different \ + error: {message}" + ); +} + +// Marvin-todo +/// Same confirmation as `amm_add_liquidity_private_lp_holding_is_not_expressible`, but for the +/// deposit side instead of the LP-mint side: `user_holding_a`/`user_holding_b` (the accounts +/// debited to fund the deposit) are existing private holdings (`PrivateAuthorizedUpdate` — `nsk` +/// known, matching how `swap`'s deposit leg is tested), while `user_holding_lp` (the mint +/// destination) stays public, as in the base `amm_add_liquidity` test. Expected to hit the same +/// circuit-level `"Invalid account_identities length"` bug regardless of which accounts are +/// private. +#[test] +fn amm_add_liquidity_private_user_holdings_is_not_expressible() { + let mut state = state_for_amm_tests(); + + let user_a_nsk = PrivateKeys::user_a_nsk(); + let user_a_vpk = PrivateKeys::user_a_vpk(); + let user_a_id = PrivateKeys::user_a_id(); + let user_a_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_a_definition(), + balance: Balances::user_a_init(), + }), + nonce: Nonce::private_account_nonce_init(&user_a_id), + }; + + let user_b_nsk = PrivateKeys::user_b_nsk(); + let user_b_vpk = PrivateKeys::user_b_vpk(); + let user_b_id = PrivateKeys::user_b_id(); + let user_b_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_b_definition(), + balance: Balances::user_b_init(), + }), + nonce: Nonce::private_account_nonce_init(&user_b_id), + }; + + state = state.with_private_accounts([ + ( + Commitment::new(&user_a_id, &user_a_account), + Nullifier::for_account_initialization(&user_a_id), + ), + ( + Commitment::new(&user_b_id, &user_b_account), + Nullifier::for_account_initialization(&user_b_id), + ), + ]); + let user_a_membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account)) + .expect("user_a's commitment must be in the set"); + let user_b_membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_b_id, &user_b_account)) + .expect("user_b's commitment must be in the set"); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); + let vault_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); + let user_b_pre = AccountWithMetadata::new(user_b_account, true, user_b_id); + let user_lp_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_lp()), false, Ids::user_lp()); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::AddLiquidity { + min_amount_liquidity: Balances::add_min_lp(), + max_amount_to_add_token_a: Balances::add_max_a(), + max_amount_to_add_token_b: Balances::add_max_b(), + deadline: u64::MAX, + }; + + let result = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_a_nsk, &user_a_vpk, user_a_membership_proof, 0), + private_authorized_update_identity(user_b_nsk, &user_b_vpk, user_b_membership_proof, 1), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ); + + let err = result.expect_err( + "AddLiquidity must be rejected by the privacy-preserving circuit: the same \ + 'Invalid account_identities length' bug also fires with real private deposit holdings", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Invalid account_identities length"), + "expected the known circuit-level length-mismatch bug, got a different error: {message}" + ); +} diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index c39c0cfe..8e82003e 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -678,17 +678,21 @@ fn token_shield_into_private_pda_via_external_seed() { /// `Ids::holder()` to a new private account keyed by `PrivateKeys::recipient_*`. /// Returns the resulting private recipient account. #[cfg(test)] -fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { +fn shielded_token_transfer( + amount: u128, + state: &mut V03State, + recipient_is_authorized: bool, + recipient_identity: InputAccountIdentity, +) -> Account { let sender_id = Ids::holder(); let sender_account = state.get_account_by_id(sender_id); let sender_nonce = sender_account.nonce; - let recipient_npk = PrivateKeys::recipient_npk(); - let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); let sender = AccountWithMetadata::new(sender_account, true, sender_id); - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); + let recipient = + AccountWithMetadata::new(Account::default(), recipient_is_authorized, recipient_id); let instruction = token_core::Instruction::Transfer { amount_to_transfer: amount, @@ -696,10 +700,7 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { let (output, proof) = execute_and_prove( vec![sender, recipient], Program::serialize_instruction(instruction).unwrap(), - vec![ - InputAccountIdentity::Public, - private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), - ], + vec![InputAccountIdentity::Public, recipient_identity], &token_program().into(), ) .unwrap(); @@ -721,7 +722,14 @@ fn token_shielded_transfer() { let mut state = state_for_token_tests(); let amount = 500_000_u128; - let recipient_account = shielded_token_transfer(amount, &mut state); + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_account = shielded_token_transfer( + amount, + &mut state, + false, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), + ); assert_eq!( state.get_account_by_id(Ids::holder()), @@ -741,49 +749,25 @@ fn token_shielded_transfer_authorized_private_init() { let mut state = state_for_token_tests(); let amount = 500_000_u128; - let sender_id = Ids::holder(); - let sender_account = state.get_account_by_id(sender_id); - let sender_nonce = sender_account.nonce; - - let recipient_nsk = PrivateKeys::recipient_nsk(); - let recipient_vpk = PrivateKeys::recipient_vpk(); - let recipient_id = PrivateKeys::recipient_id(); - - let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); - let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id); - - let instruction = token_core::Instruction::Transfer { - amount_to_transfer: amount, - }; - let (output, proof) = execute_and_prove( - vec![sender_pre, recipient_pre], - Program::serialize_instruction(instruction).unwrap(), - vec![ - InputAccountIdentity::Public, - private_authorized_init_identity(recipient_nsk, &recipient_vpk, 0), - ], - &token_program().into(), - ) - .unwrap(); - - let message = - Message::try_from_circuit_output(vec![sender_id], vec![sender_nonce], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::holder_key()]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - state - .transition_from_privacy_preserving_transaction(&tx, 0, 0) - .unwrap(); + let recipient_account = shielded_token_transfer( + amount, + &mut state, + true, + private_authorized_init_identity( + PrivateKeys::recipient_nsk(), + &PrivateKeys::recipient_vpk(), + 0, + ), + ); assert_eq!( - state.get_account_by_id(sender_id), + state.get_account_by_id(Ids::holder()), Accounts::token_holding(1_000_000 - amount, Nonce(1)) ); - let recipient_account = - Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id)); + let recipient_commitment = Commitment::new(&PrivateKeys::recipient_id(), &recipient_account); assert!(state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .get_proof_for_commitment(&recipient_commitment) .is_some()); } @@ -794,7 +778,12 @@ fn token_private_transfer() { let transfer_amount = 200_000_u128; // Shield tokens into a private account (becomes the sender for the private transfer). - let sender_account = shielded_token_transfer(shielded_amount, &mut state); + let sender_account = shielded_token_transfer( + shielded_amount, + &mut state, + false, + private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0), + ); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); @@ -859,7 +848,12 @@ fn token_deshielded_transfer() { let deshield_amount = 300_000_u128; // Shield tokens into a private account, then deshield some back to a public account. - let sender_account = shielded_token_transfer(shielded_amount, &mut state); + let sender_account = shielded_token_transfer( + shielded_amount, + &mut state, + false, + private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0), + ); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); @@ -917,7 +911,7 @@ fn token_deshielded_transfer() { /// Mints directly to a new recipient private holding (`PrivateUnauthorized`). /// The recipient's cooperation is unnecessary; only known of the recipient's `npk`, `vpk`. #[test] -fn token_mint_shielded_to_private_unauthorized() { +fn token_mint_private_unauthorized() { let mut state = state_for_token_tests_without_recipient(); let amount_to_mint = 500_000_u128; From 0117e5b7544a6028fdd735616cd64f07b83e8bfd Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Tue, 14 Jul 2026 17:26:56 -0400 Subject: [PATCH 05/12] test(privacy): add findings doc, stablecoin negative-destination test, refine ATA/token coverage Adds ata_group_owned_owner_signing (Burn's missing GROUP variant) and two NewFungibleDefinition private-initial-holder tests to Token. Removes two experimental PDA-external-seed Token tests that had no real coverage purpose. Documents the root cause of the AMM privacy-test blocker (a spel-framework guest-wrapper filter silently drops the clock account before either transaction validator sees it) with full findings/matrix writeups, and reconciles table gaps found while cross-checking each program's privacy tests against docs/findings.md. --- docs/findings.md | 225 ++++---------- docs/privacy-test-matrix.md | 95 +++++- programs/integration_tests/tests/amm.rs | 101 ++++-- programs/integration_tests/tests/ata.rs | 114 ++++++- .../integration_tests/tests/stablecoin.rs | 4 +- programs/integration_tests/tests/token.rs | 288 ++++++++---------- 6 files changed, 447 insertions(+), 380 deletions(-) diff --git a/docs/findings.md b/docs/findings.md index cfd280c7..434b8f6d 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -34,7 +34,7 @@ Private account updates require knowledge of the account's `nsk`. E.g., Alice ca Only the account owner can (1) update their initialized account, and (2) use functions that require authorization with their account. ### Remark -- `PrivateUnauthorized` initialization is used for account initialization. `is_authorized = false` is a protection that does not seem crucial. Artifically, blocks some functions. (TODO: return to and shift to conclusions) +- `PrivateUnauthorized` initialization is used for account initialization. `is_authorized = false` is a protection that does not seem crucial. Artifically, blocks some functions. ## Private PDA @@ -60,77 +60,34 @@ distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse: This ensures that any member of the group can execute programs on shared accounts using either `PrivateAuthorizedInit` or `PrivateAuthorizedUpdate`. From a program's perspective, shared accounts should behave the same as regular public accounts. -# Privacy coverage for LEZ programs objectives (TODO) - -In this task, we plan to add tests for e - -| | description | | -|---------|----|----| -| PDA | -| REGULAR | -| EXIST | -| GROUP | -| CHAIN | - -- Regular private accounts -- `PrivateUnauthorized` accounts; e.g., "transfer to existing accounts". -- Group shared private accounts -- Private PDAs. - -# LEZ programs (TODO) - -## AMM program - -**Headline finding: no privacy-preserving test can be written for AMM's pool-mutating -instructions at all right now — not because of privacy, but a distinct circuit-level bug.** - -Before any private-account test, an all-public control test through `execute_and_prove` (same -discipline that caught Stablecoin's `OpenPosition` bug) turned up a second, unrelated -circuit-level issue specific to AMM: `SwapExactInput` fails inside `execute_and_prove` with -`"Invalid account_identities length"` — we supply 8 account identities, the circuit's -`states_iter` only computes 7 — with every account `Public` and zero private accounts involved. -The same pattern reproduces on `SyncReserves` (6 vs 5). The account that silently vanishes from -the circuit trace is `CLOCK_01_PROGRAM_ACCOUNT_ID` — present in the top-level input and in the -AMM program's own returned `post_states` (confirmed in `sync.rs`/`swap.rs` source), but never -seen by the circuit at any call depth. Root cause not yet found. - -Five tests confirm this **also blocks real private-account attempts**, not just the all-public -control case — `amm_swap_a_to_b_private_user_holding_is_not_expressible` and -`amm_swap_exact_output_private_user_holding_is_not_expressible` (private `user_holding_a`, 8 vs -7), `amm_add_liquidity_private_lp_holding_is_not_expressible` (private `user_holding_lp`, 10 vs -9), `amm_add_liquidity_private_user_holdings_is_not_expressible` (private `user_holding_a` + -`user_holding_b` deposit legs, 10 vs 9), `amm_remove_liquidity_private_lp_holding_is_not_expressible` -(private `user_holding_lp`, 10 vs 9) — all five fail with the identical -`"Invalid account_identities length"` panic, always exactly one account short. **Consequence**: -Swap (both variants), AddLiquidity, and RemoveLiquidity cannot be tested for any Q2 privacy -dimension until this circuit bug is fixed — every planned AMM privacy test is blocked on it. See -`docs/privacy-test-matrix.md`'s AMM section for the full bisection log. - -**⚠ To track down later — confirmed `clock` is the account that vanishes, root cause still -open**: instrumented tracing (`eprintln!`s in the pinned `lee_core` checkout's -`execution_state.rs`, exact `Display`-string matching against `CLOCK_01_PROGRAM_ACCOUNT_ID`) -confirmed the circuit's internal per-account processing (`states_iter`) never contains an entry -for `clock`, at any call depth — not the top-level AMM call, not even inside the TWAP -`UpdateCurrentTick` chained call, which itself explicitly re-passes `clock.clone()`. Ruled out a -coincidental `AccountId` collision. **Still unknown**: whether the entry is dropped inside the -AMM guest's own execution, inside the SPEL-macro-generated `#[lez_program]` wrapper code, or -inside the circuit's own bookkeeping before `validate_and_sync_states`'s per-account loop even -runs. **Next concrete step**: check whether `pre_states.len()`/`post_states.len()` already -differ from N/N *before* that loop runs — that single check localizes the bug to one side or the -other and was never executed before this investigation was paused. - -**A second, distinct finding for `RemoveLiquidity`, unrelated to the circuit bug above:** -`remove_liquidity` requires `user_holding_a`/`user_holding_b` to already exist and already be -owned by the configured Token Program (`remove.rs`'s -`assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`) — unlike -`token::transfer`'s recipient handling, which tolerates `Account::default()` and self-initializes -it. So `RemoveLiquidity` can never pay out to a brand-new private destination -(`PrivateUnauthorized` — only `npk` known, no `nsk`): the attempt -(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) fails inside the AMM -program's own precondition check, *before* any chained call or the privacy-preserving circuit is -ever reached — and would equally reject a brand-new *public* destination. Same shape of finding -as Stablecoin's `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`: -a plain program-level precondition that predates privacy entirely, not a circuit artifact. +# Privacy coverage for LEZ programs objectives + +In this task, we extend testing for LEZ programs to cover privacy features: + +| | description | +|---------|----| +| PDA | test checks for private PDA functionality. | +| REGULAR | private accounts usage using `nsk` | +| EXIST | private account initialized without `nsk`; `PrivateUnauthorized` | +| GROUP | Shared group account | +| CHAIN | private account used in a chain call | + +# LEZ programs + +## AMM program (unusual issues) + +| Function tested | Test name | Category | Description of objective | Result | +|---|---|---|---|---| +| SwapExactInput | `amm_swap_a_to_b_private_user_holding_is_not_expressible` | REGULAR, CHAIN | Private `user_holding_a` deposit leg — confirms the circuit-level account-count bug also fires with a real private account (8 vs 7 accounts), not just the all-public control case | ❌ (confirmed not-expressible — circuit bug) | +| SwapExactOutput | `amm_swap_exact_output_private_user_holding_is_not_expressible` | REGULAR, CHAIN | Same confirmation for `SwapExactOutput` — identical account/chained-call shape to `SwapExactInput` (8 vs 7 accounts) | ❌ (confirmed not-expressible — circuit bug) | +| AddLiquidity | `amm_add_liquidity_private_lp_holding_is_not_expressible` | REGULAR, CHAIN | Private LP-output holding (`user_holding_lp`) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) | +| AddLiquidity | `amm_add_liquidity_private_user_holdings_is_not_expressible` | REGULAR, CHAIN | Private deposit legs (`user_holding_a` + `user_holding_b`) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) | +| RemoveLiquidity | `amm_remove_liquidity_private_lp_holding_is_not_expressible` | REGULAR, CHAIN | Private LP holding (the account that signs/burns to remove liquidity) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) | +| RemoveLiquidity | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` | EXIST, CHAIN | Brand-new `PrivateUnauthorized` token A/B destinations — rejected by a separate, unrelated program-level precondition (destination must already exist) before the circuit bug is even reached | ❌ (confirmed not-expressible — different reason) | + +### Remarks +- `RemoveLiquidity` and `Swap`s may have issues with `PrivateUnauthorized` and `PrivateAuthorizedInit` that match issues detected in Stablecoin; e.g., explicitly requires `is_authorized = true` and non default accounts. +- `clock` account issue: clock is silent dropped during privacy executions. ## ATA program @@ -138,93 +95,33 @@ ATA program offers limited usage with private accounts. Private accounts can be | Function tested | Test name | Category | Description of objective | Result | |---|---|---|---|---| -| Create | `ata_create_from_private_owner` | BASE (private owner only; ATA account + definition public) | Any third party can bootstrap another owner's ATA using only that owner's public key material (`PrivateUnauthorized` — `npk`/`vpk` only, no `nsk`) — `Create` never asserts `owner.is_authorized` | ✅ | +| Create | `ata_create_from_private_owner` | REGULAR, EXIST | Any third party can bootstrap another owner's ATA using only that owner's public key material (`PrivateUnauthorized` — `npk`/`vpk` only, no `nsk`) — `Create` never asserts `owner.is_authorized` | ✅ | | Create | `ata_create_private_ata_holding_is_not_expressible` | PDA | Attempts to make the ATA holding itself a private account via `PrivatePdaInit`/`PrivatePdaUpdate` — confirms the public-form PDA match ATA authorizes with and the private-form binding those variants require are mutually exclusive for the same account id | ❌ (confirmed not-expressible) | -| Create | `ata_create_from_group_owned_owner` | GROUP | Group-derived owner identity used to create an ATA — **weaker than the other `GROUP` rows**: `Create` never requires `owner` to prove control, so this can't demonstrate genuine shared control the way the `Transfer`/`Burn` rows below do; it only confirms `Create` doesn't secretly care where `npk`/`vpk` came from | ✅ (defensive/symmetry coverage only) | -| Transfer | `ata_transfer_to_existing_private_recipient` | EXIST, CHAIN | Sends more into an already-shielded private recipient through ATA's *nested* chained call into Token — the first test in the whole exercise proving a private identity survives a chained call at all | ✅ | +| Create | `ata_create_from_group_owned_owner` | GROUP | Group-derived owner identity used to create an ATA — **weaker than the other `GROUP` rows**: `Create` never requires `owner` to prove control. | ✅ (defensive/symmetry coverage only) | +| Transfer | `ata_transfer_to_existing_private_recipient` | REGULAR | Sends more into an already-shielded private recipient through ATA's *nested* chained call into Token — the first test in the whole exercise proving a private identity survives a chained call at all | ✅ | +| Transfer | `ata_transfer_with_private_owner_signing` | REGULAR | Key discovery: unlike `Create` (merely `mut`), `Transfer` requires `owner` to be a *signer* (`#[account(signer)]`) — a private owner self-initializes and signs in the same transaction via `PrivateAuthorizedInit` | ✅ | | Transfer | `ata_transfer_with_group_owned_owner_signing` | GROUP | Group-owned owner (real GMS seal/unseal handshake) signs `ATA::Transfer` as the required authorizing party | ✅ | +| Burn | `ata_burn_with_private_owner_signing` | REGULAR | Same signer-authorization discovery as `ata_transfer_with_private_owner_signing`, for `Burn` | ✅ | | Burn | `ata_group_owned_owner_signing` | GROUP | Group-owned owner signs `ATA::Burn` as the required authorizing party | ✅ | -**`PDA`** is confirmed not-expressible for every ATA instruction, not just `Create` — `Transfer` -and `Burn` call the same `ata_core::verify_ata_and_get_seed` function, so the identical -public-form/private-form conflict applies to them too, even though only `Create` has a dedicated -test asserting it. - -Two tests exist outside this table's categories (not `PDA`/`GROUP`/`EXIST`/`CHAIN`, and not -`BASE` either — tagged `new: signer-authorization` in `docs/privacy-test-matrix.md`) and are -worth noting separately: `ata_burn_with_private_owner_signing` and -`ata_transfer_with_private_owner_signing` (a *personal*, non-group private owner signing -`Burn`/`Transfer`). They were the key discovery that `owner` must be a *signer* for these two -instructions (unlike `Create`) — a real finding, just a distinct dimension from any tag used -elsewhere in this table. +### Remarks +- Transfer explicitly blocks `PrivateAuthorized`. ATA's transfer checks that the recipient's account is non-default. E.g., ATA can not transfer funds to a third-party's private account. +- ATA does not permit the creation of private token accounts. E.g., ATA only emits public PDA accounts. This is based on the PDA `AccountId` formulas used. ## Stablecoin program | Function tested | Test name | Category | Description of objective | Result | |---|---|---|---|---| -| WithdrawCollateral | `stablecoin_withdraw_collateral_private_destination` | CHAIN, EXIST | Withdraws collateral through the single `Token::Transfer` chained call into an already-existing private destination holding | ✅ | -| WithdrawCollateral | `stablecoin_withdraw_collateral_group_owned_destination` | CHAIN, EXIST, GROUP | Same, but the destination holding is group-owned (real GMS seal/unseal handshake) | ✅ | +| WithdrawCollateral | `stablecoin_withdraw_collateral_private_destination` | REGULAR | Withdraws collateral through the single `Token::Transfer` chained call into an already-existing private destination holding | ✅ | +| WithdrawCollateral | `stablecoin_withdraw_collateral_group_owned_destination` | EXIST, GROUP | Same, but the destination holding is group-owned (real GMS seal/unseal handshake) | ✅ | | WithdrawCollateral | `stablecoin_group_owned_position_owner` | GROUP | The position's `owner` identity itself (not the destination) is group-derived — proves shared authority over a CDP by withdrawing collateral through it | ✅ | -| RepayDebt | `stablecoin_repay_debt_private_stablecoin_holding` | CHAIN | Burns from a private stablecoin holding through the single `Token::Burn` chained call | ✅ | -| RepayDebt | `stablecoin_repay_debt_group_owned_stablecoin_holding` | CHAIN, GROUP | Same, group-owned holding | ✅ | - -**`PDA`** has no rows, and can't even be isolated as its own question for this program: position -and vault are only ever PDA-claimed *inside* `OpenPosition`, and — see below — that instruction -can't reach the privacy circuit at all. The `PDA` question is subsumed by that finding rather -than independently testable; the ATA `PDA` finding (same `for_public_pda`-only root cause, -confirmed in `stablecoin_core`) stands as the citable reference. - -One test sits outside this table's four categories but is the headline finding for the whole -program, worth stating plainly rather than omitting silently: -**`stablecoin_open_position_via_privacy_transaction_is_not_expressible`** — `OpenPosition` -cannot be executed through a privacy-preserving transaction *at all*, for any reason connected -to privacy. Confirmed with an all-public control case (every account `InputAccountIdentity::Public`, -zero private accounts) that fails identically, proving it's a protocol incompatibility in the -`PrivacyPreservingTransaction` code path itself, not a privacy bug — `owner`'s identity type is -irrelevant. Every test above routes around it by seeding position/vault directly rather than -calling `OpenPosition` for real. - -**Root cause, precisely traced:** `open_position.rs` returns two *sibling* chained calls in one -shot (`vec![initialize_call, transfer_call]` — both discovered at once from a single execution of -`open_position`, neither nested inside the other) that both touch `vault`: `InitializeAccount` -declares it `is_authorized: true` (claimed via its PDA seed), `Transfer` then declares the *same* -account_id `is_authorized: false` (a hand-predicted post-`InitializeAccount` state, not a value -threaded through by the framework — the program author is predicting what call 1 will produce, -not observing it). This reuse of one account across two sibling calls with differing declared -authorization is the *only* thing that matters here — contrast with AMM's `remove_liquidity`, -which also returns multiple sibling chained calls at once (4: token A/B withdraw, LP burn, TWAP -tick update) but never reuses one account across two of them, so it never exercises this code -path at all. - -Both transaction-type validators re-derive `is_authorized` per occurrence and assert it matches -the declared value — but they scope that derivation differently. `validated_state_diff.rs` (the -plain `PublicTransaction` validator) computes a fresh `authorized_accounts` set once per parent -call and clones it independently for each sibling *before* any sibling runs — so `Transfer`'s -view of `vault` never sees `InitializeAccount`'s PDA-based authorization, re-derives `false`, -matches. This is why the pre-existing public `stablecoin_open_position_then_withdraw_collateral` -test works. `execution_state.rs` (the `PrivacyPreservingTransaction`/circuit validator) instead -keeps one mutable `authorized_accounts: HashSet` on `self`, threaded with no -per-branch scoping through the entire flat call queue — `InitializeAccount` processing inserts -`vault` into it, and when `Transfer` is processed next, `resolve_authorization_and_record_bindings` -short-circuits via `if authorized_accounts.contains(&pre_account_id) { return true; }`, re-deriving -`true` — which conflicts with the declared `false` and fails -`assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")`. - -**This means `OpenPosition` is fixable two ways**: either scope `execution_state.rs`'s -`authorized_accounts` per sibling branch to match `validated_state_diff.rs`'s behavior (a circuit -fix, benefits every program with this pattern), or change `open_position.rs` to not re-declare -`vault` unauthorized on its second occurrence (a one-line fix local to this program, routing -around the bug rather than fixing it). - -A second, unrelated negative result: -**`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`** — -`WithdrawCollateral` cannot pay out to a brand-new private destination (`PrivateUnauthorized`, -only `npk` known, no `nsk`). `withdraw_collateral.rs` hard-asserts -`destination.account != Account::default()` before the chained `Token::Transfer` is even -constructed, so the destination must already exist — this is a plain program precondition, not a -privacy-circuit artifact, and would equally reject a withdraw to a brand-new *public* -destination. It's why every `WithdrawCollateral` test above uses `PrivateAuthorizedUpdate` -(`nsk` known) rather than `PrivateUnauthorized` for the destination. +| RepayDebt | `stablecoin_repay_debt_private_stablecoin_holding` | REGULAR | Burns from a private stablecoin holding through the single `Token::Burn` chained call | ✅ | +| RepayDebt | `stablecoin_repay_debt_group_owned_stablecoin_holding` | GROUP | Same, group-owned holding | ✅ | + +### Remarks +- `OpenPosition` is blocked for use in privacy transactions due to the chained calls usage. `OpenPosition` calls `Token::InitializeAccount` and `Token::Transfer` for the same vault account which is disallowed behavior in privacy preserving circuit. Demonstrated with test `stablecoin_open_position_via_privacy_transaction_is_not_expressible`. +- `WithdrawCollateral` does not support withdrawals to `PrivateAuthorized` and `PrivateAuthorizedInit`; explicitly checks that the destination account is not default. Demonstrated with teh test `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. +- Vault is explicitly public PDA by formula requirement. ## Token program @@ -243,37 +140,25 @@ destination. It's why every `WithdrawCollateral` test above uses `PrivateAuthori | Burn | `token_private_burn` | REGULAR | Burn from an existing private holding via a single `PrivateAuthorizedUpdate` | ✅ | | Burn | `token_group_owned_holding_shared_control_burn` | GROUP | Shield tokens into a GMS-derived shared holding, then burn from it using an independently re-derived key | ✅ | | InitializeAccount | `token_initialize_private_account_succeeds_for_canonical_definition` | REGULAR | Self-init of a private holding via `PrivateAuthorizedInit` | ✅ | +| InitializeAccount | `token_initialize_private_account_without_nsk_is_not_expressible` | EXIST | `InitializeAccount`'s target is `#[account(init, signer)]` — a third party cannot initialize a private holding via `PrivateUnauthorized` (no `nsk`); rejected by the SPEL macro ("must be a signer") before the program's own logic runs | ❌ (confirmed not-expressible by design) | | InitializeAccount | `token_group_owned_holding_shared_control_initialize` | GROUP | A group member — not the party who created the group — self-initializes the shared holding directly via `PrivateAuthorizedInit` | ✅ | | MintWithAuthority | `token_mint_with_authority_to_private_holding` | EXIST | External-authority mint (distinct signer from the definition) directly to a fresh private recipient | ✅ | - -**`PDA`** has no Token-layer rows: Token holdings are addressed by an arbitrary `AccountId`, not -a program-derived one — there's no PDA to make private at this layer. Only testable once a -holding is wrapped by another program's PDA (ATA/AMM/Stablecoin). - -**`CHAIN`**'s "carried through chained calls" half also has no Token-layer rows: Token issues no -`ChainedCall`s of its own (only ATA/AMM/Stablecoin do) — that half is exercised for the first -time in the ATA section instead. - -| | coverage? | explanation | -|----|---------|----------------| -| REGULAR | full | REGULAR private accounts are used as sender/recipient for initialize, transfer, mint and burn | -| GROUP | full | Tested with initialize, transfer, mint and burn | -| EXIST | partial | EXIST (`PrivateUnauthorized`) cannot be used with initialize due to `is_authorize = false` | -| PDA | N/A | Token program does not use PDAs | - - - +| NewFungibleDefinition | `token_new_fungible_definition_private_initial_holder` | REGULAR | Public token definition, private initial holder that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) — same self-service shape as `InitializeAccount`'s target | ✅ | +| NewFungibleDefinition | `token_new_fungible_definition_private_holder_without_nsk_is_not_expressible` | EXIST | The initial holder cannot be created via `PrivateUnauthorized` — rejected by the SPEL macro before the program's own logic runs | ❌ (confirmed not-expressible by design) | +### Remarks +- `Initialization` is not possible for `PrivateUnauthorized` accounts due to `is_authorized = false`. +- New token definition is not permitted for `PrivateAuthorized` as Token holding due to `is_authorized = false`.E.g., both Token Definition and Token Holding for a new Token must be from an authorized account. # Conclusions -## Group shared private accounts -- Group shared accounts are authorized +Privacy coverage for LEZ program tests is greatly improved from the added tests. Though, there are a few noticable gaps: +- `PrivateUnauthorized` accounts can be blocked by programs with a check `is_authorized = true`. However, th + # Observations - Programs can be made privacy agnostic for PDAs by adjusting private PDA `AccountId` formula to match the public variant. Unclear how to precisely handle this to ensure `AMM program` generates unique pools for token pairs (in public PDA case). -- A private PDA can be initialized and used for a program without using traditional PDA lifecycle. E.g., TODO(provide example from `token.rs`) # TODO diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index a3e369a3..861899db 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -418,12 +418,11 @@ computes). Confirmed with every account `Public`. directly in `sync.rs`'s `sync_reserves` and `swap.rs`'s `finalize_swap`, both of which explicitly include `AccountPostState::new(clock.account...)`), yet it never appears in the circuit-level trace at any call depth, not even inside the TWAP chained call which also - explicitly passes `clock.clone()`. Root cause of *why* it's dropped is still not found — the - next diagnostic step (checking whether `pre_states.len()`/`post_states.len()` already differ - from 8/8 before the per-account validation loop runs, which would localize the drop to either - the AMM guest/SPEL-macro layer or the circuit's own processing) was planned but not executed. - Instrumentation was fully reverted afterward (verified byte-identical to the original checkout - and original artifact) rather than left in place. + explicitly passes `clock.clone()`. Root cause of *why* it's dropped was not yet found at this + point — **since resolved, see "Root cause found" below**: it's a `spel-framework` guest-wrapper + filter, not the circuit's own processing. Instrumentation was fully reverted afterward + (verified byte-identical to the original checkout and original artifact) rather than left in + place. - **Confirmed this also blocks real private-account attempts, not just the all-public control case (2026-07-13)**: three tests — `amm_swap_a_to_b_private_user_holding_is_not_expressible` (private `user_holding_a`, 8 vs 7 accounts), `amm_add_liquidity_private_lp_holding_is_not_expressible` @@ -459,6 +458,90 @@ validation loop in `validate_and_sync_states` runs — that would localize the d AMM guest/SPEL-macro layer or the circuit's own processing, and is the next concrete step now that instrumentation is confirmed to work end-to-end. +### ✅ Root cause found (2026-07-14) — it's in `spel-framework`, not `lee_core`, and not AMM's own code + +Investigated (via a Fable 5 subagent, source-reading only — no instrumentation needed this time) +by comparing the two transaction validators side by side and checking the guest-wrapper code +that sits between AMM's own functions and either validator. Fully verified by direct inspection +afterward (both citations below reproduced and confirmed independently). + +**The account is deleted before it ever reaches either validator.** The `#[lez_program]` macro's +generated `main()` — `spel-framework-macros/src/lib.rs:303-329`, in the pinned +`spel-393b37c2cff64018` checkout at rev `91023c9115bf88173b0d25d2e905f2a55ef0313b` — post-processes +every guest function's returned `(pre_states, post_states)` pairs before writing the +`ProgramOutput`: + +```rust +// Filter out non-program-owned, non-default-state accounts from the output. +// +// LEZ validate_execution rule 7: if post.program_owner == DEFAULT_PROGRAM_ID +// and pre.account != Account::default(), validation fails. This would happen +// for signer accounts (e.g., proposer/executor) whose nonce has been incremented +// by a prior transaction — they are not owned by the program and must not be +// returned in the program's post-states. +.filter(|(pre, post)| { + let is_default_owner = pre.account.program_owner == DEFAULT_PROGRAM_ID; + let pre_is_default = pre.account == Account::default(); + let has_claim = post.required_claim().is_some(); + !is_default_owner || pre_is_default || has_claim +}) +``` + +This was written to solve a real, narrow problem: drop *signer* accounts (proposer/executor) +whose nonce got bumped by a prior transaction, since they're not owned by the program and +`validate_execution`'s rule 7 would otherwise reject the output. But the predicate is broader +than that one case, and `clock` happens to satisfy it too: + +- `is_default_owner = true` — the clock account is seeded via `force_insert_account` with + `Account { data: , ..Account::default() }` (`advance_clock` in `amm.rs`), + so its `program_owner` stays `DEFAULT_PROGRAM_ID` — it's never claimed by any program. +- `pre_is_default = false` — its `data` field holds real, non-default clock bytes. +- `has_claim = false` — AMM never issues a `Claim` for clock; it only reads it. + +`!true || false || false` = `false` → the `(pre, post)` pair for `clock` is silently dropped from +`ProgramOutput.pre_states`/`post_states`, every single time, for every AMM instruction that +touches it — and for TWAP's `UpdateCurrentTick` too, since it's built with the exact same macro +at the exact same pin. This is exactly why the earlier `eprintln!` trace never saw `clock` at +*any* call depth, including inside the nested TWAP call: it was gone before the circuit ever got +the chance to see it, not dropped by the circuit itself. + +**Why the public-transaction path never noticed**: `ValidatedStateDiff::from_public_transaction` +(`lee/state_machine/src/validated_state_diff.rs`) only ever iterates whatever the program's +*output* actually contains (`program_output.pre_states`) and zips it against +`program_output.post_states` to build the state diff. There is no check anywhere that the +output covers every account the *caller* originally supplied — a silently-dropped, unmodified +account just never appears in the diff, and nothing asserts it should have. `validate_execution` +(the rule 7 the filter comment refers to) only checks `pre_states.len() == post_states.len()` +*within* the already-filtered output (7 == 7 — passes trivially, since both sides of the pair +were dropped together). + +**Why the privacy-preserving path panics**: the circuit builds its own account-tracking state as +the union of every `ProgramOutput.pre_states` it sees across the whole call tree — 7 accounts, +no clock. But the *caller* (the test, or in production a real wallet/client) must supply one +`InputAccountIdentity` per account it believes is involved — 8, including clock, since nothing +told the caller clock would be dropped. `compute_circuit_output`'s +`assert_eq!(account_identities.len(), states_iter.len())` (`output.rs:27`) then fails: `8 != 7`. +The public path tolerates exactly this same silent drop; only the private path's stricter +1:1 correspondence check turns it into a hard failure. + +**This is a `spel-framework` bug, not a `lez_core`/circuit bug, and not an AMM program bug.** +Neither this repo's own code nor the pinned LEZ dependency is at fault — the defect is in the +`0x-r4bbit/spel` proc-macro crate's generated wrapper, one layer removed from both. Fix options +belong upstream: scope the filter to only the specific signer-nonce-bump case it was written for +(e.g. keep any pair the handler's own logic explicitly returned, rather than blanket-filtering +by ownership), or have the circuit tolerate identities without a corresponding output pre-state. +The trigger condition is narrow but real: any account with `program_owner == DEFAULT_PROGRAM_ID` +that a program reads but never claims will hit this — not just clock, and not just AMM. It just +happens to be clock here because every pool-mutating AMM instruction reads it. + +**Soundness implication, not just a test-writing inconvenience**: because `clock` never reaches +`public_pre_states` on the privacy-preserving path, the host validator +(`check_privacy_preserving_circuit_proof_is_valid`) never checks the clock data a proof was +generated against against real chain state. A malicious prover could in principle supply an +arbitrary timestamp as a private witness and no check anywhere would catch it. Worth escalating +to the LEZ/SPEL maintainers independent of whether/when the AMM test-writing blocker itself gets +prioritized. + ### Existing 6 private tests out of 33 pre-existing public + 6 = 39. No test can yet demonstrate an diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index 1110caf6..6f2b6072 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -3109,7 +3109,8 @@ fn token_program_instance() -> Program { } fn twap_oracle_program_instance() -> Program { - Program::new(twap_oracle_methods::TWAP_ORACLE_ELF.to_vec().into()).expect("valid twap oracle ELF") + Program::new(twap_oracle_methods::TWAP_ORACLE_ELF.to_vec().into()) + .expect("valid twap oracle ELF") } fn amm_with_deps() -> ProgramWithDependencies { @@ -3159,10 +3160,16 @@ fn amm_swap_a_to_b_private_user_holding_is_not_expressible() { false, Ids::pool_definition(), ); - let vault_a_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); - let vault_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); let user_b_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); @@ -3256,10 +3263,16 @@ fn amm_swap_exact_output_private_user_holding_is_not_expressible() { false, Ids::pool_definition(), ); - let vault_a_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); - let vault_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); let user_b_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); @@ -3351,10 +3364,16 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() { false, Ids::pool_definition(), ); - let vault_a_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); - let vault_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); let token_lp_definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_lp_definition()), false, @@ -3457,10 +3476,16 @@ fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { false, Ids::pool_definition(), ); - let vault_a_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); - let vault_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); let token_lp_definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_lp_definition()), false, @@ -3560,10 +3585,16 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { false, Ids::pool_definition(), ); - let vault_a_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); - let vault_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); let token_lp_definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_lp_definition()), false, @@ -3571,8 +3602,11 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { ); let user_a_pre = AccountWithMetadata::new(Account::default(), false, user_a_id); let user_b_pre = AccountWithMetadata::new(Account::default(), false, user_b_id); - let user_lp_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::user_lp()), true, Ids::user_lp()); + let user_lp_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::user_lp()), + true, + Ids::user_lp(), + ); let current_tick_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::current_tick_account()), false, @@ -3695,10 +3729,16 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() { false, Ids::pool_definition(), ); - let vault_a_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a()); - let vault_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b()); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); let token_lp_definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_lp_definition()), false, @@ -3706,8 +3746,11 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() { ); let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); let user_b_pre = AccountWithMetadata::new(user_b_account, true, user_b_id); - let user_lp_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::user_lp()), false, Ids::user_lp()); + let user_lp_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::user_lp()), + false, + Ids::user_lp(), + ); let current_tick_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::current_tick_account()), false, diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index ebffe476..32c45e9a 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -4,10 +4,6 @@ use ata_core::{compute_ata_seed, get_associated_token_account_id}; use integration_tests::{ private_authorized_init_identity, private_unauthorized_identity, GroupOwner, }; -use key_protocol::key_management::{ - group_key_holder::{GroupKeyHolder, SealingPublicKey}, - secret_holders::SecretSpendingKey, -}; use nssa::{ execute_and_prove, privacy_preserving_transaction::{ @@ -968,6 +964,116 @@ fn ata_burn_with_private_owner_signing() { .is_some()); } +/// Group-owned variant of `ata_burn_with_private_owner_signing`: the GMS is distributed through +/// the real seal/unseal handshake, and it's Bob — not Alice, who created the group — who +/// self-initializes and signs the owner identity in the same transaction via +/// `PrivateAuthorizedInit`, then burns from the ATA holding through it. +#[test] +fn ata_group_owned_owner_signing() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + + let alice = GroupOwner::new([97_u8; 32]); + let owner_id = alice.id; + let bob_nsk = alice.admit_member(); + + // The ATA holding must stay public (per the confirmed PDA finding), so it's seeded + // directly rather than via a real `Create` transaction. + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + let ata_account = Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128, + }), + nonce: Nonce(0), + }; + state.force_insert_account(ata_id, ata_account.clone()); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let ata_pre = AccountWithMetadata::new(ata_account, false, ata_id); + let def_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + + let burn_amount = 300_000_u128; + let instruction = ata_core::Instruction::Burn { + token_program_id: Ids::token_program(), + amount: burn_amount, + }; + + let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap(); + let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap(); + let program_with_deps = ProgramWithDependencies::new( + ata_program, + HashMap::from([(Ids::token_program(), token_program)]), + ); + + let (output, proof) = execute_and_prove( + vec![owner_pre, ata_pre, def_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + private_authorized_init_identity(bob_nsk, &alice.vpk, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![ata_id, Ids::token_definition()], vec![], output) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_000_000_u128 - burn_amount, + }), + nonce: Nonce(0), + } + ); + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128 - burn_amount, + metadata_id: None, + authority: None, + }), + nonce: Nonce(0), + } + ); + + let owner_expected = Account { + nonce: Nonce::private_account_nonce_init(&owner_id), + ..Account::default() + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) + .is_some()); +} + /// Private owner #[test] fn ata_transfer_with_private_owner_signing() { diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 0d166482..d67de42b 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -464,7 +464,6 @@ fn stablecoin_with_token_deps() -> ProgramWithDependencies { ) } - /// `OpenPosition` is blocked by the `privacy_preserving_circuit` due to the handling of /// sibling chain calls of (uninitialized) private accounts. #[test] @@ -674,7 +673,8 @@ fn stablecoin_withdraw_collateral_private_destination() { /// `WithdrawCollateral` blocks withdraws to private accounts (via private donations); /// `PrivateUnauthorized` account initialization (e.g., `nsk` is not known) is not permitted -/// due to the assertion in `withdraw_collateral.rs` asserts `destination.account != Account::default()` +/// due to the assertion in `withdraw_collateral.rs` asserts `destination.account != +/// Account::default()` #[test] fn stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible() { let mut state = V03State::new(); diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index 8e82003e..5ecd9d78 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -7,14 +7,12 @@ use nssa::{ privacy_preserving_transaction::{Message, PrivacyPreservingTransaction, WitnessSet}, program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, - public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State, + public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, }; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - encryption::{EphemeralPublicKey, ViewingPublicKey}, - program::PdaSeed, - Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, - NullifierSecretKey, + encryption::ViewingPublicKey, + Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, }; use token_core::{TokenDefinition, TokenHolding}; @@ -605,75 +603,6 @@ fn token_program() -> Program { Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF") } -/// TODO -/// EXPERIMENTAL — investigating whether `PrivatePdaInit`'s `seed: Some((seed, -/// authority_program_id))` external-derivation-check path lets a private-PDA account be used as an -/// input to an *existing* program's flow (Token) without any chained call, `Claim::Pda`, or -/// awareness from the `authority_program_id` itself. Per `lee_core`'s -/// `circuit_io.rs`/`execution_state.rs`, this path binds the position purely via -/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == -/// pre_state.account_id`, checked directly against the top-level `account_identities` — no chained -/// call needed. Using `Ids::token_program()` as the `authority_program_id` here, but per the -/// circuit source this is not required to correspond to anything Token itself is aware of; it's -/// purely a hash input. -#[test] -fn token_shield_into_private_pda_via_external_seed() { - let mut state = state_for_token_tests(); - let amount = 500_000_u128; - - let sender_id = Ids::holder(); - let sender_account = state.get_account_by_id(sender_id); - let sender_nonce = sender_account.nonce; - let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); - - let authority_program_id = Ids::token_program(); - let pda_seed = PdaSeed::new([77u8; 32]); - let recipient_nsk: NullifierSecretKey = [123u8; 32]; - let recipient_npk = NullifierPublicKey::from(&recipient_nsk); - let recipient_vpk = ViewingPublicKey::from_seed(&[124u8; 32], &[125u8; 32]); - let recipient_id = - AccountId::for_private_pda(&authority_program_id, &pda_seed, &recipient_npk, 0); - - let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); - - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - - let instruction = token_core::Instruction::Transfer { - amount_to_transfer: amount, - }; - let (output, proof) = execute_and_prove( - vec![sender_pre, recipient_pre], - Program::serialize_instruction(instruction).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - npk: recipient_npk, - ssk: shared_secret, - identifier: 0, - seed: Some((pda_seed, authority_program_id)), - }, - ], - &token_program().into(), - ) - .unwrap(); - - let message = - Message::try_from_circuit_output(vec![sender_id], vec![sender_nonce], output).unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::holder_key()]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - state - .transition_from_privacy_preserving_transaction(&tx, 0, 0) - .unwrap(); - - let recipient_account = - Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id)); - assert!(state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) - .is_some()); -} - /// Performs a shielded transfer (public → private) of `amount` tokens from /// `Ids::holder()` to a new private account keyed by `PrivateKeys::recipient_*`. /// Returns the resulting private recipient account. @@ -782,7 +711,11 @@ fn token_private_transfer() { shielded_amount, &mut state, false, - private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0), + private_unauthorized_identity( + PrivateKeys::recipient_npk(), + &PrivateKeys::recipient_vpk(), + 0, + ), ); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); @@ -852,7 +785,11 @@ fn token_deshielded_transfer() { shielded_amount, &mut state, false, - private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0), + private_unauthorized_identity( + PrivateKeys::recipient_npk(), + &PrivateKeys::recipient_vpk(), + 0, + ), ); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); @@ -1457,6 +1394,112 @@ fn token_initialize_private_account_without_nsk_is_not_expressible() { ); } +#[test] +fn token_new_fungible_definition_private_initial_holder() { + let mut state = V03State::new(); + deploy_token(&mut state); + + let holder_nsk = PrivateKeys::holder_nsk(); + let holder_vpk = PrivateKeys::holder_vpk(); + let holder_id = PrivateKeys::holder_id(); + + let definition_nonce = state.get_account_by_id(Ids::token_definition()).nonce; + let definition_pre = + AccountWithMetadata::new(Account::default(), true, Ids::token_definition()); + let holder_pre = AccountWithMetadata::new(Account::default(), true, holder_id); + + let instruction = token_core::Instruction::NewFungibleDefinition { + name: String::from("Gold"), + total_supply: 1_000_000_u128, + mint_authority: None, + }; + let (output, proof) = execute_and_prove( + vec![definition_pre, holder_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + private_authorized_init_identity(holder_nsk, &holder_vpk, 0), + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![Ids::token_definition()], + vec![definition_nonce], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::def_key()]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + state + .transition_from_privacy_preserving_transaction(&tx, 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128, + metadata_id: None, + authority: None, + }), + nonce: Nonce(1), + } + ); + + let holder_account = Accounts::token_holding( + 1_000_000_u128, + Nonce::private_account_nonce_init(&holder_id), + ); + assert!(state + .get_proof_for_commitment(&Commitment::new(&holder_id, &holder_account)) + .is_some()); +} + +#[test] +fn token_new_fungible_definition_private_holder_without_nsk_is_not_expressible() { + let mut state = V03State::new(); + deploy_token(&mut state); + + let holder_npk = PrivateKeys::holder_npk(); + let holder_vpk = PrivateKeys::holder_vpk(); + let holder_id = PrivateKeys::holder_id(); + + let definition_pre = + AccountWithMetadata::new(Account::default(), true, Ids::token_definition()); + let holder_pre = AccountWithMetadata::new(Account::default(), false, holder_id); + + let instruction = token_core::Instruction::NewFungibleDefinition { + name: String::from("Gold"), + total_supply: 1_000_000_u128, + mint_authority: None, + }; + let result = execute_and_prove( + vec![definition_pre, holder_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + private_unauthorized_identity(holder_npk, &holder_vpk, 0), + ], + &token_program().into(), + ); + + let err = result.expect_err( + "creating the initial holder via PrivateUnauthorized must be rejected: \ + NewFungibleDefinition requires is_authorized == true for holding_target_account, but \ + PrivateUnauthorized forces is_authorized == false", + ); + let message = format!("{err:?}"); + assert!( + message.contains("must be a signer"), + "expected the self-service-only rejection, got a different error: {message}" + ); +} + /// Two independent parties (Alice and Bob) control a private Token holding (via `GroupKeyHolder`). /// Alice initializes the private Token account, and Bob burns tokens from the shared account. #[test] @@ -2102,96 +2145,3 @@ fn token_mint_with_authority_to_private_holding() { .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) .is_some()); } - -/// TODO -/// EXPERIMENTAL — follow-up to `token_shield_into_private_pda_via_external_seed`: proves the -/// *update* half of the same mechanism (crediting an *existing* private PDA, not just creating -/// one), completing a genuine round trip rather than a one-shot creation. `PrivatePdaUpdate`'s -/// external seed path has a different pre-condition than `Init`: `execution_state.rs` asserts -/// `pre_state.is_authorized ^ external_seed.is_some()` — with an external seed supplied, the -/// pre-state must be *unauthorized*, even though we're touching it with a real `nsk` + -/// `membership_proof`. That's incompatible with `Transfer`'s sender role, which requires a -/// framework-level `#[account(signer)]` (`is_authorized: true`) — confirmed empirically: using -/// the private-PDA holder as `Transfer`'s sender fails at the SPEL macro's own validation -/// ("must be a signer"), before Token's own logic is ever reached. `Mint`'s -/// `user_holding_account` has no such requirement (`mint_inner` never asserts `is_authorized` on -/// it, crediting an existing holding or not), so it's used here instead — mirroring the `EXIST` -/// dimension's existing-account-crediting pattern (`token_mint_into_existing_private_holding`), -/// just with a private-PDA holder instead of a regular private account. -#[test] -fn token_mint_into_existing_private_pda_via_external_seed() { - let mut state = state_for_token_tests_without_recipient(); - let holding_balance = 500_000_u128; - let amount_to_mint = 200_000_u128; - - let authority_program_id = Ids::token_program(); - let pda_seed = PdaSeed::new([88u8; 32]); - let holder_nsk: NullifierSecretKey = [131u8; 32]; - let holder_npk = NullifierPublicKey::from(&holder_nsk); - let holder_vpk = ViewingPublicKey::from_seed(&[132u8; 32], &[133u8; 32]); - let holder_id = AccountId::for_private_pda(&authority_program_id, &pda_seed, &holder_npk, 0); - - // Seed the private-PDA holding directly (established technique — no real transaction - // needed). Its eligibility as a private PDA is re-derived independently by the update-side - // check below; nothing about how it was seeded matters to that check. - let holder_account = Accounts::token_holding( - holding_balance, - Nonce::private_account_nonce_init(&holder_id), - ); - let holder_commitment = Commitment::new(&holder_id, &holder_account); - state = state.with_private_accounts([( - holder_commitment.clone(), - Nullifier::for_account_initialization(&holder_id), - )]); - let membership_proof = state - .get_proof_for_commitment(&holder_commitment) - .expect("seeded holder's commitment must be in the set"); - - let definition_account = state.get_account_by_id(Ids::token_definition()); - let definition_nonce = definition_account.nonce; - let definition_pre = - AccountWithMetadata::new(definition_account, true, Ids::token_definition()); - let holder_pre = AccountWithMetadata::new(holder_account, false, holder_id); - - let shared_secret = SharedSecretKey::encapsulate_deterministic(&holder_vpk, &[0u8; 32], 0).0; - - let instruction = token_core::Instruction::Mint { amount_to_mint }; - let (output, proof) = execute_and_prove( - vec![definition_pre, holder_pre], - Program::serialize_instruction(instruction).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivatePdaUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&holder_npk, &holder_vpk), - ssk: shared_secret, - nsk: holder_nsk, - membership_proof, - identifier: 0, - seed: Some((pda_seed, authority_program_id)), - }, - ], - &token_program().into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![Ids::token_definition()], - vec![definition_nonce], - output, - ) - .unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::def_key()]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - state - .transition_from_privacy_preserving_transaction(&tx, 0, 0) - .unwrap(); - - let holder_nonce_after = - Nonce::private_account_nonce_init(&holder_id).private_account_nonce_increment(&holder_nsk); - let new_holder_account = - Accounts::token_holding(holding_balance + amount_to_mint, holder_nonce_after); - assert!(state - .get_proof_for_commitment(&Commitment::new(&holder_id, &new_holder_account)) - .is_some()); -} From ad1207383ae2087193166b65b177dc852d32cda7 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Tue, 14 Jul 2026 17:39:40 -0400 Subject: [PATCH 06/12] chore(amm): remove Marvin-todo review flags from confirmed privacy tests --- programs/integration_tests/tests/amm.rs | 37 ------------------------- 1 file changed, 37 deletions(-) diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index 6f2b6072..6fd9c530 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -3123,12 +3123,6 @@ fn amm_with_deps() -> ProgramWithDependencies { ) } -// Marvin-todo -/// Confirms the AMM circuit-level `"Invalid account_identities length"` bug (bisected with an -/// all-`Public` control case in `docs/privacy-test-matrix.md`'s AMM section) also fires when a -/// real private account is involved in `SwapExactInput`, not just the all-public control shape — -/// i.e. this isn't an artifact of using zero private accounts, the bug blocks a genuine private -/// swap identically. #[test] fn amm_swap_a_to_b_private_user_holding_is_not_expressible() { let mut state = state_for_amm_tests(); @@ -3228,10 +3222,6 @@ fn amm_swap_a_to_b_private_user_holding_is_not_expressible() { ); } -// Marvin-todo -/// Same confirmation as `amm_swap_a_to_b_private_user_holding_is_not_expressible`, for -/// `SwapExactOutput` — identical 8-account/chained-call shape to `SwapExactInput`, so the same -/// circuit-level bug is expected to fire identically. #[test] fn amm_swap_exact_output_private_user_holding_is_not_expressible() { let mut state = state_for_amm_tests(); @@ -3330,9 +3320,6 @@ fn amm_swap_exact_output_private_user_holding_is_not_expressible() { ); } -// Marvin-todo -/// Same confirmation as `amm_swap_a_to_b_private_user_holding_is_not_expressible`, for -/// `AddLiquidity` with a private LP-output holding. #[test] fn amm_add_liquidity_private_lp_holding_is_not_expressible() { let mut state = state_for_amm_tests(); @@ -3442,9 +3429,6 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() { ); } -// Marvin-todo -/// Same confirmation as the two tests above, for `RemoveLiquidity` with a private LP holding -/// (the account that signs/burns to remove liquidity). #[test] fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { let mut state = state_for_amm_tests(); @@ -3554,19 +3538,6 @@ fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { ); } -// Marvin-todo -/// A distinct, earlier finding from `amm_remove_liquidity_private_lp_holding_is_not_expressible`: -/// `remove_liquidity` requires `user_holding_a`/`user_holding_b` to already exist and already be -/// owned by the configured Token Program (`remove.rs`'s -/// `assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`), unlike -/// `token::transfer`'s recipient handling (which tolerates `Account::default()` and -/// self-initializes it). So `RemoveLiquidity` can never pay out to a brand-new private -/// destination (`PrivateUnauthorized` — only `npk`/identifier known, no `nsk`, matching how -/// `token_mint_shielded_to_private_unauthorized` credits a fresh private account it doesn't -/// control) — this fails inside the AMM guest's own precondition check, before any chained call -/// or the privacy-preserving circuit is ever reached, and would equally reject a brand-new -/// *public* destination. Same shape of finding as Stablecoin's -/// `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. #[test] fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { let state = state_for_amm_tests(); @@ -3667,14 +3638,6 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { ); } -// Marvin-todo -/// Same confirmation as `amm_add_liquidity_private_lp_holding_is_not_expressible`, but for the -/// deposit side instead of the LP-mint side: `user_holding_a`/`user_holding_b` (the accounts -/// debited to fund the deposit) are existing private holdings (`PrivateAuthorizedUpdate` — `nsk` -/// known, matching how `swap`'s deposit leg is tested), while `user_holding_lp` (the mint -/// destination) stays public, as in the base `amm_add_liquidity` test. Expected to hit the same -/// circuit-level `"Invalid account_identities length"` bug regardless of which accounts are -/// private. #[test] fn amm_add_liquidity_private_user_holdings_is_not_expressible() { let mut state = state_for_amm_tests(); From 6097f13f645e1281afa526697686b6d4661d5693 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Wed, 15 Jul 2026 08:56:43 -0400 Subject: [PATCH 07/12] Minor fixes to findings.md --- docs/findings.md | 69 +++++------------------------------------------- 1 file changed, 6 insertions(+), 63 deletions(-) diff --git a/docs/findings.md b/docs/findings.md index 434b8f6d..73830463 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -105,7 +105,7 @@ ATA program offers limited usage with private accounts. Private accounts can be | Burn | `ata_group_owned_owner_signing` | GROUP | Group-owned owner signs `ATA::Burn` as the required authorizing party | ✅ | ### Remarks -- Transfer explicitly blocks `PrivateAuthorized`. ATA's transfer checks that the recipient's account is non-default. E.g., ATA can not transfer funds to a third-party's private account. +- Transfer explicitly blocks `PrivateUnauthorized`and `PrivateAuthorizedInit`. ATA's transfer checks that the recipient's account is non-default. E.g., ATA can not transfer funds to a third-party's private account. - ATA does not permit the creation of private token accounts. E.g., ATA only emits public PDA accounts. This is based on the PDA `AccountId` formulas used. ## Stablecoin program @@ -120,7 +120,7 @@ ATA program offers limited usage with private accounts. Private accounts can be ### Remarks - `OpenPosition` is blocked for use in privacy transactions due to the chained calls usage. `OpenPosition` calls `Token::InitializeAccount` and `Token::Transfer` for the same vault account which is disallowed behavior in privacy preserving circuit. Demonstrated with test `stablecoin_open_position_via_privacy_transaction_is_not_expressible`. -- `WithdrawCollateral` does not support withdrawals to `PrivateAuthorized` and `PrivateAuthorizedInit`; explicitly checks that the destination account is not default. Demonstrated with teh test `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. +- `WithdrawCollateral` does not support withdrawals to `PrivateUnauthorized` and `PrivateAuthorizedInit`; explicitly checks that the destination account is not default. Demonstrated with the test `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. - Vault is explicitly public PDA by formula requirement. ## Token program @@ -149,68 +149,11 @@ ATA program offers limited usage with private accounts. Private accounts can be ### Remarks - `Initialization` is not possible for `PrivateUnauthorized` accounts due to `is_authorized = false`. -- New token definition is not permitted for `PrivateAuthorized` as Token holding due to `is_authorized = false`.E.g., both Token Definition and Token Holding for a new Token must be from an authorized account. +- New token definition is not permitted for `PrivateUnauthorized` as Token holding due to `is_authorized = false`.E.g., both Token Definition and Token Holding for a new Token must be from an authorized account. # Conclusions Privacy coverage for LEZ program tests is greatly improved from the added tests. Though, there are a few noticable gaps: -- `PrivateUnauthorized` accounts can be blocked by programs with a check `is_authorized = true`. However, th - - -# Observations -- Programs can be made privacy agnostic for PDAs by adjusting private PDA `AccountId` formula to match the public variant. Unclear how to precisely handle this to ensure `AMM program` generates unique pools for token pairs (in public PDA case). - -# TODO - -- [ ] **Private PDAs used as program inputs across the above flows.** - - **Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA, - AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private - formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an - `npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions - accept an `npk` today, so it's never reachable through these programs as coded. Confirmed - empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`); - the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only - pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at - that layer, not a gap. - *Re: "could we compose a test program that uses private PDAs with these pre-existing?"* — - no. None of the four existing programs can be made to produce a `for_private_pda` address - through a test alone, since the formula choice is hardcoded in their source. Demonstrating - the mechanism at all would require either changing one of the `*_core` crates to derive via - `for_private_pda`, or standing up a small purpose-built program whose only job is to - exercise it — both are source changes, not test-writing. **This is the single most - actionable item to feed back to the protocol team.** - -- Group owned shared private account as input to programs. - -- [x] **Sending funds to an existing private account.** - **Achieved, with one real condition: cooperation is required.** Confirmed across Token - (`Transfer`, `Mint`), ATA (`Transfer`, including through a nested chained call into - Token), and Stablecoin (`WithdrawCollateral`). Every path that touches an *existing* - private account (`PrivateAuthorizedUpdate`) requires that account's own `nsk` plus a - membership proof, supplied in the same transaction — there is no blind-credit analog to - `PrivateUnauthorized` for existing accounts (only *fresh* accounts can be credited by a - stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable, - real-world condition: the recipient must be reachable to supply their `nsk` (online or - pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an - untested edge. - -- [~] **Multiple private accounts in one transaction, and private accounts carried through - chained calls.** This is two separate sub-objectives with different status — worth - splitting: - - **Multiple private accounts in one tx — Achieved.** `token_private_transfer` (sender + - recipient, both private, zero public accounts anywhere) and - `token_private_transfer_into_existing_private_holding` (same, recipient already - existing). - - **Carried through a chained call — Achieved, but only single-hop so far.** - `ata_transfer_to_existing_private_recipient` proves a private identity survives one - chained call (ATA → Token) — the first test in the whole exercise to prove this works - at all. Every private Stablecoin `WithdrawCollateral`/`RepayDebt` test also carries a - private account through exactly one chained call (Stablecoin → Token). **Not yet - tested:** deeper, multi-hop chaining — an instruction issuing more than one chained - call with a private account threaded through it (e.g. AMM's `SwapExactInput` chains - into *both* Token and the TWAP oracle in one instruction). That case is currently - unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see - the AMM section) before any chaining depth can even be exercised. So: not unclear — - genuinely proven for the single-hop case, with the deeper case blocked pending AMM. - +- `PrivateUnauthorized` accounts can be blocked by programs with a check `is_authorized = true`. However, this issue can be avoided by defining `is_authorized = true` for account initialization with `PrivateUnauthorized` (e.g., no knowledge of `npk`). Account initialization cannot be used to maliciously alter a pre-existing account, and thus `is_authorized = true` would not offer any malicious path forward for the third-party initializing the account. +- Privacy transactions have issues with chain calls in which multiple calls affect the same private account. This issue can be mitigated by adopting account diff paradigm instead of the current "account state replacement" that we currently use. +- AMM tests are blocked by issues with the clock account; bug in `spel-framework`. \ No newline at end of file From 8f2d937a9d3e496072490054d83406a85abc0f6f Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Wed, 15 Jul 2026 14:06:20 -0400 Subject: [PATCH 08/12] test(privacy): fix AMM clock ownership bug, confirm 5 chained-call privacy tests, add 5 more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: advance_clock() left the clock account DEFAULT_PROGRAM_ID-owned, which spel-framework's guest dispatcher (upstream in logos-co/spel) silently drops from a program's output as an unclaimed, non-default, default-owned account. That desynced the privacy circuit's account count from the caller-supplied identities, blocking every AMM chained-call privacy test with "Invalid account_identities length". Fix: own the fixture's clock account with a placeholder non-default ProgramId. Rewrote the 5 previously not-expressible Swap/AddLiquidity/RemoveLiquidity privacy tests to assert real success (transaction applied, resulting state and private commitments verified) now that they're unblocked. Added 5 more tests: Swap to a fresh PrivateUnauthorized/PrivateAuthorizedInit destination (both confirmed not-expressible, for two distinct reasons — guest ABI signer requirement vs. destination-must-already-exist), and NewDefinition with a private PrivateAuthorizedInit/PrivateUnauthorized initial LP holder (expressible for the former, not for the latter). Updated docs/findings.md and docs/privacy-test-matrix.md accordingly. --- docs/findings.md | 24 +- docs/privacy-test-matrix.md | 84 +- programs/integration_tests/tests/amm.rs | 1187 +++++++++++++++++++---- 3 files changed, 1055 insertions(+), 240 deletions(-) diff --git a/docs/findings.md b/docs/findings.md index 73830463..042d4330 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -78,16 +78,20 @@ In this task, we extend testing for LEZ programs to cover privacy features: | Function tested | Test name | Category | Description of objective | Result | |---|---|---|---|---| -| SwapExactInput | `amm_swap_a_to_b_private_user_holding_is_not_expressible` | REGULAR, CHAIN | Private `user_holding_a` deposit leg — confirms the circuit-level account-count bug also fires with a real private account (8 vs 7 accounts), not just the all-public control case | ❌ (confirmed not-expressible — circuit bug) | -| SwapExactOutput | `amm_swap_exact_output_private_user_holding_is_not_expressible` | REGULAR, CHAIN | Same confirmation for `SwapExactOutput` — identical account/chained-call shape to `SwapExactInput` (8 vs 7 accounts) | ❌ (confirmed not-expressible — circuit bug) | -| AddLiquidity | `amm_add_liquidity_private_lp_holding_is_not_expressible` | REGULAR, CHAIN | Private LP-output holding (`user_holding_lp`) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) | -| AddLiquidity | `amm_add_liquidity_private_user_holdings_is_not_expressible` | REGULAR, CHAIN | Private deposit legs (`user_holding_a` + `user_holding_b`) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) | -| RemoveLiquidity | `amm_remove_liquidity_private_lp_holding_is_not_expressible` | REGULAR, CHAIN | Private LP holding (the account that signs/burns to remove liquidity) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) | -| RemoveLiquidity | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` | EXIST, CHAIN | Brand-new `PrivateUnauthorized` token A/B destinations — rejected by a separate, unrelated program-level precondition (destination must already exist) before the circuit bug is even reached | ❌ (confirmed not-expressible — different reason) | +| SwapExactInput | `amm_swap_a_to_b_private_user_holding` | REGULAR, CHAIN | Private `user_holding_a` deposit leg, through the Token + TWAP-oracle chained calls | ✅ | +| SwapExactOutput | `amm_swap_exact_output_private_user_holding` | REGULAR, CHAIN | Same coverage for `SwapExactOutput` | ✅ | +| AddLiquidity | `amm_add_liquidity_private_lp_holding` | REGULAR, CHAIN | Private LP-output holding (`user_holding_lp`) receives newly-minted LP on top of an existing private balance | ✅ | +| AddLiquidity | `amm_add_liquidity_private_user_holdings` | REGULAR, CHAIN | Private deposit legs (`user_holding_a` + `user_holding_b`), public LP recipient | ✅ | +| RemoveLiquidity | `amm_remove_liquidity_private_lp_holding` | REGULAR, CHAIN | Private LP holding (the account that signs/burns to remove liquidity) | ✅ | +| RemoveLiquidity | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` | EXIST, CHAIN | Brand-new `PrivateUnauthorized` token A/B destinations — rejected by a separate, unrelated program-level precondition (destination must already exist) | ❌ (confirmed not-expressible — different reason) | +| SwapExactInput | `amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible` | EXIST, CHAIN | Swap paying out to a brand-new `PrivateUnauthorized` destination (`npk` only, no `nsk`) | ❌ (confirmed not-expressible — guest ABI requires both swap legs to be signers, which `PrivateUnauthorized` can never satisfy by construction) | +| SwapExactInput | `amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible` | REGULAR, CHAIN | Swap paying out to a brand-new `PrivateAuthorizedInit` destination (owner self-initializes with its own `nsk`) | ❌ (confirmed not-expressible — same "destination must already exist" precondition as `RemoveLiquidity`) | +| NewDefinition | `amm_new_definition_private_initial_lp_holder` | REGULAR | Pool creation with a private `PrivateAuthorizedInit` initial LP holder | ✅ | +| NewDefinition | `amm_new_definition_private_unauthorized_lp_holder_is_not_expressible` | EXIST, REGULAR | Pool creation with a `PrivateUnauthorized` initial LP holder (`npk` only, no `nsk`) | ❌ (confirmed not-expressible — guest ABI requires `user_holding_lp` to be a signer, which `PrivateUnauthorized` can never satisfy; same shape as the `Swap` `PrivateUnauthorized` finding above) | ### Remarks -- `RemoveLiquidity` and `Swap`s may have issues with `PrivateUnauthorized` and `PrivateAuthorizedInit` that match issues detected in Stablecoin; e.g., explicitly requires `is_authorized = true` and non default accounts. -- `clock` account issue: clock is silent dropped during privacy executions. +- `Swap` and `Remove` rejects any uninitialized destination account; this is a AMM design choice, and not Token program requirement. +- AMM tests were initially blocked by a bug. ## ATA program @@ -156,4 +160,6 @@ ATA program offers limited usage with private accounts. Private accounts can be Privacy coverage for LEZ program tests is greatly improved from the added tests. Though, there are a few noticable gaps: - `PrivateUnauthorized` accounts can be blocked by programs with a check `is_authorized = true`. However, this issue can be avoided by defining `is_authorized = true` for account initialization with `PrivateUnauthorized` (e.g., no knowledge of `npk`). Account initialization cannot be used to maliciously alter a pre-existing account, and thus `is_authorized = true` would not offer any malicious path forward for the third-party initializing the account. - Privacy transactions have issues with chain calls in which multiple calls affect the same private account. This issue can be mitigated by adopting account diff paradigm instead of the current "account state replacement" that we currently use. -- AMM tests are blocked by issues with the clock account; bug in `spel-framework`. \ No newline at end of file + +Additional observation: +AMM's chained-call privacy tests were blocked by the clock account being `DEFAULT_PROGRAM_ID`-owned in the test fixture, which trips a `spel-framework` dispatcher bug (upstream in `logos-co/spel`, confirmed present through v0.6.0) that silently drops any default-owned, non-default, unclaimed account from a program's output. Fixed by giving the fixture's clock account a non-default owner; see the AMM section. The dispatcher bug itself remains open upstream. \ No newline at end of file diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index 861899db..e4d535f3 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -542,13 +542,58 @@ arbitrary timestamp as a private witness and no check anywhere would catch it. W to the LEZ/SPEL maintainers independent of whether/when the AMM test-writing blocker itself gets prioritized. +**Upstream provenance checked (2026-07-15)**: cloned both `logos-co/spel` (upstream) and +`0x-r4bbit/spel` (the pinned fork) to check whether the filter was fork-introduced. It isn't. +`git log -S"is_default_owner"` on upstream `logos-co/spel` finds it added in PR #126, squash-merged +to upstream `main` as commit `1f51875` ("`SpelOutput::execute()` with auto-claim support"), still +present at upstream's current HEAD (`0cb7e09`, v0.6.0). Walking the fork's history back to its +merge point from `logos-co/release/v0.5.0` (commit `73fc462`) shows the filter already present +there too, before any of the fork's own commits. **This is an upstream `logos-co/spel` bug**, +inherited unchanged by the pinned fork — report it against the former, not the latter. + +### ✅ Fixed for these tests (2026-07-15) — test-fixture clock ownership, not a circuit workaround + +The immediate blocker for all five `CHAIN`-dimension AMM privacy tests below was that +`advance_clock` (`programs/integration_tests/tests/amm.rs`) constructed the clock account with +`..Account::default()`, leaving `program_owner == DEFAULT_PROGRAM_ID` and tripping the +`spel-framework` filter above on every AMM/TWAP call that echoes it back. The *real* production +clock account is owned by a dedicated clock program (`lez/system_accounts/src/lib.rs`'s +`clock_account()`: `program_owner: programs::clock().id()`), not `DEFAULT_PROGRAM_ID` — but that +constructor lives behind the gated `system-programs`/`artifacts` feature this test crate can't +reach, so `advance_clock` fabricates the account directly and never set an owner. + +Fix applied: `advance_clock` now sets `program_owner` to a placeholder non-default `ProgramId` +(`[42_u32; 8]`, a stand-in — not the real production clock program ID, which isn't reachable from +this crate). Nothing in AMM/TWAP-oracle logic checks the clock's `program_owner` value (only its +`account_id` against `CLOCK_01_PROGRAM_ACCOUNT_ID`), and `validate_execution`'s other rules +(no-ownership-change, no-unauthorized-data/balance-change) are satisfied trivially since clock is +echoed unchanged — so this is a safe, minimal, test-only fix. All five previously-blocked tests +were rewritten from `_is_not_expressible`/`expect_err` assertions to real success assertions +(rebuilding the `PrivacyPreservingTransaction`, applying it via +`transition_from_privacy_preserving_transaction`, and checking resulting public state + +commitments for the private accounts) and now pass. Full `amm.rs` suite: 40/40 passing. + +The `spel-framework` dispatcher filter itself is untouched and remains an open upstream bug +(confirmed to originate in `logos-co/spel`, not the pinned fork — see above) — it would still +silently drop any other `DEFAULT_PROGRAM_ID`-owned, non-default, unclaimed account threaded +through a chained call. Worth reporting upstream regardless of this fixture-level fix. + ### Existing -6 private tests out of 33 pre-existing public + 6 = 39. No test can yet demonstrate an -actually-working AMM privacy path — five exist purely to confirm the circuit bug also blocks -real private accounts (not just the all-public control case), and one -(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) found a second, distinct, -earlier blocker specific to `RemoveLiquidity`. +10 private tests out of 34 pre-existing public + 10 = 44. Six demonstrate an actually-working AMM +privacy path end-to-end (private account touched, transaction applied, resulting public state and +private commitments verified): the five chained-call tests unblocked by the clock test-fixture fix +above, plus `amm_new_definition_private_initial_lp_holder` (pool creation with a fresh +`PrivateAuthorizedInit` LP holder — `new_definition.rs` explicitly permits this, unlike +`swap`/`remove`). Four are confirmed not-expressible, splitting into two distinct root causes: +`amm_remove_liquidity_private_new_user_holdings_is_not_expressible` and +`amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible` both hit the same +"destination must already exist and be Token-Program-owned" precondition; +`amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible` and +`amm_new_definition_private_unauthorized_lp_holder_is_not_expressible` both hit an earlier, more +fundamental guest-ABI signer requirement that a `PrivateUnauthorized` identity can never satisfy — +any account declared a required signer in the guest ABI structurally excludes `PrivateUnauthorized` +recipients, regardless of what the program's own internal precondition would otherwise allow. **Second finding, unrelated to the circuit bug (2026-07-13)**: `remove_liquidity` requires `user_holding_a`/`user_holding_b` to already exist and already be owned by the configured Token @@ -568,25 +613,28 @@ program-level precondition that predates privacy entirely, not a circuit artifac | Instruction | Dimension | Test | Priority | Depends on | Status | |---|---|---|---|---|---| -| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — private `user_holding_a`, fails identically to the all-public control (8 vs 7 accounts) | -| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — identical 8-account/chained-call shape to `SwapExactInput`, fails identically (8 vs 7 accounts) | -| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings_is_not_expressible` — private deposit legs (`user_holding_a`/`user_holding_b`) | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — fails identically (10 vs 9 accounts) | -| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding_is_not_expressible` — private LP output holding | P1 | Token | **Confirmed not-expressible** — private `user_holding_lp`, fails identically (10 vs 9 accounts) | -| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — private `user_holding_lp`, fails identically (10 vs 9 accounts) | -| RemoveLiquidity | `EXIST` (negative) | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` — brand-new `PrivateUnauthorized` token A/B destinations | P1 | Token | **Confirmed not-expressible for a different reason** — AMM's own precondition requires the destination to already be owned by the Token Program; fails before the circuit bug is even reached | -| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | **Blocked** — see above | -| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | **Blocked** — see above (also issues chained calls reusing `pool`-derived accounts; check on resolution) | -| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started (also behind the blocker above) | -| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | **Blocked** — see above | +| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — private `user_holding_a`, unblocked by the clock test-fixture fix | +| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — same fix | +| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` — private deposit legs (`user_holding_a`/`user_holding_b`) | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — same fix | +| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | ✅ **Expressible** — private `user_holding_lp`, same fix | +| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — private `user_holding_lp`, same fix | +| RemoveLiquidity | `EXIST` (negative) | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` — brand-new `PrivateUnauthorized` token A/B destinations | P1 | Token | **Confirmed not-expressible for a different reason** — AMM's own precondition requires the destination to already be owned by the Token Program; unrelated to the clock issue | +| Swap | `EXIST` (negative) | `amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible` — brand-new `PrivateUnauthorized` recipient (`npk` only) | P1 | Token | **Confirmed not-expressible** — the guest ABI (`#[account(mut, signer)]` on both `user_holding_a`/`user_holding_b` in `methods/guest/src/bin/amm.rs`) requires every swap participant to be a signer; `PrivateUnauthorized` is authorized by nobody by construction, so it can never satisfy this, independent of the destination-must-exist issue below | +| Swap | `EXIST` (negative) | `amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible` — brand-new `PrivateAuthorizedInit` recipient (self-initializes with its own `nsk`, satisfies the signer requirement) | P1 | Token | **Confirmed not-expressible for a different reason** — same "destination must already exist and be Token-Program-owned" precondition as `RemoveLiquidity`'s finding above (`swap.rs` asserts `user_holding_b.account.program_owner == token_program_id` unconditionally) | +| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | Not started — swap into an *already-existing* private destination (`PrivateAuthorizedUpdate`); distinct from the two fresh-destination rows above, which are both confirmed not-expressible | +| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | ✅ **Expressible** — `new_definition.rs`'s own precondition on `user_holding_lp` (`account != Account::default() || is_authorized`) explicitly permits a fresh, authorized LP holder; confirmed working with a `PrivateAuthorizedInit` initial LP holder, unblocked by the clock fix above | +| NewDefinition | `EXIST` (negative), BASE | `amm_new_definition_private_unauthorized_lp_holder_is_not_expressible` — `PrivateUnauthorized` initial LP holder (`npk` only) | P2 | Token | **Confirmed not-expressible** — same guest-ABI signer requirement as the `Swap` `PrivateUnauthorized` finding above: `user_holding_lp` is `#[account(mut, signer)]` unconditionally, so it's rejected before `new_definition.rs`'s own "fresh LP holding requires user authorization" precondition is ever reached | +| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started — blocked by the separate PDA-formula finding, not the clock issue | +| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | Not started — no longer blocked by the clock issue (fixed above) | | Pool/Config (any) | `PDA` | `amm_attempt_private_pool_pda` — same predicted not-expressible outcome as above; low priority given the vault row already confirms the root cause for this program | P3 | Token | Not started | | Initialize, UpdateConfig, CreatePriceObservations, CreateOraclePriceAccount, SyncReserves | — | **Not planned** — admin/infra instructions over public protocol state; a private admin authority is legitimate but low value | — | — | Out of scope (for now) | Note: every Swap/AddLiquidity/RemoveLiquidity chains to *both* Token (transfers) and TWAP oracle (tick refresh) in one instruction — so every `CHAIN` row above is automatically also a "some legs private, some public" test. Call that out explicitly when the test is written, -not as an incidental detail. **All of these are currently blocked by the circuit-level issue -above, since it fires with zero private accounts involved — no privacy dimension can be tested -on any pool-mutating AMM instruction until it's resolved.** +not as an incidental detail. **The clock-account blocker above is fixed (2026-07-15) — the +remaining unwritten rows are open to pick up, except the `PDA` rows, which are separately +blocked by the `for_public_pda`-only formula finding.** --- diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index 6fd9c530..b827a37a 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -10,11 +10,16 @@ use amm_core::{ MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; -use integration_tests::{private_authorized_update_identity, private_unauthorized_identity}; +use integration_tests::{ + private_authorized_init_identity, private_authorized_update_identity, + private_unauthorized_identity, +}; use nssa::{ error::LeeError, execute_and_prove, - privacy_preserving_transaction::circuit::ProgramWithDependencies, + privacy_preserving_transaction::{ + circuit::ProgramWithDependencies, Message, PrivacyPreservingTransaction, WitnessSet, + }, program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, @@ -1738,19 +1743,16 @@ fn amm_create_price_observations_without_current_tick_account_fails() { /// Advances the canonical 1-block clock to `timestamp` by writing the clock account directly into /// state. `RecordTick` reads this account (`CLOCK_01_PROGRAM_ACCOUNT_ID`), so the TWAP tests use it /// to simulate the passage of time between observations. -/// -/// rc6 moved the clock program out of `nssa` into the separate system-programs crate (gated behind -/// the guest-building `artifacts` feature), so the clock can no longer be ticked by submitting a -/// real clock transaction here. Instead we set the account state directly via -/// `force_insert_account`, matching how the upstream rc6 state-machine tests seed accounts. #[cfg(test)] fn advance_clock(state: &mut V03State, timestamp: u64) { + let clock_id: nssa_core::program::ProgramId = [42_u32; 8]; let data = ClockAccountData { block_id: 0, timestamp, } .to_bytes(); let clock_account = Account { + program_owner: clock_id, data: Data::try_from(data).expect("clock account data fits"), ..Account::default() }; @@ -3124,7 +3126,7 @@ fn amm_with_deps() -> ProgramWithDependencies { } #[test] -fn amm_swap_a_to_b_private_user_holding_is_not_expressible() { +fn amm_swap_a_to_b_private_user_holding() { let mut state = state_for_amm_tests(); let user_a_nsk = PrivateKeys::user_a_nsk(); @@ -3185,7 +3187,7 @@ fn amm_swap_a_to_b_private_user_holding_is_not_expressible() { deadline: u64::MAX, }; - let result = execute_and_prove( + let (output, proof) = execute_and_prove( vec![ config_pre, pool_pre, @@ -3208,43 +3210,74 @@ fn amm_swap_a_to_b_private_user_holding_is_not_expressible() { InputAccountIdentity::Public, ], &amm_with_deps(), - ); + ) + .expect("SwapExactInput with a private user holding must succeed now that the clock account is properly owned"); - let err = result.expect_err( - "SwapExactInput must be rejected by the privacy-preserving circuit: the same \ - 'Invalid account_identities length' bug confirmed with an all-public control case \ - also fires with a real private user holding", + let message = Message::try_from_circuit_output( + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![current_nonce(&state, Ids::user_b())], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::user_b()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::pool_definition()), + Accounts::pool_definition_swap_2() ); - let message = format!("{err:?}"); - assert!( - message.contains("Invalid account_identities length"), - "expected the known circuit-level length-mismatch bug, got a different error: {message}" + assert_eq!( + state.get_account_by_id(Ids::vault_a()), + Accounts::vault_a_swap_2() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_b()), + Accounts::vault_b_swap_2() + ); + assert_eq!( + state.get_account_by_id(Ids::user_b()), + Accounts::user_b_holding_swap_2() ); -} - -#[test] -fn amm_swap_exact_output_private_user_holding_is_not_expressible() { - let mut state = state_for_amm_tests(); - let user_a_nsk = PrivateKeys::user_a_nsk(); - let user_a_vpk = PrivateKeys::user_a_vpk(); - let user_a_id = PrivateKeys::user_a_id(); - let user_a_account = Account { + let user_a_nonce_after = + Nonce::private_account_nonce_init(&user_a_id).private_account_nonce_increment(&user_a_nsk); + let new_user_a_account = Account { program_owner: Ids::token_program(), balance: 0, data: Data::from(&TokenHolding::Fungible { definition_id: Ids::token_a_definition(), - balance: Balances::user_a_init(), + balance: Balances::user_a_swap_2(), }), - nonce: Nonce::private_account_nonce_init(&user_a_id), + nonce: user_a_nonce_after, }; - state = state.with_private_accounts([( - Commitment::new(&user_a_id, &user_a_account), - Nullifier::for_account_initialization(&user_a_id), - )]); - let membership_proof = state - .get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account)) - .expect("user_a's commitment must be in the set"); + assert!(state + .get_proof_for_commitment(&Commitment::new(&user_a_id, &new_user_a_account)) + .is_some()); +} + +/// Swap that pays out to `PrivateUnauthorized` is prohibited: both swap legs must be signers, +/// which `PrivateUnauthorized` (no `nsk`) can never be. +#[test] +fn amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible() { + let state = state_for_amm_tests(); + + let user_b_npk = PrivateKeys::user_b_npk(); + let user_b_vpk = PrivateKeys::user_b_vpk(); + let user_b_id = PrivateKeys::user_b_id(); let config_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); @@ -3263,9 +3296,9 @@ fn amm_swap_exact_output_private_user_holding_is_not_expressible() { false, Ids::vault_b(), ); - let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); - let user_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); + let user_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), true, Ids::user_a()); + let user_b_pre = AccountWithMetadata::new(Account::default(), false, user_b_id); let current_tick_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::current_tick_account()), false, @@ -3277,9 +3310,9 @@ fn amm_swap_exact_output_private_user_holding_is_not_expressible() { CLOCK_01_PROGRAM_ACCOUNT_ID, ); - let instruction = amm_core::Instruction::SwapExactOutput { - exact_amount_out: Balances::swap_min_out(), - max_amount_in: Balances::swap_amount_in(), + let instruction = amm_core::Instruction::SwapExactInput { + swap_amount_in: Balances::swap_amount_in(), + min_amount_out: Balances::swap_min_out(), token_definition_id_in: Ids::token_a_definition(), deadline: u64::MAX, }; @@ -3301,8 +3334,8 @@ fn amm_swap_exact_output_private_user_holding_is_not_expressible() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - private_authorized_update_identity(user_a_nsk, &user_a_vpk, membership_proof, 0), InputAccountIdentity::Public, + private_unauthorized_identity(user_b_npk, &user_b_vpk, 0), InputAccountIdentity::Public, InputAccountIdentity::Public, ], @@ -3310,39 +3343,27 @@ fn amm_swap_exact_output_private_user_holding_is_not_expressible() { ); let err = result.expect_err( - "SwapExactOutput must be rejected by the privacy-preserving circuit: the same \ - 'Invalid account_identities length' bug also fires with a real private user holding", + "SwapExactInput must be rejected: user_holding_b must be a signer per the guest ABI, \ + which a PrivateUnauthorized identity (no nsk, unauthorized by construction) can never \ + satisfy", ); let message = format!("{err:?}"); assert!( - message.contains("Invalid account_identities length"), - "expected the known circuit-level length-mismatch bug, got a different error: {message}" + message.contains("must be a signer"), + "expected the guest ABI's signer requirement on user_holding_b, got a different \ + error: {message}" ); } +/// Swap that pays out to `PrivateAuthorizedInit` is prohibited. Payment +/// is only permitted to already initialized accounts. #[test] -fn amm_add_liquidity_private_lp_holding_is_not_expressible() { - let mut state = state_for_amm_tests(); +fn amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible() { + let state = state_for_amm_tests(); - let user_lp_nsk = PrivateKeys::user_lp_nsk(); - let user_lp_vpk = PrivateKeys::user_lp_vpk(); - let user_lp_id = PrivateKeys::user_lp_id(); - let user_lp_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_lp_definition(), - balance: 500, - }), - nonce: Nonce::private_account_nonce_init(&user_lp_id), - }; - state = state.with_private_accounts([( - Commitment::new(&user_lp_id, &user_lp_account), - Nullifier::for_account_initialization(&user_lp_id), - )]); - let membership_proof = state - .get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account)) - .expect("user_lp's commitment must be in the set"); + let user_b_nsk = PrivateKeys::user_b_nsk(); + let user_b_vpk = PrivateKeys::user_b_vpk(); + let user_b_id = PrivateKeys::user_b_id(); let config_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); @@ -3361,16 +3382,9 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() { false, Ids::vault_b(), ); - let token_lp_definition_pre = AccountWithMetadata::new( - state.get_account_by_id(Ids::token_lp_definition()), - false, - Ids::token_lp_definition(), - ); let user_a_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), true, Ids::user_a()); - let user_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); - let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id); + let user_b_pre = AccountWithMetadata::new(Account::default(), true, user_b_id); let current_tick_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::current_tick_account()), false, @@ -3382,10 +3396,10 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() { CLOCK_01_PROGRAM_ACCOUNT_ID, ); - let instruction = amm_core::Instruction::AddLiquidity { - min_amount_liquidity: Balances::add_min_lp(), - max_amount_to_add_token_a: Balances::add_max_a(), - max_amount_to_add_token_b: Balances::add_max_b(), + let instruction = amm_core::Instruction::SwapExactInput { + swap_amount_in: Balances::swap_amount_in(), + min_amount_out: Balances::swap_min_out(), + token_definition_id_in: Ids::token_a_definition(), deadline: u64::MAX, }; @@ -3395,10 +3409,8 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() { pool_pre, vault_a_pre, vault_b_pre, - token_lp_definition_pre, user_a_pre, user_b_pre, - user_lp_pre, current_tick_pre, clock_pre, ], @@ -3409,9 +3421,7 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::Public, - InputAccountIdentity::Public, - private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0), + private_authorized_init_identity(user_b_nsk, &user_b_vpk, 0), InputAccountIdentity::Public, InputAccountIdentity::Public, ], @@ -3419,39 +3429,41 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() { ); let err = result.expect_err( - "AddLiquidity must be rejected by the privacy-preserving circuit: the same \ - 'Invalid account_identities length' bug also fires with a real private LP holding", + "SwapExactInput must be rejected by the AMM program itself: user_holding_b must already \ + be initialized and owned by the configured Token Program before any chained call or the \ + privacy-preserving circuit is ever reached", ); let message = format!("{err:?}"); assert!( - message.contains("Invalid account_identities length"), - "expected the known circuit-level length-mismatch bug, got a different error: {message}" + message.contains("User Token B holding must be owned by the configured Token Program"), + "expected the AMM program's own initialized-destination precondition, got a different \ + error: {message}" ); } #[test] -fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { +fn amm_swap_exact_output_private_user_holding() { let mut state = state_for_amm_tests(); - let user_lp_nsk = PrivateKeys::user_lp_nsk(); - let user_lp_vpk = PrivateKeys::user_lp_vpk(); - let user_lp_id = PrivateKeys::user_lp_id(); - let user_lp_account = Account { + let user_a_nsk = PrivateKeys::user_a_nsk(); + let user_a_vpk = PrivateKeys::user_a_vpk(); + let user_a_id = PrivateKeys::user_a_id(); + let user_a_account = Account { program_owner: Ids::token_program(), balance: 0, data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_lp_definition(), - balance: Balances::remove_lp(), + definition_id: Ids::token_a_definition(), + balance: Balances::user_a_init(), }), - nonce: Nonce::private_account_nonce_init(&user_lp_id), + nonce: Nonce::private_account_nonce_init(&user_a_id), }; state = state.with_private_accounts([( - Commitment::new(&user_lp_id, &user_lp_account), - Nullifier::for_account_initialization(&user_lp_id), + Commitment::new(&user_a_id, &user_a_account), + Nullifier::for_account_initialization(&user_a_id), )]); let membership_proof = state - .get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account)) - .expect("user_lp's commitment must be in the set"); + .get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account)) + .expect("user_a's commitment must be in the set"); let config_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); @@ -3470,16 +3482,9 @@ fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { false, Ids::vault_b(), ); - let token_lp_definition_pre = AccountWithMetadata::new( - state.get_account_by_id(Ids::token_lp_definition()), - false, - Ids::token_lp_definition(), - ); - let user_a_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), false, Ids::user_a()); + let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); let user_b_pre = - AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), false, Ids::user_b()); - let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id); + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); let current_tick_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::current_tick_account()), false, @@ -3491,23 +3496,21 @@ fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { CLOCK_01_PROGRAM_ACCOUNT_ID, ); - let instruction = amm_core::Instruction::RemoveLiquidity { - remove_liquidity_amount: Balances::remove_lp(), - min_amount_to_remove_token_a: Balances::remove_min_a(), - min_amount_to_remove_token_b: Balances::remove_min_b(), + let instruction = amm_core::Instruction::SwapExactOutput { + exact_amount_out: Balances::swap_min_out(), + max_amount_in: Balances::swap_amount_in(), + token_definition_id_in: Ids::token_a_definition(), deadline: u64::MAX, }; - let result = execute_and_prove( + let (output, proof) = execute_and_prove( vec![ config_pre, pool_pre, vault_a_pre, vault_b_pre, - token_lp_definition_pre, user_a_pre, user_b_pre, - user_lp_pre, current_tick_pre, clock_pre, ], @@ -3517,37 +3520,121 @@ fn amm_remove_liquidity_private_lp_holding_is_not_expressible() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, + private_authorized_update_identity(user_a_nsk, &user_a_vpk, membership_proof, 0), InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0), - InputAccountIdentity::Public, - InputAccountIdentity::Public, ], &amm_with_deps(), - ); + ) + .expect("SwapExactOutput with a private user holding must succeed now that the clock account is properly owned"); - let err = result.expect_err( - "RemoveLiquidity must be rejected by the privacy-preserving circuit: the same \ - 'Invalid account_identities length' bug also fires with a real private LP holding", - ); - let message = format!("{err:?}"); - assert!( - message.contains("Invalid account_identities length"), - "expected the known circuit-level length-mismatch bug, got a different error: {message}" + let message = Message::try_from_circuit_output( + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![current_nonce(&state, Ids::user_b())], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::user_b()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + // exact_amount_out = 200 (token B), max_amount_in = 1_000 (token A cap), 30 bps fee, against + // the fresh pool's initial reserves (5_000 A / 2_500 B): + // effective_in_min = ceil(5_000 * 200 / (2_500 - 200)) = ceil(1_000_000 / 2_300) = 435 + // deposit_amount = ceil(435 * 10_000 / (10_000 - 30)) = ceil(4_350_000 / 9_970) = 437 + let deposit_amount = 437_u128; + let withdraw_amount = Balances::swap_min_out(); + + let pool = pool_definition(&state.get_account_by_id(Ids::pool_definition())); + assert_eq!(pool.reserve_a, Balances::vault_a_init() + deposit_amount); + assert_eq!(pool.reserve_b, Balances::vault_b_init() - withdraw_amount); + match TokenHolding::try_from(&state.get_account_by_id(Ids::vault_a()).data) + .expect("valid holding") + { + TokenHolding::Fungible { balance, .. } => { + assert_eq!(balance, Balances::vault_a_init() + deposit_amount); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible vault holding") + } + } + match TokenHolding::try_from(&state.get_account_by_id(Ids::vault_b()).data) + .expect("valid holding") + { + TokenHolding::Fungible { balance, .. } => { + assert_eq!(balance, Balances::vault_b_init() - withdraw_amount); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible vault holding") + } + } + assert_eq!( + state.get_account_by_id(Ids::user_b()), + Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_b_definition(), + balance: Balances::user_b_init() + withdraw_amount, + }), + nonce: Nonce(1), + } ); + + let user_a_nonce_after = + Nonce::private_account_nonce_init(&user_a_id).private_account_nonce_increment(&user_a_nsk); + let new_user_a_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_a_definition(), + balance: Balances::user_a_init() - deposit_amount, + }), + nonce: user_a_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&user_a_id, &new_user_a_account)) + .is_some()); } #[test] -fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { - let state = state_for_amm_tests(); +fn amm_add_liquidity_private_lp_holding() { + let mut state = state_for_amm_tests(); - let user_a_npk = PrivateKeys::user_a_npk(); - let user_a_vpk = PrivateKeys::user_a_vpk(); - let user_a_id = PrivateKeys::user_a_id(); - let user_b_npk = PrivateKeys::user_b_npk(); - let user_b_vpk = PrivateKeys::user_b_vpk(); - let user_b_id = PrivateKeys::user_b_id(); + let user_lp_nsk = PrivateKeys::user_lp_nsk(); + let user_lp_vpk = PrivateKeys::user_lp_vpk(); + let user_lp_id = PrivateKeys::user_lp_id(); + let user_lp_initial_balance = 500_u128; + let user_lp_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance: user_lp_initial_balance, + }), + nonce: Nonce::private_account_nonce_init(&user_lp_id), + }; + state = state.with_private_accounts([( + Commitment::new(&user_lp_id, &user_lp_account), + Nullifier::for_account_initialization(&user_lp_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account)) + .expect("user_lp's commitment must be in the set"); let config_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); @@ -3571,13 +3658,11 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { false, Ids::token_lp_definition(), ); - let user_a_pre = AccountWithMetadata::new(Account::default(), false, user_a_id); - let user_b_pre = AccountWithMetadata::new(Account::default(), false, user_b_id); - let user_lp_pre = AccountWithMetadata::new( - state.get_account_by_id(Ids::user_lp()), - true, - Ids::user_lp(), - ); + let user_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), true, Ids::user_a()); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); + let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id); let current_tick_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::current_tick_account()), false, @@ -3589,20 +3674,652 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { CLOCK_01_PROGRAM_ACCOUNT_ID, ); - let instruction = amm_core::Instruction::RemoveLiquidity { - remove_liquidity_amount: Balances::remove_lp(), + let instruction = amm_core::Instruction::AddLiquidity { + min_amount_liquidity: Balances::add_min_lp(), + max_amount_to_add_token_a: Balances::add_max_a(), + max_amount_to_add_token_b: Balances::add_max_b(), + deadline: u64::MAX, + }; + + let (output, proof) = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ) + .expect("AddLiquidity with a private LP holding must succeed now that the clock account is properly owned"); + + let message = Message::try_from_circuit_output( + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::token_lp_definition(), + Ids::user_a(), + Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![ + current_nonce(&state, Ids::user_a()), + current_nonce(&state, Ids::user_b()), + ], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::user_a(), &Keys::user_b()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::pool_definition()), + Accounts::pool_definition_add() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_a()), + Accounts::vault_a_add() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_b()), + Accounts::vault_b_add() + ); + assert_eq!( + state.get_account_by_id(Ids::token_lp_definition()), + Accounts::token_lp_definition_add() + ); + assert_eq!( + state.get_account_by_id(Ids::user_a()), + Accounts::user_a_holding_add() + ); + assert_eq!( + state.get_account_by_id(Ids::user_b()), + Accounts::user_b_holding_add() + ); + + // Minted LP = post-add total supply - pre-add total supply, independent of who holds it. + let minted_lp = Balances::token_lp_supply_add() - Balances::pool_lp_supply_init(); + let user_lp_nonce_after = Nonce::private_account_nonce_init(&user_lp_id) + .private_account_nonce_increment(&user_lp_nsk); + let new_user_lp_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance: user_lp_initial_balance + minted_lp, + }), + nonce: user_lp_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&user_lp_id, &new_user_lp_account)) + .is_some()); +} + +#[test] +fn amm_remove_liquidity_private_lp_holding() { + let mut state = state_for_amm_tests(); + + let user_lp_nsk = PrivateKeys::user_lp_nsk(); + let user_lp_vpk = PrivateKeys::user_lp_vpk(); + let user_lp_id = PrivateKeys::user_lp_id(); + let user_lp_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance: Balances::remove_lp(), + }), + nonce: Nonce::private_account_nonce_init(&user_lp_id), + }; + state = state.with_private_accounts([( + Commitment::new(&user_lp_id, &user_lp_account), + Nullifier::for_account_initialization(&user_lp_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account)) + .expect("user_lp's commitment must be in the set"); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let user_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), false, Ids::user_a()); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), false, Ids::user_b()); + let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::RemoveLiquidity { + remove_liquidity_amount: Balances::remove_lp(), + min_amount_to_remove_token_a: Balances::remove_min_a(), + min_amount_to_remove_token_b: Balances::remove_min_b(), + deadline: u64::MAX, + }; + + let (output, proof) = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ) + .expect("RemoveLiquidity with a private LP holding must succeed now that the clock account is properly owned"); + + let message = Message::try_from_circuit_output( + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::token_lp_definition(), + Ids::user_a(), + Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::pool_definition()), + Accounts::pool_definition_remove() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_a()), + Accounts::vault_a_remove() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_b()), + Accounts::vault_b_remove() + ); + assert_eq!( + state.get_account_by_id(Ids::token_lp_definition()), + Accounts::token_lp_definition_remove() + ); + assert_eq!( + state.get_account_by_id(Ids::user_a()), + Accounts::user_a_holding_remove() + ); + assert_eq!( + state.get_account_by_id(Ids::user_b()), + Accounts::user_b_holding_remove() + ); + + // user_lp burned its entire private balance (remove_liquidity_amount == its full holding). + let user_lp_nonce_after = Nonce::private_account_nonce_init(&user_lp_id) + .private_account_nonce_increment(&user_lp_nsk); + let new_user_lp_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_lp_definition(), + balance: 0, + }), + nonce: user_lp_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&user_lp_id, &new_user_lp_account)) + .is_some()); +} + +#[test] +fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { + let state = state_for_amm_tests(); + + let user_a_npk = PrivateKeys::user_a_npk(); + let user_a_vpk = PrivateKeys::user_a_vpk(); + let user_a_id = PrivateKeys::user_a_id(); + let user_b_npk = PrivateKeys::user_b_npk(); + let user_b_vpk = PrivateKeys::user_b_vpk(); + let user_b_id = PrivateKeys::user_b_id(); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let user_a_pre = AccountWithMetadata::new(Account::default(), false, user_a_id); + let user_b_pre = AccountWithMetadata::new(Account::default(), false, user_b_id); + let user_lp_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::user_lp()), + true, + Ids::user_lp(), + ); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::RemoveLiquidity { + remove_liquidity_amount: Balances::remove_lp(), min_amount_to_remove_token_a: Balances::remove_min_a(), min_amount_to_remove_token_b: Balances::remove_min_b(), deadline: u64::MAX, }; - let result = execute_and_prove( + let result = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_unauthorized_identity(user_a_npk, &user_a_vpk, 0), + private_unauthorized_identity(user_b_npk, &user_b_vpk, 1), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ); + + let err = result.expect_err( + "RemoveLiquidity must be rejected by the AMM program itself: user_holding_a/b must \ + already be initialized and owned by the configured Token Program before any chained \ + call or the privacy-preserving circuit is ever reached", + ); + let message = format!("{err:?}"); + assert!( + message.contains("User Token A holding must be owned by the configured Token Program"), + "expected the AMM program's own initialized-destination precondition, got a different \ + error: {message}" + ); +} + +#[test] +fn amm_add_liquidity_private_user_holdings() { + let mut state = state_for_amm_tests(); + + let user_a_nsk = PrivateKeys::user_a_nsk(); + let user_a_vpk = PrivateKeys::user_a_vpk(); + let user_a_id = PrivateKeys::user_a_id(); + let user_a_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_a_definition(), + balance: Balances::user_a_init(), + }), + nonce: Nonce::private_account_nonce_init(&user_a_id), + }; + + let user_b_nsk = PrivateKeys::user_b_nsk(); + let user_b_vpk = PrivateKeys::user_b_vpk(); + let user_b_id = PrivateKeys::user_b_id(); + let user_b_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_b_definition(), + balance: Balances::user_b_init(), + }), + nonce: Nonce::private_account_nonce_init(&user_b_id), + }; + + state = state.with_private_accounts([ + ( + Commitment::new(&user_a_id, &user_a_account), + Nullifier::for_account_initialization(&user_a_id), + ), + ( + Commitment::new(&user_b_id, &user_b_account), + Nullifier::for_account_initialization(&user_b_id), + ), + ]); + let user_a_membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account)) + .expect("user_a's commitment must be in the set"); + let user_b_membership_proof = state + .get_proof_for_commitment(&Commitment::new(&user_b_id, &user_b_account)) + .expect("user_b's commitment must be in the set"); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); + let user_b_pre = AccountWithMetadata::new(user_b_account, true, user_b_id); + let user_lp_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::user_lp()), + false, + Ids::user_lp(), + ); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::AddLiquidity { + min_amount_liquidity: Balances::add_min_lp(), + max_amount_to_add_token_a: Balances::add_max_a(), + max_amount_to_add_token_b: Balances::add_max_b(), + deadline: u64::MAX, + }; + + let (output, proof) = execute_and_prove( + vec![ + config_pre, + pool_pre, + vault_a_pre, + vault_b_pre, + token_lp_definition_pre, + user_a_pre, + user_b_pre, + user_lp_pre, + current_tick_pre, + clock_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_authorized_update_identity(user_a_nsk, &user_a_vpk, user_a_membership_proof, 0), + private_authorized_update_identity(user_b_nsk, &user_b_vpk, user_b_membership_proof, 1), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &amm_with_deps(), + ) + .expect("AddLiquidity with private deposit holdings must succeed now that the clock account is properly owned"); + + let message = Message::try_from_circuit_output( + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::token_lp_definition(), + Ids::user_lp(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::pool_definition()), + Accounts::pool_definition_add() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_a()), + Accounts::vault_a_add() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_b()), + Accounts::vault_b_add() + ); + assert_eq!( + state.get_account_by_id(Ids::token_lp_definition()), + Accounts::token_lp_definition_add() + ); + // user_lp is public and already seeded by state_for_amm_tests() (Accounts::user_lp_holding(), + // balance Balances::user_lp_init()); it just receives the same minted LP as the all-public + // amm_add_liquidity test, landing on the same post-state. + assert_eq!( + state.get_account_by_id(Ids::user_lp()), + Accounts::user_lp_holding_add() + ); + + let user_a_nonce_after = + Nonce::private_account_nonce_init(&user_a_id).private_account_nonce_increment(&user_a_nsk); + let new_user_a_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_a_definition(), + balance: Balances::user_a_init() - Balances::add_max_a(), + }), + nonce: user_a_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&user_a_id, &new_user_a_account)) + .is_some()); + + let user_b_nonce_after = + Nonce::private_account_nonce_init(&user_b_id).private_account_nonce_increment(&user_b_nsk); + let new_user_b_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_b_definition(), + balance: Balances::user_b_init() - Balances::add_max_b(), + }), + nonce: user_b_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&user_b_id, &new_user_b_account)) + .is_some()); +} + +/// Initialized pool mints LP tokens to `PrivateAuthorizedInit`. +#[test] +fn amm_new_definition_private_initial_lp_holder() { + let mut state = state_for_amm_tests_with_new_def(); + state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); + state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); + + let user_lp_nsk = PrivateKeys::user_lp_nsk(); + let user_lp_vpk = PrivateKeys::user_lp_vpk(); + let user_lp_id = PrivateKeys::user_lp_id(); + + let config_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); + let pool_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::pool_definition()), + false, + Ids::pool_definition(), + ); + let vault_a_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_a()), + false, + Ids::vault_a(), + ); + let vault_b_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::vault_b()), + false, + Ids::vault_b(), + ); + let token_lp_definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_lp_definition()), + false, + Ids::token_lp_definition(), + ); + let lp_lock_holding_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::lp_lock_holding()), + false, + Ids::lp_lock_holding(), + ); + let user_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), true, Ids::user_a()); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); + let user_lp_pre = AccountWithMetadata::new(Account::default(), true, user_lp_id); + let current_tick_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::current_tick_account()), + false, + Ids::current_tick_account(), + ); + let clock_pre = AccountWithMetadata::new( + state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID), + false, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + + let instruction = amm_core::Instruction::NewDefinition { + token_a_amount: Balances::vault_a_init(), + token_b_amount: Balances::vault_b_init(), + fees: Balances::fee_tier(), + deadline: u64::MAX, + }; + + let (output, proof) = execute_and_prove( vec![ config_pre, pool_pre, vault_a_pre, vault_b_pre, token_lp_definition_pre, + lp_lock_holding_pre, user_a_pre, user_b_pre, user_lp_pre, @@ -3616,74 +4333,111 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - private_unauthorized_identity(user_a_npk, &user_a_vpk, 0), - private_unauthorized_identity(user_b_npk, &user_b_vpk, 1), InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, + private_authorized_init_identity(user_lp_nsk, &user_lp_vpk, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, ], &amm_with_deps(), - ); + ) + .expect("NewDefinition with a private initial LP holder must succeed: the program's own precondition allows a fresh, authorized user_holding_lp"); - let err = result.expect_err( - "RemoveLiquidity must be rejected by the AMM program itself: user_holding_a/b must \ - already be initialized and owned by the configured Token Program before any chained \ - call or the privacy-preserving circuit is ever reached", + let message = Message::try_from_circuit_output( + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::token_lp_definition(), + Ids::lp_lock_holding(), + Ids::user_a(), + Ids::user_b(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![ + current_nonce(&state, Ids::user_a()), + current_nonce(&state, Ids::user_b()), + ], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::user_a(), &Keys::user_b()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::pool_definition()), + Accounts::pool_definition_new_init() ); - let message = format!("{err:?}"); - assert!( - message.contains("User Token A holding must be owned by the configured Token Program"), - "expected the AMM program's own initialized-destination precondition, got a different \ - error: {message}" + assert_eq!( + state.get_account_by_id(Ids::vault_a()), + Accounts::vault_a_init() + ); + assert_eq!( + state.get_account_by_id(Ids::vault_b()), + Accounts::vault_b_init() + ); + assert_eq!( + state.get_account_by_id(Ids::token_lp_definition()), + Accounts::token_lp_definition_new_init() + ); + assert_eq!( + state.get_account_by_id(Ids::lp_lock_holding()), + Accounts::lp_lock_holding_new_init() + ); + assert_eq!( + state.get_account_by_id(Ids::user_a()), + Accounts::user_a_holding_new_init() + ); + assert_eq!( + state.get_account_by_id(Ids::user_b()), + Accounts::user_b_holding_new_init() ); -} -#[test] -fn amm_add_liquidity_private_user_holdings_is_not_expressible() { - let mut state = state_for_amm_tests(); + let current_tick = state.get_account_by_id(Ids::current_tick_account()); + assert_eq!(current_tick.program_owner, Ids::twap_oracle_program()); + let tick_account = twap_oracle_core::CurrentTickAccount::try_from(¤t_tick.data) + .expect("current tick account must hold a valid CurrentTickAccount"); + let expected_tick = twap_oracle_core::price_to_tick(amm_core::spot_price_q64_64( + Balances::vault_a_init(), + Balances::vault_b_init(), + )); + assert_eq!(tick_account.tick, expected_tick); - let user_a_nsk = PrivateKeys::user_a_nsk(); - let user_a_vpk = PrivateKeys::user_a_vpk(); - let user_a_id = PrivateKeys::user_a_id(); - let user_a_account = Account { + // A fresh PrivateAuthorizedInit account's nonce starts at private_account_nonce_init, not + // incremented — this is its first-ever commitment, not an update to an existing one. + let new_user_lp_account = Account { program_owner: Ids::token_program(), balance: 0, data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_a_definition(), - balance: Balances::user_a_init(), + definition_id: Ids::token_lp_definition(), + balance: Balances::lp_user_init(), }), - nonce: Nonce::private_account_nonce_init(&user_a_id), + nonce: Nonce::private_account_nonce_init(&user_lp_id), }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&user_lp_id, &new_user_lp_account)) + .is_some()); +} - let user_b_nsk = PrivateKeys::user_b_nsk(); - let user_b_vpk = PrivateKeys::user_b_vpk(); - let user_b_id = PrivateKeys::user_b_id(); - let user_b_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_b_definition(), - balance: Balances::user_b_init(), - }), - nonce: Nonce::private_account_nonce_init(&user_b_id), - }; +/// Initialized pool cannot mint LP tokens to `PrivateUnauthorized`. +#[test] +fn amm_new_definition_private_unauthorized_lp_holder_is_not_expressible() { + let mut state = state_for_amm_tests_with_new_def(); + state.force_insert_account(Ids::vault_a(), Accounts::vault_a_reinitializable()); + state.force_insert_account(Ids::vault_b(), Accounts::vault_b_reinitializable()); - state = state.with_private_accounts([ - ( - Commitment::new(&user_a_id, &user_a_account), - Nullifier::for_account_initialization(&user_a_id), - ), - ( - Commitment::new(&user_b_id, &user_b_account), - Nullifier::for_account_initialization(&user_b_id), - ), - ]); - let user_a_membership_proof = state - .get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account)) - .expect("user_a's commitment must be in the set"); - let user_b_membership_proof = state - .get_proof_for_commitment(&Commitment::new(&user_b_id, &user_b_account)) - .expect("user_b's commitment must be in the set"); + let user_lp_npk = PrivateKeys::user_lp_npk(); + let user_lp_vpk = PrivateKeys::user_lp_vpk(); + let user_lp_id = PrivateKeys::user_lp_id(); let config_pre = AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config()); @@ -3707,13 +4461,16 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() { false, Ids::token_lp_definition(), ); - let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id); - let user_b_pre = AccountWithMetadata::new(user_b_account, true, user_b_id); - let user_lp_pre = AccountWithMetadata::new( - state.get_account_by_id(Ids::user_lp()), + let lp_lock_holding_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::lp_lock_holding()), false, - Ids::user_lp(), + Ids::lp_lock_holding(), ); + let user_a_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), true, Ids::user_a()); + let user_b_pre = + AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b()); + let user_lp_pre = AccountWithMetadata::new(Account::default(), false, user_lp_id); let current_tick_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::current_tick_account()), false, @@ -3725,10 +4482,10 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() { CLOCK_01_PROGRAM_ACCOUNT_ID, ); - let instruction = amm_core::Instruction::AddLiquidity { - min_amount_liquidity: Balances::add_min_lp(), - max_amount_to_add_token_a: Balances::add_max_a(), - max_amount_to_add_token_b: Balances::add_max_b(), + let instruction = amm_core::Instruction::NewDefinition { + token_a_amount: Balances::vault_a_init(), + token_b_amount: Balances::vault_b_init(), + fees: Balances::fee_tier(), deadline: u64::MAX, }; @@ -3739,6 +4496,7 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() { vault_a_pre, vault_b_pre, token_lp_definition_pre, + lp_lock_holding_pre, user_a_pre, user_b_pre, user_lp_pre, @@ -3752,22 +4510,25 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - private_authorized_update_identity(user_a_nsk, &user_a_vpk, user_a_membership_proof, 0), - private_authorized_update_identity(user_b_nsk, &user_b_vpk, user_b_membership_proof, 1), InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, + private_unauthorized_identity(user_lp_npk, &user_lp_vpk, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, ], &amm_with_deps(), ); let err = result.expect_err( - "AddLiquidity must be rejected by the privacy-preserving circuit: the same \ - 'Invalid account_identities length' bug also fires with real private deposit holdings", + "NewDefinition must be rejected: user_holding_lp must be a signer per the guest ABI, \ + which a PrivateUnauthorized identity (no nsk, unauthorized by construction) can never \ + satisfy", ); let message = format!("{err:?}"); assert!( - message.contains("Invalid account_identities length"), - "expected the known circuit-level length-mismatch bug, got a different error: {message}" + message.contains("must be a signer"), + "expected the guest ABI's signer requirement on user_holding_lp, got a different \ + error: {message}" ); } From a7b95ddb4b61d06c02c7f583f79044a895027ef9 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Wed, 15 Jul 2026 14:19:25 -0400 Subject: [PATCH 09/12] docs(privacy): reattribute clock bug's deeper cause to logos-execution-zone The spel-framework dispatcher filter that drops the clock account is real, but ValidatedStateDiff::from_public_transaction never checks that a program's output accounts match the caller-declared message.account_ids, unlike the privacy circuit's own account_identities.len() == states_iter.len() check. That absence is why the drop went unnoticed by every pre-existing public AMM test. Update findings.md's conclusion and add a dated correction to privacy-test-matrix.md attributing this second, independent gap to logos-execution-zone. --- docs/findings.md | 4 ++-- docs/privacy-test-matrix.md | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/findings.md b/docs/findings.md index 042d4330..bf6adff3 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -74,7 +74,7 @@ In this task, we extend testing for LEZ programs to cover privacy features: # LEZ programs -## AMM program (unusual issues) +## AMM program | Function tested | Test name | Category | Description of objective | Result | |---|---|---|---|---| @@ -162,4 +162,4 @@ Privacy coverage for LEZ program tests is greatly improved from the added tests. - Privacy transactions have issues with chain calls in which multiple calls affect the same private account. This issue can be mitigated by adopting account diff paradigm instead of the current "account state replacement" that we currently use. Additional observation: -AMM's chained-call privacy tests were blocked by the clock account being `DEFAULT_PROGRAM_ID`-owned in the test fixture, which trips a `spel-framework` dispatcher bug (upstream in `logos-co/spel`, confirmed present through v0.6.0) that silently drops any default-owned, non-default, unclaimed account from a program's output. Fixed by giving the fixture's clock account a non-default owner; see the AMM section. The dispatcher bug itself remains open upstream. \ No newline at end of file +AMM's chained-call privacy tests were blocked by the clock account being `DEFAULT_PROGRAM_ID`-owned in the test fixture, which trips a `spel-framework` dispatcher filter (upstream in `logos-co/spel`) that silently drops any default-owned, non-default, unclaimed account from a program's output. Fixed by giving the fixture's clock account a non-default owner; see the AMM section. But the deeper bug is in `logos-execution-zone` itself: `ValidatedStateDiff::from_public_transaction` never checks that the accounts touched in a program's output match the caller-declared `message.account_ids` — no count, no membership check, nothing like the privacy circuit's own `account_identities.len() == states_iter.len()` assertion. That's why the `spel-framework` drop went unnoticed by every pre-existing public AMM test: the public path has no validation capable of catching a silently-dropped account at all. Both remain open upstream. \ No newline at end of file diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index e4d535f3..dfd7fdab 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -551,6 +551,26 @@ merge point from `logos-co/release/v0.5.0` (commit `73fc462`) shows the filter a there too, before any of the fork's own commits. **This is an upstream `logos-co/spel` bug**, inherited unchanged by the pinned fork — report it against the former, not the latter. +**Correction — there's a second, independent bug in `logos-execution-zone` itself (2026-07-15)**: +the "not a `lez_core`/circuit bug" claim above is too narrow. `ValidatedStateDiff:: +from_public_transaction` (`lee/state_machine/src/validated_state_diff.rs`) never checks that the +accounts touched in a program's output match the caller-declared `message.account_ids` — no count +check, no membership check, nothing analogous to the privacy circuit's own `account_identities. +len() == states_iter.len()` assertion in `compute_circuit_output`. The reconciliation loop just +does `state_diff.insert(pre.account_id, post.account().clone())` for whatever pairs the program's +output happens to contain, however many that is, and returns `Ok(...)` regardless. + +That absence is why the `spel-framework` drop went unnoticed by every one of this repo's ~34 +pre-existing public AMM tests: the public path has no validation capable of catching a +silently-dropped account at all, so a bug with zero privacy dimension to it hid behind passing +public tests until the privacy circuit's stricter, positional bookkeeping happened to expose it. +Two independent upstream defects, not one: `spel-framework`'s over-broad filter (which creates the +drop) and `logos-execution-zone`'s missing account-accounting check on the public path (which lets +any such drop — from this or any future bug — go completely undetected). The latter is arguably +the more consequential of the two, since it's a general soundness gap independent of clock, AMM, +or privacy entirely. Both should be reported upstream; report the `lee` gap against +`logos-co/logos-execution-zone`, not `logos-co/spel`. + ### ✅ Fixed for these tests (2026-07-15) — test-fixture clock ownership, not a circuit workaround The immediate blocker for all five `CHAIN`-dimension AMM privacy tests below was that From 9a2d5f8f5f116f1bfc155e873de61d7aaa8c10dc Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Wed, 15 Jul 2026 14:24:54 -0400 Subject: [PATCH 10/12] fix(deps): finish the v0.2.0 logos-execution-zone bump for integration_tests ff89025 bumped the workspace's nssa/nssa_core to logos-execution-zone v0.2.0 but missed integration_tests' own directly-pinned clock_core/key_protocol deps, left on v0.2.0-rc6. That split the dependency graph across two source revisions of lee_core, so types like NullifierPublicKey stopped unifying across crate boundaries and integration_tests failed to compile. Bump the two strays to v0.2.0 to match the rest of the workspace. --- Cargo.lock | 14 +++++++------- programs/integration_tests/Cargo.toml | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0933877d..84c99778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,7 +724,7 @@ dependencies = [ [[package]] name = "authenticated_transfer_core" version = "0.1.0" -source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" dependencies = [ "serde", ] @@ -1024,7 +1024,7 @@ dependencies = [ [[package]] name = "bridge_core" version = "0.1.0" -source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" dependencies = [ "lee_core", "serde", @@ -1238,7 +1238,7 @@ dependencies = [ [[package]] name = "common" version = "0.1.0" -source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" dependencies = [ "anyhow", "authenticated_transfer_core", @@ -2030,7 +2030,7 @@ dependencies = [ [[package]] name = "faucet_core" version = "0.1.0" -source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" dependencies = [ "lee_core", "serde", @@ -3171,7 +3171,7 @@ dependencies = [ [[package]] name = "key_protocol" version = "0.1.0" -source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" dependencies = [ "aes-gcm", "anyhow", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "programs" version = "0.1.0" -source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" dependencies = [ "build_utils", "lee", @@ -6924,7 +6924,7 @@ dependencies = [ [[package]] name = "system_accounts" version = "0.1.0" -source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" dependencies = [ "bridge_core", "clock_core", diff --git a/programs/integration_tests/Cargo.toml b/programs/integration_tests/Cargo.toml index 9eb2b101..d46022fa 100644 --- a/programs/integration_tests/Cargo.toml +++ b/programs/integration_tests/Cargo.toml @@ -9,8 +9,8 @@ workspace = true [dependencies] nssa = { workspace = true } nssa_core = { workspace = true, features = ["host", "test_utils"] } -clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" } -key_protocol = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } +key_protocol = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } amm_core = { workspace = true } token_core = { workspace = true } ata_core = { workspace = true } From c211fe0dabbcf4d7840ff82d5534474b4cc3787f Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Fri, 24 Jul 2026 16:54:11 -0400 Subject: [PATCH 11/12] docs(privacy): clarify findings.md per PR review, remove privacy-test-matrix.md Address 0x-r4bbit's review comments on PR 215: expand the is_authorized reasoning, explain the AMM account-drop bug and its soundness implication, tag every not-expressible result with whether it's resolved (PR #621/#625) or still open, and add an Action items table attributing each open item to Zones (logos-execution-zone) or Programs (lez-programs). privacy-test-matrix.md was the AI-generated working scaffold behind findings.md; its load-bearing content has been folded into findings.md, so it's removed rather than left to drift out of sync. --- docs/privacy-test-matrix.md | 791 ------------------------------------ 1 file changed, 791 deletions(-) delete mode 100644 docs/privacy-test-matrix.md diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md deleted file mode 100644 index dfd7fdab..00000000 --- a/docs/privacy-test-matrix.md +++ /dev/null @@ -1,791 +0,0 @@ -# Privacy test matrix (Q2 privacy validation) - -Living tracker for the Q2 privacy-feature validation commitment: add privacy-preserving -variants of the existing `token` / `ata` / `amm` / `stablecoin` integration test flows, and -record which combinations work, fail, or cannot be expressed. Every row starting in -**Not started** should end the effort as **Pass** (test merged) or **Fail** / -**Not-expressible** (folded into `docs/privacy-gap-report.md` with root cause). - -This is the tracking scaffold, not the final deliverable — `docs/privacy-gap-report.md` gets -written from the resolved state of this table. - -## Key findings so far (highest priority — read this before anything else) - -1. **`OpenPosition` cannot be called via a `PrivacyPreservingTransaction` at all**, for any - reason related to privacy — confirmed with an all-public control case (zero private - accounts, still fails identically). `open_position.rs` issues two chained calls that both - reuse `vault`: `Token::InitializeAccount` authorizes it via `pda_seeds`, then - `Token::Transfer` re-declares it `is_authorized: false` on its second occurrence (a - legitimate choice on the public-transaction path, per that file's own comment). The privacy - circuit's `authorized_accounts` bookkeeping is monotonic — once authorized, an account must - stay declared `is_authorized: true` on every later occurrence — so this is rejected with - `"Inconsistent authorization for account {id}"` (`lee_core`'s `execution_state.rs:301`). - Likely fixable by not re-declaring `vault` unauthorized on its second occurrence. See - `stablecoin_open_position_via_privacy_transaction_is_not_expressible` and the Stablecoin - section below for the full writeup. **Single most actionable item for the protocol team.** -2. **Private PDAs are structurally impossible under every program's current derivation** — ATA, - AMM, and Stablecoin all derive PDAs via `for_public_pda` only, which can never satisfy - `PrivatePdaInit`/`PrivatePdaUpdate`'s binding requirement (traced precisely in - `execution_state.rs`; see the ATA section). Fixable only by a source change to - `for_private_pda` in each `*_core` crate. -3. **Sending to an existing private account requires the recipient's cooperation** — no - "blind credit" path exists; confirmed across Token/ATA/Stablecoin instructions. Real - wallet-UX implication, not a bug. -4. **Group-owned (shared) accounts work identically to personal ones** wherever tried — - Transfer, Burn, InitializeAccount, and as the signing `owner` behind a PDA-locked resource - (ATA, Stablecoin) — using the real seal/unseal GMS distribution, not just key reuse. -5. **AMM cannot be privacy-tested at all yet** — a *second*, distinct circuit-level issue - blocks every pool-mutating AMM instruction (`Swap*`, `AddLiquidity`, `RemoveLiquidity`, - `SyncReserves`) from the privacy-preserving transaction type, confirmed with all-public - control tests (zero private accounts, still fails): `"Invalid account_identities length"` - inside `execute_and_prove` itself. Ruled out "two different callee programs" as the cause - (a TWAP-only instruction fails identically to a Token+TWAP one); leading unconfirmed - suspect is AMM's pattern of passing an already-mutated `pool` copy into its chained TWAP - call. Root-causing further requires the Docker-based guest rebuild pipeline (`make - build-programs`), not plain `cargo test` — parked pending that investment. See the AMM - section below for the full bisection trail. - -## Legend - -**Dimension** — which cross-cutting Q2 feature (or baseline coverage gap) a row exercises: - -| Code | Meaning | -|---|---| -| `BASE` | Extends the already-proven single-private-account pattern (shield / private→private / deshield) to an instruction that has no private coverage yet. Not itself one of the four Q2 checkboxes. | -| `PDA` | Private PDAs used as program inputs | -| `GROUP` | Sharing a private account (group-owned) used as a program account | -| `EXIST` | Sending funds to an existing private account (not a fresh one) | -| `CHAIN` | Multiple private accounts in one transaction, and/or a private account carried through a `ChainedCall` | - -**Priority** — `P1` build first, `P2` second wave, `P3` stretch/optional. - -**Status** — `Not started` / `Pass` / `Fail` / `Not-expressible` / `Blocked`. - -**Depends on** — which other deployed program(s) or new crate dependencies the row needs. - ---- - -## Token (`token.rs`) — no program dependencies - -Foundation layer. Has no PDAs of its own and issues no `ChainedCall`s, so `PDA` and `CHAIN` -don't apply here — it's the substrate the other three programs build on. - -### Cross-cutting checkbox audit (end of Token phase, 2026-07-07) - -Checked against the 4 Q2 checkboxes explicitly, not assumed: - -| Checkbox | Status | Basis | -|---|---|---| -| Private PDAs used as program inputs | **N/A at this layer** | `token_core` has no `for_public_pda`/`for_private_pda` calls anywhere — Token holdings are addressed by arbitrary `AccountId`, not program-derived. Only testable once wrapped by another program's PDA (ATA/AMM/Stablecoin) — correctly deferred, not a gap in Token coverage. | -| Sharing a private account (group-owned) | **Covered** | `token_group_owned_holding_shared_control_burn`/`_transfer`/`_initialize` — see finding below. | -| Sending funds to an existing private account | **Covered** | `token_transfer_into_existing_private_holding` — see finding above. | -| Multiple private accounts in one tx / private accounts through chained calls | **Partially covered** | "Multiple private accounts in one tx" half: covered, but by the *pre-existing* `token_private_transfer` (two private legs, zero public), not by anything added this phase — none of the new tests this phase have more than one private leg. "Carried through chained calls" half: N/A at this layer, Token issues no `ChainedCall`s (only ATA/AMM/Stablecoin do); deferred. | - -Net: of the 4 checkboxes, Token-phase work directly validated 2 (`EXIST`, `GROUP`), leaned on a -pre-existing test for half of a 3rd (`CHAIN`'s multi-account half), and the remaining checkbox -(`PDA`) plus the other half of `CHAIN` are structurally out of reach until ATA/AMM/Stablecoin -phases — not oversights specific to this phase. - -**Update (2026-07-08):** the one remaining planned row, `token_mint_with_authority_to_private_holding` -(`BASE`, P3), passed — see the finding under Planned below. It doesn't move any of the 4 -checkboxes above (it's `BASE`, not `PDA`/`GROUP`/`EXIST`/`CHAIN`), but it closes the last open -instruction/private-recipient combination at this layer. **Token phase is now complete.** - -### Existing - -| Instruction | Dimension | Test | Status | -|---|---|---|---| -| Transfer | BASE (shield) | `token_shielded_transfer` | Pass | -| Transfer | BASE (private→private) | `token_private_transfer` | Pass | -| Transfer | BASE (deshield) | `token_deshielded_transfer` | Pass | -| Transfer | BASE (authorized variant) | `token_shielded_transfer_authorized_private_init` — fresh recipient self-initializes via `PrivateAuthorizedInit` instead of being passively credited via `PrivateUnauthorized` | Pass | -| Mint | BASE | `token_mint_shielded` — mint directly to a fresh private recipient (self-authority signer + `PrivateUnauthorized` recipient) | Pass | -| Mint | BASE (authorized variant) | `token_mint_authorized_init` — mint to a fresh recipient that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) instead of being passively credited | Pass | -| Burn | BASE | `token_private_burn` — burn from an existing private holding via a single `PrivateAuthorizedUpdate` | Pass | -| Transfer | `EXIST` | `token_transfer_into_existing_private_holding` — second transfer into an already-shielded recipient | Pass — **with a finding**, see below | -| Transfer | `EXIST` + `CHAIN` (fully private) | `token_private_transfer_into_existing_private_holding` — both legs private, recipient already existing (not fresh); two distinct accounts both via `PrivateAuthorizedUpdate` in one tx | Pass | -| InitializeAccount | BASE | `token_initialize_private_account` — self-init of a private holding via `PrivateAuthorizedInit` | Pass | -| InitializeAccount | new: self-service-only boundary | `token_initialize_private_account_without_nsk_is_not_expressible` | **Not-expressible — confirmed by design, not a gap** | -| Burn | `GROUP` | `token_group_owned_holding_shared_control_burn` — shield into a GMS-derived shared holding, burn from it via an independently-derived key | Pass | -| Transfer | `GROUP` | `token_group_owned_holding_shared_control_transfer` — group-owned sender spends outward via Transfer to a fresh private recipient, instead of destroying the funds via Burn | Pass | -| InitializeAccount | `GROUP` | `token_group_owned_holding_shared_control_initialize` — a group member (not the group's creator) self-initializes the shared holding directly via `PrivateAuthorizedInit` | Pass | -| Mint | `EXIST` | `token_mint_into_existing_private_holding` — mint once to establish the holding, mint again into it via `PrivateAuthorizedUpdate` | Pass | -| MintWithAuthority | BASE | `token_mint_with_authority_to_private_holding` — external-authority mint (distinct signer from the definition) directly to a fresh private recipient | Pass | - -**Finding (`GROUP`, confirmed 2026-07-07):** sharing a private account genuinely works, and the test -was built to prove *sharing*, not just code reuse: "Alice" creates a `GroupKeyHolder` (fresh GMS) -and derives the shared account's npk/vpk via `derive_keys_for_shared_account`; she shields tokens -into it. The GMS is then distributed to "Bob" through the real `seal_for`/`unseal` ML-KEM-768 -handshake — Bob never touches Alice's `GroupKeyHolder` object, only the sealed bytes. Bob -independently re-derives the identical nsk/npk from the unsealed GMS and successfully burns from -the shared holding using his own derivation. Required adding `key_protocol` as a new git dependency -(same repo/tag as `nssa`/`nssa_core`) to `integration_tests/Cargo.toml` — it wasn't previously a -dependency of `lez-programs`. Passed on the first attempt; no gap found for this dimension at the -Token layer. - -**Finding (group-owned spend + self-init, confirmed 2026-07-07):** the `_burn` test only proved -group funds could be *destroyed*; `token_group_owned_holding_shared_control_transfer` closes -that gap by having Bob spend outward via `Transfer` to a fresh private recipient instead — -same seal/unseal rigor, both legs private (group sender via `PrivateAuthorizedUpdate`, fresh -recipient via `PrivateUnauthorized`), no public account anywhere in the transaction. -`token_group_owned_holding_shared_control_initialize` closes the other gap: a group *member* -(not the party who created the group) self-initializing the shared holding directly via -`InitializeAccount`/`PrivateAuthorizedInit`, rather than the holding only ever coming into -existence as a side effect of a shield. Both passed on the first attempt — group-owned -accounts behave identically to personal ones across every instruction tried so far. - -**Finding (`EXIST`, confirmed 2026-07-07):** crediting an *existing* private account works, but only if the -recipient cooperates in the same transaction. Confirmed directly against `InputAccountIdentity`'s -doc comments and `output.rs` in `lee_core`: every variant that touches an existing private account -(`PrivateAuthorizedUpdate`, `PrivatePdaUpdate`) requires that account's own `nsk` + a membership -proof. There is no "blind credit" variant analogous to how any public account can be unilaterally -credited — a sender cannot push funds into an existing private account without the recipient -actively co-signing (supplying their nsk) in that same transaction. This is a real protocol/UX -property, not a bug: worth flagging to the privacy work as the answer to "can you send to an -existing private account" being **yes, but only cooperatively**, which has real wallet-UX -implications (recipient must be online / pre-coordinate, unlike a public transfer or a fresh -shield). - -**Finding (`token_private_transfer_into_existing_private_holding`, confirmed 2026-07-07):** -fills the last open combination for Transfer — every prior private test had at most one -existing-and-private leg (`token_transfer_into_existing_private_holding`'s recipient) or a -fresh second leg (`token_private_transfer`'s recipient), never both legs private *and* the -recipient already existing. Two distinct private accounts, each independently proven via its -own `PrivateAuthorizedUpdate` (one spending, one crediting an existing balance), compose in a -single transaction with no public account anywhere — no signer, no public message ids at all. -Passed on the first attempt; built entirely on direct seeding (`with_private_accounts`) for -both sides, no real setup transactions needed. - -**Finding (`token_mint_into_existing_private_holding`, confirmed 2026-07-07):** the `EXIST` -cooperation requirement generalizes across instructions, not just Transfer. `mint_inner` -already supports crediting an existing holding on the public side (branches on -`user_holding_account.account == Account::default()`); the private side needs the same -`PrivateAuthorizedUpdate` cooperation as Transfer — no instruction-specific escape hatch. -Passed on the first attempt once modeled on `token_transfer_into_existing_private_holding`. - -### Planned - -All originally-planned Token rows are now resolved (`token_mint_with_authority_to_private_holding` -passed — moved into the `Existing` table above) — Token phase is complete. - -| Instruction | Dimension | Test | Priority | Depends on | Status | -|---|---|---|---|---|---| -| NewFungibleDefinition, NewDefinitionWithMetadata, SetAuthority(WithAuthority), PrintNft | — | **Not planned** — these operate on canonical, publicly-resolvable definitions/authorities; a "private token definition" has no coherent meaning since holders/traders must resolve it | — | — | Out of scope | - -**Finding (`token_mint_with_authority_to_private_holding`, confirmed 2026-07-08):** closes the -last open Token combination — external-authority minting (`MintWithAuthority`, distinct signer -from the definition account) composed with a private recipient. Every prior `MintWithAuthority` -coverage minted to a public holder; every prior private-recipient mint test used self/PDA -authority (plain `Mint`). `mint_inner` never asserts `is_authorized` on `user_holding_account` -regardless of authority mode, so a passive `PrivateUnauthorized` recipient works here exactly as -it does under plain `Mint`. Passed on the first attempt after correcting the `Message` -construction: with two public accounts in the same privacy transaction (`definition`, not a -signer, plus `authority`, the signer), `public_account_ids` must list *both* — in their -`execute_and_prove` input order — for the circuit's public post-states to zip correctly, while -`nonces` lists *only* the signer(s), positionally matched to the witness keys (`signer_account_ids` -is derived from the witness set's public keys, not from `public_account_ids`). This is the first -test in the file with more than one public account alongside a private one, so it's worth -carrying forward: `public_account_ids` (post-state zipping) and `nonces` (signature/nonce -verification) are two independently-sized lists, not one shared list. - -**Correction (`token_initialize_private_account`, resolved 2026-07-07):** originally flagged as a -plausible `Not-expressible` case because `initialize.rs` hard-asserts `is_authorized == true` while -a fresh account created via `PrivateUnauthorized` must be `false`. That flag was based on picking -the wrong identity variant, not a real protocol limit. `InitializeAccount`'s guest requires the -target to be a *signer* (`#[account(init, signer)]`) — i.e. self-initialization, the same shape as -`PrivateAuthorizedInit` (owner supplies their own `nsk` directly, `is_authorized: true` is -legitimate), not `PrivateUnauthorized` (third party credits an account they don't control, `nsk` -withheld, `is_authorized` must be `false`). Matching the identity variant to the instruction's -actual authorization shape resolved it cleanly — passed on the first attempt once corrected. - -**Finding (self-service-only boundary, confirmed 2026-07-07 — prompted by a direct question, -not originally in the matrix):** can a third party initialize a private Token holding for an -`(npk, vpk, identifier)` whose `nsk` they don't possess? No — and this is a deliberate design -boundary, not a gap. Unlike `Transfer`/`Mint`, whose recipient-side host logic never asserts -`is_authorized` (which is exactly why third-party shielding into a fresh recipient works there -via `PrivateUnauthorized`), `InitializeAccount`'s guest declares `account_to_initialize` as -`#[account(init, signer)]`. Attempting it via `PrivateUnauthorized` (`is_authorized: false`, -no `nsk` needed) is rejected — empirically confirmed — at the SPEL macro's own account -validation layer ("`must be a signer`"), before `token_program::initialize::initialize_account`'s -own `is_authorized` assert is even reached. The only variant that can construct a fresh private -account here is `PrivateAuthorizedInit`, which requires supplying `nsk` directly. Net: this -instruction is self-service-only by construction — you can initialize your own private holding, -but not one on someone else's behalf without their key material. Worth carrying into the gap -report as a scoping note on `EXIST`/`BASE`, not a defect. - -**Finding (`token_mint_authorized_init`, confirmed 2026-07-07):** the self-service-only -boundary above is specific to `InitializeAccount`, not a general rule about "authorized" private -identities. `Mint`'s guest marks `user_holding_account` as `#[account(mut)]` only (no -`signer`), and `mint_inner` never asserts `is_authorized` on it — confirmed by reading -`token/src/mint.rs` before writing the test, then verified empirically. So minting to a -recipient that self-initializes via `PrivateAuthorizedInit` (their own `nsk` supplied) works -just as well as `token_mint_shielded`'s passive `PrivateUnauthorized` recipient — passed on the -first attempt. Worth stating plainly in the gap report: whether a "self-authorized fresh -recipient" is accepted is instruction-specific (gated by that instruction's own signer -requirement), not a blanket protocol rule. - -**Finding (`token_shielded_transfer_authorized_private_init`, confirmed 2026-07-07):** the same -`PrivateAuthorizedInit`-instead-of-`PrivateUnauthorized` variant generalizes to `Transfer` too, -closing the last instruction where every fresh-recipient test used only `PrivateUnauthorized` -(`token_shielded_transfer`, `token_private_transfer`'s new recipient, the group test's shield -step). `transfer.rs` asserts `is_authorized` only on the sender, never the recipient — same -shape as `Mint` — so this was expected and passed on the first attempt. Between this and the -`Mint`/`InitializeAccount` results, the picture is now complete: whether a fresh recipient can -choose to self-initialize (`PrivateAuthorizedInit`) instead of being passively credited -(`PrivateUnauthorized`) depends entirely on whether that instruction's guest marks the target -as a signer — true for `InitializeAccount` only (where `PrivateUnauthorized` is actually -rejected), optional for `Transfer`/`Mint` (both variants accepted). - ---- - -## ATA (`ata.rs`) — depends on Token - -### Existing - -| Instruction | Dimension | Test | Status | -|---|---|---|---| -| Create | BASE (private owner only; ATA account + definition public) | `ata_create_from_private_owner` | Pass | -| Create | `PDA` | `ata_create_private_ata_holding_is_not_expressible` | **Not-expressible — confirmed** | - -Verified in `ata/src/create.rs`: the owner account is **not** forwarded into the -`ChainedCall` to Token — only `token_definition` and the ATA holding are. So the existing -`ata_create_from_private_owner` test proves a private account can seed a PDA derivation and -appear as a top-level tx participant, but does **not** prove a private account traveling -through a chained call. That gap is still open despite appearances. - -**Finding (third-party bootstrap, confirmed 2026-07-07 — positive finding, not a gap):** -`Create` never asserts `owner.is_authorized`, and the only private identity variant compatible -with an unauthorized owner (`PrivateUnauthorized`) structurally has no `nsk` field at all — it's -built from `npk`/`vpk` alone. So `ata_create_from_private_owner` demonstrates something worth -stating plainly rather than leaving implicit: **any third party can bootstrap another owner's -ATA using only that owner's public key material, without the owner ever exposing (or even -needing to possess yet) their `nsk`.** This mirrors Token's finding that anyone can shield funds -into a fresh private recipient who has never been online — here a wallet provider, faucet, or -counterparty program can pre-create a user's per-token account the same way, purely from public -inputs. The boundary is exactly where signing starts: the moment an instruction needs to *move* -value or prove ongoing control (`Transfer`, `Burn`), `nsk` becomes mandatory — see the -signer-authorization finding below. - -**Finding (`PDA`, confirmed 2026-07-07 — root cause, not just an observation):** the ATA -holding can never be made a private account as ATA is currently coded, and this is a -structural fact provable from `lee_core`'s circuit source, not empirical friction. Traced -precisely: `Create`'s `ChainedCall.pda_seeds` authorizes Token to mutate -`for_public_pda(ata_program_id, seed)` — a match under the *public* PDA formula. In -`resolve_authorization_and_record_bindings` (`execution_state.rs`), a caller-seed match only -gets recorded into `private_pda_bound_positions` when it matches under `for_private_pda` -(`is_private_form == true`) — a public-form match authorizes the account but never binds it -as a private PDA. Every `PrivatePdaInit`/`PrivatePdaUpdate` identity requires its position to -appear in that binding map (hard `assert!` at `execution_state.rs:211`), and ATA's own -`verify_ata_and_get_seed` independently requires the account id to equal -`for_public_pda(ata_program_id, seed)` — never `for_private_pda`'s output, by construction of -two different hash domains. These two requirements are mutually exclusive for the same -account_id, full stop — confirmed empirically by attempting exactly this and getting the -precise, deterministic rejection (`ata_create_private_ata_holding_is_not_expressible`, which -asserts on the exact panic text). -**This generalizes**: AMM's vault/pool and Stablecoin's position/vault use the identical -`for_public_pda`-only derivation, so they will hit the *same* wall for the *same* reason — no -need to rediscover this per program, just confirm each one uses `for_public_pda` (already -verified for both in `amm_core`/`stablecoin_core`) and cite this finding. **The only fix** is a -source change to `ata_core`/`amm_core`/`stablecoin_core` to derive PDAs via `for_private_pda` -instead — out of scope for this test-writing task, but this is the single clearest, most -actionable item to feed back to the privacy/protocol work. - -All originally-planned ATA rows are now resolved — see updated `Existing` table below. ATA phase -is complete. - -| Instruction | Dimension | Test | Status | -|---|---|---|---| -| Transfer | `CHAIN` + `EXIST` (collapsed — see finding) | `ata_transfer_to_existing_private_recipient` | Pass | -| Burn | new: signer-authorization | `ata_burn_with_private_owner_signing` | Pass | -| Burn | `GROUP` + signer-authorization | `ata_group_owned_owner_signing` | Pass | -| Transfer | new: signer-authorization | `ata_transfer_with_private_owner_signing` | Pass | -| Transfer | `GROUP` + signer-authorization | `ata_transfer_with_group_owned_owner_signing` | Pass | -| Create | `GROUP` (defensive/symmetry only — see finding) | `ata_create_from_group_owned_owner` | Pass | - -**Finding (`CHAIN` + `EXIST`, confirmed 2026-07-07):** `ata_program::transfer::transfer_from_associated_token_account` -hard-asserts `recipient.account != Account::default()` ("Recipient token holding must be -initialized"). That means a *fresh* private recipient (shield-style, `PrivateUnauthorized`) can -never be created through `ATA::Transfer` — only an already-existing account can be credited. -This collapses what the matrix originally planned as two separate rows (`BASE` and `EXIST`) -into one: `ata_transfer_to_existing_private_recipient` funds a private holding via a direct -(non-ATA) `Token::Transfer` shield first, then sends more into it through ATA's chained call, -with the recipient cooperating via `PrivateAuthorizedUpdate` (consistent with the Token-phase -`EXIST` finding). This is also the first test in the whole exercise where a private account -identity travels through a *nested* `ChainedCall` rather than a top-level instruction call — -and it worked on the first attempt, with no special handling needed. - -**Finding (signer-authorization, confirmed 2026-07-07 — new angle, not in the original matrix):** -`Transfer`/`Burn` require `owner` to be a *signer* (`#[account(signer)]`), unlike `Create` -(merely `mut`). Every existing private-owner test only used owner passively (`Create`, no -signer requirement). `ata_burn_with_private_owner_signing` tests whether a private account can -satisfy a signer requirement by self-initializing *and* signing in the same transaction via -`PrivateAuthorizedInit` — it does, cleanly, on the first attempt. `ata_group_owned_owner_signing` -composes this with `GROUP`: the GMS is distributed through the real seal/unseal handshake (as -in `token_group_owned_holding_shared_control_burn`), and "Bob" — who never touches Alice's -`GroupKeyHolder` object — independently re-derives the matching nsk/npk and signs. Both pass. -Worth feeding back as a positive finding: private/shared accounts can serve as full signing -authorities for instructions that require it, not just as passive recipients. - -**Follow-up (confirmed 2026-07-08 — closing a coverage review gap, not a new dimension):** a -review pass noticed `Burn` had both personal and group-owned signer coverage but `Transfer` -(identical `#[account(signer)]` requirement on `owner`) only had the pre-existing public-owner -test — a private owner had never actually been tried signing `ATA::Transfer`. -`ata_transfer_with_private_owner_signing` / `ata_transfer_with_group_owned_owner_signing` close -that gap directly, mirroring the `Burn` pair exactly (self-init + sign via `PrivateAuthorizedInit`, -personal and group-owned). Both passed on the first attempt, as expected given `Burn`'s identical -shape. Also added `ata_create_from_group_owned_owner` for symmetry — but **this one is a weaker -test by construction, not a gap closure**: `Create` places no signer requirement on `owner` at -all, and its only compatible private identity (`PrivateUnauthorized`) never touches `nsk`, so a -group-derived `owner` is indistinguishable from a personal one at this instruction. The test -confirms that empirically (nothing in `Create` secretly assumes anything about where `npk`/`vpk` -came from) but does **not** demonstrate genuine shared control the way the `Transfer`/`Burn` -group tests do — there is nothing for `Create` to prove sharing over, since it never asks anyone -to prove control of `owner` in the first place. Net: `Create`'s "group ownership" question isn't -an open gap, it's a category mismatch — worth stating that plainly in the gap report rather than -implying it was untested. - -**Finding (ATA cannot originate a fresh private holding, confirmed 2026-07-08 — synthesizes two -separate facts above into one conclusion worth stating plainly): no ATA instruction can bring a -new private token holding into existence, for two independent reasons covering the two accounts -involved.** (1) The ATA's own holding can never be private at all — the confirmed `PDA` finding: -`Create` authorizes it via `for_public_pda` only, which can never satisfy -`PrivatePdaInit`/`PrivatePdaUpdate`'s binding requirement. (2) Even a separate, non-ATA private -recipient can't be freshly created through `ATA::Transfer` — `transfer_from_associated_token_account` -hard-asserts `recipient.account != Account::default()`, rejecting a shield-style fresh -`PrivateUnauthorized` recipient outright; only an *already-existing* recipient can be credited -(per the `CHAIN` + `EXIST` finding above). So ATA can send value *toward* a private destination, -but only one that already exists via some other path — every private holding that appears in -these tests was originated by a direct, non-ATA `Token` call -(`ata_transfer_to_existing_private_recipient`'s setup shields the recipient via `Token::Transfer` -before the ATA transfer under test ever runs). Worth stating as its own line in the gap report: -"ATA cannot emit private token holdings" is a real, structural limitation, not a coverage gap -in the tests written here. - ---- - -## AMM (`amm.rs`) — depends on Token, TWAP oracle - -33 public tests, 0 private. Confirmed in `amm_core`: all 5 PDAs (config, pool, vault×2, -liquidity-token, lp-lock) use `for_public_pda` exclusively. - -Not every account is an equally meaningful privacy target: Pool/Config are the AMM's public -price surface (reserves must be readable to quote a swap; TWAP needs a continuously -observable tick) — privatizing them fights the AMM's purpose. Vault/LP-lock are the credible -middle case. User-held token/LP balances are the highest-value target. - -### ⚠ Blocked pending investigation (2026-07-08) — read before starting AMM test-writing - -Before writing any private AMM test, an all-public control test through `execute_and_prove` -(the same discipline that found Stablecoin's `OpenPosition` bug) turned up a **second, -distinct circuit-level issue specific to AMM**, unrelated to any privacy dimension. No AMM -privacy tests have been written yet — this needs resolving (or explicitly working around) -first. - -**Symptom**: `SwapExactInput` (8 top-level accounts, 3 chained calls: 2×`Token::Transfer` + -1×`TWAP::UpdateCurrentTick`) fails *inside* `execute_and_prove`, before any private account is -even involved, with `"Invalid account_identities length"` (`lee_core`'s `output.rs:27`) — -`account_identities.len()` (8, what we supply) vs `states_iter.len()` (7, what the circuit -computes). Confirmed with every account `Public`. - -**Bisection done so far**: -- **Ruled out "two different callee programs"**: `SyncReserves` (6 accounts, *one* chained - call, into TWAP oracle only — zero Token calls) fails with the identical pattern (6 vs 5). - So it's not about chaining into two different programs. -- **Ruled out "any multi-account reuse in one chained call"**: Stablecoin's - `WithdrawCollateral` reuses *two* accounts (`vault`, `destination`) inside its single chained - call and works fine — so plain reuse-of-multiple-accounts isn't sufficient on its own to - trigger this. -- **Simplest AMM instruction works**: `UpdateConfig` (2 accounts, zero chained calls) gets - *past* `execute_and_prove` cleanly — it fails later, at `transition_from_privacy_preserving_transaction`, - with `InvalidInput("Empty commitments and empty nullifiers found in message")`. This looks - like an unrelated, general protocol rule (a `PrivacyPreservingTransaction` needs at least one - actual private account, or use `PublicTransaction` instead) — not a bug, but worth noting: - **the "all-public control" methodology needs at least one trivial private leg to get past - this check for future control tests**, not just all-`Public` identities. -- **Leading structural lead, superseded below**: every AMM instruction that hits the length - mismatch passes a *post-update* copy of `pool` (`pool_price_source`, holding `pool_post` — - the already-mutated state, not the original pre-state) into its chained TWAP call. This - "pass what's about to become the post-state as the next call's own pre-state" pattern is - proven correct on the public-transaction path (33 passing tests) but nothing in - Token/ATA/Stablecoin ever exercised it under the privacy circuit. This was the leading lead - at the time, but is likely **not** the real cause — see the more precise finding below, which - identifies the specific missing account directly. -- **Precisely identified the missing account (2026-07-13)**: instrumented `execution_state.rs`'s - per-account loop in `validate_and_sync_states` with `eprintln!` tracing (see below for how this - was made to actually take effect) and confirmed via exact string-level `AccountId` matching - that `CLOCK_01_PROGRAM_ACCOUNT_ID` is the account that vanishes — it's supplied as a top-level - input and is clearly present in the AMM program's own returned `post_states` (confirmed - directly in `sync.rs`'s `sync_reserves` and `swap.rs`'s `finalize_swap`, both of which - explicitly include `AccountPostState::new(clock.account...)`), yet it never appears in the - circuit-level trace at any call depth, not even inside the TWAP chained call which also - explicitly passes `clock.clone()`. Root cause of *why* it's dropped was not yet found at this - point — **since resolved, see "Root cause found" below**: it's a `spel-framework` guest-wrapper - filter, not the circuit's own processing. Instrumentation was fully reverted afterward - (verified byte-identical to the original checkout and original artifact) rather than left in - place. -- **Confirmed this also blocks real private-account attempts, not just the all-public control - case (2026-07-13)**: three tests — `amm_swap_a_to_b_private_user_holding_is_not_expressible` - (private `user_holding_a`, 8 vs 7 accounts), `amm_add_liquidity_private_lp_holding_is_not_expressible` - (private `user_holding_lp`, 10 vs 9), `amm_remove_liquidity_private_lp_holding_is_not_expressible` - (private `user_holding_lp`, 10 vs 9) — all fail with the identical - `"Invalid account_identities length"` panic, always exactly one account short. This rules out - "the bug only manifests because there are zero private accounts" as an explanation; it's a - structural property of these instructions' account/chained-call shape, independent of privacy - entirely. - -**How the instrumentation was made to actually take effect (2026-07-08 attempt failed, 2026-07-13 -attempt succeeded)**: `eprintln!` tracing added directly to the pinned `lee_core` checkout's -`execution_state.rs` first appeared to have no effect — prints never surfaced, and the original -panic kept firing from the same file/line even after `cargo clean -p lee -p lee_core` and a fresh -compile. Root cause: real guest execution runs a separately cross-compiled RISC-V ELF -(`risc0_build::embed_methods!`), and the pinned `PRIVACY_PRESERVING_CIRCUIT_ELF` artifact is a -**pre-built, checked-in binary** (`artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin` -in the checkout) embedded via `build_utils::include_artifacts` — editing the `.rs` source alone -never touches that binary. Fix: rebuild the guest ELF directly with -`cargo risczero build -p privacy_preserving_circuit_program --manifest-path /Cargo.toml` -(matching the checkout's own `Justfile` `build-artifacts` recipe) and copy the result over the -checked-in `.bin` — **plus** `cargo clean -p lee -p lee_core` again afterward, since -`cargo:rerun-if-changed` was scoped to the artifacts *directory*, and overwriting a file's -content in place doesn't change the directory's own mtime, so cargo's incremental build silently -kept using the old compiled rlib (with the old bytes baked in via `include_bytes!`) even after -the file swap. Once both steps were done, the `eprintln!` output finally appeared and led -directly to the `CLOCK_01_PROGRAM_ACCOUNT_ID` finding above. All instrumentation (source edits, -rebuilt artifact) was fully reverted afterward and verified byte-identical to the original. - -**Next step when this is picked back up**: check whether `output_pre_states.len()`/ -`output_post_states.len()` already differ from 8/8 (or 6/6, etc.) *before* the per-account -validation loop in `validate_and_sync_states` runs — that would localize the drop to either the -AMM guest/SPEL-macro layer or the circuit's own processing, and is the next concrete step now -that instrumentation is confirmed to work end-to-end. - -### ✅ Root cause found (2026-07-14) — it's in `spel-framework`, not `lee_core`, and not AMM's own code - -Investigated (via a Fable 5 subagent, source-reading only — no instrumentation needed this time) -by comparing the two transaction validators side by side and checking the guest-wrapper code -that sits between AMM's own functions and either validator. Fully verified by direct inspection -afterward (both citations below reproduced and confirmed independently). - -**The account is deleted before it ever reaches either validator.** The `#[lez_program]` macro's -generated `main()` — `spel-framework-macros/src/lib.rs:303-329`, in the pinned -`spel-393b37c2cff64018` checkout at rev `91023c9115bf88173b0d25d2e905f2a55ef0313b` — post-processes -every guest function's returned `(pre_states, post_states)` pairs before writing the -`ProgramOutput`: - -```rust -// Filter out non-program-owned, non-default-state accounts from the output. -// -// LEZ validate_execution rule 7: if post.program_owner == DEFAULT_PROGRAM_ID -// and pre.account != Account::default(), validation fails. This would happen -// for signer accounts (e.g., proposer/executor) whose nonce has been incremented -// by a prior transaction — they are not owned by the program and must not be -// returned in the program's post-states. -.filter(|(pre, post)| { - let is_default_owner = pre.account.program_owner == DEFAULT_PROGRAM_ID; - let pre_is_default = pre.account == Account::default(); - let has_claim = post.required_claim().is_some(); - !is_default_owner || pre_is_default || has_claim -}) -``` - -This was written to solve a real, narrow problem: drop *signer* accounts (proposer/executor) -whose nonce got bumped by a prior transaction, since they're not owned by the program and -`validate_execution`'s rule 7 would otherwise reject the output. But the predicate is broader -than that one case, and `clock` happens to satisfy it too: - -- `is_default_owner = true` — the clock account is seeded via `force_insert_account` with - `Account { data: , ..Account::default() }` (`advance_clock` in `amm.rs`), - so its `program_owner` stays `DEFAULT_PROGRAM_ID` — it's never claimed by any program. -- `pre_is_default = false` — its `data` field holds real, non-default clock bytes. -- `has_claim = false` — AMM never issues a `Claim` for clock; it only reads it. - -`!true || false || false` = `false` → the `(pre, post)` pair for `clock` is silently dropped from -`ProgramOutput.pre_states`/`post_states`, every single time, for every AMM instruction that -touches it — and for TWAP's `UpdateCurrentTick` too, since it's built with the exact same macro -at the exact same pin. This is exactly why the earlier `eprintln!` trace never saw `clock` at -*any* call depth, including inside the nested TWAP call: it was gone before the circuit ever got -the chance to see it, not dropped by the circuit itself. - -**Why the public-transaction path never noticed**: `ValidatedStateDiff::from_public_transaction` -(`lee/state_machine/src/validated_state_diff.rs`) only ever iterates whatever the program's -*output* actually contains (`program_output.pre_states`) and zips it against -`program_output.post_states` to build the state diff. There is no check anywhere that the -output covers every account the *caller* originally supplied — a silently-dropped, unmodified -account just never appears in the diff, and nothing asserts it should have. `validate_execution` -(the rule 7 the filter comment refers to) only checks `pre_states.len() == post_states.len()` -*within* the already-filtered output (7 == 7 — passes trivially, since both sides of the pair -were dropped together). - -**Why the privacy-preserving path panics**: the circuit builds its own account-tracking state as -the union of every `ProgramOutput.pre_states` it sees across the whole call tree — 7 accounts, -no clock. But the *caller* (the test, or in production a real wallet/client) must supply one -`InputAccountIdentity` per account it believes is involved — 8, including clock, since nothing -told the caller clock would be dropped. `compute_circuit_output`'s -`assert_eq!(account_identities.len(), states_iter.len())` (`output.rs:27`) then fails: `8 != 7`. -The public path tolerates exactly this same silent drop; only the private path's stricter -1:1 correspondence check turns it into a hard failure. - -**This is a `spel-framework` bug, not a `lez_core`/circuit bug, and not an AMM program bug.** -Neither this repo's own code nor the pinned LEZ dependency is at fault — the defect is in the -`0x-r4bbit/spel` proc-macro crate's generated wrapper, one layer removed from both. Fix options -belong upstream: scope the filter to only the specific signer-nonce-bump case it was written for -(e.g. keep any pair the handler's own logic explicitly returned, rather than blanket-filtering -by ownership), or have the circuit tolerate identities without a corresponding output pre-state. -The trigger condition is narrow but real: any account with `program_owner == DEFAULT_PROGRAM_ID` -that a program reads but never claims will hit this — not just clock, and not just AMM. It just -happens to be clock here because every pool-mutating AMM instruction reads it. - -**Soundness implication, not just a test-writing inconvenience**: because `clock` never reaches -`public_pre_states` on the privacy-preserving path, the host validator -(`check_privacy_preserving_circuit_proof_is_valid`) never checks the clock data a proof was -generated against against real chain state. A malicious prover could in principle supply an -arbitrary timestamp as a private witness and no check anywhere would catch it. Worth escalating -to the LEZ/SPEL maintainers independent of whether/when the AMM test-writing blocker itself gets -prioritized. - -**Upstream provenance checked (2026-07-15)**: cloned both `logos-co/spel` (upstream) and -`0x-r4bbit/spel` (the pinned fork) to check whether the filter was fork-introduced. It isn't. -`git log -S"is_default_owner"` on upstream `logos-co/spel` finds it added in PR #126, squash-merged -to upstream `main` as commit `1f51875` ("`SpelOutput::execute()` with auto-claim support"), still -present at upstream's current HEAD (`0cb7e09`, v0.6.0). Walking the fork's history back to its -merge point from `logos-co/release/v0.5.0` (commit `73fc462`) shows the filter already present -there too, before any of the fork's own commits. **This is an upstream `logos-co/spel` bug**, -inherited unchanged by the pinned fork — report it against the former, not the latter. - -**Correction — there's a second, independent bug in `logos-execution-zone` itself (2026-07-15)**: -the "not a `lez_core`/circuit bug" claim above is too narrow. `ValidatedStateDiff:: -from_public_transaction` (`lee/state_machine/src/validated_state_diff.rs`) never checks that the -accounts touched in a program's output match the caller-declared `message.account_ids` — no count -check, no membership check, nothing analogous to the privacy circuit's own `account_identities. -len() == states_iter.len()` assertion in `compute_circuit_output`. The reconciliation loop just -does `state_diff.insert(pre.account_id, post.account().clone())` for whatever pairs the program's -output happens to contain, however many that is, and returns `Ok(...)` regardless. - -That absence is why the `spel-framework` drop went unnoticed by every one of this repo's ~34 -pre-existing public AMM tests: the public path has no validation capable of catching a -silently-dropped account at all, so a bug with zero privacy dimension to it hid behind passing -public tests until the privacy circuit's stricter, positional bookkeeping happened to expose it. -Two independent upstream defects, not one: `spel-framework`'s over-broad filter (which creates the -drop) and `logos-execution-zone`'s missing account-accounting check on the public path (which lets -any such drop — from this or any future bug — go completely undetected). The latter is arguably -the more consequential of the two, since it's a general soundness gap independent of clock, AMM, -or privacy entirely. Both should be reported upstream; report the `lee` gap against -`logos-co/logos-execution-zone`, not `logos-co/spel`. - -### ✅ Fixed for these tests (2026-07-15) — test-fixture clock ownership, not a circuit workaround - -The immediate blocker for all five `CHAIN`-dimension AMM privacy tests below was that -`advance_clock` (`programs/integration_tests/tests/amm.rs`) constructed the clock account with -`..Account::default()`, leaving `program_owner == DEFAULT_PROGRAM_ID` and tripping the -`spel-framework` filter above on every AMM/TWAP call that echoes it back. The *real* production -clock account is owned by a dedicated clock program (`lez/system_accounts/src/lib.rs`'s -`clock_account()`: `program_owner: programs::clock().id()`), not `DEFAULT_PROGRAM_ID` — but that -constructor lives behind the gated `system-programs`/`artifacts` feature this test crate can't -reach, so `advance_clock` fabricates the account directly and never set an owner. - -Fix applied: `advance_clock` now sets `program_owner` to a placeholder non-default `ProgramId` -(`[42_u32; 8]`, a stand-in — not the real production clock program ID, which isn't reachable from -this crate). Nothing in AMM/TWAP-oracle logic checks the clock's `program_owner` value (only its -`account_id` against `CLOCK_01_PROGRAM_ACCOUNT_ID`), and `validate_execution`'s other rules -(no-ownership-change, no-unauthorized-data/balance-change) are satisfied trivially since clock is -echoed unchanged — so this is a safe, minimal, test-only fix. All five previously-blocked tests -were rewritten from `_is_not_expressible`/`expect_err` assertions to real success assertions -(rebuilding the `PrivacyPreservingTransaction`, applying it via -`transition_from_privacy_preserving_transaction`, and checking resulting public state + -commitments for the private accounts) and now pass. Full `amm.rs` suite: 40/40 passing. - -The `spel-framework` dispatcher filter itself is untouched and remains an open upstream bug -(confirmed to originate in `logos-co/spel`, not the pinned fork — see above) — it would still -silently drop any other `DEFAULT_PROGRAM_ID`-owned, non-default, unclaimed account threaded -through a chained call. Worth reporting upstream regardless of this fixture-level fix. - -### Existing - -10 private tests out of 34 pre-existing public + 10 = 44. Six demonstrate an actually-working AMM -privacy path end-to-end (private account touched, transaction applied, resulting public state and -private commitments verified): the five chained-call tests unblocked by the clock test-fixture fix -above, plus `amm_new_definition_private_initial_lp_holder` (pool creation with a fresh -`PrivateAuthorizedInit` LP holder — `new_definition.rs` explicitly permits this, unlike -`swap`/`remove`). Four are confirmed not-expressible, splitting into two distinct root causes: -`amm_remove_liquidity_private_new_user_holdings_is_not_expressible` and -`amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible` both hit the same -"destination must already exist and be Token-Program-owned" precondition; -`amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible` and -`amm_new_definition_private_unauthorized_lp_holder_is_not_expressible` both hit an earlier, more -fundamental guest-ABI signer requirement that a `PrivateUnauthorized` identity can never satisfy — -any account declared a required signer in the guest ABI structurally excludes `PrivateUnauthorized` -recipients, regardless of what the program's own internal precondition would otherwise allow. - -**Second finding, unrelated to the circuit bug (2026-07-13)**: `remove_liquidity` requires -`user_holding_a`/`user_holding_b` to already exist and already be owned by the configured Token -Program (`remove.rs`'s `assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`) -— unlike `token::transfer`'s recipient handling, which tolerates `Account::default()` and -self-initializes it. So `RemoveLiquidity` can never pay out to a brand-new private destination -(`PrivateUnauthorized` — only `npk` known, no `nsk`, the pattern -`token_mint_shielded_to_private_unauthorized` uses): the attempt -(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) fails inside the AMM -program's own precondition check (`"User Token A holding must be owned by the configured Token -Program"`), *before* any chained call or the privacy-preserving circuit is ever reached — and -would equally reject a brand-new *public* destination. Same shape of finding as Stablecoin's -`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`: a plain -program-level precondition that predates privacy entirely, not a circuit artifact. - -### Planned - -| Instruction | Dimension | Test | Priority | Depends on | Status | -|---|---|---|---|---|---| -| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — private `user_holding_a`, unblocked by the clock test-fixture fix | -| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — same fix | -| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` — private deposit legs (`user_holding_a`/`user_holding_b`) | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — same fix | -| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | ✅ **Expressible** — private `user_holding_lp`, same fix | -| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | ✅ **Expressible** — private `user_holding_lp`, same fix | -| RemoveLiquidity | `EXIST` (negative) | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` — brand-new `PrivateUnauthorized` token A/B destinations | P1 | Token | **Confirmed not-expressible for a different reason** — AMM's own precondition requires the destination to already be owned by the Token Program; unrelated to the clock issue | -| Swap | `EXIST` (negative) | `amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible` — brand-new `PrivateUnauthorized` recipient (`npk` only) | P1 | Token | **Confirmed not-expressible** — the guest ABI (`#[account(mut, signer)]` on both `user_holding_a`/`user_holding_b` in `methods/guest/src/bin/amm.rs`) requires every swap participant to be a signer; `PrivateUnauthorized` is authorized by nobody by construction, so it can never satisfy this, independent of the destination-must-exist issue below | -| Swap | `EXIST` (negative) | `amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible` — brand-new `PrivateAuthorizedInit` recipient (self-initializes with its own `nsk`, satisfies the signer requirement) | P1 | Token | **Confirmed not-expressible for a different reason** — same "destination must already exist and be Token-Program-owned" precondition as `RemoveLiquidity`'s finding above (`swap.rs` asserts `user_holding_b.account.program_owner == token_program_id` unconditionally) | -| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | Not started — swap into an *already-existing* private destination (`PrivateAuthorizedUpdate`); distinct from the two fresh-destination rows above, which are both confirmed not-expressible | -| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | ✅ **Expressible** — `new_definition.rs`'s own precondition on `user_holding_lp` (`account != Account::default() || is_authorized`) explicitly permits a fresh, authorized LP holder; confirmed working with a `PrivateAuthorizedInit` initial LP holder, unblocked by the clock fix above | -| NewDefinition | `EXIST` (negative), BASE | `amm_new_definition_private_unauthorized_lp_holder_is_not_expressible` — `PrivateUnauthorized` initial LP holder (`npk` only) | P2 | Token | **Confirmed not-expressible** — same guest-ABI signer requirement as the `Swap` `PrivateUnauthorized` finding above: `user_holding_lp` is `#[account(mut, signer)]` unconditionally, so it's rejected before `new_definition.rs`'s own "fresh LP holding requires user authorization" precondition is ever reached | -| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started — blocked by the separate PDA-formula finding, not the clock issue | -| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | Not started — no longer blocked by the clock issue (fixed above) | -| Pool/Config (any) | `PDA` | `amm_attempt_private_pool_pda` — same predicted not-expressible outcome as above; low priority given the vault row already confirms the root cause for this program | P3 | Token | Not started | -| Initialize, UpdateConfig, CreatePriceObservations, CreateOraclePriceAccount, SyncReserves | — | **Not planned** — admin/infra instructions over public protocol state; a private admin authority is legitimate but low value | — | — | Out of scope (for now) | - -Note: every Swap/AddLiquidity/RemoveLiquidity chains to *both* Token (transfers) and TWAP -oracle (tick refresh) in one instruction — so every `CHAIN` row above is automatically also -a "some legs private, some public" test. Call that out explicitly when the test is written, -not as an incidental detail. **The clock-account blocker above is fixed (2026-07-15) — the -remaining unwritten rows are open to pick up, except the `PDA` rows, which are separately -blocked by the `for_public_pda`-only formula finding.** - ---- - -## Stablecoin (`stablecoin.rs`) — depends on Token - -2 pre-existing public tests (`stablecoin_open_position_then_withdraw_collateral`, -`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt`). Both PDAs (position, position -vault) are `for_public_pda` only, per the ATA `PDA` finding. - -### Existing - -| Instruction | Dimension | Test | Status | -|---|---|---|---| -| OpenPosition | new: chained-call re-authorization | `stablecoin_open_position_via_privacy_transaction_is_not_expressible` | **Not-expressible — confirmed, root cause traced** | -| WithdrawCollateral | `CHAIN` + `EXIST` | `stablecoin_withdraw_collateral_private_destination` | Pass | -| WithdrawCollateral | `CHAIN` + `EXIST` + `GROUP` | `stablecoin_withdraw_collateral_group_owned_destination` | Pass | -| RepayDebt | `CHAIN` | `stablecoin_repay_debt_private_stablecoin_holding` | Pass | -| RepayDebt | `CHAIN` + `GROUP` | `stablecoin_repay_debt_group_owned_stablecoin_holding` | Pass | -| WithdrawCollateral (owner identity) | `GROUP` | `stablecoin_group_owned_position_owner` | Pass | -| WithdrawCollateral | new: destination must pre-exist | `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible` | **Not-expressible — confirmed** | - -**Finding (`OpenPosition`, confirmed 2026-07-08 — the headline finding for this program, and -arguably the whole exercise): `OpenPosition` cannot be executed through the privacy-preserving -transaction type at all, for any reason related to privacy.** Confirmed with an all-public -control test (every account `Public`, zero private accounts) that fails with the *identical* -error as the private attempt. Root cause traced precisely in `lee_core`'s -`execution_state.rs`: `authorized_accounts` is a monotonic/sticky set — once an account is -authorized via one chained call's `pda_seeds` match, every later occurrence of that same -account must *also* declare `is_authorized: true`, or -`assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")` -fails. `open_position.rs` issues two chained calls that both reuse `vault`: the first -(`Token::InitializeAccount`) authorizes it via `pda_seeds`, sticking it as authorized; the -second (`Token::Transfer`) then deliberately constructs `post_init_vault` with -`is_authorized: false` — a legitimate choice on the public-transaction path (the file's own -comment: "the recipient is already initialized, so no second PDA claim is needed here") — but -the privacy circuit rejects that as inconsistent. **This means no privacy-preserving test can -ever open a position** — not because of anything about privacy, but because the instruction -itself is incompatible with the privacy transaction machinery as currently coded. Every test -below routes around it by seeding position/vault directly via `force_insert_account` (public -accounts, no real `OpenPosition` call), matching how the pre-existing public -`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt` test already worked before this -phase. This is the single most actionable, most severe finding to feed back to the protocol -team — it blocks privacy for `OpenPosition` categorically, independent of the four Q2 -dimensions, and is likely fixable by having `open_position.rs` mark `post_init_vault` as -authorized (or otherwise not re-declare it unauthorized) on its second occurrence. - -**Consequence for the `PDA` dimension**: the originally-planned -`stablecoin_open_position_private_pda` confirmation test was dropped as redundant. Position and -vault are *only* ever claimed (via `Claim::Pda` and chained `pda_seeds` respectively) inside -`OpenPosition` — and since that instruction can't reach the privacy circuit at all, the `PDA` -question for Stablecoin can't even be isolated independently; it's subsumed by the finding -above. No separate test needed — the ATA `PDA` finding (same `for_public_pda`-only root cause) -still stands as the citable reference. - -**Finding (`stablecoin_withdraw_collateral_private_destination` / `..._group_owned_destination`, -confirmed 2026-07-08):** unlike `OpenPosition`, `WithdrawCollateral` issues only *one* chained -call (`Token::Transfer`, reusing `vault` exactly once) — it doesn't hit the re-authorization -bug, and passed on the first attempt with a private, pre-existing destination (`EXIST`, -requiring the destination's `PrivateAuthorizedUpdate` cooperation per the Token/ATA-phase -finding) and again with a group-owned destination (real seal/unseal distribution, `GROUP`). - -**Finding (`stablecoin_repay_debt_private_stablecoin_holding` / `..._group_owned_...`, confirmed -2026-07-08):** `RepayDebt` also has only one chained call (`Token::Burn`) and isn't affected by -the `OpenPosition` bug. `user_stablecoin_holding` is notably *not* PDA-locked (unlike ATA's own -holdings) — it's an ordinary user-controlled token holding — so it's free to be private with no -structural obstacle at all. Passed personal and group-owned variants on the first attempt. - -**Finding (`stablecoin_group_owned_position_owner`, confirmed 2026-07-08 — reframes what -"group-owned position" means):** the position/vault themselves can never be private or -group-owned (the `PDA` finding), and can't even be *opened* through the privacy machinery (the -finding above) — but `owner` is just an `AccountId` used for PDA seed derivation and signer -verification, so it doesn't need to be a plain public keypair. Directly mirroring -`ata_group_owned_owner_signing`'s precedent: position/vault are seeded directly (bypassing the -blocked `OpenPosition`), keyed to a group-derived `owner` identity; "Bob" — who only ever -receives the sealed GMS — self-initializes *and* signs that owner identity in one transaction -via `PrivateAuthorizedInit`, then withdraws collateral through it. Passed on the first attempt. -This is the correct, expressible version of "joint control over a CDP": shared control of the -*authority* over a PDA-locked resource, not shared privacy of the resource itself. - -**Finding (`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`, -confirmed 2026-07-09 — second, unrelated not-expressible result for this program):** -`WithdrawCollateral` cannot pay out to a brand-new private destination. `withdraw_collateral.rs` -hard-asserts `destination.account != Account::default()` before the chained `Token::Transfer` is -even constructed — a plain host-side program precondition, unrelated to the `OpenPosition` -authorization-bookkeeping bug above. It fires regardless of privacy: a brand-new *public* -destination would be rejected identically. Confirmed by attempting `WithdrawCollateral` with a -`PrivateUnauthorized` destination (fresh `Account::default()` pre-state, only `npk` known) and -observing the exact `"Destination must be initialized"` panic surface as the circuit-execution -error. Consequence: every `WithdrawCollateral` test in this phase necessarily uses -`PrivateAuthorizedUpdate` (`nsk` known) for the destination — a pre-existing private destination -is the *only* expressible shape, not a coverage choice. - -`ProtocolParameters` remains out of scope — not yet consumed by any instruction (no -freeze/admin logic wired up), nothing to test. - ---- - -## Phase 0 prerequisites (blocking every remaining `GROUP` row) - -- ~~Add `key_protocol` as a git dependency~~ — **done** (2026-07-07), added to - `integration_tests/Cargo.toml` pinned to the same repo/tag as `nssa`/`nssa_core`. Unblocks the - remaining `GROUP` rows in ATA/AMM/Stablecoin; each still needs its own program-specific test - (PDA-based group ownership, not just the regular-account path proven for Token). -- Build the shared privacy test kit in `integration_tests/src/lib.rs` — **mostly done** - (2026-07-08): `private_unauthorized_identity`/`private_authorized_init_identity`/ - `private_authorized_update_identity` (build an `InputAccountIdentity` from just the key - material) and `GroupOwner` (the Alice-creates/Bob-admitted GMS handshake, via `::new(seed)` + - `.admit_member()`) now live there and are used throughout `token.rs`, `stablecoin.rs` (fully - migrated), and the newer `ata.rs` group tests. Only the original `ata_group_owned_owner_signing` - still has its own independent inline copy — not yet migrated. Low priority; revisit - before/during AMM if it's still outstanding then. - -**Implementation technique worth carrying into AMM/Stablecoin (found 2026-07-07):** private -account preconditions don't need a real proven transaction to set up. `V03State::with_private_accounts(impl IntoIterator)` -is a genuine, non-test-gated builder method — pair `Commitment::new(&id, &account)` with -`Nullifier::for_account_initialization(&id)` (the same pairing a real `PrivateUnauthorized`/ -`PrivateAuthorizedInit` would have produced) and the seeded state is indistinguishable from a -real one to any subsequent transaction. Confirmed against `lee`'s own test suite pattern before -using it, then applied to refactor `token_private_burn`, `token_transfer_into_existing_private_holding`, -and `token_mint_into_existing_private_holding`'s setup legs — all still pass. Caveat: seeding -skips whatever *public*-side effect the bypassed transaction would have had (sender debit for a -shield, supply increase for a mint) — assertions on public state must account for that, matching -how public fixtures (`Accounts::holder_init()`) already set balances without a real mint ever -having produced them. This will matter more for AMM/Stablecoin, where setup transactions are -heavier (chained calls, multiple accounts) than a single shield. - -## Row count summary - -| Program | Existing private / confirmed | Planned rows | Out-of-scope instructions noted | -|---|---|---|---| -| Token | 16 (3 pre-existing + 13 new: 12 pass + 1 confirmed not-expressible by design) — phase complete | 0 | 5 | -| ATA | 8 (7 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | -| AMM | 6 (all confirmed not-expressible: Swap/SwapExactOutput/AddLiquidity (both LP and deposit legs)/RemoveLiquidity blocked by the same circuit bug, plus RemoveLiquidity's separate new-destination precondition; 2 further rows predicted not-expressible pending confirmation via the `PDA` finding) | 5 | 5 | -| Stablecoin | 7 (5 pass + 2 confirmed not-expressible — phase complete) | 0 | 1 | From 6ea75010ec6aba2df15b1ead94ee2b54fbbc3657 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Fri, 24 Jul 2026 17:05:57 -0400 Subject: [PATCH 12/12] docs(privacy): clarify findings.md per PR review Address 0x-r4bbit's review comments on PR 215: expand the is_authorized reasoning, explain the AMM account-drop bug and its soundness implication, tag every not-expressible result with whether it's resolved (PR #621/#625) or still open, and add an Action items table attributing each open item to Zones (logos-execution-zone) or Programs (lez-programs). --- docs/findings.md | 62 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/docs/findings.md b/docs/findings.md index bf6adff3..3f4ddf38 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -1,7 +1,7 @@ # Privacy coverage in LEZ programs -LEZ programs, ideally, are privacy agnostic. E.g., a program should work the same for public and private accounts. Currently, LEZ program integration tests only cover public accounts. This task, we expand the tests for LEZ programs to determine how adaptable (TODO-probably wrong word) LEZ programs are to selective privacy. +LEZ programs, ideally, are privacy agnostic. E.g., a program should work the same for public and private accounts. Currently, LEZ program integration tests only cover public accounts. This task, we expand the tests for LEZ programs to determine how compatible LEZ programs are with selective privacy. # Private account variants in LEE @@ -15,7 +15,7 @@ Regular private accounts can be initialized with or without knowledge of the acc - `PrivateUnauthorized` - A special case for private accounts initialization that uses only public keys `npk` and `vpk`. Example: Alice can use Bob's keys (`npk`, `vpk`) and an `identifier` to send Bob a private transaction. Since Alice does not know the corresponding `nsk`, she is spend the resulting private account. E.g., Alice cannot authorize the transaction. + A special case for private accounts initialization that uses only public keys `npk` and `vpk`. Example: Alice can use Bob's keys (`npk`, `vpk`) and an `identifier` to send Bob a private transaction. Since Alice does not know the corresponding `nsk`, she is unable to spend the resulting private account. E.g., Alice cannot authorize the transaction. - `PrivateAuthorizedInit` Private account initialized using the account's `nsk` (and some `identifier`). This operation cannot be done by the a third-party (an entity that does not possess spending authority of the account). @@ -34,7 +34,7 @@ Private account updates require knowledge of the account's `nsk`. E.g., Alice ca Only the account owner can (1) update their initialized account, and (2) use functions that require authorization with their account. ### Remark -- `PrivateUnauthorized` initialization is used for account initialization. `is_authorized = false` is a protection that does not seem crucial. Artifically, blocks some functions. +- `is_authorized = false` is crucial for public accounts, since a public account id can be freely referenced and used by anyone in a transaction — `is_authorized` is what stops that. Private accounts don't have this exposure: only the account owner (via `nsk`) can ever update a private account, regardless of `is_authorized`. So for `PrivateUnauthorized` specifically — a third party initializing a *new* account on the owner's behalf, which nobody but the owner can subsequently update — `is_authorized = false` doesn't protect anything; it only blocks legitimate program functions that require a signer. This issue has been resolved by [PR 621](https://github.com/logos-blockchain/logos-execution-zone/pull/621); for more information see the Action items section. ## Private PDA @@ -58,7 +58,7 @@ distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse: 3. Bob **unseals** it with his own sealing secret key, then independently re-derives the account's keys from the same seed. -This ensures that any member of the group can execute programs on shared accounts using either `PrivateAuthorizedInit` or `PrivateAuthorizedUpdate`. From a program's perspective, shared accounts should behave the same as regular public accounts. +This ensures that any member of the group can execute programs on shared accounts using either `PrivateAuthorizedInit` or `PrivateAuthorizedUpdate`. From a program's perspective, shared accounts should behave the same as regular private accounts. # Privacy coverage for LEZ programs objectives @@ -72,6 +72,9 @@ In this task, we extend testing for LEZ programs to cover privacy features: | GROUP | Shared group account | | CHAIN | private account used in a chain call | +A test function whose name ends in `_is_not_expressible` is a negative test: it demonstrates that +a desirable privacy pattern currently fails. + # LEZ programs ## AMM program @@ -83,15 +86,16 @@ In this task, we extend testing for LEZ programs to cover privacy features: | AddLiquidity | `amm_add_liquidity_private_lp_holding` | REGULAR, CHAIN | Private LP-output holding (`user_holding_lp`) receives newly-minted LP on top of an existing private balance | ✅ | | AddLiquidity | `amm_add_liquidity_private_user_holdings` | REGULAR, CHAIN | Private deposit legs (`user_holding_a` + `user_holding_b`), public LP recipient | ✅ | | RemoveLiquidity | `amm_remove_liquidity_private_lp_holding` | REGULAR, CHAIN | Private LP holding (the account that signs/burns to remove liquidity) | ✅ | -| RemoveLiquidity | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` | EXIST, CHAIN | Brand-new `PrivateUnauthorized` token A/B destinations — rejected by a separate, unrelated program-level precondition (destination must already exist) | ❌ (confirmed not-expressible — different reason) | -| SwapExactInput | `amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible` | EXIST, CHAIN | Swap paying out to a brand-new `PrivateUnauthorized` destination (`npk` only, no `nsk`) | ❌ (confirmed not-expressible — guest ABI requires both swap legs to be signers, which `PrivateUnauthorized` can never satisfy by construction) | -| SwapExactInput | `amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible` | REGULAR, CHAIN | Swap paying out to a brand-new `PrivateAuthorizedInit` destination (owner self-initializes with its own `nsk`) | ❌ (confirmed not-expressible — same "destination must already exist" precondition as `RemoveLiquidity`) | +| RemoveLiquidity | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` | EXIST, CHAIN | Brand-new `PrivateUnauthorized` token A/B destinations — rejected by a separate, unrelated program-level precondition (destination must already exist) | ❌ Not-expressible — AMM's own precondition requires the destination to already be owned by the Token Program. **[Open — Programs]** | +| SwapExactInput | `amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible` | EXIST, CHAIN | Swap paying out to a brand-new `PrivateUnauthorized` destination (`npk` only, no `nsk`) | ❌ Not-expressible — the guest's signer check requires `is_authorized == true` on both swap legs, and `PrivateUnauthorized` always initializes with `is_authorized == false`. **[Resolved — PR #621]** | +| SwapExactInput | `amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible` | REGULAR, CHAIN | Swap paying out to a brand-new `PrivateAuthorizedInit` destination (owner self-initializes with its own `nsk`) | ❌ Not-expressible — same "destination must already exist" precondition as `RemoveLiquidity`. **[Open — Programs]** | | NewDefinition | `amm_new_definition_private_initial_lp_holder` | REGULAR | Pool creation with a private `PrivateAuthorizedInit` initial LP holder | ✅ | -| NewDefinition | `amm_new_definition_private_unauthorized_lp_holder_is_not_expressible` | EXIST, REGULAR | Pool creation with a `PrivateUnauthorized` initial LP holder (`npk` only, no `nsk`) | ❌ (confirmed not-expressible — guest ABI requires `user_holding_lp` to be a signer, which `PrivateUnauthorized` can never satisfy; same shape as the `Swap` `PrivateUnauthorized` finding above) | +| NewDefinition | `amm_new_definition_private_unauthorized_lp_holder_is_not_expressible` | EXIST, REGULAR | Pool creation with a `PrivateUnauthorized` initial LP holder (`npk` only, no `nsk`) | ❌ Not-expressible — same `is_authorized == true` signer check as the `Swap` row above, on `user_holding_lp`. **[Resolved — PR #621]** | ### Remarks - `Swap` and `Remove` rejects any uninitialized destination account; this is a AMM design choice, and not Token program requirement. -- AMM tests were initially blocked by a bug. +- AMM's chained-call privacy tests were initially blocked by a "bug" in `logos-execution-zone`. An account was silently dropped from the programs output (pre and post states) before the transaction was validated. This behavior was acceptable in public transactions, but not for privacy transactions. This issue has been resolved by [PR 625](https://github.com/logos-blockchain/logos-execution-zone/pull/625); for more information see the Action items section. + - The `clock` account was the offending account in the tests. For `integration_tests` the default account id was used which resulted in the account being dropped by LEZ. This behavior does not occur in practice as the `clock` account id is used for real. The AMM tests have been updated to avoid this issue for public and privacy tests. ## ATA program @@ -100,7 +104,7 @@ ATA program offers limited usage with private accounts. Private accounts can be | Function tested | Test name | Category | Description of objective | Result | |---|---|---|---|---| | Create | `ata_create_from_private_owner` | REGULAR, EXIST | Any third party can bootstrap another owner's ATA using only that owner's public key material (`PrivateUnauthorized` — `npk`/`vpk` only, no `nsk`) — `Create` never asserts `owner.is_authorized` | ✅ | -| Create | `ata_create_private_ata_holding_is_not_expressible` | PDA | Attempts to make the ATA holding itself a private account via `PrivatePdaInit`/`PrivatePdaUpdate` — confirms the public-form PDA match ATA authorizes with and the private-form binding those variants require are mutually exclusive for the same account id | ❌ (confirmed not-expressible) | +| Create | `ata_create_private_ata_holding_is_not_expressible` | PDA | Attempts to make the ATA holding itself a private account via `PrivatePdaInit`/`PrivatePdaUpdate` — confirms the public-form PDA match ATA authorizes with and the private-form binding those variants require are mutually exclusive for the same account id | ❌ Not-expressible — `ata_core` derives the ATA holding's `AccountId` via the public-only formula, and the public/private formulas are mutually exclusive for the same account id. **[Open — Zones]**, a unified `AccountId` formula needs to be devised before programs can support private PDAs | | Create | `ata_create_from_group_owned_owner` | GROUP | Group-derived owner identity used to create an ATA — **weaker than the other `GROUP` rows**: `Create` never requires `owner` to prove control. | ✅ (defensive/symmetry coverage only) | | Transfer | `ata_transfer_to_existing_private_recipient` | REGULAR | Sends more into an already-shielded private recipient through ATA's *nested* chained call into Token — the first test in the whole exercise proving a private identity survives a chained call at all | ✅ | | Transfer | `ata_transfer_with_private_owner_signing` | REGULAR | Key discovery: unlike `Create` (merely `mut`), `Transfer` requires `owner` to be a *signer* (`#[account(signer)]`) — a private owner self-initializes and signs in the same transaction via `PrivateAuthorizedInit` | ✅ | @@ -123,9 +127,9 @@ ATA program offers limited usage with private accounts. Private accounts can be | RepayDebt | `stablecoin_repay_debt_group_owned_stablecoin_holding` | GROUP | Same, group-owned holding | ✅ | ### Remarks -- `OpenPosition` is blocked for use in privacy transactions due to the chained calls usage. `OpenPosition` calls `Token::InitializeAccount` and `Token::Transfer` for the same vault account which is disallowed behavior in privacy preserving circuit. Demonstrated with test `stablecoin_open_position_via_privacy_transaction_is_not_expressible`. -- `WithdrawCollateral` does not support withdrawals to `PrivateUnauthorized` and `PrivateAuthorizedInit`; explicitly checks that the destination account is not default. Demonstrated with the test `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. -- Vault is explicitly public PDA by formula requirement. +- `OpenPosition` is blocked for use in privacy transactions due to the chained calls usage. `OpenPosition` calls `Token::InitializeAccount` and `Token::Transfer` for the same vault account which is disallowed behavior in privacy preserving circuit. Demonstrated with test `stablecoin_open_position_via_privacy_transaction_is_not_expressible`. **[Open — Zones]**, see Conclusions. +- `WithdrawCollateral` does not support withdrawals to `PrivateUnauthorized` and `PrivateAuthorizedInit`; explicitly checks that the destination account is not default. Demonstrated with the test `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. **[Open — Programs]** +- Vault is explicitly public PDA by formula requirement. **[Open — Zones]**, same `AccountId` formula issue as ATA's — see the Action items section. ## Token program @@ -144,22 +148,38 @@ ATA program offers limited usage with private accounts. Private accounts can be | Burn | `token_private_burn` | REGULAR | Burn from an existing private holding via a single `PrivateAuthorizedUpdate` | ✅ | | Burn | `token_group_owned_holding_shared_control_burn` | GROUP | Shield tokens into a GMS-derived shared holding, then burn from it using an independently re-derived key | ✅ | | InitializeAccount | `token_initialize_private_account_succeeds_for_canonical_definition` | REGULAR | Self-init of a private holding via `PrivateAuthorizedInit` | ✅ | -| InitializeAccount | `token_initialize_private_account_without_nsk_is_not_expressible` | EXIST | `InitializeAccount`'s target is `#[account(init, signer)]` — a third party cannot initialize a private holding via `PrivateUnauthorized` (no `nsk`); rejected by the SPEL macro ("must be a signer") before the program's own logic runs | ❌ (confirmed not-expressible by design) | +| InitializeAccount | `token_initialize_private_account_without_nsk_is_not_expressible` | EXIST | `InitializeAccount`'s target is `#[account(init, signer)]` — the guest's signer check requires `is_authorized == true`, and a third party initializing via `PrivateUnauthorized` (no `nsk`) always gets `is_authorized == false`, rejected before the program's own logic runs | ❌ Not-expressible. **[Resolved — PR #621]** | | InitializeAccount | `token_group_owned_holding_shared_control_initialize` | GROUP | A group member — not the party who created the group — self-initializes the shared holding directly via `PrivateAuthorizedInit` | ✅ | | MintWithAuthority | `token_mint_with_authority_to_private_holding` | EXIST | External-authority mint (distinct signer from the definition) directly to a fresh private recipient | ✅ | | NewFungibleDefinition | `token_new_fungible_definition_private_initial_holder` | REGULAR | Public token definition, private initial holder that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) — same self-service shape as `InitializeAccount`'s target | ✅ | -| NewFungibleDefinition | `token_new_fungible_definition_private_holder_without_nsk_is_not_expressible` | EXIST | The initial holder cannot be created via `PrivateUnauthorized` — rejected by the SPEL macro before the program's own logic runs | ❌ (confirmed not-expressible by design) | +| NewFungibleDefinition | `token_new_fungible_definition_private_holder_without_nsk_is_not_expressible` | EXIST | The initial holder cannot be created via `PrivateUnauthorized` — same `is_authorized == true` signer check as `InitializeAccount` above, rejected before the program's own logic runs | ❌ Not-expressible. **[Resolved — PR #621]** | ### Remarks -- `Initialization` is not possible for `PrivateUnauthorized` accounts due to `is_authorized = false`. -- New token definition is not permitted for `PrivateUnauthorized` as Token holding due to `is_authorized = false`.E.g., both Token Definition and Token Holding for a new Token must be from an authorized account. +- `Initialization` is not possible for `PrivateUnauthorized` accounts due to `is_authorized = false`. **[Resolved — PR #621]** +- New token definition is not permitted for `PrivateUnauthorized` as Token holding due to `is_authorized = false`. E.g., both Token Definition and Token Holding for a new Token must be from an authorized account. **[Resolved — PR #621]** # Conclusions Privacy coverage for LEZ program tests is greatly improved from the added tests. Though, there are a few noticable gaps: -- `PrivateUnauthorized` accounts can be blocked by programs with a check `is_authorized = true`. However, this issue can be avoided by defining `is_authorized = true` for account initialization with `PrivateUnauthorized` (e.g., no knowledge of `npk`). Account initialization cannot be used to maliciously alter a pre-existing account, and thus `is_authorized = true` would not offer any malicious path forward for the third-party initializing the account. -- Privacy transactions have issues with chain calls in which multiple calls affect the same private account. This issue can be mitigated by adopting account diff paradigm instead of the current "account state replacement" that we currently use. +- `PrivateUnauthorized` accounts can be blocked by programs with a check requiring `is_authorized = true`, since a fresh `PrivateUnauthorized` account is always initialized with `is_authorized = false`. This is a `logos-execution-zone` protocol-level issue, not something programs can work around — see the `PrivateUnauthorized` remark above for the full reasoning. This has been resolved by [PR 621](https://github.com/logos-blockchain/logos-execution-zone/pull/621); see the Action items section. +- Privacy transactions have issues with chain calls in which multiple calls affect the same private account. The privacy preserving circuit's `authorized_accounts` bookkeeping is monotonic (once an account is authorized, every later occurrence within the same transaction must also declare it authorized), which rejects some call patterns that are valid on the public-transaction path (E.g. `Stablecoin::OpenPosition`). This is a `logos-execution-zone` protocol level issue. A proposed revision to account updates would mitigate this issue: accounts updated iteratively based on their state diff rather than "full replacement". + +Additionally, testing undercovered a "bug" in LEZ: +AMM's chained-call privacy tests were initially blocked because the `clock` account — seeded in the test fixture with a default (unclaimed) account id, `DEFAULT_PROGRAM_ID`, rather than owned by a dedicated clock program as in production — was silently dropped from a program's output, undetected by `logos-execution-zone`'s public-transaction validation: `ValidatedStateDiff::from_public_transaction` never checked that the accounts touched in a program's output matched the caller-declared `message.account_ids` — no count check, no membership check, nothing like the privacy circuit's own `account_identities.len() == states_iter.len()` assertion. That gap is why no pre-existing public AMM test ever caught the clock account being dropped: the public path had no validation capable of catching a silently-dropped account at all; only the privacy circuit's stricter bookkeeping turned it into a hard failure. Worked around at the test-fixture level in the meantime (giving the fixture's clock account a non-default owner; see the AMM section). This also had a soundness implication beyond blocking AMM tests: because `clock` never reached `public_pre_states` on the privacy-preserving path, the host validator never checked the clock data a proof was generated against real chain state — a malicious prover could in principle have supplied an arbitrary timestamp as a private witness with nothing to catch it. This has been resolved by [PR 625](https://github.com/logos-blockchain/logos-execution-zone/pull/625), which closes both the test-blocking symptom and the soundness gap; see the Action items section. + +## Action items + +Every open or resolved issue raised in this report, grouped by which repo the fix lives in — +**Zones** owns `logos-execution-zone`, **Programs** owns `lez-programs`: + +| Owner | Item | Status | +|---|---|---| +| Zones | `is_authorized = false` blocks `PrivateUnauthorized` on signer-gated instructions | ✅ Resolved — [PR #621](https://github.com/logos-blockchain/logos-execution-zone/pull/621) | +| Zones | Public transactions don't detect a silently-dropped declared account | ✅ Resolved — [PR #625](https://github.com/logos-blockchain/logos-execution-zone/pull/625) | +| Zones | `OpenPosition`-shaped chain calls (re-authorizing the same account across chained calls) fail under the privacy circuit | 🔲 Open — proposed circuit revision, no PR yet | +| Zones | ATA/AMM/Stablecoin PDAs derive via `for_public_pda` only, blocking private PDA use — the public and private `AccountId` formulas are mutually exclusive for the same account, so a unified formula needs to be devised before programs can adopt it | 🔲 Open | +| Programs | AMM/Stablecoin reject any destination account that isn't already initialized, blocking fresh private destinations (3 tests) | 🔲 Open | +| Programs | ATA's `Transfer` rejects a non-default (fresh) recipient, blocking shield-style transfers into a brand-new private destination | 🔲 Open | -Additional observation: -AMM's chained-call privacy tests were blocked by the clock account being `DEFAULT_PROGRAM_ID`-owned in the test fixture, which trips a `spel-framework` dispatcher filter (upstream in `logos-co/spel`) that silently drops any default-owned, non-default, unclaimed account from a program's output. Fixed by giving the fixture's clock account a non-default owner; see the AMM section. But the deeper bug is in `logos-execution-zone` itself: `ValidatedStateDiff::from_public_transaction` never checks that the accounts touched in a program's output match the caller-declared `message.account_ids` — no count, no membership check, nothing like the privacy circuit's own `account_identities.len() == states_iter.len()` assertion. That's why the `spel-framework` drop went unnoticed by every pre-existing public AMM test: the public path has no validation capable of catching a silently-dropped account at all. Both remain open upstream. \ No newline at end of file +Resolved items from Zones land in `dev` branch of `logos-execution-zone`. \ No newline at end of file