diff --git a/crates/holdings-contract/Cargo.lock b/crates/holdings-contract/Cargo.lock new file mode 100644 index 0000000..4d1e681 --- /dev/null +++ b/crates/holdings-contract/Cargo.lock @@ -0,0 +1,1449 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "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.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +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", + "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.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "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", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "freenet-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26e5c6bee9ffa0e2683bc8b41a7bd9f1e57e7fd49ac4f6ae433e5a82e9006df" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "freenet-stdlib" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62aeba7634d1ae5c2d02f78db33ee40442511858275be5be2099d24a13b2559" +dependencies = [ + "bincode", + "blake3", + "bs58", + "byteorder", + "bytes", + "chrono", + "flatbuffers", + "freenet-macros", + "futures", + "semver", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "persona-state" +version = "1.7.0-alpha" +dependencies = [ + "bincode", + "blake3", + "chacha20poly1305", + "ed25519-dalek", + "serde", + "x25519-dalek", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.1", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "soulstream-holdings-contract" +version = "0.1.0" +dependencies = [ + "bincode", + "blake3", + "freenet-stdlib", + "persona-state", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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-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-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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/holdings-contract/Cargo.toml b/crates/holdings-contract/Cargo.toml new file mode 100644 index 0000000..9bc9061 --- /dev/null +++ b/crates/holdings-contract/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "soulstream-holdings-contract" +version = "0.1.0" +edition = "2021" +description = "The SoulStream holdings contract — one member's album list in one Space, a thin shell over persona_state::holdings. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +freenet-stdlib = { version = "=0.8.5" } +persona-state = { path = "../persona-state" } +bincode = "1" +blake3 = "1" + +[features] +default = ["freenet-main-contract", "contract"] +freenet-main-contract = [] +contract = ["freenet-stdlib/contract"] +trace = ["freenet-stdlib/trace"] + +[profile.release] +strip = true +lto = true + +# Its own workspace: this crate targets wasm32 and must never join the +# app's host build. +[workspace] diff --git a/crates/holdings-contract/src/lib.rs b/crates/holdings-contract/src/lib.rs new file mode 100644 index 0000000..3f2d1b0 --- /dev/null +++ b/crates/holdings-contract/src/lib.rs @@ -0,0 +1,132 @@ +//! The SoulStream holdings contract. Single writer: one member's latest +//! signed album list in one Space, at the address (space id, member key). +//! Merge is the pointer contract's total order, (sequence, blake3) — +//! `persona_state::holdings::merge` decides, so every delivery order +//! converges, and a stale replay cannot bring a withdrawn list back. +//! +//! The body is opaque here: sealed to the room's epoch key in a private +//! Space, and the contract only ever checks the envelope and its signature. +//! +//! Build: `cargo build --release --target wasm32-unknown-unknown` in this +//! directory; copy `target/wasm32-unknown-unknown/release/ +//! soulstream_holdings_contract.wasm` to `src-tauri/assets/holdings_contract.wasm` +//! and re-pin the code hash in `src-tauri/src/holdings.rs`. + +use freenet_stdlib::prelude::*; +use persona_state::holdings as t; + +pub struct Contract; + +fn parts(parameters: &Parameters<'static>) -> Result<([u8; 16], [u8; 32]), ContractError> { + t::params_parts(parameters.as_ref()) + .ok_or_else(|| ContractError::Other("params are not family+space+member".into())) +} + +fn summary_of(bytes: &[u8], s: &t::HoldingsState) -> Vec { + let (seq, hash) = t::rank(bytes, s); + let mut out = Vec::with_capacity(40); + out.extend_from_slice(&seq.to_le_bytes()); + out.extend_from_slice(&hash); + out +} + +#[contract] +impl ContractInterface for Contract { + fn validate_state( + parameters: Parameters<'static>, + state: State<'static>, + _related: RelatedContracts<'static>, + ) -> Result { + let (space_id, member) = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(ValidateResult::Valid); + } + match t::validate(&space_id, &member, state.as_ref()) { + Ok(_) => Ok(ValidateResult::Valid), + Err(e) => Err(ContractError::Other(format!("invalid holdings: {e}"))), + } + } + + fn update_state( + parameters: Parameters<'static>, + state: State<'static>, + data: Vec>, + ) -> Result, ContractError> { + let (space_id, member) = parts(¶meters)?; + let mut candidates: Vec> = Vec::new(); + if !state.as_ref().is_empty() { + candidates.push(state.as_ref().to_vec()); + } + let mut incoming_valid = 0usize; + for ud in &data { + let mut push = |bytes: &[u8]| { + if bytes.is_empty() { + return; + } + if t::validate(&space_id, &member, bytes).is_ok() { + incoming_valid += 1; + } + candidates.push(bytes.to_vec()); + }; + match ud { + UpdateData::State(s) => push(s.as_ref()), + UpdateData::Delta(d) => push(d.as_ref()), + UpdateData::StateAndDelta { state, delta } => { + push(state.as_ref()); + push(delta.as_ref()); + } + _ => {} + } + } + if incoming_valid == 0 && !data.is_empty() { + return Err(ContractError::InvalidUpdate); + } + match t::merge(&space_id, &member, candidates.iter().map(|v| v.as_slice())) { + Some(bytes) => Ok(UpdateModification::valid(State::from(bytes))), + None => Ok(UpdateModification::valid(state)), + } + } + + fn summarize_state( + parameters: Parameters<'static>, + state: State<'static>, + ) -> Result, ContractError> { + let (space_id, member) = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(StateSummary::from(Vec::new())); + } + let s = t::validate(&space_id, &member, state.as_ref()) + .map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?; + Ok(StateSummary::from(summary_of(state.as_ref(), &s))) + } + + fn get_state_delta( + parameters: Parameters<'static>, + state: State<'static>, + summary: StateSummary<'static>, + ) -> Result, ContractError> { + let (space_id, member) = parts(¶meters)?; + if state.as_ref().is_empty() { + return Ok(StateDelta::from(Vec::new())); + } + let s = t::validate(&space_id, &member, state.as_ref()) + .map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?; + let ours = t::rank(state.as_ref(), &s); + let theirs = { + let b = summary.as_ref(); + if b.len() == 40 { + let seq = u64::from_le_bytes(b[..8].try_into().unwrap()); + let mut h = [0u8; 32]; + h.copy_from_slice(&b[8..40]); + Some((seq, h)) + } else { + None + } + }; + let newer = match theirs { + Some(t) => ours > t, + None => true, + }; + Ok(StateDelta::from(if newer { state.as_ref().to_vec() } else { Vec::new() })) + } +} diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index 4696894..f16c8fd 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -811,6 +811,14 @@ impl Library { Ok(rows.filter_map(|r| r.ok()).collect()) } + /// Album id → MusicBrainz release id, for the albums that have one. + pub fn album_mbids(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, mbid FROM albums WHERE mbid IS NOT NULL AND mbid != ''")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + /// Per-album aggregates for the Library page: what the FILES add up to. /// One call for the whole library, because the page derives its stat row, /// its Format grouping and four of its sorts (length, size, bitrate, diff --git a/crates/persona-state/src/holdings.rs b/crates/persona-state/src/holdings.rs new file mode 100644 index 0000000..aa214c9 --- /dev/null +++ b/crates/persona-state/src/holdings.rs @@ -0,0 +1,446 @@ +//! Holdings — what a member of a Space holds, as album keys and never as +//! files. One contract per (space, member): the member's latest signed list, +//! sealed to the room's epoch key in a private Space. Merge is the pointer +//! contract's total order, (sequence, blake3): a withdrawal is just a newer +//! sequence with an empty body, and a stale replay cannot resurrect a list. +//! +//! An album is named by a key, not a name: blake3 over the room's id and the +//! same normalised (artist, album) the library-overlap comparison uses, so a +//! holding matches exactly when overlap would have said "you own this". The +//! room id salts it — the same album hashes differently in every room, so +//! two rooms' lists cannot be joined. A member can still learn whether you +//! hold any album they can NAME, by hashing it and looking; that is the +//! feature, and the switch's disclosure says so. +//! +//! Nothing here reads a library or a network; the app does both. + +use serde::{Deserialize, Serialize}; + +pub const SCHEMA_VERSION: u32 = 1; +pub const PARAMS_FAMILY: &[u8] = b"soulstream.holdings.v1"; +/// The Space family's domain, not one of its own: the identity delegate +/// signs only under an allowlist, and a new domain would be a delegate +/// generation (a migration event) for the one case — an owner signing their +/// own list — that needs it. The signed payload's shape keeps a holdings +/// signature from ever reading as a Space message or a checkpoint. +pub const SIGN_DOMAIN: &str = crate::space::SIGN_DOMAIN; +/// The contract's gate: envelope plus body, sealed or not. +pub const MAX_STATE_BYTES: usize = 1024 * 1024; +/// Entries per list. A `Holding` is at most 66 bytes on the wire, so this +/// keeps a full list — with its username and the envelope — under the gate +/// with room to spare. A library above it publishes its newest entries. +pub const MAX_ENTRIES: usize = 14_000; +pub const MAX_USERNAME_BYTES: usize = 64; + +pub const TIER_UNKNOWN: u8 = 0; +pub const TIER_LOSSY: u8 = 1; +pub const TIER_LOSSLESS: u8 = 2; + +/// One album, as the room sees it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Holding { + /// `album_key` of the normalised (artist, album), salted with the room. + pub key: [u8; 32], + /// `mbid_key` when the library knows the release's MusicBrainz id. + pub mbid: Option<[u8; 32]>, + /// One word about quality: lossless, lossy, or unknown. Never a format, + /// a bitrate, or a size. + pub tier: u8, +} + +/// The plaintext a list carries. Sealed in a private Space, so the Soulseek +/// name inside it is readable by members of that epoch only — it is what +/// lets "who has this" become "get it from them". +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HoldingsBody { + pub schema_version: u32, + pub slsk_username: Option, + /// Sorted by key, no duplicates — `encode_body` enforces it. + pub entries: Vec, +} + +/// The contract state: the member's latest list, signed by the key they +/// wear in the room. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HoldingsState { + pub schema_version: u32, + pub space_id: [u8; 16], + pub member: [u8; 32], + pub sequence: u64, + /// The epoch whose key sealed `body`; readers look their key up by it. + pub epoch: u64, + /// False in a public Space: `body` is the plain encoding. + pub sealed: bool, + pub published_at: u64, + /// Withdrawn: the list is gone from current state (`body` empty). Copies + /// members already read stay with them — never claim erasure. + pub withdrawn: bool, + pub body: Vec, + pub sig: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Invalid { + Oversized(usize), + Malformed, + WrongSchema(u32), + WrongAddress, + BadSignature, + TooManyEntries(usize), + FieldTooLong(&'static str, usize), +} + +impl core::fmt::Display for Invalid { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"), + Invalid::Malformed => write!(f, "does not decode"), + Invalid::WrongSchema(v) => write!(f, "schema {v} is not {SCHEMA_VERSION}"), + Invalid::WrongAddress => write!(f, "names another room or member"), + Invalid::BadSignature => write!(f, "the signature does not verify"), + Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap of {MAX_ENTRIES}"), + Invalid::FieldTooLong(what, n) => write!(f, "{what} is {n} bytes, over its cap"), + } + } +} + +// ---------------------------------------------------------------- params + +pub fn make_params(space_id: &[u8; 16], member: &[u8; 32]) -> Vec { + let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48); + p.extend_from_slice(PARAMS_FAMILY); + p.extend_from_slice(space_id); + p.extend_from_slice(member); + p +} + +pub fn params_parts(params: &[u8]) -> Option<([u8; 16], [u8; 32])> { + let t = PARAMS_FAMILY.len(); + if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY { + return None; + } + Some((params[t..t + 16].try_into().unwrap(), params[t + 16..t + 48].try_into().unwrap())) +} + +// ---------------------------------------------------------------- keys + +/// The key an album is held under in ONE room. `artist` and `album` are +/// the overlap normalisation's output (lowercase alphanumerics, brackets +/// gone, a leading "the" and a filing year dropped) — the caller's job, so +/// a wasm build of this module never carries the normaliser. +pub fn album_key(space_id: &[u8; 16], artist: &str, album: &str) -> [u8; 32] { + let mut h = blake3::Hasher::new_derive_key("soulstream.holding.v1"); + h.update(space_id); + h.update(artist.as_bytes()); + h.update(&[0]); + h.update(album.as_bytes()); + *h.finalize().as_bytes() +} + +/// The same album by its MusicBrainz release id, for the day identity is +/// anchored rather than named. +pub fn mbid_key(space_id: &[u8; 16], mbid: &str) -> [u8; 32] { + let mut h = blake3::Hasher::new_derive_key("soulstream.holding-mbid.v1"); + h.update(space_id); + h.update(mbid.trim().to_lowercase().as_bytes()); + *h.finalize().as_bytes() +} + +// ---------------------------------------------------------------- body + +/// Encode a list for the wire: sorted by key, duplicates folded (the better +/// tier wins), caps enforced. +pub fn encode_body(mut b: HoldingsBody) -> Result, Invalid> { + if let Some(u) = &b.slsk_username { + if u.len() > MAX_USERNAME_BYTES { + return Err(Invalid::FieldTooLong("slsk_username", u.len())); + } + } + b.schema_version = SCHEMA_VERSION; + b.entries.sort_by(|x, y| x.key.cmp(&y.key).then(y.tier.cmp(&x.tier))); + b.entries.dedup_by(|later, earlier| { + if later.key != earlier.key { + return false; + } + // The first of a run has the higher tier already; keep its mbid if + // the later one had none. + if earlier.mbid.is_none() { + earlier.mbid = later.mbid; + } + true + }); + if b.entries.len() > MAX_ENTRIES { + return Err(Invalid::TooManyEntries(b.entries.len())); + } + bincode::serialize(&b).map_err(|_| Invalid::Malformed) +} + +pub fn decode_body(bytes: &[u8]) -> Option { + let b: HoldingsBody = bincode::deserialize(bytes).ok()?; + if b.schema_version != SCHEMA_VERSION || b.entries.len() > MAX_ENTRIES { + return None; + } + Some(b) +} + +// ---------------------------------------------------------------- sealing + +fn body_key(space_key: &[u8; 32]) -> [u8; 32] { + blake3::derive_key("soulstream.holdings-body.v1", space_key) +} + +/// The AAD binds a body to its envelope: another member's sealed bytes +/// under this address, or this member's older sequence, do not open. +fn aad(s: &HoldingsState) -> Vec { + let mut v = Vec::with_capacity(64); + v.extend_from_slice(&s.space_id); + v.extend_from_slice(&s.member); + v.extend_from_slice(&s.sequence.to_le_bytes()); + v.extend_from_slice(&s.epoch.to_le_bytes()); + v +} + +/// Unique per (member, sequence) under one room — a sequence is never +/// published twice by the same key, which is the nonce's whole contract. +fn nonce(s: &HoldingsState) -> [u8; 12] { + blake3::hash(&aad(s)).as_bytes()[..12].try_into().unwrap() +} + +/// Seal `s.body` (the plain encoding going in) under the room's epoch key. +/// Call BEFORE signing; the signature covers the sealed bytes. +pub fn seal(space_key: &[u8; 32], s: &HoldingsState) -> Vec { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + let key = body_key(space_key); + ChaCha20Poly1305::new(Key::from_slice(&key)) + .encrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) }) + .expect("seal") +} + +/// Open a sealed body. None = wrong key for this epoch, or tampered. +pub fn open(space_key: &[u8; 32], s: &HoldingsState) -> Option> { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + let key = body_key(space_key); + ChaCha20Poly1305::new(Key::from_slice(&key)) + .decrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) }) + .ok() +} + +// ---------------------------------------------------------------- signing + +#[derive(Serialize)] +struct SigBody<'a> { + schema_version: u32, + space_id: &'a [u8; 16], + member: &'a [u8; 32], + sequence: u64, + epoch: u64, + sealed: bool, + published_at: u64, + withdrawn: bool, + body: &'a [u8], +} + +/// What the member signs: blake3 of the envelope, the sealed body included. +pub fn sign_payload(s: &HoldingsState) -> Vec { + let body = bincode::serialize(&SigBody { + schema_version: s.schema_version, + space_id: &s.space_id, + member: &s.member, + sequence: s.sequence, + epoch: s.epoch, + sealed: s.sealed, + published_at: s.published_at, + withdrawn: s.withdrawn, + body: &s.body, + }) + .expect("sig body serializes"); + blake3::hash(&body).as_bytes().to_vec() +} + +/// Sign as the key worn in the room (a room-local member key, or an +/// owner's persona through the delegate — the app decides which). +pub fn sign_with(secret: &[u8; 32], s: &HoldingsState) -> Vec { + crate::sign_local(SIGN_DOMAIN, secret, &sign_payload(s)) +} + +// ---------------------------------------------------------------- validate / merge + +/// Strict, in the plan's order: size → parse → schema → address → caps → +/// signature. A state that passes is exactly what its member published. +pub fn validate(space_id: &[u8; 16], member: &[u8; 32], bytes: &[u8]) -> Result { + if bytes.len() > MAX_STATE_BYTES { + return Err(Invalid::Oversized(bytes.len())); + } + let s: HoldingsState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?; + if s.schema_version != SCHEMA_VERSION { + return Err(Invalid::WrongSchema(s.schema_version)); + } + if &s.space_id != space_id || &s.member != member { + return Err(Invalid::WrongAddress); + } + if s.withdrawn && !s.body.is_empty() { + return Err(Invalid::Malformed); + } + if !crate::verify_sig_in(SIGN_DOMAIN, member, &sign_payload(&s), &s.sig) { + return Err(Invalid::BadSignature); + } + Ok(s) +} + +/// The order every replica agrees on: highest sequence, then the byte hash. +pub fn rank(bytes: &[u8], s: &HoldingsState) -> (u64, [u8; 32]) { + (s.sequence, *blake3::hash(bytes).as_bytes()) +} + +/// The winning candidate's bytes, or None when nothing valid was offered. +pub fn merge<'a>(space_id: &[u8; 16], member: &[u8; 32], candidates: impl IntoIterator) -> Option> { + let mut best: Option<((u64, [u8; 32]), &[u8])> = None; + for bytes in candidates { + let Ok(s) = validate(space_id, member, bytes) else { continue }; + let r = rank(bytes, &s); + if best.as_ref().is_none_or(|(b, _)| r > *b) { + best = Some((r, bytes)); + } + } + best.map(|(_, b)| b.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn secret(n: u8) -> [u8; 32] { + [n; 32] + } + fn state(secret: &[u8; 32], seq: u64, body: Vec, withdrawn: bool) -> HoldingsState { + let mut s = HoldingsState { + schema_version: SCHEMA_VERSION, + space_id: [3; 16], + member: crate::public_of(secret), + sequence: seq, + epoch: 4, + sealed: false, + published_at: 1_700_000_000, + withdrawn, + body, + sig: vec![], + }; + s.sig = sign_with(secret, &s); + s + } + fn bytes(s: &HoldingsState) -> Vec { + bincode::serialize(s).unwrap() + } + fn holding(n: u8, tier: u8) -> Holding { + Holding { key: [n; 32], mbid: None, tier } + } + + #[test] + fn a_list_round_trips_and_refuses_tampering() { + let k = secret(7); + let member = crate::public_of(&k); + let body = encode_body(HoldingsBody { schema_version: 0, slsk_username: Some("zoocone".into()), entries: vec![holding(2, TIER_LOSSY), holding(1, TIER_LOSSLESS)] }).unwrap(); + let s = state(&k, 1, body.clone(), false); + let b = bytes(&s); + let back = validate(&[3; 16], &member, &b).unwrap(); + assert_eq!(back, s); + let d = decode_body(&back.body).unwrap(); + assert_eq!(d.entries.iter().map(|h| h.key[0]).collect::>(), [1, 2], "sorted by key"); + assert_eq!(d.slsk_username.as_deref(), Some("zoocone")); + + // another room, another member, a flipped byte, a foreign signature + assert_eq!(validate(&[4; 16], &member, &b), Err(Invalid::WrongAddress)); + assert_eq!(validate(&[3; 16], &crate::public_of(&secret(8)), &b), Err(Invalid::WrongAddress)); + let mut t = s.clone(); + t.sequence = 9; + assert_eq!(validate(&[3; 16], &member, &bytes(&t)), Err(Invalid::BadSignature)); + let mut f = s.clone(); + f.sig = sign_with(&secret(8), &f); + assert_eq!(validate(&[3; 16], &member, &bytes(&f)), Err(Invalid::BadSignature)); + let mut w = state(&k, 2, body, true); + w.sig = sign_with(&k, &w); + assert_eq!(validate(&[3; 16], &member, &bytes(&w)), Err(Invalid::Malformed), "withdrawn keeps no body"); + } + + #[test] + fn merge_is_by_sequence_then_hash_and_a_replay_cannot_resurrect() { + let k = secret(5); + let member = crate::public_of(&k); + let one = bytes(&state(&k, 1, vec![1], false)); + let two = bytes(&state(&k, 2, vec![2], false)); + let gone = bytes(&state(&k, 3, vec![], true)); + let stranger = bytes(&state(&secret(6), 9, vec![9], false)); + let sid = [3; 16]; + assert_eq!(merge(&sid, &member, [one.as_slice(), two.as_slice()]), Some(two.clone())); + assert_eq!(merge(&sid, &member, [two.as_slice(), one.as_slice()]), Some(two.clone())); + assert_eq!(merge(&sid, &member, [gone.as_slice(), two.as_slice(), one.as_slice()]), Some(gone.clone())); + assert_eq!(merge(&sid, &member, [stranger.as_slice()]), None); + assert_eq!(merge(&sid, &member, [stranger.as_slice(), one.as_slice()]), Some(one.clone())); + // same sequence twice: the hash breaks the tie the same way in any order + let a = bytes(&state(&k, 4, vec![1, 1], false)); + let b = bytes(&state(&k, 4, vec![2, 2], false)); + let ab = merge(&sid, &member, [a.as_slice(), b.as_slice()]); + let ba = merge(&sid, &member, [b.as_slice(), a.as_slice()]); + assert_eq!(ab, ba); + assert!(ab == Some(a.clone()) || ab == Some(b.clone())); + } + + #[test] + fn a_private_list_opens_only_with_the_epoch_key_and_only_in_its_envelope() { + let k = secret(2); + let room_key = [11u8; 32]; + let plain = encode_body(HoldingsBody { schema_version: 0, slsk_username: None, entries: vec![holding(1, TIER_LOSSLESS)] }).unwrap(); + let mut s = state(&k, 1, plain.clone(), false); + s.sealed = true; + s.body = seal(&room_key, &s); + s.sig = sign_with(&k, &s); + assert_ne!(s.body, plain); + assert_eq!(open(&room_key, &s), Some(plain.clone())); + assert_eq!(open(&[12u8; 32], &s), None); + // the same ciphertext under another sequence is not this list + let mut moved = s.clone(); + moved.sequence = 2; + assert_eq!(open(&room_key, &moved), None); + // validate does not need the key: the envelope stands on its own + assert!(validate(&[3; 16], &crate::public_of(&k), &bytes(&s)).is_ok()); + } + + #[test] + fn keys_are_salted_by_room_and_the_body_folds_duplicates_to_the_better_tier() { + let a = album_key(&[1; 16], "broadcast", "tenderbuttons"); + let b = album_key(&[2; 16], "broadcast", "tenderbuttons"); + assert_ne!(a, b); + assert_eq!(a, album_key(&[1; 16], "broadcast", "tenderbuttons")); + assert_ne!(a, album_key(&[1; 16], "broadcasttender", "buttons"), "the separator keeps the two names apart"); + assert_ne!(mbid_key(&[1; 16], "abc"), mbid_key(&[2; 16], "ABC")); + assert_eq!(mbid_key(&[1; 16], " abc "), mbid_key(&[1; 16], "ABC")); + + let body = encode_body(HoldingsBody { + schema_version: 0, + slsk_username: None, + entries: vec![ + Holding { key: [1; 32], mbid: None, tier: TIER_LOSSY }, + Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }, + Holding { key: [1; 32], mbid: None, tier: TIER_UNKNOWN }, + ], + }) + .unwrap(); + let d = decode_body(&body).unwrap(); + assert_eq!(d.entries, vec![Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }]); + + let too_many = HoldingsBody { schema_version: 0, slsk_username: None, entries: (0..MAX_ENTRIES + 1).map(|i| Holding { key: blake3::hash(&i.to_le_bytes()).into(), mbid: None, tier: 0 }).collect() }; + assert!(matches!(encode_body(too_many), Err(Invalid::TooManyEntries(_)))); + let long = HoldingsBody { schema_version: 0, slsk_username: Some("x".repeat(65)), entries: vec![] }; + assert!(matches!(encode_body(long), Err(Invalid::FieldTooLong("slsk_username", 65)))); + } + + #[test] + fn params_round_trip() { + let p = make_params(&[3; 16], &[4; 32]); + assert_eq!(params_parts(&p), Some(([3; 16], [4; 32]))); + assert_eq!(params_parts(&p[..p.len() - 1]), None); + assert_eq!(params_parts(&crate::space::make_params(&[4; 32], &[3; 16])), None); + } +} diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index cd755da..995709b 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -21,6 +21,7 @@ pub mod playlist; pub mod inbox; pub mod space; pub mod proposals; +pub mod holdings; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; diff --git a/src-tauri/assets/holdings_contract.wasm b/src-tauri/assets/holdings_contract.wasm new file mode 100644 index 0000000..25e4eb9 Binary files /dev/null and b/src-tauri/assets/holdings_contract.wasm differ diff --git a/src-tauri/src/holdings.rs b/src-tauri/src/holdings.rs new file mode 100644 index 0000000..29b876b --- /dev/null +++ b/src-tauri/src/holdings.rs @@ -0,0 +1,809 @@ +//! Holdings — "who has this, and get it from them". +//! +//! Each member of a Space may publish what they HOLD: the albums of their +//! library as room-salted keys (`persona_state::holdings::album_key` over +//! `overlap::norm`), one quality word each, and their Soulseek name — sealed +//! to the room's epoch key in a private Space, signed by the key they wear +//! there, one contract per (room, member). Never files, paths, sizes or +//! bitrates: the same rule the overlap shelf lives by, and the switch's +//! disclosure says exactly what a member can learn. +//! +//! Reading a room caches each member's list on disk (`holdings.json`, the +//! overlap ledger's pattern), so the ASK is local and instant and works with +//! the holders offline: hash the album per room, look it up, name the +//! members. "Get it" is the hub's album grab aimed at that member's share — +//! `find_folder` walks their browse tree with overlap's `pair_of` and hands +//! the files back for the frontend to queue; a holder who is offline goes on +//! a local wait list the frontend drains when their presence flips. +//! +//! Nothing is ever pushed to a holder. The only new network object is the +//! list, and only the room's members can read it. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use freenet_adapter::FreenetAdapter; +use persona_state::holdings as hd; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager}; + +use crate::pipeline::LibraryState; +use crate::state::AppState; +use crate::{ledger, overlap, persona, slsk_link, spaces}; + +const HOLDINGS_WASM: &[u8] = include_bytes!("../assets/holdings_contract.wasm"); +/// The ledger changed (a publish, a read, a wait): the page re-reads. +pub const EVENT: &str = "community:holdings"; +const LEDGER_FILE: &str = "holdings.json"; +/// A member's cached list is re-read after this long, or on Refresh. +const FRESH_SECS: u64 = 10 * 60; +/// A member with no list is asked again after this long: a GET for a +/// contract nobody put is a ring search that costs seconds. +const ABSENT_SECS: u64 = 60 * 60; +const LOSSLESS: &[&str] = &["flac", "alac", "wav", "aiff", "aif", "ape", "wv", "dsf", "dff", "tta", "shn"]; +const LOSSY: &[&str] = &["mp3", "ogg", "oga", "opus", "m4a", "aac", "wma", "mpc"]; + +// ---------------------------------------------------------------- ledger + +/// What this device last published into one room. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Mine { + pub sequence: u64, + pub epoch: u64, + pub published_at: u64, + pub count: u32, + /// blake3 over the sorted keys, so a library that has not changed does + /// not republish. + pub digest: String, + #[serde(default)] + pub withdrawn: bool, +} + +/// One member's list as this device last read it. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct Cached { + sequence: u64, + epoch: u64, + read_at: u64, + /// No contract under that address when last asked. + absent: bool, + /// Sealed to an epoch this device has no key for. + unreadable: bool, + withdrawn: bool, + slsk_username: Option, + /// (key b58, mbid key b58, tier) — the same three the wire carries. + entries: Vec<(String, Option, u8)>, +} + +/// An album to fetch from someone the moment they come online. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Wait { + pub username: String, + pub artist: String, + pub album: String, + pub since: u64, + /// The room the holder was found in, for the row's provenance. + #[serde(default)] + pub via: Option, +} + +#[derive(Default, Serialize, Deserialize)] +struct Ledger { + #[serde(default)] + mine: BTreeMap, + /// room record id → member key (b58) → the list. + #[serde(default)] + cache: BTreeMap>, + #[serde(default)] + waits: Vec, +} + +fn path(app: &tauri::AppHandle) -> Result { + Ok(crate::paths::app_data(app)?.join("freenet").join(LEDGER_FILE)) +} +fn load(app: &tauri::AppHandle) -> Ledger { + path(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} +fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { + let p = path(app)?; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(&p, serde_json::to_vec(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + let _ = app.emit(EVENT, ()); + Ok(()) +} +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} +fn b58(bytes: &[u8]) -> String { + bs58::encode(bytes).into_string() +} + +// ---------------------------------------------------------------- my list + +/// The tier a dominant extension speaks for. +fn tier_of(ext: &str) -> u8 { + let e = ext.to_lowercase(); + if LOSSLESS.contains(&e.as_str()) { + hd::TIER_LOSSLESS + } else if LOSSY.contains(&e.as_str()) { + hd::TIER_LOSSY + } else { + hd::TIER_UNKNOWN + } +} + +/// The library as one room would hold it: keyed, tiered, sorted. Reads the +/// database, so it runs off the async runtime. +fn my_list(lib: &library::Library, space_id: &[u8; 16]) -> Result<(Vec, u32), String> { + let albums = lib.list_albums().map_err(|e| e.to_string())?; + let mbids = lib.album_mbids().unwrap_or_default(); + let tiers: BTreeMap = lib + .album_stats(0) + .map_err(|e| e.to_string())? + .into_iter() + .map(|s| (s.album_id, s.formats.first().map(|f| tier_of(f)).unwrap_or(hd::TIER_UNKNOWN))) + .collect(); + let total = albums.len() as u32; + // Newest first is the library's own order, so a list over the cap keeps + // what was added last. + let entries: Vec = albums + .iter() + .filter_map(|a| { + let (na, nt) = (overlap::norm(&a.artist), overlap::norm(&a.title)); + if na.is_empty() || nt.is_empty() { + return None; + } + Some(hd::Holding { + key: hd::album_key(space_id, &na, &nt), + mbid: mbids.get(&a.id).map(|m| hd::mbid_key(space_id, m)), + tier: tiers.get(&a.id).copied().unwrap_or(hd::TIER_UNKNOWN), + }) + }) + .take(hd::MAX_ENTRIES) + .collect(); + Ok((entries, total)) +} + +fn digest_of(entries: &[hd::Holding]) -> String { + let mut buf = Vec::with_capacity(entries.len() * 33); + for e in entries { + buf.extend_from_slice(&e.key); + buf.push(e.tier); + } + b58(&persona_state::content_hash(&buf)) +} + +// ---------------------------------------------------------------- network + +fn adapter(app: &tauri::AppHandle) -> FreenetAdapter { + app.state::().freenet.clone() +} + +/// The list at one address, verified. None when nothing was ever put there. +async fn fetch(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32]) -> Result, String> { + let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(space_id, member)); + match adapter(app).contract_get(&ids.instance, false).await { + Ok(bytes) if bytes.is_empty() => Ok(None), + Ok(bytes) => hd::validate(space_id, member, &bytes) + .map(Some) + .map_err(|e| format!("holdings failed verification: {e}")), + Err(e) if e.to_string().contains("not found") => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +async fn offer(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32], state: &hd::HoldingsState, first: bool) -> Result<(), String> { + let bytes = bincode::serialize(state).map_err(|e| e.to_string())?; + let params = hd::make_params(space_id, member); + let a = adapter(app); + if first { + a.contract_put(HOLDINGS_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ()) + } else { + a.get_then_update(HOLDINGS_WASM.to_vec(), ¶ms, bytes).await.map(|_| ()) + } + .map_err(|e| e.to_string()) +} + +/// Sign as the key worn in the room: the room secret for a member, the +/// delegate for an owner (their persona is their key there). +async fn sign(app: &tauri::AppHandle, me: &[u8; 32], worn: &[u8; 32], room_secret: Option<[u8; 32]>, s: &hd::HoldingsState) -> Result, String> { + match room_secret { + Some(secret) if worn != me => Ok(hd::sign_with(&secret, s)), + _ => persona::sign(&adapter(app), hd::SIGN_DOMAIN, hd::sign_payload(s)).await, + } +} + +/// Everything a publish or a withdrawal needs to know about where it goes. +struct Seat { + me: [u8; 32], + worn: [u8; 32], + room_secret: Option<[u8; 32]>, + space_id: [u8; 16], + epoch: u64, + epoch_key: Option<[u8; 32]>, +} + +async fn seat(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { + let me = spaces::my_key(app).await?; + let owner = spaces::key_of(owner_b58)?; + let space_id = spaces::id_of(space_b58)?; + spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; + let (worn, room_secret) = spaces::worn_identity(app, owner_b58, space_b58, &me); + let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; + let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; + if !c.members.iter().any(|m| m.key == worn) { + return Err("you are not in this Space's roster".into()); + } + let epoch_key = if c.private { + Some(spaces::epoch_key_for(app, owner_b58, space_b58, c.epoch).ok_or("no key for this epoch yet — the owner has to hand it to you")?) + } else { + None + }; + Ok(Seat { me, worn, room_secret, space_id, epoch: c.epoch, epoch_key }) +} + +/// Publish this device's list into one room. Read-and-bump: the sequence +/// follows whatever is on the network, so a device that lost its ledger +/// cannot publish under a sequence the room has already seen. +pub async fn publish(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { + let st = seat(app, owner_b58, space_b58).await?; + let lib = app.state::().library.clone(); + let sid = st.space_id; + let (entries, total) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid)) + .await + .map_err(|e| e.to_string())??; + let count = entries.len() as u32; + let digest = digest_of(&entries); + let body = hd::encode_body(hd::HoldingsBody { + schema_version: hd::SCHEMA_VERSION, + slsk_username: slsk_link::own_linked_username(app), + entries, + }) + .map_err(|e| e.to_string())?; + let rid = spaces::record_id(owner_b58, space_b58); + let on_net = fetch(app, &st.space_id, &st.worn).await?; + let known = load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0); + let sequence = on_net.as_ref().map(|s| s.sequence).unwrap_or(0).max(known) + 1; + let mut s = hd::HoldingsState { + schema_version: hd::SCHEMA_VERSION, + space_id: st.space_id, + member: st.worn, + sequence, + epoch: st.epoch, + sealed: st.epoch_key.is_some(), + published_at: now_secs(), + withdrawn: false, + body, + sig: vec![], + }; + if let Some(k) = &st.epoch_key { + s.body = hd::seal(k, &s); + } + s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?; + offer(app, &st.space_id, &st.worn, &s, on_net.is_none()).await?; + let mine = Mine { sequence, epoch: st.epoch, published_at: s.published_at, count, digest, withdrawn: false }; + { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.mine.insert(rid, mine.clone()); + save(app, &l)?; + } + log::info!("holdings: published {count} of {total} albums into {space_b58} as sequence {sequence} (epoch {})", st.epoch); + Ok(mine) +} + +/// Take the list out of current state. Copies members already read stay +/// with them — never claim erasure. +pub async fn withdraw(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> { + let st = seat(app, owner_b58, space_b58).await?; + let rid = spaces::record_id(owner_b58, space_b58); + let on_net = fetch(app, &st.space_id, &st.worn).await?; + let Some(current) = on_net else { + // Nothing there to withdraw; forget what the ledger thought. + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.mine.remove(&rid); + return save(app, &l); + }; + if current.withdrawn { + return Ok(()); + } + let sequence = current.sequence.max(load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0)) + 1; + let mut s = hd::HoldingsState { + schema_version: hd::SCHEMA_VERSION, + space_id: st.space_id, + member: st.worn, + sequence, + epoch: st.epoch, + sealed: false, + published_at: now_secs(), + withdrawn: true, + body: vec![], + sig: vec![], + }; + s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?; + offer(app, &st.space_id, &st.worn, &s, false).await?; + { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.mine.insert(rid, Mine { sequence, epoch: st.epoch, published_at: s.published_at, count: 0, digest: String::new(), withdrawn: true }); + save(app, &l)?; + } + log::info!("holdings: withdrawn from {space_b58} as sequence {sequence}"); + Ok(()) +} + +/// The switch. On: publish now. Off: withdraw. Either way the flag is set +/// first, so a publish that fails on the network is retried by the next +/// library change rather than forgotten. +pub async fn set_share(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> { + spaces::set_share_holdings_flag(app, owner_b58, space_b58, on).await?; + if on { + publish(app, owner_b58, space_b58).await.map(|_| ()) + } else { + withdraw(app, owner_b58, space_b58).await + } +} + +/// The library changed: every room with the switch on gets the new list, +/// unless the list is byte-for-byte what that room already has. Quiet per +/// room — a failed republish is logged and tried again next change. +pub async fn republish_all(app: &tauri::AppHandle) -> Result { + let rooms = spaces::rooms_sharing_holdings(app); + let mut n = 0; + for (owner, space) in rooms { + let stale = { + let lib = app.state::().library.clone(); + let sid = spaces::id_of(&space)?; + let (entries, _) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid)) + .await + .map_err(|e| e.to_string())??; + let l = load(app); + l.mine.get(&spaces::record_id(&owner, &space)).map(|m| m.withdrawn || m.digest != digest_of(&entries)).unwrap_or(true) + }; + if !stale { + continue; + } + match publish(app, &owner, &space).await { + Ok(_) => n += 1, + Err(e) => log::info!("holdings: could not republish into {space}: {e}"), + } + } + Ok(n) +} + +// ---------------------------------------------------------------- reading a room + +#[derive(Debug, Serialize)] +pub struct MemberHoldings { + pub key: String, + pub username: Option, + pub is_me: bool, + /// "shared" | "none" | "sealed" (no key for its epoch) | "withdrawn" | "unread" + pub state: String, + pub count: u32, + pub sequence: u64, + pub read_at: u64, +} + +#[derive(Debug, Serialize)] +pub struct RoomHoldings { + pub owner: String, + pub space_id: String, + pub name: String, + pub share_holdings: bool, + pub mine: Option, + pub members: Vec, + pub sharing: u32, +} + +/// The room's lists as this device knows them, reading the network for +/// members whose cached copy is stale (or all of them, on `refresh`). The +/// reads are sequential ring searches — seconds each for a member who +/// never published — so this runs from a spawned task and the page hears +/// the result by event; it never blocks the Space opening. +pub async fn read_room(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, refresh: bool) -> Result { + let me = spaces::my_key(app).await?; + let owner = spaces::key_of(owner_b58)?; + let space_id = spaces::id_of(space_b58)?; + let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; + let (worn, _) = spaces::worn_identity(app, owner_b58, space_b58, &me); + let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; + let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; + let rid = spaces::record_id(owner_b58, space_b58); + let now = now_secs(); + let members: Vec<[u8; 32]> = c.members.iter().map(|m| m.key).filter(|k| *k != worn).collect(); + for member in members { + let mb58 = b58(&member); + let cached = load(app).cache.get(&rid).and_then(|m| m.get(&mb58)).cloned(); + let fresh = cached.as_ref().is_some_and(|c| { + let age = now.saturating_sub(c.read_at); + if c.absent { age < ABSENT_SECS } else { age < FRESH_SECS } + }); + if fresh && !refresh { + continue; + } + let entry = match fetch(app, &space_id, &member).await { + Ok(None) => Cached { read_at: now, absent: true, ..Default::default() }, + Ok(Some(s)) if s.withdrawn => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, withdrawn: true, ..Default::default() }, + Ok(Some(s)) => { + let plain = if s.sealed { + match spaces::epoch_key_for(app, owner_b58, space_b58, s.epoch) { + Some(k) => hd::open(&k, &s), + None => None, + } + } else { + Some(s.body.clone()) + }; + match plain.as_deref().and_then(hd::decode_body) { + Some(b) => Cached { + sequence: s.sequence, + epoch: s.epoch, + read_at: now, + slsk_username: b.slsk_username, + entries: b.entries.iter().map(|h| (b58(&h.key), h.mbid.map(|m| b58(&m)), h.tier)).collect(), + ..Default::default() + }, + None => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, unreadable: true, ..Default::default() }, + } + } + Err(e) => { + log::info!("holdings: could not read {mb58} in {space_b58}: {e}"); + continue; + } + }; + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + l.cache.entry(rid.clone()).or_default().insert(mb58, entry); + save(app, &l)?; + } + Ok(room_view(app, owner_b58, space_b58, &facts, &c, &worn)) +} + +/// The room as the cache has it — no network. +pub fn room_now(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { + let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; + let l = load(app); + let rid = spaces::record_id(owner_b58, space_b58); + let mine = l.mine.get(&rid).cloned(); + let cache = l.cache.get(&rid).cloned().unwrap_or_default(); + let members = cache + .iter() + .map(|(k, c)| MemberHoldings { + key: k.clone(), + username: c.slsk_username.clone().or_else(|| spaces::username_behind(app, owner_b58, space_b58, k)), + is_me: false, + state: state_word(c).into(), + count: c.entries.len() as u32, + sequence: c.sequence, + read_at: c.read_at, + }) + .collect::>(); + let sharing = members.iter().filter(|m| m.state == "shared").count() as u32 + u32::from(mine.as_ref().is_some_and(|m| !m.withdrawn)); + Ok(RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name, share_holdings: facts.share_holdings, mine, members, sharing }) +} + +fn state_word(c: &Cached) -> &'static str { + if c.absent { + "none" + } else if c.withdrawn { + "withdrawn" + } else if c.unreadable { + "sealed" + } else { + "shared" + } +} + +fn room_view(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, facts: &spaces::RoomFacts, c: &persona_state::space::Checkpoint, worn: &[u8; 32]) -> RoomHoldings { + let l = load(app); + let rid = spaces::record_id(owner_b58, space_b58); + let mine = l.mine.get(&rid).cloned(); + let cache = l.cache.get(&rid).cloned().unwrap_or_default(); + let members: Vec = c + .members + .iter() + .map(|m| { + let k = b58(&m.key); + let is_me = &m.key == worn; + let cached = cache.get(&k); + MemberHoldings { + username: cached.and_then(|c| c.slsk_username.clone()).or_else(|| spaces::username_behind(app, owner_b58, space_b58, &k)), + is_me, + state: if is_me { + match &mine { + Some(m) if !m.withdrawn => "shared", + _ => "none", + } + } else { + cached.map(state_word).unwrap_or("unread") + } + .into(), + count: if is_me { mine.as_ref().map(|m| m.count).unwrap_or(0) } else { cached.map(|c| c.entries.len() as u32).unwrap_or(0) }, + sequence: if is_me { mine.as_ref().map(|m| m.sequence).unwrap_or(0) } else { cached.map(|c| c.sequence).unwrap_or(0) }, + read_at: cached.map(|c| c.read_at).unwrap_or(0), + key: k, + } + }) + .collect(); + let sharing = members.iter().filter(|m| m.state == "shared").count() as u32; + RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name.clone(), share_holdings: facts.share_holdings, mine, members, sharing } +} + +// ---------------------------------------------------------------- the ask + +#[derive(Debug, Deserialize)] +pub struct AskItem { + pub artist: String, + pub album: String, + #[serde(default)] + pub mbid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Holder { + pub owner: String, + pub space_id: String, + pub space_name: String, + pub member: String, + pub username: Option, + pub tier: u8, + /// How the holding matched: "name" | "mbid". + pub matched_by: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct Answer { + pub artist: String, + pub album: String, + pub holders: Vec, +} + +/// Who holds each album, across every room this device is in — from the +/// cache, no network. A person in two rooms is one holder per room; the +/// frontend folds by username when it wants to. +pub fn ask(app: &tauri::AppHandle, items: &[AskItem]) -> Vec { + let l = load(app); + let rooms = spaces::rooms(app); + // Per room: member → (key set, mbid set, username, tier by key). + struct RoomIndex { + owner: String, + space_id: String, + name: String, + sid: [u8; 16], + members: Vec<(String, Option, BTreeMap, BTreeSet)>, + } + let index: Vec = rooms + .into_iter() + .filter_map(|(owner, space, name)| { + let sid = spaces::id_of(&space).ok()?; + let cache = l.cache.get(&spaces::record_id(&owner, &space))?; + let members = cache + .iter() + .filter(|(_, c)| !c.absent && !c.withdrawn && !c.unreadable && !c.entries.is_empty()) + .map(|(k, c)| { + let mut keys = BTreeMap::new(); + let mut mbids = BTreeSet::new(); + for (key, mbid, tier) in &c.entries { + keys.insert(key.clone(), *tier); + if let Some(m) = mbid { + mbids.insert(m.clone()); + } + } + (k.clone(), c.slsk_username.clone().or_else(|| spaces::username_behind(app, &owner, &space, k)), keys, mbids) + }) + .collect(); + Some(RoomIndex { owner, space_id: space, name, sid, members }) + }) + .collect(); + items + .iter() + .map(|it| { + let (na, nt) = (overlap::norm(&it.artist), overlap::norm(&it.album)); + let mut holders = Vec::new(); + if !na.is_empty() && !nt.is_empty() { + for r in &index { + let key = b58(&hd::album_key(&r.sid, &na, &nt)); + let mk = it.mbid.as_deref().filter(|m| !m.trim().is_empty()).map(|m| b58(&hd::mbid_key(&r.sid, m))); + for (member, username, keys, mbids) in &r.members { + let by_name = keys.get(&key).copied(); + let by_mbid = mk.as_ref().filter(|m| mbids.contains(*m)).map(|_| hd::TIER_UNKNOWN); + if let Some((tier, how)) = by_name.map(|t| (t, "name")).or(by_mbid.map(|t| (t, "mbid"))) { + holders.push(Holder { + owner: r.owner.clone(), + space_id: r.space_id.clone(), + space_name: r.name.clone(), + member: member.clone(), + username: username.clone(), + tier, + matched_by: how, + }); + } + } + } + } + // Lossless first, then those who can actually be asked. + holders.sort_by(|a, b| b.tier.cmp(&a.tier).then(b.username.is_some().cmp(&a.username.is_some()))); + Answer { artist: it.artist.clone(), album: it.album.clone(), holders } + }) + .collect() +} + +// ---------------------------------------------------------------- getting it + +#[derive(Debug, Serialize)] +pub struct FileRef { + pub path: String, + pub name: String, + pub size: u64, +} + +#[derive(Debug, Serialize)] +pub struct FolderMatch { + pub username: String, + pub folder: String, + pub files: Vec, + pub tier: u8, +} + +/// Browse a holder's share and find the folder that names this album — the +/// overlap walk, so what the room said they hold is what gets grabbed. Disc +/// subfolders come along. None when their share does not name it (they +/// moved it, or the room's word was a near-name). +pub async fn find_folder(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result, String> { + let (na, nt) = (overlap::norm(artist), overlap::norm(album)); + if na.is_empty() || nt.is_empty() { + return Err("that album has no name to look for".into()); + } + let fut = { + let state = app.state::(); + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.browse(username) + }; + let dirs = fut.await.map_err(|e| e.to_string())?; + overlap::note_browse(app, username, &dirs); + let mut files = Vec::new(); + let mut folder: Option = None; + for d in &dirs { + let Some((a, b)) = overlap::pair_of(&d.name) else { continue }; + let (da, db) = (overlap::norm(&a), overlap::norm(&b)); + let hit = da == na && (db == nt || (nt.len() >= 5 && db.len() >= 5 && (db.contains(nt.as_str()) || nt.contains(db.as_str())))); + if !hit { + continue; + } + let audio: Vec<&soulseek_proto::types::File> = d.files.iter().filter(|f| overlap::is_audio(f)).collect(); + if audio.is_empty() { + continue; + } + if folder.is_none() { + folder = Some(d.name.clone()); + } + for f in audio { + files.push(FileRef { path: format!("{}\\{}", d.name, f.filename), name: f.filename.clone(), size: f.size }); + } + } + let Some(folder) = folder else { return Ok(None) }; + let tier = files + .iter() + .map(|f| tier_of(f.name.rsplit('.').next().unwrap_or_default())) + .max() + .unwrap_or(hd::TIER_UNKNOWN); + Ok(Some(FolderMatch { username: username.to_string(), folder, files, tier })) +} + +// ---------------------------------------------------------------- waits + +pub async fn wait_add(app: &tauri::AppHandle, w: Wait) -> Result, String> { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + let same = |x: &Wait| x.username.eq_ignore_ascii_case(&w.username) && overlap::norm(&x.artist) == overlap::norm(&w.artist) && overlap::norm(&x.album) == overlap::norm(&w.album); + if !l.waits.iter().any(same) { + l.waits.push(Wait { since: now_secs(), ..w }); + save(app, &l)?; + } + Ok(l.waits) +} + +pub async fn wait_remove(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result, String> { + let _guard = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + let (na, nt) = (overlap::norm(artist), overlap::norm(album)); + l.waits.retain(|x| !(x.username.eq_ignore_ascii_case(username) && overlap::norm(&x.artist) == na && overlap::norm(&x.album) == nt)); + save(app, &l)?; + Ok(l.waits) +} + +// ---------------------------------------------------------------- commands + +#[tauri::command] +pub async fn holdings_set_share(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result { + set_share(&app, &owner, &space, on).await?; + room_now(&app, &owner, &space) +} + +/// The room's lists. `refresh` re-reads every member; otherwise only stale +/// ones, and only from a spawned task so the answer is immediate — the +/// page hears the read finish by event. +#[tauri::command] +pub async fn holdings_room(app: tauri::AppHandle, owner: String, space: String, refresh: bool) -> Result { + let now = room_now(&app, &owner, &space)?; + let app2 = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = read_room(&app2, &owner, &space, refresh).await { + log::info!("holdings: reading {space}: {e}"); + } + }); + Ok(now) +} + +#[tauri::command] +pub fn holdings_ask(app: tauri::AppHandle, items: Vec) -> Vec { + ask(&app, &items) +} + +#[tauri::command] +pub async fn holdings_find_folder(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result, String> { + find_folder(&app, &username, &artist, &album).await +} + +#[tauri::command] +pub async fn holdings_republish(app: tauri::AppHandle) -> Result { + republish_all(&app).await +} + +#[tauri::command] +pub fn holdings_waits(app: tauri::AppHandle) -> Vec { + load(&app).waits +} + +#[tauri::command] +pub async fn holdings_wait_add(app: tauri::AppHandle, username: String, artist: String, album: String, via: Option) -> Result, String> { + wait_add(&app, Wait { username, artist, album, since: 0, via }).await +} + +#[tauri::command] +pub async fn holdings_wait_remove(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result, String> { + wait_remove(&app, &username, &artist, &album).await +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The bundled contract is a commitment: a changed wasm re-keys every + /// list on the network. + #[test] + fn contract_generation_is_pinned() { + let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(&[1u8; 16], &[7u8; 32])); + assert_eq!(ids.code_hash, "3roNW8VMniLcQVWSzrAZ9Vum8KvFY7DD2dFkrXy43h9Q"); + } + + #[test] + fn a_dominant_format_speaks_for_the_tier() { + assert_eq!(tier_of("flac"), hd::TIER_LOSSLESS); + assert_eq!(tier_of("FLAC"), hd::TIER_LOSSLESS); + assert_eq!(tier_of("mp3"), hd::TIER_LOSSY); + assert_eq!(tier_of("opus"), hd::TIER_LOSSY); + assert_eq!(tier_of("bin"), hd::TIER_UNKNOWN); + } + + /// An older ledger loads, and a wait is one per (person, album) however + /// the names are spelled. + #[test] + fn the_ledger_reads_older_files_and_waits_fold_by_name() { + let l: Ledger = serde_json::from_str("{}").unwrap(); + assert!(l.mine.is_empty() && l.cache.is_empty() && l.waits.is_empty()); + let w = Wait { username: "Kraven".into(), artist: "Stars of the Lid".into(), album: "And Their Refinement of the Decline".into(), since: 1, via: None }; + let same = Wait { username: "kraven".into(), artist: "stars of the lid".into(), album: "And Their Refinement Of The Decline (2007) [FLAC]".into(), since: 2, via: None }; + assert_eq!(overlap::norm(&w.album), overlap::norm(&same.album)); + assert!(w.username.eq_ignore_ascii_case(&same.username)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e0a8d6..6e5c7a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod smtc; mod spaces; mod proposals; mod overlap; +mod holdings; mod state; mod thumbbar; mod vocal_sep; @@ -186,6 +187,14 @@ pub fn run() { overlap::overlap_list, overlap::overlap_compute, overlap::overlap_forget, + holdings::holdings_set_share, + holdings::holdings_room, + holdings::holdings_ask, + holdings::holdings_find_folder, + holdings::holdings_republish, + holdings::holdings_waits, + holdings::holdings_wait_add, + holdings::holdings_wait_remove, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src-tauri/src/overlap.rs b/src-tauri/src/overlap.rs index 80040af..ba521db 100644 --- a/src-tauri/src/overlap.rs +++ b/src-tauri/src/overlap.rs @@ -90,7 +90,7 @@ fn key(username: &str) -> String { /// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and /// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and /// "sigur ros" are not the same string here, and that is a known miss. -fn norm(s: &str) -> String { +pub(crate) fn norm(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut depth = 0i32; for c in s.chars() { @@ -127,7 +127,7 @@ fn is_disc_folder(s: &str) -> bool { false } -fn is_audio(f: &soulseek_proto::types::File) -> bool { +pub(crate) fn is_audio(f: &soulseek_proto::types::File) -> bool { let ext = if f.extension.is_empty() { f.filename.rsplit('.').next().unwrap_or_default() } else { @@ -138,7 +138,7 @@ fn is_audio(f: &soulseek_proto::types::File) -> bool { } /// The (artist, album) a share folder names, with display spellings. -fn pair_of(dir: &str) -> Option<(String, String)> { +pub(crate) fn pair_of(dir: &str) -> Option<(String, String)> { let segs: Vec<&str> = dir .split(['\\', '/']) .map(str::trim) diff --git a/src-tauri/src/slsk_link.rs b/src-tauri/src/slsk_link.rs index a476753..65d0626 100644 --- a/src-tauri/src/slsk_link.rs +++ b/src-tauri/src/slsk_link.rs @@ -293,6 +293,12 @@ pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) { /// The Soulseek account a verified persona key belongs to, if this device /// verified one (our own included). +/// The Soulseek name this device has VERIFIED as its own through the link +/// handshake, if any — not merely the account signed in right now. +pub(crate) fn own_linked_username(app: &tauri::AppHandle) -> Option { + load(app).own.map(|o| o.username) +} + pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option { let links = load(app); if let Some(o) = &links.own { diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index ee25527..ae7fef7 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -66,6 +66,11 @@ struct SpaceRecord { /// room is not consent to be watched in it. #[serde(default)] share_listens: bool, + /// Whether this device publishes what it HOLDS into this Space — album + /// keys sealed to the room, never files (`holdings.rs`). Per Space and + /// off until asked, for the same reason as listens. + #[serde(default)] + share_holdings: bool, /// The last listen posted here — what, and when. A reload or a restart /// re-reports whatever is already past the player's threshold, and this /// is the only side that knows what a room was last told. @@ -153,6 +158,8 @@ pub struct SpaceView { pub can_post: bool, /// Whether THIS device posts what it plays here. pub share_listens: bool, + /// Whether THIS device publishes its holdings here. + pub share_holdings: bool, pub has_key: bool, /// The contract exists on the network (a Space someone invited us to /// may not have propagated to this node yet). @@ -226,13 +233,13 @@ fn rand32() -> [u8; 32] { rand::thread_rng().fill_bytes(&mut n); n } -fn b58(bytes: &[u8]) -> String { +pub(crate) fn b58(bytes: &[u8]) -> String { bs58::encode(bytes).into_string() } -fn key_of(s: &str) -> Result<[u8; 32], String> { +pub(crate) fn key_of(s: &str) -> Result<[u8; 32], String> { inbox::key_of(s) } -fn id_of(s: &str) -> Result<[u8; 16], String> { +pub(crate) fn id_of(s: &str) -> Result<[u8; 16], String> { bs58::decode(s) .into_vec() .map_err(|e| format!("bad id: {e}"))? @@ -250,7 +257,7 @@ fn cap(s: &str, max: usize) -> String { } s[..end].to_string() } -fn record_id(owner: &str, space_id: &str) -> String { +pub(crate) fn record_id(owner: &str, space_id: &str) -> String { format!("{owner}:{space_id}") } @@ -281,7 +288,7 @@ fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { Ok(()) } -async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { +pub(crate) async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { let adapter = app.state::().freenet.clone(); let info = persona::current(&adapter) .await? @@ -404,6 +411,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me listens: vec![], can_post: false, share_listens: rec.share_listens, + share_holdings: rec.share_holdings, has_key: false, reachable, }; @@ -490,6 +498,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me .collect(), can_post: is_member && has_key, share_listens: rec.share_listens, + share_holdings: rec.share_holdings, has_key, reachable, } @@ -563,6 +572,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva roster: BTreeMap::new(), invited: BTreeMap::new(), share_listens: false, + share_holdings: false, last_listen: None, join_sent_at: 0, }; @@ -826,6 +836,53 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str /// Turn posting-what-I-play on or off for one Space. Nothing is published /// by flipping it; it only decides what the next track does. +/// What `holdings.rs` needs to know about a room, without the record. +pub(crate) struct RoomFacts { + pub name: String, + pub private: bool, + pub my_role: String, + pub share_holdings: bool, +} + +pub(crate) fn room_facts(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Option { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58))?; + Some(RoomFacts { name: rec.name.clone(), private: rec.private, my_role: rec.my_role.clone(), share_holdings: rec.share_holdings }) +} + +/// The room's key for one epoch, if this device was handed it. +pub(crate) fn epoch_key_for(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, epoch: u64) -> Option<[u8; 32]> { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58))?; + rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok()) +} + +/// Every room this device is in: (owner, space id, name). +pub(crate) fn rooms(app: &tauri::AppHandle) -> Vec<(String, String, String)> { + load(app).spaces.values().map(|r| (r.owner.clone(), r.space_id.clone(), r.name.clone())).collect() +} + +/// The rooms this device publishes its holdings into. +pub(crate) fn rooms_sharing_holdings(app: &tauri::AppHandle) -> Vec<(String, String)> { + load(app).spaces.values().filter(|r| r.share_holdings).map(|r| (r.owner.clone(), r.space_id.clone())).collect() +} + +/// The Soulseek name behind a room key as this device knows it: through the +/// owner's private roster, or the key itself when it is a persona (an owner's). +pub(crate) fn username_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, member_b58: &str) -> Option { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58))?; + slsk_link::username_for_key(app, rec.roster.get(member_b58).map(String::as_str).unwrap_or(member_b58)) +} + +pub(crate) async fn set_share_holdings_flag(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> { + let _ledger = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + let rec = l.spaces.get_mut(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; + rec.share_holdings = on; + save(app, &l) +} + pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result { { let _ledger = ledger::hold(LEDGER_FILE).await; @@ -946,6 +1003,7 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in roster: BTreeMap::new(), invited: BTreeMap::new(), share_listens: false, + share_holdings: false, last_listen: None, join_sent_at: 0, }); @@ -1197,6 +1255,7 @@ mod tests { roster: BTreeMap::new(), invited: BTreeMap::new(), share_listens: false, + share_holdings: false, last_listen: None, join_sent_at: 0, }