diff --git a/Cargo.lock b/Cargo.lock index 062de85..16af5c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,7 +162,7 @@ dependencies = [ "alloy-transport", "futures", "futures-util", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -204,7 +204,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "crc", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -228,7 +228,7 @@ dependencies = [ "alloy-rlp", "k256 0.13.4", "serde", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -286,7 +286,7 @@ dependencies = [ "alloy-sol-types", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", ] @@ -312,7 +312,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -343,7 +343,7 @@ dependencies = [ "rand 0.8.5", "serde_json", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "url", ] @@ -360,7 +360,7 @@ dependencies = [ "const-hex", "derive_more 2.0.1", "foldhash", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "indexmap", "itoa", "k256 0.13.4", @@ -407,7 +407,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tracing", "url", @@ -433,7 +433,7 @@ checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -511,7 +511,7 @@ dependencies = [ "itertools 0.14.0", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -537,7 +537,7 @@ dependencies = [ "either", "elliptic-curve 0.13.8", "k256 0.13.4", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -553,7 +553,7 @@ dependencies = [ "async-trait", "k256 0.13.4", "rand 0.8.5", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -567,7 +567,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -584,7 +584,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "syn-solidity", "tiny-keccak", ] @@ -603,7 +603,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.104", + "syn 2.0.106", "syn-solidity", ] @@ -641,7 +641,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tower 0.5.2", "tracing", @@ -697,9 +697,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -727,29 +727,29 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "ark-bn254" @@ -794,7 +794,7 @@ checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -810,7 +810,7 @@ dependencies = [ "ark-std 0.5.0", "educe", "fnv", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "itertools 0.13.0", "num-bigint", "num-integer", @@ -905,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -943,7 +943,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -974,7 +974,7 @@ dependencies = [ "ark-std 0.5.0", "educe", "fnv", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "rayon", ] @@ -1050,7 +1050,7 @@ checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1130,20 +1130,26 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "auto_impl" version = "1.3.0" @@ -1152,7 +1158,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1331,9 +1337,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" [[package]] name = "bitvec" @@ -1442,9 +1448,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.27" +version = "1.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" dependencies = [ "shlex", ] @@ -1460,9 +1466,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chacha20" @@ -1532,9 +1538,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "const-hex" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e22e0ed40b96a48d3db274f72fd365bd78f67af39b6bbd47e8a15e1c6207ff" +checksum = "dccd746bf9b1038c0507b7cec21eb2b11222db96a2902c96e8c185d6d20fb9c4" dependencies = [ "cfg-if", "cpufeatures", @@ -1620,9 +1626,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1742,7 +1748,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1782,7 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1806,7 +1812,7 @@ dependencies = [ "openmls_basic_credential", "openmls_rust_crypto", "openmls_traits", - "prost", + "prost 0.13.5", "prost-build", "rand 0.8.5", "secp256k1 0.30.0", @@ -1814,7 +1820,6 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 1.0.69", - "tls_codec 0.3.0", "tokio", "tokio-util", "tower-http 0.4.4", @@ -1881,7 +1886,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1892,7 +1897,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -1925,7 +1930,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1955,9 +1960,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -2014,9 +2019,9 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", @@ -2036,7 +2041,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2123,7 +2128,7 @@ checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2345,7 +2350,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2447,9 +2452,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "group" @@ -2481,9 +2486,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", @@ -2578,7 +2583,7 @@ dependencies = [ "hpke-rs-crypto", "log", "serde", - "tls_codec 0.4.2", + "tls_codec", "zeroize", ] @@ -2699,7 +2704,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2708,18 +2713,20 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "http 1.3.1", "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2733,7 +2740,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "native-tls", "tokio", @@ -2743,9 +2750,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -2754,12 +2761,12 @@ dependencies = [ "futures-util", "http 1.3.1", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.7.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "tokio", "tower-service", "tracing", @@ -2913,17 +2920,17 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "indexmap" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "serde", ] @@ -2945,6 +2952,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.9.2", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -3021,7 +3039,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3085,7 +3103,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "uuid", ] @@ -3122,9 +3140,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libloading" @@ -3133,7 +3151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -3230,7 +3248,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -3241,7 +3259,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3458,7 +3476,7 @@ checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3516,7 +3534,7 @@ dependencies = [ "rayon", "serde", "thiserror 1.0.69", - "tls_codec 0.4.2", + "tls_codec", ] [[package]] @@ -3530,7 +3548,7 @@ dependencies = [ "p256", "rand 0.8.5", "serde", - "tls_codec 0.4.2", + "tls_codec", ] [[package]] @@ -3568,7 +3586,7 @@ dependencies = [ "serde", "sha2 0.10.9", "thiserror 1.0.69", - "tls_codec 0.4.2", + "tls_codec", ] [[package]] @@ -3578,7 +3596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "443afa05406adc75fbfa0c3e04db93c5647b763861a474ae1aa8a99c362b80f8" dependencies = [ "serde", - "tls_codec 0.4.2", + "tls_codec", ] [[package]] @@ -3587,7 +3605,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "cfg-if", "foreign-types", "libc", @@ -3604,7 +3622,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3681,7 +3699,7 @@ dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3727,7 +3745,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.13", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -3766,7 +3784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.16", "ucd-trie", ] @@ -3797,7 +3815,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3896,12 +3914,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3986,14 +4004,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -4006,10 +4024,10 @@ checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.1", + "bitflags 2.9.2", "lazy_static", "num-traits", - "rand 0.9.1", + "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax 0.8.5", @@ -4025,7 +4043,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +dependencies = [ + "bytes", + "prost-derive 0.14.1", ] [[package]] @@ -4041,10 +4069,10 @@ dependencies = [ "once_cell", "petgraph", "prettyplease", - "prost", + "prost 0.13.5", "prost-types", "regex", - "syn 2.0.104", + "syn 2.0.106", "tempfile", ] @@ -4058,7 +4086,20 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", +] + +[[package]] +name = "prost-derive" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -4067,7 +4108,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost", + "prost 0.13.5", ] [[package]] @@ -4111,9 +4152,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -4168,9 +4209,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.7.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -4178,9 +4219,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -4197,11 +4238,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", ] [[package]] @@ -4241,9 +4282,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", @@ -4251,7 +4292,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-tls", "hyper-util", "js-sys", @@ -4299,7 +4340,7 @@ dependencies = [ [[package]] name = "rln" version = "0.8.0" -source = "git+https://github.com/vacp2p/zerokit.git?branch=master#baf474e7470f905e7015bbb0b9f71ed8f6641a08" +source = "git+https://github.com/vacp2p/zerokit.git?branch=master#9da80dd80727ab9df06bbd2af596aa23d952dc4e" dependencies = [ "ark-bn254", "ark-ec", @@ -4315,14 +4356,16 @@ dependencies = [ "num-bigint", "num-traits", "once_cell", - "prost", + "prost 0.14.1", "rand 0.8.5", "rand_chacha 0.3.1", + "rayon", "ruint", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", "tiny-keccak", + "zeroize", "zerokit_utils", ] @@ -4338,9 +4381,9 @@ dependencies = [ [[package]] name = "ruint" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11256b5fe8c68f56ac6f39ef0720e592f33d2367a4782740d9c9142e889c7fb4" +checksum = "9ecb38f82477f20c5c3d62ef52d7c4e536e38ea9b73fb570a20c5cae0e14bcf6" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", @@ -4355,7 +4398,7 @@ dependencies = [ "primitive-types", "proptest", "rand 0.8.5", - "rand 0.9.1", + "rand 0.9.2", "rlp", "ruint-macro", "serde", @@ -4371,9 +4414,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -4417,7 +4460,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4426,15 +4469,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4448,9 +4491,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" @@ -4529,7 +4572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4124a35fe33ae14259c490fd70fa199a32b9ce9502f2ee6bc4f81ec06fa65894" dependencies = [ "rand 0.8.5", - "secp256k1-sys 0.8.1", + "secp256k1-sys 0.8.2", "serde", ] @@ -4555,9 +4598,9 @@ dependencies = [ [[package]] name = "secp256k1-sys" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a129b9e9efbfb223753b9163c4ab3b13cff7fd9c7f010fbac25ab4099fa07e" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" dependencies = [ "cc", ] @@ -4577,7 +4620,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "core-foundation", "core-foundation-sys", "libc", @@ -4657,14 +4700,14 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -4757,9 +4800,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -4786,9 +4829,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "sled" @@ -4836,6 +4879,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spki" version = "0.6.0" @@ -4894,24 +4947,23 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4933,9 +4985,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -4951,7 +5003,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4989,7 +5041,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5000,15 +5052,15 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix 1.0.8", + "windows-sys 0.60.2", ] [[package]] @@ -5022,11 +5074,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -5037,18 +5089,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5079,15 +5131,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tls_codec" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1e621cbf57f36f5b51ebf366b57ba153be7fed133182a9513e443ecdf506e" -dependencies = [ - "zeroize", -] - [[package]] name = "tls_codec" version = "0.4.2" @@ -5107,26 +5150,28 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "tokio" -version = "1.45.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5137,7 +5182,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5176,9 +5221,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -5250,7 +5295,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "bytes", "futures-core", "futures-util", @@ -5268,7 +5313,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "bytes", "futures-util", "http 1.3.1", @@ -5312,7 +5357,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5448,36 +5493,33 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", - "rand 0.9.1", + "rand 0.9.2", "uuid-macro-internal", "wasm-bindgen", ] [[package]] name = "uuid-macro-internal" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b682e8c381995ea03130e381928e0e005b7c9eb483c6c8682f50e07b33c2b7" +checksum = "22b7ad00068276db5fea436dba78daa7891b8d60db76e4f51cbdefbdecdab97e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "vacp2p_pmtree" -version = "2.0.2" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632293f506ca10d412dbe1d427295317b4c794fa9ddfd66fbd2fa971de88c1f6" -dependencies = [ - "rayon", -] +checksum = "47145034d8885c2f7ff7562c504ee8be6e78b564a60cc26fc174f861c61bdec2" [[package]] name = "valuable" @@ -5589,7 +5631,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -5624,7 +5666,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5717,7 +5759,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5728,7 +5770,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5779,7 +5821,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -5800,10 +5842,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5912,9 +5955,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" dependencies = [ "memchr", ] @@ -5925,7 +5968,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", ] [[package]] @@ -5975,7 +6018,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure 0.13.2", ] @@ -5996,7 +6039,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6016,7 +6059,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure 0.13.2", ] @@ -6037,13 +6080,13 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "zerokit_utils" version = "0.6.0" -source = "git+https://github.com/vacp2p/zerokit.git?branch=master#baf474e7470f905e7015bbb0b9f71ed8f6641a08" +source = "git+https://github.com/vacp2p/zerokit.git?branch=master#9da80dd80727ab9df06bbd2af596aa23d952dc4e" dependencies = [ "ark-ff 0.5.0", "hex", @@ -6052,7 +6095,7 @@ dependencies = [ "rayon", "serde_json", "sled", - "thiserror 2.0.12", + "thiserror 2.0.16", "vacp2p_pmtree", ] @@ -6069,9 +6112,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -6086,5 +6129,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] diff --git a/Cargo.toml b/Cargo.toml index cc4e6fa..bbc58d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,6 @@ waku-sys = { git = "https://github.com/waku-org/waku-rust-bindings.git", branch rand = "0.8.5" serde_json = "1.0" serde = { version = "1.0.163", features = ["derive"] } -tls_codec = "0.3.0" chrono = "0.4" sha2 = "0.10.8" diff --git a/README.md b/README.md index 2dec979..3f405ad 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Decentralized MLS PoC using a smart contract for group coordination -> Note: The frontend implementation is based on [chatr](https://github.com/0xLaurens/chatr), a real-time chat application built with Rust and SvelteKit +> Note: The frontend implementation is based on [chatr](https://github.com/0xLaurens/chatr), +> a real-time chat application built with Rust and SvelteKit ## Run Test Waku Node @@ -44,6 +45,48 @@ Run from the root directory RUST_BACKTRACE=full RUST_LOG=info NODE_PORT=60001 PEER_ADDRESSES=/ip4/x.x.x.x/tcp/60000/p2p/xxxx...xxxx,/ip4/y.y.y.y/tcp/60000/p2p/yyyy...yyyy cargo run -- --nocapture ``` +## Steward State Management + +The system implements a robust state machine for managing steward epochs with the following states: + +### States + +- **Working**: Normal operation where all users can send any message type freely +- **Waiting**: Steward epoch active, only steward can send BATCH_PROPOSALS_MESSAGE +- **Voting**: Consensus voting phase with only voting-related messages: + - Everyone: VOTE, USER_VOTE + - Steward only: VOTING_PROPOSAL, PROPOSAL + - All other messages blocked during voting + +### State Transitions + +```text +Working --start_steward_epoch()--> Waiting (if proposals exist) +Working --start_steward_epoch()--> Working (if no proposals - no state change) +Waiting --start_voting()---------> Voting +Waiting --no_proposals_found()---> Working (edge case: proposals disappear) +Voting --complete_voting(YES)----> Waiting --apply_proposals()--> Working +Voting --complete_voting(NO)-----> Working +``` + +### Steward Flow Scenarios + +1. **No Proposals**: Steward stays in Working state throughout epoch +2. **Successful Vote**: + - **Steward**: Working → Waiting → Voting → Waiting → Working + - **Non-Steward**: Working → Waiting → Voting → Working +3. **Failed Vote**: + - **Steward**: Working → Waiting → Voting → Working + - **Non-Steward**: Working → Waiting → Voting → Working +4. **Edge Case**: Working → Waiting → Working (if proposals disappear during voting) + +### Guarantees + +- Steward always returns to Working state after epoch completion +- No infinite loops or stuck states +- All edge cases properly handled +- Robust error handling with detailed logging + ### Example of ban user In chat message block run ban command, note that user wallet address should be in the format without `0x` diff --git a/ds/src/waku_actor.rs b/ds/src/waku_actor.rs index 6a789a6..5c9a378 100644 --- a/ds/src/waku_actor.rs +++ b/ds/src/waku_actor.rs @@ -108,7 +108,7 @@ impl WakuNode { for peer_address in peer_addresses { info!("Connecting to peer: {peer_address:?}"); self.node - .connect(&peer_address, None) + .connect(&peer_address, Some(Duration::from_secs(10))) .await .map_err(|e| DeliveryServiceError::WakuConnectPeerError(e.to_string()))?; info!("Connected to peer: {peer_address:?}"); @@ -148,12 +148,12 @@ impl WakuMessageToSend { /// - subtopic: The subtopic to send the message to /// - group_id: The group to send the message to /// - app_id: The app is unique identifier for the application that is sending the message for filtering own messages - pub fn new(msg: Vec, subtopic: &str, group_id: String, app_id: Vec) -> Self { + pub fn new(msg: Vec, subtopic: &str, group_id: &str, app_id: &[u8]) -> Self { Self { msg, subtopic: subtopic.to_string(), - group_id, - app_id, + group_id: group_id.to_string(), + app_id: app_id.to_vec(), } } /// Build a WakuMessage from the message to send @@ -178,10 +178,15 @@ pub async fn run_waku_node( node_port: String, peer_addresses: Option>, waku_sender: Sender, - reciever: &mut Receiver, + receiver: &mut Receiver, ) -> Result<(), DeliveryServiceError> { info!("Initializing waku node"); - let waku_node_init = WakuNode::new(node_port.parse::().unwrap()).await?; + let waku_node_init = WakuNode::new( + node_port + .parse::() + .expect("Failed to parse node port"), + ) + .await?; let waku_node = waku_node_init.start(waku_sender).await?; info!("Waku node started"); @@ -191,7 +196,7 @@ pub async fn run_waku_node( } info!("Waiting for message to send to waku"); - while let Some(msg) = reciever.recv().await { + while let Some(msg) = receiver.recv().await { info!("Received message to send to waku"); let id = waku_node.send_message(msg).await?; info!("Successfully publish message with id: {id:?}"); diff --git a/ds/tests/ds_waku_test.rs b/ds/tests/ds_waku_test.rs index 9dd0029..f3146ad 100644 --- a/ds/tests/ds_waku_test.rs +++ b/ds/tests/ds_waku_test.rs @@ -45,7 +45,7 @@ impl Message for Application { #[tokio::test(flavor = "multi_thread")] async fn test_waku_client() { env_logger::init(); - let group_name = "new_group".to_string(); + let group_name = "new_group"; let mut pubsub = PubSub::::new(); let (sender, _) = channel::(100); @@ -98,8 +98,8 @@ async fn test_waku_client() { .send_message(WakuMessageToSend::new( "test_message_1".as_bytes().to_vec(), APP_MSG_SUBTOPIC, - group_name.clone(), - uuid.clone(), + group_name, + &uuid, )) .await; info!("res: {:?}", res); diff --git a/frontend/.netlify/functions-internal/render.json b/frontend/.netlify/functions-internal/render.json new file mode 100644 index 0000000..98d3d2e --- /dev/null +++ b/frontend/.netlify/functions-internal/render.json @@ -0,0 +1 @@ +{"config":{"nodeModuleFormat":"esm"},"version":1} \ No newline at end of file diff --git a/frontend/.netlify/functions-internal/render.mjs b/frontend/.netlify/functions-internal/render.mjs new file mode 100644 index 0000000..c9dfa1a --- /dev/null +++ b/frontend/.netlify/functions-internal/render.mjs @@ -0,0 +1,37 @@ +import { init } from '../serverless.js'; + +export const handler = init({ + appDir: "_app", + appPath: "_app", + assets: new Set(["favicon.png"]), + mimeTypes: {".png":"image/png"}, + _: { + client: {"start":{"file":"_app/immutable/entry/start.530cd74f.js","imports":["_app/immutable/entry/start.530cd74f.js","_app/immutable/chunks/index.b5cfe40e.js","_app/immutable/chunks/singletons.995fdd8e.js","_app/immutable/chunks/index.0a9737cc.js"],"stylesheets":[],"fonts":[]},"app":{"file":"_app/immutable/entry/app.e73d9bc3.js","imports":["_app/immutable/entry/app.e73d9bc3.js","_app/immutable/chunks/index.b5cfe40e.js"],"stylesheets":[],"fonts":[]}}, + nodes: [ + () => import('../server/nodes/0.js'), + () => import('../server/nodes/1.js'), + () => import('../server/nodes/2.js'), + () => import('../server/nodes/3.js') + ], + routes: [ + { + id: "/", + pattern: /^\/$/, + params: [], + page: { layouts: [0], errors: [1], leaf: 2 }, + endpoint: null + }, + { + id: "/chat", + pattern: /^\/chat\/?$/, + params: [], + page: { layouts: [0], errors: [1], leaf: 3 }, + endpoint: null + } + ], + matchers: async () => { + + return { }; + } + } +}); diff --git a/frontend/.netlify/server/_app/immutable/assets/Toaster.d4bfa763.css b/frontend/.netlify/server/_app/immutable/assets/Toaster.d4bfa763.css new file mode 100644 index 0000000..2cb7b2a --- /dev/null +++ b/frontend/.netlify/server/_app/immutable/assets/Toaster.d4bfa763.css @@ -0,0 +1 @@ +div.svelte-lzwg39{width:20px;opacity:0;height:20px;border-radius:10px;background:var(--primary, #61d345);position:relative;transform:rotate(45deg);animation:svelte-lzwg39-circleAnimation 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;animation-delay:100ms}div.svelte-lzwg39::after{content:'';box-sizing:border-box;animation:svelte-lzwg39-checkmarkAnimation 0.2s ease-out forwards;opacity:0;animation-delay:200ms;position:absolute;border-right:2px solid;border-bottom:2px solid;border-color:var(--secondary, #fff);bottom:6px;left:6px;height:10px;width:6px}@keyframes svelte-lzwg39-circleAnimation{from{transform:scale(0) rotate(45deg);opacity:0}to{transform:scale(1) rotate(45deg);opacity:1}}@keyframes svelte-lzwg39-checkmarkAnimation{0%{height:0;width:0;opacity:0}40%{height:0;width:6px;opacity:1}100%{opacity:1;height:10px}}div.svelte-10jnndo{width:20px;opacity:0;height:20px;border-radius:10px;background:var(--primary, #ff4b4b);position:relative;transform:rotate(45deg);animation:svelte-10jnndo-circleAnimation 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;animation-delay:100ms}div.svelte-10jnndo::after,div.svelte-10jnndo::before{content:'';animation:svelte-10jnndo-firstLineAnimation 0.15s ease-out forwards;animation-delay:150ms;position:absolute;border-radius:3px;opacity:0;background:var(--secondary, #fff);bottom:9px;left:4px;height:2px;width:12px}div.svelte-10jnndo:before{animation:svelte-10jnndo-secondLineAnimation 0.15s ease-out forwards;animation-delay:180ms;transform:rotate(90deg)}@keyframes svelte-10jnndo-circleAnimation{from{transform:scale(0) rotate(45deg);opacity:0}to{transform:scale(1) rotate(45deg);opacity:1}}@keyframes svelte-10jnndo-firstLineAnimation{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}@keyframes svelte-10jnndo-secondLineAnimation{from{transform:scale(0) rotate(90deg);opacity:0}to{transform:scale(1) rotate(90deg);opacity:1}}div.svelte-bj4lu8{width:12px;height:12px;box-sizing:border-box;border:2px solid;border-radius:100%;border-color:var(--secondary, #e0e0e0);border-right-color:var(--primary, #616161);animation:svelte-bj4lu8-rotate 1s linear infinite}@keyframes svelte-bj4lu8-rotate{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}.indicator.svelte-1c92bpz{position:relative;display:flex;justify-content:center;align-items:center;min-width:20px;min-height:20px}.status.svelte-1c92bpz{position:absolute}.animated.svelte-1c92bpz{position:relative;transform:scale(0.6);opacity:0.4;min-width:20px;animation:svelte-1c92bpz-enter 0.3s 0.12s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards}@keyframes svelte-1c92bpz-enter{from{transform:scale(0.6);opacity:0.4}to{transform:scale(1);opacity:1}}.message.svelte-o805t1{display:flex;justify-content:center;margin:4px 10px;color:inherit;flex:1 1 auto;white-space:pre-line}@keyframes svelte-15lyehg-enterAnimation{0%{transform:translate3d(0, calc(var(--factor) * -200%), 0) scale(0.6);opacity:0.5}100%{transform:translate3d(0, 0, 0) scale(1);opacity:1}}@keyframes svelte-15lyehg-exitAnimation{0%{transform:translate3d(0, 0, -1px) scale(1);opacity:1}100%{transform:translate3d(0, calc(var(--factor) * -150%), -1px) scale(0.6);opacity:0}}@keyframes svelte-15lyehg-fadeInAnimation{0%{opacity:0}100%{opacity:1}}@keyframes svelte-15lyehg-fadeOutAnimation{0%{opacity:1}100%{opacity:0}}.base.svelte-15lyehg{display:flex;align-items:center;background:#fff;color:#363636;line-height:1.3;will-change:transform;box-shadow:0 3px 10px rgba(0, 0, 0, 0.1), 0 3px 3px rgba(0, 0, 0, 0.05);max-width:350px;pointer-events:auto;padding:8px 10px;border-radius:8px}.transparent.svelte-15lyehg{opacity:0}.enter.svelte-15lyehg{animation:svelte-15lyehg-enterAnimation 0.35s cubic-bezier(0.21, 1.02, 0.73, 1) forwards}.exit.svelte-15lyehg{animation:svelte-15lyehg-exitAnimation 0.4s cubic-bezier(0.06, 0.71, 0.55, 1) forwards}.fadeIn.svelte-15lyehg{animation:svelte-15lyehg-fadeInAnimation 0.35s cubic-bezier(0.21, 1.02, 0.73, 1) forwards}.fadeOut.svelte-15lyehg{animation:svelte-15lyehg-fadeOutAnimation 0.4s cubic-bezier(0.06, 0.71, 0.55, 1) forwards}.wrapper.svelte-1pakgpd{left:0;right:0;display:flex;position:absolute;transform:translateY(calc(var(--offset, 16px) * var(--factor) * 1px))}.transition.svelte-1pakgpd{transition:all 230ms cubic-bezier(0.21, 1.02, 0.73, 1)}.active.svelte-1pakgpd{z-index:9999}.active.svelte-1pakgpd>*{pointer-events:auto}.toaster.svelte-jyff3d{--default-offset:16px;position:fixed;z-index:9999;top:var(--default-offset);left:var(--default-offset);right:var(--default-offset);bottom:var(--default-offset);pointer-events:none} \ No newline at end of file diff --git a/frontend/.netlify/server/_app/immutable/assets/_layout.ba8665a3.css b/frontend/.netlify/server/_app/immutable/assets/_layout.ba8665a3.css new file mode 100644 index 0000000..5546ebc --- /dev/null +++ b/frontend/.netlify/server/_app/immutable/assets/_layout.ba8665a3.css @@ -0,0 +1,3085 @@ +/* +! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com +*//* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; /* 1 */ + border-width: 0; /* 2 */ + border-style: solid; /* 2 */ + border-color: #e5e7eb; /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +*/ + +html { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + -moz-tab-size: 4; /* 3 */ + -o-tab-size: 4; + tab-size: 4; /* 3 */ + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */ + font-feature-settings: normal; /* 5 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; /* 1 */ + line-height: inherit; /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font family by default. +2. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + font-weight: inherit; /* 1 */ + line-height: inherit; /* 1 */ + color: inherit; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; /* 1 */ + background-color: transparent; /* 2 */ + background-image: none; /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; /* 1 */ + color: #9ca3af; /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; /* 1 */ + color: #9ca3af; /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ +[hidden] { + display: none; +} + +:root, +[data-theme] { + background-color: hsla(var(--b1) / var(--tw-bg-opacity, 1)); + color: hsla(var(--bc) / var(--tw-text-opacity, 1)); +} + +html { + -webkit-tap-highlight-color: transparent; +} + +:root { + color-scheme: light; + --pf: 258.89 94.378% 40.941%; + --sf: 314 100% 37.647%; + --af: 174 60% 40.784%; + --nf: 219 14.085% 22.275%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 258.89 94.378% 51.176%; + --pc: 0 0% 100%; + --s: 314 100% 47.059%; + --sc: 0 0% 100%; + --a: 174 60% 50.98%; + --ac: 174.71 43.59% 15.294%; + --n: 219 14.085% 27.843%; + --nc: 0 0% 100%; + --b1: 0 0% 100%; + --b2: 0 0% 94.902%; + --b3: 180 1.9608% 90%; + --bc: 215 27.907% 16.863%; +} + +@media (prefers-color-scheme: dark) { + + :root { + color-scheme: dark; + --pf: 262.35 80.315% 40.157%; + --sf: 315.75 70.196% 40%; + --af: 174.69 70.335% 32.784%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 262.35 80.315% 50.196%; + --pc: 0 0% 100%; + --s: 315.75 70.196% 50%; + --sc: 0 0% 100%; + --a: 174.69 70.335% 40.98%; + --ac: 0 0% 100%; + --n: 218.18 18.033% 11.961%; + --nf: 222.86 17.073% 8.0392%; + --nc: 220 13.376% 69.216%; + --b1: 220 17.647% 20%; + --b2: 220 17.241% 17.059%; + --b3: 218.57 17.949% 15.294%; + --bc: 220 13.376% 69.216%; + } +} + +[data-theme=light] { + color-scheme: light; + --pf: 258.89 94.378% 40.941%; + --sf: 314 100% 37.647%; + --af: 174 60% 40.784%; + --nf: 219 14.085% 22.275%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 258.89 94.378% 51.176%; + --pc: 0 0% 100%; + --s: 314 100% 47.059%; + --sc: 0 0% 100%; + --a: 174 60% 50.98%; + --ac: 174.71 43.59% 15.294%; + --n: 219 14.085% 27.843%; + --nc: 0 0% 100%; + --b1: 0 0% 100%; + --b2: 0 0% 94.902%; + --b3: 180 1.9608% 90%; + --bc: 215 27.907% 16.863%; +} + +[data-theme=dark] { + color-scheme: dark; + --pf: 262.35 80.315% 40.157%; + --sf: 315.75 70.196% 40%; + --af: 174.69 70.335% 32.784%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 262.35 80.315% 50.196%; + --pc: 0 0% 100%; + --s: 315.75 70.196% 50%; + --sc: 0 0% 100%; + --a: 174.69 70.335% 40.98%; + --ac: 0 0% 100%; + --n: 218.18 18.033% 11.961%; + --nf: 222.86 17.073% 8.0392%; + --nc: 220 13.376% 69.216%; + --b1: 220 17.647% 20%; + --b2: 220 17.241% 17.059%; + --b3: 218.57 17.949% 15.294%; + --bc: 220 13.376% 69.216%; +} + +[data-theme=cupcake] { + color-scheme: light; + --pf: 183.03 47.368% 47.216%; + --sf: 338.25 71.429% 62.431%; + --af: 39 84.112% 46.431%; + --nf: 280 46.479% 11.137%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 183.03 100% 11.804%; + --sc: 338.25 100% 15.608%; + --ac: 39 100% 11.608%; + --nc: 280 82.688% 82.784%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --p: 183.03 47.368% 59.02%; + --s: 338.25 71.429% 78.039%; + --a: 39 84.112% 58.039%; + --n: 280 46.479% 13.922%; + --b1: 24 33.333% 97.059%; + --b2: 26.667 21.951% 91.961%; + --b3: 22.5 14.286% 89.02%; + --bc: 280 46.479% 13.922%; + --rounded-btn: 1.9rem; + --tab-border: 2px; + --tab-radius: .5rem; +} + +[data-theme=bumblebee] { + color-scheme: light; + --pf: 41.124 74.167% 42.353%; + --sf: 49.901 94.393% 46.431%; + --af: 240 33.333% 11.294%; + --nf: 240 33.333% 11.294%; + --b2: 0 0% 90%; + --b3: 0 0% 81%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 0 0% 20%; + --ac: 240 60.274% 82.824%; + --nc: 240 60.274% 82.824%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 41.124 74.167% 52.941%; + --pc: 240 33.333% 14.118%; + --s: 49.901 94.393% 58.039%; + --sc: 240 33.333% 14.118%; + --a: 240 33.333% 14.118%; + --n: 240 33.333% 14.118%; + --b1: 0 0% 100%; +} + +[data-theme=emerald] { + color-scheme: light; + --pf: 141.18 50% 48%; + --sf: 218.88 96.078% 48%; + --af: 9.8901 81.25% 44.863%; + --nf: 219.23 20.312% 20.078%; + --b2: 0 0% 90%; + --b3: 0 0% 81%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --btn-text-case: uppercase; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 141.18 50% 60%; + --pc: 151.11 28.421% 18.627%; + --s: 218.88 96.078% 60%; + --sc: 210 20% 98.039%; + --a: 9.8901 81.25% 56.078%; + --ac: 210 20% 98.039%; + --n: 219.23 20.312% 25.098%; + --nc: 210 20% 98.039%; + --b1: 0 0% 100%; + --bc: 219.23 20.312% 25.098%; + --animation-btn: 0; + --animation-input: 0; + --btn-focus-scale: 1; +} + +[data-theme=corporate] { + color-scheme: light; + --pf: 229.09 95.652% 51.137%; + --sf: 214.91 26.316% 47.216%; + --af: 154.2 49.02% 48%; + --nf: 233.33 27.273% 10.353%; + --b2: 0 0% 90%; + --b3: 0 0% 81%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 229.09 100% 92.784%; + --sc: 214.91 100% 11.804%; + --ac: 154.2 100% 12%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --btn-text-case: uppercase; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 229.09 95.652% 63.922%; + --s: 214.91 26.316% 59.02%; + --a: 154.2 49.02% 60%; + --n: 233.33 27.273% 12.941%; + --nc: 210 38.462% 94.902%; + --b1: 0 0% 100%; + --bc: 233.33 27.273% 12.941%; + --rounded-box: 0.25rem; + --rounded-btn: .125rem; + --rounded-badge: .125rem; + --animation-btn: 0; + --animation-input: 0; + --btn-focus-scale: 1; +} + +[data-theme=synthwave] { + color-scheme: dark; + --pf: 320.73 69.62% 55.216%; + --sf: 197.03 86.592% 51.922%; + --af: 48 89.041% 45.647%; + --nf: 253.22 60.825% 15.216%; + --b2: 253.85 59.091% 23.294%; + --b3: 253.85 59.091% 20.965%; + --pc: 320.73 100% 13.804%; + --sc: 197.03 100% 12.98%; + --ac: 48 100% 11.412%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 320.73 69.62% 69.02%; + --s: 197.03 86.592% 64.902%; + --a: 48 89.041% 57.059%; + --n: 253.22 60.825% 19.02%; + --nc: 260 60% 98.039%; + --b1: 253.85 59.091% 25.882%; + --bc: 260 60% 98.039%; + --in: 199.13 86.957% 63.922%; + --inc: 257.45 63.218% 17.059%; + --su: 168.1 74.233% 68.039%; + --suc: 257.45 63.218% 17.059%; + --wa: 48 89.041% 57.059%; + --wac: 257.45 63.218% 17.059%; + --er: 351.85 73.636% 56.863%; + --erc: 260 60% 98.039%; +} + +[data-theme=retro] { + color-scheme: light; + --pf: 2.6667 73.77% 60.863%; + --sf: 144.62 27.273% 57.569%; + --af: 49.024 67.213% 60.863%; + --nf: 41.667 16.822% 33.569%; + --inc: 221.21 100% 90.667%; + --suc: 142.13 100% 87.255%; + --wac: 32.133 100% 8.7451%; + --erc: 0 100% 90.118%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 2.6667 73.77% 76.078%; + --pc: 345 5.2632% 14.902%; + --s: 144.62 27.273% 71.961%; + --sc: 345 5.2632% 14.902%; + --a: 49.024 67.213% 76.078%; + --ac: 345 5.2632% 14.902%; + --n: 41.667 16.822% 41.961%; + --nc: 45 47.059% 80%; + --b1: 45 47.059% 80%; + --b2: 45.283 37.063% 71.961%; + --b3: 42.188 35.955% 65.098%; + --bc: 345 5.2632% 14.902%; + --in: 221.21 83.193% 53.333%; + --su: 142.13 76.216% 36.275%; + --wa: 32.133 94.619% 43.725%; + --er: 0 72.222% 50.588%; + --rounded-box: 0.4rem; + --rounded-btn: 0.4rem; + --rounded-badge: 0.4rem; +} + +[data-theme=cyberpunk] { + color-scheme: light; + --pf: 344.78 100% 58.353%; + --sf: 195.12 80.392% 56%; + --af: 276 74.324% 56.784%; + --nf: 57.273 100% 10.353%; + --b2: 56 100% 45%; + --b3: 56 100% 40.5%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 56 100% 10%; + --pc: 344.78 100% 14.588%; + --sc: 195.12 100% 14%; + --ac: 276 100% 14.196%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace; + --p: 344.78 100% 72.941%; + --s: 195.12 80.392% 70%; + --a: 276 74.324% 70.98%; + --n: 57.273 100% 12.941%; + --nc: 56 100% 50%; + --b1: 56 100% 50%; + --rounded-box: 0; + --rounded-btn: 0; + --rounded-badge: 0; + --tab-radius: 0; +} + +[data-theme=valentine] { + color-scheme: light; + --pf: 353.23 73.81% 53.647%; + --sf: 254.12 86.441% 61.49%; + --af: 181.41 55.556% 56%; + --nf: 336 42.857% 38.431%; + --b2: 318.46 46.429% 80.118%; + --b3: 318.46 46.429% 72.106%; + --pc: 353.23 100% 13.412%; + --sc: 254.12 100% 15.373%; + --ac: 181.41 100% 14%; + --inc: 221.21 100% 90.667%; + --suc: 142.13 100% 87.255%; + --wac: 32.133 100% 8.7451%; + --erc: 0 100% 90.118%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 353.23 73.81% 67.059%; + --s: 254.12 86.441% 76.863%; + --a: 181.41 55.556% 70%; + --n: 336 42.857% 48.039%; + --nc: 318.46 46.429% 89.02%; + --b1: 318.46 46.429% 89.02%; + --bc: 343.64 38.462% 28.039%; + --in: 221.21 83.193% 53.333%; + --su: 142.13 76.216% 36.275%; + --wa: 32.133 94.619% 43.725%; + --er: 0 72.222% 50.588%; + --rounded-btn: 1.9rem; +} + +[data-theme=halloween] { + color-scheme: dark; + --pf: 31.927 89.344% 41.725%; + --sf: 271.22 45.794% 33.569%; + --af: 91.071 100% 26.353%; + --nf: 180 3.5714% 8.7843%; + --b2: 0 0% 11.647%; + --b3: 0 0% 10.482%; + --bc: 0 0% 82.588%; + --sc: 271.22 100% 88.392%; + --ac: 91.071 100% 6.5882%; + --nc: 180 4.8458% 82.196%; + --inc: 221.21 100% 90.667%; + --suc: 142.13 100% 87.255%; + --wac: 32.133 100% 8.7451%; + --erc: 0 100% 90.118%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 31.927 89.344% 52.157%; + --pc: 180 7.3171% 8.0392%; + --s: 271.22 45.794% 41.961%; + --a: 91.071 100% 32.941%; + --n: 180 3.5714% 10.98%; + --b1: 0 0% 12.941%; + --in: 221.21 83.193% 53.333%; + --su: 142.13 76.216% 36.275%; + --wa: 32.133 94.619% 43.725%; + --er: 0 72.222% 50.588%; +} + +[data-theme=garden] { + color-scheme: light; + --pf: 138.86 15.982% 34.353%; + --sf: 96.923 37.143% 74.51%; + --af: 0 67.742% 75.137%; + --nf: 0 3.9106% 28.078%; + --b2: 0 4.3478% 81.882%; + --b3: 0 4.3478% 73.694%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 138.86 100% 88.588%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 138.86 15.982% 42.941%; + --s: 96.923 37.143% 93.137%; + --sc: 96 32.468% 15.098%; + --a: 0 67.742% 93.922%; + --ac: 0 21.951% 16.078%; + --n: 0 3.9106% 35.098%; + --nc: 0 4.3478% 90.98%; + --b1: 0 4.3478% 90.98%; + --bc: 0 3.2258% 6.0784%; +} + +[data-theme=forest] { + color-scheme: dark; + --pf: 141.04 71.963% 33.569%; + --sf: 140.98 74.694% 38.431%; + --af: 35.148 68.98% 41.569%; + --nf: 0 9.6774% 4.8627%; + --b2: 0 12.195% 7.2353%; + --b3: 0 12.195% 6.5118%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 0 11.727% 81.608%; + --sc: 140.98 100% 9.6078%; + --ac: 35.148 100% 10.392%; + --nc: 0 6.8894% 81.216%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 141.04 71.963% 41.961%; + --pc: 140.66 100% 88.039%; + --s: 140.98 74.694% 48.039%; + --a: 35.148 68.98% 51.961%; + --n: 0 9.6774% 6.0784%; + --b1: 0 12.195% 8.0392%; + --rounded-btn: 1.9rem; +} + +[data-theme=aqua] { + color-scheme: dark; + --pf: 181.79 92.857% 39.529%; + --sf: 274.41 30.909% 45.49%; + --af: 47.059 100% 64%; + --nf: 205.4 53.725% 40%; + --b2: 218.61 52.511% 38.647%; + --b3: 218.61 52.511% 34.782%; + --bc: 218.61 100% 88.588%; + --sc: 274.41 100% 91.373%; + --ac: 47.059 100% 16%; + --nc: 205.4 100% 90%; + --inc: 221.21 100% 90.667%; + --suc: 142.13 100% 87.255%; + --wac: 32.133 100% 8.7451%; + --erc: 0 100% 90.118%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 181.79 92.857% 49.412%; + --pc: 181.41 100% 16.667%; + --s: 274.41 30.909% 56.863%; + --a: 47.059 100% 80%; + --n: 205.4 53.725% 50%; + --b1: 218.61 52.511% 42.941%; + --in: 221.21 83.193% 53.333%; + --su: 142.13 76.216% 36.275%; + --wa: 32.133 94.619% 43.725%; + --er: 0 72.222% 50.588%; +} + +[data-theme=lofi] { + color-scheme: light; + --pf: 0 0% 4.0784%; + --sf: 0 1.9608% 8%; + --af: 0 0% 11.922%; + --nf: 0 0% 0%; + --btn-text-case: uppercase; + --border-btn: 1px; + --tab-border: 1px; + --p: 0 0% 5.098%; + --pc: 0 0% 100%; + --s: 0 1.9608% 10%; + --sc: 0 0% 100%; + --a: 0 0% 14.902%; + --ac: 0 0% 100%; + --n: 0 0% 0%; + --nc: 0 0% 100%; + --b1: 0 0% 100%; + --b2: 0 0% 94.902%; + --b3: 0 1.9608% 90%; + --bc: 0 0% 0%; + --in: 212.35 100% 47.647%; + --inc: 0 0% 100%; + --su: 136.84 72.152% 46.471%; + --suc: 0 0% 100%; + --wa: 4.5614 100% 66.471%; + --wac: 0 0% 100%; + --er: 325.05 77.6% 49.02%; + --erc: 0 0% 100%; + --rounded-box: 0.25rem; + --rounded-btn: 0.125rem; + --rounded-badge: 0.125rem; + --animation-btn: 0; + --animation-input: 0; + --btn-focus-scale: 1; + --tab-radius: 0; +} + +[data-theme=pastel] { + color-scheme: light; + --pf: 283.64 21.569% 64%; + --sf: 351.63 70.492% 70.431%; + --af: 158.49 54.639% 64.784%; + --nf: 198.62 43.719% 48.784%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --bc: 0 0% 20%; + --pc: 283.64 59.314% 16%; + --sc: 351.63 100% 17.608%; + --ac: 158.49 100% 16.196%; + --nc: 198.62 100% 12.196%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 283.64 21.569% 80%; + --s: 351.63 70.492% 88.039%; + --a: 158.49 54.639% 80.98%; + --n: 198.62 43.719% 60.98%; + --b1: 0 0% 100%; + --b2: 210 20% 98.039%; + --b3: 216 12.195% 83.922%; + --rounded-btn: 1.9rem; +} + +[data-theme=fantasy] { + color-scheme: light; + --pf: 296.04 82.813% 20.078%; + --sf: 200 100% 29.647%; + --af: 30.894 94.378% 40.941%; + --nf: 215 27.907% 13.49%; + --b2: 0 0% 90%; + --b3: 0 0% 81%; + --in: 198 93% 60%; + --su: 158 64% 52%; + --wa: 43 96% 56%; + --er: 0 91% 71%; + --pc: 296.04 100% 85.02%; + --sc: 200 100% 87.412%; + --ac: 30.894 100% 10.235%; + --nc: 215 62.264% 83.373%; + --inc: 198 100% 12%; + --suc: 158 100% 10%; + --wac: 43 100% 11%; + --erc: 0 100% 14%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 296.04 82.813% 25.098%; + --s: 200 100% 37.059%; + --a: 30.894 94.378% 51.176%; + --n: 215 27.907% 16.863%; + --b1: 0 0% 100%; + --bc: 215 27.907% 16.863%; +} + +[data-theme=wireframe] { + color-scheme: light; + --pf: 0 0% 57.725%; + --sf: 0 0% 57.725%; + --af: 0 0% 57.725%; + --nf: 0 0% 73.725%; + --bc: 0 0% 20%; + --pc: 0 0% 14.431%; + --sc: 0 0% 14.431%; + --ac: 0 0% 14.431%; + --nc: 0 0% 18.431%; + --inc: 240 100% 90%; + --suc: 120 100% 85.02%; + --wac: 60 100% 10%; + --erc: 0 100% 90%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + font-family: Chalkboard,comic sans ms,"sanssecondaryerif"; + --p: 0 0% 72.157%; + --s: 0 0% 72.157%; + --a: 0 0% 72.157%; + --n: 0 0% 92.157%; + --b1: 0 0% 100%; + --b2: 0 0% 93.333%; + --b3: 0 0% 86.667%; + --in: 240 100% 50%; + --su: 120 100% 25.098%; + --wa: 60 30.196% 50%; + --er: 0 100% 50%; + --rounded-box: 0.2rem; + --rounded-btn: 0.2rem; + --rounded-badge: 0.2rem; + --tab-radius: 0.2rem; +} + +[data-theme=black] { + color-scheme: dark; + --pf: 0 1.9608% 16%; + --sf: 0 1.9608% 16%; + --af: 0 1.9608% 16%; + --bc: 0 0% 80%; + --pc: 0 5.3922% 84%; + --sc: 0 5.3922% 84%; + --ac: 0 5.3922% 84%; + --nc: 0 2.5404% 83.02%; + --inc: 240 100% 90%; + --suc: 120 100% 85.02%; + --wac: 60 100% 10%; + --erc: 0 100% 90%; + --border-btn: 1px; + --tab-border: 1px; + --p: 0 1.9608% 20%; + --s: 0 1.9608% 20%; + --a: 0 1.9608% 20%; + --b1: 0 0% 0%; + --b2: 0 0% 5.098%; + --b3: 0 1.9608% 10%; + --n: 0 1.2987% 15.098%; + --nf: 0 1.9608% 20%; + --in: 240 100% 50%; + --su: 120 100% 25.098%; + --wa: 60 100% 50%; + --er: 0 100% 50%; + --rounded-box: 0; + --rounded-btn: 0; + --rounded-badge: 0; + --animation-btn: 0; + --animation-input: 0; + --btn-text-case: lowercase; + --btn-focus-scale: 1; + --tab-radius: 0; +} + +[data-theme=luxury] { + color-scheme: dark; + --pf: 0 0% 80%; + --sf: 218.4 54.348% 14.431%; + --af: 318.62 21.805% 20.863%; + --nf: 270 4.3478% 7.2157%; + --pc: 0 0% 20%; + --sc: 218.4 100% 83.608%; + --ac: 318.62 84.615% 85.216%; + --inc: 202.35 100% 14%; + --suc: 89.007 100% 10.392%; + --wac: 53.906 100% 12.706%; + --erc: 0 100% 14.353%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 0 0% 100%; + --s: 218.4 54.348% 18.039%; + --a: 318.62 21.805% 26.078%; + --n: 270 4.3478% 9.0196%; + --nc: 37.083 67.29% 58.039%; + --b1: 240 10% 3.9216%; + --b2: 270 4.3478% 9.0196%; + --b3: 270 2.1739% 18.039%; + --bc: 37.083 67.29% 58.039%; + --in: 202.35 100% 70%; + --su: 89.007 61.633% 51.961%; + --wa: 53.906 68.817% 63.529%; + --er: 0 100% 71.765%; +} + +[data-theme=dracula] { + color-scheme: dark; + --pf: 325.52 100% 58.98%; + --sf: 264.71 89.474% 62.118%; + --af: 31.02 100% 56.941%; + --nf: 229.57 15.033% 24%; + --b2: 231.43 14.894% 16.588%; + --b3: 231.43 14.894% 14.929%; + --pc: 325.52 100% 14.745%; + --sc: 264.71 100% 15.529%; + --ac: 31.02 100% 14.235%; + --nc: 229.57 70.868% 86%; + --inc: 190.53 100% 15.373%; + --suc: 135.18 100% 12.941%; + --wac: 64.909 100% 15.294%; + --erc: 0 100% 93.333%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 325.52 100% 73.725%; + --s: 264.71 89.474% 77.647%; + --a: 31.02 100% 71.176%; + --n: 229.57 15.033% 30%; + --b1: 231.43 14.894% 18.431%; + --bc: 60 30% 96.078%; + --in: 190.53 96.61% 76.863%; + --su: 135.18 94.444% 64.706%; + --wa: 64.909 91.667% 76.471%; + --er: 0 100% 66.667%; +} + +[data-theme=cmyk] { + color-scheme: light; + --pf: 202.72 83.251% 48.157%; + --sf: 335.25 77.67% 47.686%; + --af: 56.195 100% 47.843%; + --nf: 0 0% 8.1569%; + --b2: 0 0% 90%; + --b3: 0 0% 81%; + --bc: 0 0% 20%; + --pc: 202.72 100% 12.039%; + --sc: 335.25 100% 91.922%; + --ac: 56.195 100% 11.961%; + --nc: 0 0% 82.039%; + --inc: 192.2 100% 10.431%; + --suc: 291.06 100% 87.608%; + --wac: 25.027 100% 11.333%; + --erc: 3.956 100% 91.137%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 202.72 83.251% 60.196%; + --s: 335.25 77.67% 59.608%; + --a: 56.195 100% 59.804%; + --n: 0 0% 10.196%; + --b1: 0 0% 100%; + --in: 192.2 48.361% 52.157%; + --su: 291.06 48.454% 38.039%; + --wa: 25.027 84.615% 56.667%; + --er: 3.956 80.531% 55.686%; +} + +[data-theme=autumn] { + color-scheme: light; + --pf: 344.23 95.804% 22.431%; + --sf: 0.44444 63.38% 46.588%; + --af: 27.477 56.021% 50.039%; + --nf: 22.105 17.117% 34.824%; + --b2: 0 0% 85.059%; + --b3: 0 0% 76.553%; + --bc: 0 0% 18.902%; + --pc: 344.23 100% 85.608%; + --sc: 0.44444 100% 91.647%; + --ac: 27.477 100% 12.51%; + --nc: 22.105 100% 88.706%; + --inc: 186.94 100% 9.9216%; + --suc: 164.59 100% 8.6275%; + --wac: 30.141 100% 9.9216%; + --erc: 353.6 100% 89.765%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 344.23 95.804% 28.039%; + --s: 0.44444 63.38% 58.235%; + --a: 27.477 56.021% 62.549%; + --n: 22.105 17.117% 43.529%; + --b1: 0 0% 94.51%; + --in: 186.94 47.826% 49.608%; + --su: 164.59 33.636% 43.137%; + --wa: 30.141 84.19% 49.608%; + --er: 353.6 79.116% 48.824%; +} + +[data-theme=business] { + color-scheme: dark; + --pf: 210 64.103% 24.471%; + --sf: 200 12.931% 43.608%; + --af: 12.515 79.512% 47.843%; + --nf: 212.73 13.58% 12.706%; + --b2: 0 0% 11.294%; + --b3: 0 0% 10.165%; + --bc: 0 0% 82.51%; + --pc: 210 100% 86.118%; + --sc: 200 100% 10.902%; + --ac: 12.515 100% 11.961%; + --nc: 212.73 28.205% 83.176%; + --inc: 199.15 100% 88.353%; + --suc: 144 100% 11.137%; + --wac: 39.231 100% 12.078%; + --erc: 6.3415 100% 88.667%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 210 64.103% 30.588%; + --s: 200 12.931% 54.51%; + --a: 12.515 79.512% 59.804%; + --n: 212.73 13.58% 15.882%; + --b1: 0 0% 12.549%; + --in: 199.15 100% 41.765%; + --su: 144 30.973% 55.686%; + --wa: 39.231 64.356% 60.392%; + --er: 6.3415 55.656% 43.333%; + --rounded-box: 0.25rem; + --rounded-btn: .125rem; + --rounded-badge: .125rem; +} + +[data-theme=acid] { + color-scheme: light; + --pf: 302.59 100% 40%; + --sf: 27.294 100% 40%; + --af: 72 98.425% 40.157%; + --nf: 238.42 43.182% 13.804%; + --b2: 0 0% 88.235%; + --b3: 0 0% 79.412%; + --bc: 0 0% 19.608%; + --pc: 302.59 100% 90%; + --sc: 27.294 100% 10%; + --ac: 72 100% 10.039%; + --nc: 238.42 99.052% 83.451%; + --inc: 209.85 100% 11.569%; + --suc: 148.87 100% 11.608%; + --wac: 52.574 100% 11.451%; + --erc: 0.78261 100% 89.02%; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 302.59 100% 50%; + --s: 27.294 100% 50%; + --a: 72 98.425% 50.196%; + --n: 238.42 43.182% 17.255%; + --b1: 0 0% 98.039%; + --in: 209.85 91.628% 57.843%; + --su: 148.87 49.533% 58.039%; + --wa: 52.574 92.661% 57.255%; + --er: 0.78261 100% 45.098%; + --rounded-box: 1.25rem; + --rounded-btn: 1rem; + --rounded-badge: 1rem; +} + +[data-theme=lemonade] { + color-scheme: light; + --pf: 88.8 96.154% 24.471%; + --sf: 60 80.952% 43.765%; + --af: 62.553 79.661% 70.745%; + --nf: 238.42 43.182% 13.804%; + --b2: 0 0% 90%; + --b3: 0 0% 81%; + --bc: 0 0% 20%; + --pc: 88.8 100% 86.118%; + --sc: 60 100% 10.941%; + --ac: 62.553 100% 17.686%; + --nc: 238.42 99.052% 83.451%; + --inc: 191.61 79.118% 16.902%; + --suc: 74.458 100% 15.725%; + --wac: 50.182 100% 15.059%; + --erc: 0.98361 100% 16.588%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 88.8 96.154% 30.588%; + --s: 60 80.952% 54.706%; + --a: 62.553 79.661% 88.431%; + --n: 238.42 43.182% 17.255%; + --b1: 0 0% 100%; + --in: 191.61 39.241% 84.51%; + --su: 74.458 76.147% 78.627%; + --wa: 50.182 87.302% 75.294%; + --er: 0.98361 70.115% 82.941%; +} + +[data-theme=night] { + color-scheme: dark; + --pf: 198.44 93.204% 47.686%; + --sf: 234.45 89.474% 59.137%; + --af: 328.85 85.621% 56%; + --b2: 222.22 47.368% 10.059%; + --b3: 222.22 47.368% 9.0529%; + --bc: 222.22 65.563% 82.235%; + --pc: 198.44 100% 11.922%; + --sc: 234.45 100% 14.784%; + --ac: 328.85 100% 14%; + --nc: 217.24 75.772% 83.49%; + --inc: 198.46 100% 9.6078%; + --suc: 172.46 100% 10.078%; + --wac: 40.61 100% 12.706%; + --erc: 350.94 100% 14.235%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 198.44 93.204% 59.608%; + --s: 234.45 89.474% 73.922%; + --a: 328.85 85.621% 70%; + --n: 217.24 32.584% 17.451%; + --nf: 217.06 30.357% 21.961%; + --b1: 222.22 47.368% 11.176%; + --in: 198.46 90.204% 48.039%; + --su: 172.46 66.008% 50.392%; + --wa: 40.61 88.172% 63.529%; + --er: 350.94 94.558% 71.176%; +} + +[data-theme=coffee] { + color-scheme: dark; + --pf: 29.583 66.667% 46.118%; + --sf: 182.4 24.752% 15.843%; + --af: 194.19 74.4% 19.608%; + --nf: 300 20% 4.7059%; + --b2: 306 18.519% 9.5294%; + --b3: 306 18.519% 8.5765%; + --pc: 29.583 100% 11.529%; + --sc: 182.4 67.237% 83.961%; + --ac: 194.19 100% 84.902%; + --nc: 300 13.75% 81.176%; + --inc: 171.15 100% 13.451%; + --suc: 92.5 100% 12.471%; + --wac: 43.125 100% 13.725%; + --erc: 9.7561 100% 14.941%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 29.583 66.667% 57.647%; + --s: 182.4 24.752% 19.804%; + --a: 194.19 74.4% 24.51%; + --n: 300 20% 5.8824%; + --b1: 306 18.519% 10.588%; + --bc: 36.667 8.3333% 42.353%; + --in: 171.15 36.527% 67.255%; + --su: 92.5 25% 62.353%; + --wa: 43.125 100% 68.627%; + --er: 9.7561 95.349% 74.706%; +} + +[data-theme=winter] { + color-scheme: light; + --pf: 211.79 100% 40.627%; + --sf: 246.92 47.273% 34.51%; + --af: 310.41 49.388% 41.569%; + --nf: 217.02 92.157% 8%; + --pc: 211.79 100% 90.157%; + --sc: 246.92 100% 88.627%; + --ac: 310.41 100% 90.392%; + --nc: 217.02 100% 82%; + --inc: 191.54 100% 15.608%; + --suc: 181.5 100% 13.255%; + --wac: 32.308 100% 16.706%; + --erc: 0 100% 14.431%; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-text-case: uppercase; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 211.79 100% 50.784%; + --s: 246.92 47.273% 43.137%; + --a: 310.41 49.388% 51.961%; + --n: 217.02 92.157% 10%; + --b1: 0 0% 100%; + --b2: 216.92 100% 97.451%; + --b3: 218.82 43.59% 92.353%; + --bc: 214.29 30.061% 31.961%; + --in: 191.54 92.857% 78.039%; + --su: 181.5 46.512% 66.275%; + --wa: 32.308 61.905% 83.529%; + --er: 0 63.38% 72.157%; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} +.container { + width: 100%; +} +@media (min-width: 640px) { + + .container { + max-width: 640px; + } +} +@media (min-width: 768px) { + + .container { + max-width: 768px; + } +} +@media (min-width: 1024px) { + + .container { + max-width: 1024px; + } +} +@media (min-width: 1280px) { + + .container { + max-width: 1280px; + } +} +@media (min-width: 1536px) { + + .container { + max-width: 1536px; + } +} +.avatar.placeholder > div { + display: flex; + align-items: center; + justify-content: center; +} +.btn { + display: inline-flex; + flex-shrink: 0; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-wrap: wrap; + align-items: center; + justify-content: center; + border-color: transparent; + border-color: hsl(var(--n) / var(--tw-border-opacity)); + text-align: center; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-duration: 200ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + border-radius: var(--rounded-btn, 0.5rem); + height: 3rem; + padding-left: 1rem; + padding-right: 1rem; + font-size: 0.875rem; + line-height: 1.25rem; + line-height: 1em; + min-height: 3rem; + font-weight: 600; + text-transform: uppercase; + text-transform: var(--btn-text-case, uppercase); + text-decoration-line: none; + border-width: var(--border-btn, 1px); + animation: button-pop var(--animation-btn, 0.25s) ease-out; + --tw-border-opacity: 1; + --tw-bg-opacity: 1; + background-color: hsl(var(--n) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--nc) / var(--tw-text-opacity)); +} +.btn-disabled, + .btn[disabled] { + pointer-events: none; +} +.btn-square { + height: 3rem; + width: 3rem; + padding: 0px; +} +.btn.loading, + .btn.loading:hover { + pointer-events: none; +} +.btn.loading:before { + margin-right: 0.5rem; + height: 1rem; + width: 1rem; + border-radius: 9999px; + border-width: 2px; + animation: spin 2s linear infinite; + content: ""; + border-top-color: transparent; + border-left-color: transparent; + border-bottom-color: currentColor; + border-right-color: currentColor; +} +@media (prefers-reduced-motion: reduce) { + + .btn.loading:before { + animation: spin 10s linear infinite; + } +} +@keyframes spin { + + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} +.btn-group > input[type="radio"].btn { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} +.btn-group > input[type="radio"].btn:before { + content: attr(data-title); +} +.card { + position: relative; + display: flex; + flex-direction: column; + border-radius: var(--rounded-box, 1rem); +} +.card:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} +.card-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + padding: var(--padding-card, 2rem); + gap: 0.5rem; +} +.card-body :where(p) { + flex-grow: 1; +} +.card-actions { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem; +} +.card figure { + display: flex; + align-items: center; + justify-content: center; +} +.card.image-full { + display: grid; +} +.card.image-full:before { + position: relative; + content: ""; + z-index: 10; + --tw-bg-opacity: 1; + background-color: hsl(var(--n) / var(--tw-bg-opacity)); + opacity: 0.75; + border-radius: var(--rounded-box, 1rem); +} +.card.image-full:before, + .card.image-full > * { + grid-column-start: 1; + grid-row-start: 1; +} +.card.image-full > figure img { + height: 100%; + -o-object-fit: cover; + object-fit: cover; +} +.card.image-full > .card-body { + position: relative; + z-index: 20; + --tw-text-opacity: 1; + color: hsl(var(--nc) / var(--tw-text-opacity)); +} +.chat { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + -moz-column-gap: 0.75rem; + column-gap: 0.75rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} +.checkbox { + flex-shrink: 0; + --chkbg: var(--bc); + --chkfg: var(--b1); + height: 1.5rem; + width: 1.5rem; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-width: 1px; + border-color: hsl(var(--bc) / var(--tw-border-opacity)); + --tw-border-opacity: 0.2; + border-radius: var(--rounded-btn, 0.5rem); +} +.form-control { + display: flex; + flex-direction: column; +} +.label { + display: flex; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + align-items: center; + justify-content: space-between; + padding-left: 0.25rem; + padding-right: 0.25rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} +.input { + flex-shrink: 1; + height: 3rem; + padding-left: 1rem; + padding-right: 1rem; + font-size: 1rem; + line-height: 2; + line-height: 1.5rem; + border-width: 1px; + border-color: hsl(var(--bc) / var(--tw-border-opacity)); + --tw-border-opacity: 0; + --tw-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--tw-bg-opacity)); + border-radius: var(--rounded-btn, 0.5rem); +} +.input-group > .input { + isolation: isolate; +} +.input-group > *, + .input-group > .input, + .input-group > .textarea, + .input-group > .select { + border-radius: 0px; +} +.link { + cursor: pointer; + text-decoration-line: underline; +} +.menu > :where(li.disabled > *:not(ul):focus) { + cursor: auto; +} +.toast { + position: fixed; + display: flex; + min-width: -moz-fit-content; + min-width: fit-content; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; +} +.tooltip { + position: relative; + display: inline-block; + --tooltip-offset: calc(100% + 1px + var(--tooltip-tail, 0px)); + text-align: center; + --tooltip-tail: 3px; + --tooltip-color: hsl(var(--n)); + --tooltip-text-color: hsl(var(--nc)); + --tooltip-tail-offset: calc(100% + 1px - var(--tooltip-tail)); +} +.tooltip:before { + position: absolute; + pointer-events: none; + z-index: 1; + content: var(--tw-content); + --tw-content: attr(data-tip); + max-width: 20rem; + border-radius: 0.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: var(--tooltip-color); + color: var(--tooltip-text-color); + width: -moz-max-content; + width: max-content; +} +.tooltip:before, .tooltip-top:before { + transform: translateX(-50%); + top: auto; + left: 50%; + right: auto; + bottom: var(--tooltip-offset); +} +.btn-outline .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--nf, var(--n)) / var(--tw-border-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--nc) / var(--tw-text-opacity)); +} +.btn-outline.btn-primary .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--p) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--pc) / var(--tw-text-opacity)); +} +.btn-outline.btn-secondary .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--s) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--s) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--sc) / var(--tw-text-opacity)); +} +.btn-outline.btn-accent .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--a) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--a) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--ac) / var(--tw-text-opacity)); +} +.btn-outline .badge.outline { + --tw-border-opacity: 1; + border-color: hsl(var(--nf, var(--n)) / var(--tw-border-opacity)); + background-color: transparent; +} +.btn-outline.btn-primary .badge-outline { + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--p) / var(--tw-text-opacity)); +} +.btn-outline.btn-secondary .badge-outline { + --tw-border-opacity: 1; + border-color: hsl(var(--s) / var(--tw-border-opacity)); + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--s) / var(--tw-text-opacity)); +} +.btn-outline.btn-accent .badge-outline { + --tw-border-opacity: 1; + border-color: hsl(var(--a) / var(--tw-border-opacity)); + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--a) / var(--tw-text-opacity)); +} +.btn-outline.btn-info .badge-outline { + --tw-border-opacity: 1; + border-color: hsl(var(--in) / var(--tw-border-opacity)); + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--in) / var(--tw-text-opacity)); +} +.btn-outline.btn-success .badge-outline { + --tw-border-opacity: 1; + border-color: hsl(var(--su) / var(--tw-border-opacity)); + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--su) / var(--tw-text-opacity)); +} +.btn-outline.btn-warning .badge-outline { + --tw-border-opacity: 1; + border-color: hsl(var(--wa) / var(--tw-border-opacity)); + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--wa) / var(--tw-text-opacity)); +} +.btn-outline.btn-error .badge-outline { + --tw-border-opacity: 1; + border-color: hsl(var(--er) / var(--tw-border-opacity)); + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--er) / var(--tw-text-opacity)); +} +.btn-outline:hover .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--b2, var(--b1)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--b2, var(--b1)) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--bc) / var(--tw-text-opacity)); +} +.btn-outline:hover .badge.outline { + --tw-border-opacity: 1; + border-color: hsl(var(--b2, var(--b1)) / var(--tw-border-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--nc) / var(--tw-text-opacity)); +} +.btn-outline.btn-primary:hover .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--pc) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--pc) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--p) / var(--tw-text-opacity)); +} +.btn-outline.btn-primary:hover .badge.outline { + --tw-border-opacity: 1; + border-color: hsl(var(--pc) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--pf, var(--p)) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--pc) / var(--tw-text-opacity)); +} +.btn-outline.btn-secondary:hover .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--sc) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--sc) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--s) / var(--tw-text-opacity)); +} +.btn-outline.btn-secondary:hover .badge.outline { + --tw-border-opacity: 1; + border-color: hsl(var(--sc) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--sf, var(--s)) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--sc) / var(--tw-text-opacity)); +} +.btn-outline.btn-accent:hover .badge { + --tw-border-opacity: 1; + border-color: hsl(var(--ac) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--ac) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--a) / var(--tw-text-opacity)); +} +.btn-outline.btn-accent:hover .badge.outline { + --tw-border-opacity: 1; + border-color: hsl(var(--ac) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--af, var(--a)) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--ac) / var(--tw-text-opacity)); +} +.btm-nav>*:where(.active) { + border-top-width: 2px; + --tw-bg-opacity: 1; + background-color: hsl(var(--b1) / var(--tw-bg-opacity)); +} +.btm-nav>*.disabled, + .btm-nav>*.disabled:hover, + .btm-nav>*[disabled], + .btm-nav>*[disabled]:hover { + pointer-events: none; + --tw-border-opacity: 0; + background-color: hsl(var(--n) / var(--tw-bg-opacity)); + --tw-bg-opacity: 0.1; + color: hsl(var(--bc) / var(--tw-text-opacity)); + --tw-text-opacity: 0.2; +} +.btm-nav>* .label { + font-size: 1rem; + line-height: 1.5rem; +} +.btn:active:hover, + .btn:active:focus { + animation: none; + transform: scale(var(--btn-focus-scale, 0.95)); +} +.btn:hover, + .btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--nf, var(--n)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--nf, var(--n)) / var(--tw-bg-opacity)); +} +.btn:focus-visible { + outline: 2px solid hsl(var(--nf)); + outline-offset: 2px; +} +.btn-primary { + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--p) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--pc) / var(--tw-text-opacity)); +} +.btn-primary:hover, + .btn-primary.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--pf, var(--p)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--pf, var(--p)) / var(--tw-bg-opacity)); +} +.btn-primary:focus-visible { + outline: 2px solid hsl(var(--p)); +} +.btn-accent { + --tw-border-opacity: 1; + border-color: hsl(var(--a) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--a) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--ac) / var(--tw-text-opacity)); +} +.btn-accent:hover, + .btn-accent.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--af, var(--a)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--af, var(--a)) / var(--tw-bg-opacity)); +} +.btn-accent:focus-visible { + outline: 2px solid hsl(var(--a)); +} +.btn-success { + --tw-border-opacity: 1; + border-color: hsl(var(--su) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--su) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--suc, var(--nc)) / var(--tw-text-opacity)); +} +.btn-success:hover, + .btn-success.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--su) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--su) / var(--tw-bg-opacity)); +} +.btn-success:focus-visible { + outline: 2px solid hsl(var(--su)); +} +.btn-warning { + --tw-border-opacity: 1; + border-color: hsl(var(--wa) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--wac, var(--nc)) / var(--tw-text-opacity)); +} +.btn-warning:hover, + .btn-warning.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--wa) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--tw-bg-opacity)); +} +.btn-warning:focus-visible { + outline: 2px solid hsl(var(--wa)); +} +.btn-error { + --tw-border-opacity: 1; + border-color: hsl(var(--er) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--er) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--erc, var(--nc)) / var(--tw-text-opacity)); +} +.btn-error:hover, + .btn-error.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--er) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--er) / var(--tw-bg-opacity)); +} +.btn-error:focus-visible { + outline: 2px solid hsl(var(--er)); +} +.btn.glass:hover, + .btn.glass.btn-active { + --glass-opacity: 25%; + --glass-border-opacity: 15%; +} +.btn.glass:focus-visible { + outline: 2px solid currentColor; +} +.btn-ghost { + border-width: 1px; + border-color: transparent; + background-color: transparent; + color: currentColor; +} +.btn-ghost:hover, + .btn-ghost.btn-active { + --tw-border-opacity: 0; + background-color: hsl(var(--bc) / var(--tw-bg-opacity)); + --tw-bg-opacity: 0.2; +} +.btn-ghost:focus-visible { + outline: 2px solid currentColor; +} +.btn-outline { + border-color: currentColor; + background-color: transparent; + --tw-text-opacity: 1; + color: hsl(var(--bc) / var(--tw-text-opacity)); +} +.btn-outline:hover, + .btn-outline.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--bc) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--b1) / var(--tw-text-opacity)); +} +.btn-outline.btn-primary { + --tw-text-opacity: 1; + color: hsl(var(--p) / var(--tw-text-opacity)); +} +.btn-outline.btn-primary:hover, + .btn-outline.btn-primary.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--pf, var(--p)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--pf, var(--p)) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--pc) / var(--tw-text-opacity)); +} +.btn-outline.btn-secondary { + --tw-text-opacity: 1; + color: hsl(var(--s) / var(--tw-text-opacity)); +} +.btn-outline.btn-secondary:hover, + .btn-outline.btn-secondary.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--sf, var(--s)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--sf, var(--s)) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--sc) / var(--tw-text-opacity)); +} +.btn-outline.btn-accent { + --tw-text-opacity: 1; + color: hsl(var(--a) / var(--tw-text-opacity)); +} +.btn-outline.btn-accent:hover, + .btn-outline.btn-accent.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--af, var(--a)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--af, var(--a)) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--ac) / var(--tw-text-opacity)); +} +.btn-outline.btn-success { + --tw-text-opacity: 1; + color: hsl(var(--su) / var(--tw-text-opacity)); +} +.btn-outline.btn-success:hover, + .btn-outline.btn-success.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--su) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--su) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--suc, var(--nc)) / var(--tw-text-opacity)); +} +.btn-outline.btn-info { + --tw-text-opacity: 1; + color: hsl(var(--in) / var(--tw-text-opacity)); +} +.btn-outline.btn-info:hover, + .btn-outline.btn-info.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--in) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--in) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--inc, var(--nc)) / var(--tw-text-opacity)); +} +.btn-outline.btn-warning { + --tw-text-opacity: 1; + color: hsl(var(--wa) / var(--tw-text-opacity)); +} +.btn-outline.btn-warning:hover, + .btn-outline.btn-warning.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--wa) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--wac, var(--nc)) / var(--tw-text-opacity)); +} +.btn-outline.btn-error { + --tw-text-opacity: 1; + color: hsl(var(--er) / var(--tw-text-opacity)); +} +.btn-outline.btn-error:hover, + .btn-outline.btn-error.btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--er) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--er) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--erc, var(--nc)) / var(--tw-text-opacity)); +} +.btn-disabled, + .btn-disabled:hover, + .btn[disabled], + .btn[disabled]:hover { + --tw-border-opacity: 0; + background-color: hsl(var(--n) / var(--tw-bg-opacity)); + --tw-bg-opacity: 0.2; + color: hsl(var(--bc) / var(--tw-text-opacity)); + --tw-text-opacity: 0.2; +} +.btn.loading.btn-square:before, + .btn.loading.btn-circle:before { + margin-right: 0px; +} +.btn.loading.btn-xl:before, + .btn.loading.btn-lg:before { + height: 1.25rem; + width: 1.25rem; +} +.btn.loading.btn-sm:before, + .btn.loading.btn-xs:before { + height: 0.75rem; + width: 0.75rem; +} +.btn-group > input[type="radio"]:checked.btn, + .btn-group > .btn-active { + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--p) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--pc) / var(--tw-text-opacity)); +} +.btn-group > input[type="radio"]:checked.btn:focus-visible, .btn-group > .btn-active:focus-visible { + outline: 2px solid hsl(var(--p)); +} +@keyframes button-pop { + + 0% { + transform: scale(var(--btn-focus-scale, 0.95)); + } + + 40% { + transform: scale(1.02); + } + + 100% { + transform: scale(1); + } +} +.card :where(figure:first-child) { + overflow: hidden; + border-start-start-radius: inherit; + border-start-end-radius: inherit; + border-end-start-radius: unset; + border-end-end-radius: unset; +} +.card :where(figure:last-child) { + overflow: hidden; + border-start-start-radius: unset; + border-start-end-radius: unset; + border-end-start-radius: inherit; + border-end-end-radius: inherit; +} +.card:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} +.card.bordered { + border-width: 1px; + --tw-border-opacity: 1; + border-color: hsl(var(--b2, var(--b1)) / var(--tw-border-opacity)); +} +.card.compact .card-body { + padding: 1rem; + font-size: 0.875rem; + line-height: 1.25rem; +} +.card-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + line-height: 1.75rem; + font-weight: 600; +} +.card.image-full :where(figure) { + overflow: hidden; + border-radius: inherit; +} +.checkbox:focus-visible { + outline: 2px solid hsl(var(--bc)); + outline-offset: 2px; +} +.checkbox:checked, + .checkbox[checked="true"], + .checkbox[aria-checked="true"] { + --tw-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--tw-bg-opacity)); + background-repeat: no-repeat; + animation: checkmark var(--animation-input, 0.2s) ease-in-out; + background-image: linear-gradient(-45deg, transparent 65%, hsl(var(--chkbg)) 65.99%), linear-gradient(45deg, transparent 75%, hsl(var(--chkbg)) 75.99%), linear-gradient(-45deg, hsl(var(--chkbg)) 40%, transparent 40.99%), linear-gradient(45deg, hsl(var(--chkbg)) 30%, hsl(var(--chkfg)) 30.99%, hsl(var(--chkfg)) 40%, transparent 40.99%), linear-gradient(-45deg, hsl(var(--chkfg)) 50%, hsl(var(--chkbg)) 50.99%); +} +.checkbox:indeterminate { + --tw-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--tw-bg-opacity)); + background-repeat: no-repeat; + animation: checkmark var(--animation-input, 0.2s) ease-in-out; + background-image: linear-gradient(90deg, transparent 80%, hsl(var(--chkbg)) 80%), linear-gradient(-90deg, transparent 80%, hsl(var(--chkbg)) 80%), linear-gradient(0deg, hsl(var(--chkbg)) 43%, hsl(var(--chkfg)) 43%, hsl(var(--chkfg)) 57%, hsl(var(--chkbg)) 57%); +} +.checkbox-primary { + --chkbg: var(--p); + --chkfg: var(--pc); + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); +} +.checkbox-primary:hover { + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); +} +.checkbox-primary:focus-visible { + outline: 2px solid hsl(var(--p)); +} +.checkbox-primary:checked, + .checkbox-primary[checked="true"], + .checkbox-primary[aria-checked="true"] { + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--p) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--pc) / var(--tw-text-opacity)); +} +.checkbox:disabled { + cursor: not-allowed; + border-color: transparent; + --tw-bg-opacity: 1; + background-color: hsl(var(--bc) / var(--tw-bg-opacity)); + opacity: 0.2; +} +@keyframes checkmark { + + 0% { + background-position-y: 5px; + } + + 50% { + background-position-y: -2px; + } + + 100% { + background-position-y: 0; + } +} +[dir="rtl"] .checkbox:checked, + [dir="rtl"] .checkbox[checked="true"], + [dir="rtl"] .checkbox[aria-checked="true"] { + background-image: linear-gradient(45deg, transparent 65%, hsl(var(--chkbg)) 65.99%), linear-gradient(-45deg, transparent 75%, hsl(var(--chkbg)) 75.99%), linear-gradient(45deg, hsl(var(--chkbg)) 40%, transparent 40.99%), linear-gradient(-45deg, hsl(var(--chkbg)) 30%, hsl(var(--chkfg)) 30.99%, hsl(var(--chkfg)) 40%, transparent 40.99%), linear-gradient(45deg, hsl(var(--chkfg)) 50%, hsl(var(--chkbg)) 50.99%); +} +.drawer-toggle:focus-visible ~ .drawer-content .drawer-button.btn-primary { + outline: 2px solid hsl(var(--p)); +} +.drawer-toggle:focus-visible ~ .drawer-content .drawer-button.btn-accent { + outline: 2px solid hsl(var(--a)); +} +.drawer-toggle:focus-visible ~ .drawer-content .drawer-button.btn-success { + outline: 2px solid hsl(var(--su)); +} +.drawer-toggle:focus-visible ~ .drawer-content .drawer-button.btn-warning { + outline: 2px solid hsl(var(--wa)); +} +.drawer-toggle:focus-visible ~ .drawer-content .drawer-button.btn-error { + outline: 2px solid hsl(var(--er)); +} +.drawer-toggle:focus-visible ~ .drawer-content .drawer-button.btn-ghost { + outline: 2px solid currentColor; +} +.label-text { + font-size: 0.875rem; + line-height: 1.25rem; + --tw-text-opacity: 1; + color: hsl(var(--bc) / var(--tw-text-opacity)); +} +.label a:hover { + --tw-text-opacity: 1; + color: hsl(var(--bc) / var(--tw-text-opacity)); +} +.input[list]::-webkit-calendar-picker-indicator { + line-height: 1em; +} +.input-bordered { + --tw-border-opacity: 0.2; +} +.input:focus { + outline: 2px solid hsla(var(--bc) / 0.2); + outline-offset: 2px; +} +.input-primary { + --tw-border-opacity: 1; + border-color: hsl(var(--p) / var(--tw-border-opacity)); +} +.input-primary:focus { + outline: 2px solid hsl(var(--p)); +} +.input-disabled, + .input[disabled] { + cursor: not-allowed; + --tw-border-opacity: 1; + border-color: hsl(var(--b2, var(--b1)) / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: hsl(var(--b2, var(--b1)) / var(--tw-bg-opacity)); + --tw-text-opacity: 0.2; +} +.input-disabled::-moz-placeholder, .input[disabled]::-moz-placeholder { + color: hsl(var(--bc) / var(--tw-placeholder-opacity)); + --tw-placeholder-opacity: 0.2; +} +.input-disabled::placeholder, + .input[disabled]::placeholder { + color: hsl(var(--bc) / var(--tw-placeholder-opacity)); + --tw-placeholder-opacity: 0.2; +} +.link-accent { + --tw-text-opacity: 1; + color: hsl(var(--a) / var(--tw-text-opacity)); +} +.link-accent:hover { + --tw-text-opacity: 1; + color: hsl(var(--af, var(--a)) / var(--tw-text-opacity)); +} +.link:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} +.link:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} +.menu :where(li:not(.menu-title):not(:empty)) > :where(:not(ul).active), + .menu :where(li:not(.menu-title):not(:empty)) > :where(*:not(ul):active) { + --tw-bg-opacity: 1; + background-color: hsl(var(--p) / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: hsl(var(--pc) / var(--tw-text-opacity)); +} +.menu li.disabled > * { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + color: hsl(var(--bc) / var(--tw-text-opacity)); + --tw-text-opacity: 0.2; +} +.menu li.disabled > *:hover { + background-color: transparent; +} +.mockup-phone .display { + overflow: hidden; + border-radius: 40px; + margin-top: -25px; +} +@keyframes progress-loading { + + 50% { + left: 107%; + } +} +@keyframes radiomark { + + 0% { + box-shadow: 0 0 0 12px hsl(var(--b1)) inset, 0 0 0 12px hsl(var(--b1)) inset; + } + + 50% { + box-shadow: 0 0 0 3px hsl(var(--b1)) inset, 0 0 0 3px hsl(var(--b1)) inset; + } + + 100% { + box-shadow: 0 0 0 4px hsl(var(--b1)) inset, 0 0 0 4px hsl(var(--b1)) inset; + } +} +@keyframes rating-pop { + + 0% { + transform: translateY(-0.125em); + } + + 40% { + transform: translateY(-0.125em); + } + + 100% { + transform: translateY(0); + } +} +.table tr.active th, + .table tr.active td, + .table tr.active:nth-child(even) th, + .table tr.active:nth-child(even) td { + --tw-bg-opacity: 1; + background-color: hsl(var(--b3, var(--b2)) / var(--tw-bg-opacity)); +} +.table tr.hover:hover th, + .table tr.hover:hover td, + .table tr.hover:nth-child(even):hover th, + .table tr.hover:nth-child(even):hover td { + --tw-bg-opacity: 1; + background-color: hsl(var(--b3, var(--b2)) / var(--tw-bg-opacity)); +} +.toast>* { + animation: toast-pop 0.25s ease-out; +} +@keyframes toast-pop { + + 0% { + transform: scale(0.9); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} +.tooltip:before, +.tooltip:after { + opacity: 0; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-delay: 100ms; + transition-duration: 200ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} +.tooltip:after { + position: absolute; + content: ""; + border-style: solid; + border-width: var(--tooltip-tail, 0); + width: 0; + height: 0; + display: block; +} +.tooltip.tooltip-open:before, +.tooltip.tooltip-open:after, +.tooltip:hover:before, +.tooltip:hover:after { + opacity: 1; + transition-delay: 75ms; +} +.tooltip:not([data-tip]):hover:before, +.tooltip:not([data-tip]):hover:after { + visibility: hidden; + opacity: 0; +} +.tooltip:after, .tooltip-top:after { + transform: translateX(-50%); + border-color: var(--tooltip-color) transparent transparent transparent; + top: auto; + left: 50%; + right: auto; + bottom: var(--tooltip-tail-offset); +} +.btm-nav-xs>*:where(.active) { + border-top-width: 1px; +} +.btm-nav-sm>*:where(.active) { + border-top-width: 2px; +} +.btm-nav-md>*:where(.active) { + border-top-width: 2px; +} +.btm-nav-lg>*:where(.active) { + border-top-width: 4px; +} +.btn-sm { + height: 2rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + min-height: 2rem; + font-size: 0.875rem; +} +.btn-md { + height: 3rem; + padding-left: 1rem; + padding-right: 1rem; + min-height: 3rem; + font-size: 0.875rem; +} +.btn-wide { + width: 16rem; +} +.btn-square:where(.btn-xs) { + height: 1.5rem; + width: 1.5rem; + padding: 0px; +} +.btn-square:where(.btn-sm) { + height: 2rem; + width: 2rem; + padding: 0px; +} +.btn-square:where(.btn-md) { + height: 3rem; + width: 3rem; + padding: 0px; +} +.btn-square:where(.btn-lg) { + height: 4rem; + width: 4rem; + padding: 0px; +} +.btn-circle:where(.btn-sm) { + height: 2rem; + width: 2rem; + border-radius: 9999px; + padding: 0px; +} +.btn-circle:where(.btn-md) { + height: 3rem; + width: 3rem; + border-radius: 9999px; + padding: 0px; +} +:where(.toast) { + right: 0px; + left: auto; + top: auto; + bottom: 0px; + --tw-translate-x: 0px; + --tw-translate-y: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.toast:where(.toast-start) { + right: auto; + left: 0px; + --tw-translate-x: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.toast:where(.toast-center) { + right: 50%; + left: 50%; + --tw-translate-x: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.toast:where(.toast-end) { + right: 0px; + left: auto; + --tw-translate-x: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.toast:where(.toast-bottom) { + top: auto; + bottom: 0px; + --tw-translate-y: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.toast:where(.toast-middle) { + top: 50%; + bottom: auto; + --tw-translate-y: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.toast:where(.toast-top) { + top: 0px; + bottom: auto; + --tw-translate-y: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.avatar.offline:before { + content: ""; + position: absolute; + z-index: 10; + display: block; + border-radius: 9999px; + --tw-bg-opacity: 1; + background-color: hsl(var(--b3, var(--b2)) / var(--tw-bg-opacity)); + width: 15%; + height: 15%; + top: 7%; + right: 7%; + box-shadow: 0 0 0 2px hsl(var(--b1)); +} +.btn-group .btn:not(:first-child):not(:last-child), .btn-group.btn-group-horizontal .btn:not(:first-child):not(:last-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group .btn:first-child:not(:last-child), .btn-group.btn-group-horizontal .btn:first-child:not(:last-child) { + margin-left: -1px; + margin-top: -0px; + border-top-left-radius: var(--rounded-btn, 0.5rem); + border-top-right-radius: 0; + border-bottom-left-radius: var(--rounded-btn, 0.5rem); + border-bottom-right-radius: 0; +} +.btn-group .btn:last-child:not(:first-child), .btn-group.btn-group-horizontal .btn:last-child:not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: var(--rounded-btn, 0.5rem); + border-bottom-left-radius: 0; + border-bottom-right-radius: var(--rounded-btn, 0.5rem); +} +.btn-group.btn-group-vertical .btn:first-child:not(:last-child) { + margin-left: -0px; + margin-top: -1px; + border-top-left-radius: var(--rounded-btn, 0.5rem); + border-top-right-radius: var(--rounded-btn, 0.5rem); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group.btn-group-vertical .btn:last-child:not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: var(--rounded-btn, 0.5rem); + border-bottom-right-radius: var(--rounded-btn, 0.5rem); +} +.card-compact .card-body { + padding: 1rem; + font-size: 0.875rem; + line-height: 1.25rem; +} +.card-compact .card-title { + margin-bottom: 0.25rem; +} +.card-normal .card-body { + padding: var(--padding-card, 2rem); + font-size: 1rem; + line-height: 1.5rem; +} +.card-normal .card-title { + margin-bottom: 0.75rem; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.my-10 { + margin-top: 2.5rem; + margin-bottom: 2.5rem; +} +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} +.my-3 { + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} +.my-5 { + margin-top: 1.25rem; + margin-bottom: 1.25rem; +} +.mb-2 { + margin-bottom: 0.5rem; +} +.mb-4 { + margin-bottom: 1rem; +} +.mb-8 { + margin-bottom: 2rem; +} +.ml-4 { + margin-left: 1rem; +} +.mr-3 { + margin-right: 0.75rem; +} +.mt-4 { + margin-top: 1rem; +} +.flex { + display: flex; +} +.contents { + display: contents; +} +.h-96 { + height: 24rem; +} +.h-screen { + height: 100vh; +} +.max-h-80 { + max-height: 20rem; +} +.w-96 { + width: 24rem; +} +.w-\[40rem\] { + width: 40rem; +} +.w-\[51rem\] { + width: 51rem; +} +.w-full { + width: 100%; +} +.max-w-4xl { + max-width: 56rem; +} +.flex-grow { + flex-grow: 1; +} +.cursor-pointer { + cursor: pointer; +} +.flex-col { + flex-direction: column; +} +.items-center { + align-items: center; +} +.justify-end { + justify-content: flex-end; +} +.justify-center { + justify-content: center; +} +.justify-between { + justify-content: space-between; +} +.self-center { + align-self: center; +} +.overflow-y-auto { + overflow-y: auto; +} +.scroll-smooth { + scroll-behavior: smooth; +} +.whitespace-pre-line { + white-space: pre-line; +} +.bg-base-200 { + --tw-bg-opacity: 1; + background-color: hsl(var(--b2, var(--b1)) / var(--tw-bg-opacity)); +} +.bg-base-300 { + --tw-bg-opacity: 1; + background-color: hsl(var(--b3, var(--b2)) / var(--tw-bg-opacity)); +} +.bg-warning { + --tw-bg-opacity: 1; + background-color: hsl(var(--wa) / var(--tw-bg-opacity)); +} +.p-4 { + padding: 1rem; +} +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} +.pt-24 { + padding-top: 6rem; +} +.text-center { + text-align: center; +} +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} +.font-bold { + font-weight: 700; +} +.text-warning-content { + --tw-text-opacity: 1; + color: hsl(var(--wac, var(--nc)) / var(--tw-text-opacity)); +} +.shadow-xl { + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} +@media (min-width: 640px) { + + .sm\:w-auto { + width: auto; + } + + .sm\:w-full { + width: 100%; + } + + .sm\:items-center { + align-items: center; + } + + .sm\:justify-center { + justify-content: center; + } + + .sm\:pt-0 { + padding-top: 0px; + } +} +@media (min-width: 768px) { + + .md\:w-4\/5 { + width: 80%; + } + + .md\:px-0 { + padding-left: 0px; + padding-right: 0px; + } +} +@media (min-width: 1024px) { + + .lg\:w-3\/5 { + width: 60%; + } +} \ No newline at end of file diff --git a/frontend/.netlify/server/chunks/Toaster.svelte_svelte_type_style_lang.js b/frontend/.netlify/server/chunks/Toaster.svelte_svelte_type_style_lang.js new file mode 100644 index 0000000..10604d0 --- /dev/null +++ b/frontend/.netlify/server/chunks/Toaster.svelte_svelte_type_style_lang.js @@ -0,0 +1,242 @@ +import { d as derived, w as writable } from "./index2.js"; +import { k as get_store_value } from "./index3.js"; +function writableDerived(origins, derive, reflect, initial) { + var childDerivedSetter, originValues, blockNextDerive = false; + var reflectOldValues = reflect.length >= 2; + var wrappedDerive = (got, set) => { + childDerivedSetter = set; + if (reflectOldValues) { + originValues = got; + } + if (!blockNextDerive) { + let returned = derive(got, set); + if (derive.length < 2) { + set(returned); + } else { + return returned; + } + } + blockNextDerive = false; + }; + var childDerived = derived(origins, wrappedDerive, initial); + var singleOrigin = !Array.isArray(origins); + function doReflect(reflecting) { + var setWith = reflect(reflecting, originValues); + if (singleOrigin) { + blockNextDerive = true; + origins.set(setWith); + } else { + setWith.forEach((value, i) => { + blockNextDerive = true; + origins[i].set(value); + }); + } + blockNextDerive = false; + } + var tryingSet = false; + function update2(fn) { + var isUpdated, mutatedBySubscriptions, oldValue, newValue; + if (tryingSet) { + newValue = fn(get_store_value(childDerived)); + childDerivedSetter(newValue); + return; + } + var unsubscribe = childDerived.subscribe((value) => { + if (!tryingSet) { + oldValue = value; + } else if (!isUpdated) { + isUpdated = true; + } else { + mutatedBySubscriptions = true; + } + }); + newValue = fn(oldValue); + tryingSet = true; + childDerivedSetter(newValue); + unsubscribe(); + tryingSet = false; + if (mutatedBySubscriptions) { + newValue = get_store_value(childDerived); + } + if (isUpdated) { + doReflect(newValue); + } + } + return { + subscribe: childDerived.subscribe, + set(value) { + update2(() => value); + }, + update: update2 + }; +} +const TOAST_LIMIT = 20; +const toasts = writable([]); +const pausedAt = writable(null); +const toastTimeouts = /* @__PURE__ */ new Map(); +const addToRemoveQueue = (toastId) => { + if (toastTimeouts.has(toastId)) { + return; + } + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + remove(toastId); + }, 1e3); + toastTimeouts.set(toastId, timeout); +}; +const clearFromRemoveQueue = (toastId) => { + const timeout = toastTimeouts.get(toastId); + if (timeout) { + clearTimeout(timeout); + } +}; +function update(toast2) { + if (toast2.id) { + clearFromRemoveQueue(toast2.id); + } + toasts.update(($toasts) => $toasts.map((t) => t.id === toast2.id ? { ...t, ...toast2 } : t)); +} +function add(toast2) { + toasts.update(($toasts) => [toast2, ...$toasts].slice(0, TOAST_LIMIT)); +} +function upsert(toast2) { + if (get_store_value(toasts).find((t) => t.id === toast2.id)) { + update(toast2); + } else { + add(toast2); + } +} +function dismiss(toastId) { + toasts.update(($toasts) => { + if (toastId) { + addToRemoveQueue(toastId); + } else { + $toasts.forEach((toast2) => { + addToRemoveQueue(toast2.id); + }); + } + return $toasts.map((t) => t.id === toastId || toastId === void 0 ? { ...t, visible: false } : t); + }); +} +function remove(toastId) { + toasts.update(($toasts) => { + if (toastId === void 0) { + return []; + } + return $toasts.filter((t) => t.id !== toastId); + }); +} +function startPause(time) { + pausedAt.set(time); +} +function endPause(time) { + let diff; + pausedAt.update(($pausedAt) => { + diff = time - ($pausedAt || 0); + return null; + }); + toasts.update(($toasts) => $toasts.map((t) => ({ + ...t, + pauseDuration: t.pauseDuration + diff + }))); +} +const defaultTimeouts = { + blank: 4e3, + error: 4e3, + success: 2e3, + loading: Infinity, + custom: 4e3 +}; +function useToasterStore(toastOptions = {}) { + const mergedToasts = writableDerived(toasts, ($toasts) => $toasts.map((t) => ({ + ...toastOptions, + ...toastOptions[t.type], + ...t, + duration: t.duration || toastOptions[t.type]?.duration || toastOptions?.duration || defaultTimeouts[t.type], + style: [toastOptions.style, toastOptions[t.type]?.style, t.style].join(";") + })), ($toasts) => $toasts); + return { + toasts: mergedToasts, + pausedAt + }; +} +const isFunction = (valOrFunction) => typeof valOrFunction === "function"; +const resolveValue = (valOrFunction, arg) => isFunction(valOrFunction) ? valOrFunction(arg) : valOrFunction; +const genId = (() => { + let count = 0; + return () => { + count += 1; + return count.toString(); + }; +})(); +const prefersReducedMotion = (() => { + let shouldReduceMotion; + return () => { + if (shouldReduceMotion === void 0 && typeof window !== "undefined") { + const mediaQuery = matchMedia("(prefers-reduced-motion: reduce)"); + shouldReduceMotion = !mediaQuery || mediaQuery.matches; + } + return shouldReduceMotion; + }; +})(); +const createToast = (message, type = "blank", opts) => ({ + createdAt: Date.now(), + visible: true, + type, + ariaProps: { + role: "status", + "aria-live": "polite" + }, + message, + pauseDuration: 0, + ...opts, + id: opts?.id || genId() +}); +const createHandler = (type) => (message, options) => { + const toast2 = createToast(message, type, options); + upsert(toast2); + return toast2.id; +}; +const toast = (message, opts) => createHandler("blank")(message, opts); +toast.error = createHandler("error"); +toast.success = createHandler("success"); +toast.loading = createHandler("loading"); +toast.custom = createHandler("custom"); +toast.dismiss = (toastId) => { + dismiss(toastId); +}; +toast.remove = (toastId) => remove(toastId); +toast.promise = (promise, msgs, opts) => { + const id = toast.loading(msgs.loading, { ...opts, ...opts?.loading }); + promise.then((p) => { + toast.success(resolveValue(msgs.success, p), { + id, + ...opts, + ...opts?.success + }); + return p; + }).catch((e) => { + toast.error(resolveValue(msgs.error, e), { + id, + ...opts, + ...opts?.error + }); + }); + return promise; +}; +const CheckmarkIcon_svelte_svelte_type_style_lang = ""; +const ErrorIcon_svelte_svelte_type_style_lang = ""; +const LoaderIcon_svelte_svelte_type_style_lang = ""; +const ToastIcon_svelte_svelte_type_style_lang = ""; +const ToastMessage_svelte_svelte_type_style_lang = ""; +const ToastBar_svelte_svelte_type_style_lang = ""; +const ToastWrapper_svelte_svelte_type_style_lang = ""; +const Toaster_svelte_svelte_type_style_lang = ""; +export { + update as a, + endPause as e, + prefersReducedMotion as p, + startPause as s, + toast as t, + useToasterStore as u +}; diff --git a/frontend/.netlify/server/chunks/index.js b/frontend/.netlify/server/chunks/index.js new file mode 100644 index 0000000..c63b566 --- /dev/null +++ b/frontend/.netlify/server/chunks/index.js @@ -0,0 +1,78 @@ +let HttpError = class HttpError2 { + /** + * @param {number} status + * @param {{message: string} extends App.Error ? (App.Error | string | undefined) : App.Error} body + */ + constructor(status, body) { + this.status = status; + if (typeof body === "string") { + this.body = { message: body }; + } else if (body) { + this.body = body; + } else { + this.body = { message: `Error: ${status}` }; + } + } + toString() { + return JSON.stringify(this.body); + } +}; +let Redirect = class Redirect2 { + /** + * @param {300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308} status + * @param {string} location + */ + constructor(status, location) { + this.status = status; + this.location = location; + } +}; +let ActionFailure = class ActionFailure2 { + /** + * @param {number} status + * @param {T} [data] + */ + constructor(status, data) { + this.status = status; + this.data = data; + } +}; +function error(status, message) { + if (isNaN(status) || status < 400 || status > 599) { + throw new Error(`HTTP error status codes must be between 400 and 599 — ${status} is invalid`); + } + return new HttpError(status, message); +} +function json(data, init) { + const body = JSON.stringify(data); + const headers = new Headers(init?.headers); + if (!headers.has("content-length")) { + headers.set("content-length", encoder.encode(body).byteLength.toString()); + } + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return new Response(body, { + ...init, + headers + }); +} +const encoder = new TextEncoder(); +function text(body, init) { + const headers = new Headers(init?.headers); + if (!headers.has("content-length")) { + headers.set("content-length", encoder.encode(body).byteLength.toString()); + } + return new Response(body, { + ...init, + headers + }); +} +export { + ActionFailure as A, + HttpError as H, + Redirect as R, + error as e, + json as j, + text as t +}; diff --git a/frontend/.netlify/server/chunks/index2.js b/frontend/.netlify/server/chunks/index2.js new file mode 100644 index 0000000..5663a88 --- /dev/null +++ b/frontend/.netlify/server/chunks/index2.js @@ -0,0 +1,92 @@ +import { n as noop, l as safe_not_equal, h as subscribe, r as run_all, p as is_function } from "./index3.js"; +const subscriber_queue = []; +function readable(value, start) { + return { + subscribe: writable(value, start).subscribe + }; +} +function writable(value, start = noop) { + let stop; + const subscribers = /* @__PURE__ */ new Set(); + function set(new_value) { + if (safe_not_equal(value, new_value)) { + value = new_value; + if (stop) { + const run_queue = !subscriber_queue.length; + for (const subscriber of subscribers) { + subscriber[1](); + subscriber_queue.push(subscriber, value); + } + if (run_queue) { + for (let i = 0; i < subscriber_queue.length; i += 2) { + subscriber_queue[i][0](subscriber_queue[i + 1]); + } + subscriber_queue.length = 0; + } + } + } + } + function update(fn) { + set(fn(value)); + } + function subscribe2(run, invalidate = noop) { + const subscriber = [run, invalidate]; + subscribers.add(subscriber); + if (subscribers.size === 1) { + stop = start(set) || noop; + } + run(value); + return () => { + subscribers.delete(subscriber); + if (subscribers.size === 0 && stop) { + stop(); + stop = null; + } + }; + } + return { set, update, subscribe: subscribe2 }; +} +function derived(stores, fn, initial_value) { + const single = !Array.isArray(stores); + const stores_array = single ? [stores] : stores; + const auto = fn.length < 2; + return readable(initial_value, (set) => { + let started = false; + const values = []; + let pending = 0; + let cleanup = noop; + const sync = () => { + if (pending) { + return; + } + cleanup(); + const result = fn(single ? values[0] : values, set); + if (auto) { + set(result); + } else { + cleanup = is_function(result) ? result : noop; + } + }; + const unsubscribers = stores_array.map((store, i) => subscribe(store, (value) => { + values[i] = value; + pending &= ~(1 << i); + if (started) { + sync(); + } + }, () => { + pending |= 1 << i; + })); + started = true; + sync(); + return function stop() { + run_all(unsubscribers); + cleanup(); + started = false; + }; + }); +} +export { + derived as d, + readable as r, + writable as w +}; diff --git a/frontend/.netlify/server/chunks/index3.js b/frontend/.netlify/server/chunks/index3.js new file mode 100644 index 0000000..b120979 --- /dev/null +++ b/frontend/.netlify/server/chunks/index3.js @@ -0,0 +1,250 @@ +function noop() { +} +function run(fn) { + return fn(); +} +function blank_object() { + return /* @__PURE__ */ Object.create(null); +} +function run_all(fns) { + fns.forEach(run); +} +function is_function(thing) { + return typeof thing === "function"; +} +function safe_not_equal(a, b) { + return a != a ? b == b : a !== b || (a && typeof a === "object" || typeof a === "function"); +} +function subscribe(store, ...callbacks) { + if (store == null) { + return noop; + } + const unsub = store.subscribe(...callbacks); + return unsub.unsubscribe ? () => unsub.unsubscribe() : unsub; +} +function get_store_value(store) { + let value; + subscribe(store, (_) => value = _)(); + return value; +} +let current_component; +function set_current_component(component) { + current_component = component; +} +function get_current_component() { + if (!current_component) + throw new Error("Function called outside component initialization"); + return current_component; +} +function onDestroy(fn) { + get_current_component().$$.on_destroy.push(fn); +} +function setContext(key, context) { + get_current_component().$$.context.set(key, context); + return context; +} +function getContext(key) { + return get_current_component().$$.context.get(key); +} +const _boolean_attributes = [ + "allowfullscreen", + "allowpaymentrequest", + "async", + "autofocus", + "autoplay", + "checked", + "controls", + "default", + "defer", + "disabled", + "formnovalidate", + "hidden", + "inert", + "ismap", + "itemscope", + "loop", + "multiple", + "muted", + "nomodule", + "novalidate", + "open", + "playsinline", + "readonly", + "required", + "reversed", + "selected" +]; +const boolean_attributes = /* @__PURE__ */ new Set([..._boolean_attributes]); +const invalid_attribute_name_character = /[\s'">/=\u{FDD0}-\u{FDEF}\u{FFFE}\u{FFFF}\u{1FFFE}\u{1FFFF}\u{2FFFE}\u{2FFFF}\u{3FFFE}\u{3FFFF}\u{4FFFE}\u{4FFFF}\u{5FFFE}\u{5FFFF}\u{6FFFE}\u{6FFFF}\u{7FFFE}\u{7FFFF}\u{8FFFE}\u{8FFFF}\u{9FFFE}\u{9FFFF}\u{AFFFE}\u{AFFFF}\u{BFFFE}\u{BFFFF}\u{CFFFE}\u{CFFFF}\u{DFFFE}\u{DFFFF}\u{EFFFE}\u{EFFFF}\u{FFFFE}\u{FFFFF}\u{10FFFE}\u{10FFFF}]/u; +function spread(args, attrs_to_add) { + const attributes = Object.assign({}, ...args); + if (attrs_to_add) { + const classes_to_add = attrs_to_add.classes; + const styles_to_add = attrs_to_add.styles; + if (classes_to_add) { + if (attributes.class == null) { + attributes.class = classes_to_add; + } else { + attributes.class += " " + classes_to_add; + } + } + if (styles_to_add) { + if (attributes.style == null) { + attributes.style = style_object_to_string(styles_to_add); + } else { + attributes.style = style_object_to_string(merge_ssr_styles(attributes.style, styles_to_add)); + } + } + } + let str = ""; + Object.keys(attributes).forEach((name) => { + if (invalid_attribute_name_character.test(name)) + return; + const value = attributes[name]; + if (value === true) + str += " " + name; + else if (boolean_attributes.has(name.toLowerCase())) { + if (value) + str += " " + name; + } else if (value != null) { + str += ` ${name}="${value}"`; + } + }); + return str; +} +function merge_ssr_styles(style_attribute, style_directive) { + const style_object = {}; + for (const individual_style of style_attribute.split(";")) { + const colon_index = individual_style.indexOf(":"); + const name = individual_style.slice(0, colon_index).trim(); + const value = individual_style.slice(colon_index + 1).trim(); + if (!name) + continue; + style_object[name] = value; + } + for (const name in style_directive) { + const value = style_directive[name]; + if (value) { + style_object[name] = value; + } else { + delete style_object[name]; + } + } + return style_object; +} +const ATTR_REGEX = /[&"]/g; +const CONTENT_REGEX = /[&<]/g; +function escape(value, is_attr = false) { + const str = String(value); + const pattern = is_attr ? ATTR_REGEX : CONTENT_REGEX; + pattern.lastIndex = 0; + let escaped = ""; + let last = 0; + while (pattern.test(str)) { + const i = pattern.lastIndex - 1; + const ch = str[i]; + escaped += str.substring(last, i) + (ch === "&" ? "&" : ch === '"' ? """ : "<"); + last = i + 1; + } + return escaped + str.substring(last); +} +function escape_attribute_value(value) { + const should_escape = typeof value === "string" || value && typeof value === "object"; + return should_escape ? escape(value, true) : value; +} +function escape_object(obj) { + const result = {}; + for (const key in obj) { + result[key] = escape_attribute_value(obj[key]); + } + return result; +} +function each(items, fn) { + let str = ""; + for (let i = 0; i < items.length; i += 1) { + str += fn(items[i], i); + } + return str; +} +const missing_component = { + $$render: () => "" +}; +function validate_component(component, name) { + if (!component || !component.$$render) { + if (name === "svelte:component") + name += " this={...}"; + throw new Error(`<${name}> is not a valid SSR component. You may need to review your build config to ensure that dependencies are compiled, rather than imported as pre-compiled modules. Otherwise you may need to fix a <${name}>.`); + } + return component; +} +let on_destroy; +function create_ssr_component(fn) { + function $$render(result, props, bindings, slots, context) { + const parent_component = current_component; + const $$ = { + on_destroy, + context: new Map(context || (parent_component ? parent_component.$$.context : [])), + // these will be immediately discarded + on_mount: [], + before_update: [], + after_update: [], + callbacks: blank_object() + }; + set_current_component({ $$ }); + const html = fn(result, props, bindings, slots); + set_current_component(parent_component); + return html; + } + return { + render: (props = {}, { $$slots = {}, context = /* @__PURE__ */ new Map() } = {}) => { + on_destroy = []; + const result = { title: "", head: "", css: /* @__PURE__ */ new Set() }; + const html = $$render(result, props, {}, $$slots, context); + run_all(on_destroy); + return { + html, + css: { + code: Array.from(result.css).map((css) => css.code).join("\n"), + map: null + // TODO + }, + head: result.title + result.head + }; + }, + $$render + }; +} +function add_attribute(name, value, boolean) { + if (value == null || boolean && !value) + return ""; + const assignment = boolean && value === true ? "" : `="${escape(value, true)}"`; + return ` ${name}${assignment}`; +} +function style_object_to_string(style_object) { + return Object.keys(style_object).filter((key) => style_object[key]).map((key) => `${key}: ${escape_attribute_value(style_object[key])};`).join(" "); +} +function add_styles(style_object) { + const styles = style_object_to_string(style_object); + return styles ? ` style="${styles}"` : ""; +} +export { + add_styles as a, + spread as b, + create_ssr_component as c, + escape_object as d, + escape as e, + merge_ssr_styles as f, + add_attribute as g, + subscribe as h, + each as i, + getContext as j, + get_store_value as k, + safe_not_equal as l, + missing_component as m, + noop as n, + onDestroy as o, + is_function as p, + run_all as r, + setContext as s, + validate_component as v +}; diff --git a/frontend/.netlify/server/chunks/internal.js b/frontend/.netlify/server/chunks/internal.js new file mode 100644 index 0000000..89831b1 --- /dev/null +++ b/frontend/.netlify/server/chunks/internal.js @@ -0,0 +1,179 @@ +import { c as create_ssr_component, s as setContext, v as validate_component, m as missing_component } from "./index3.js"; +import "./shared-server.js"; +let base = ""; +let assets = base; +const initial = { base, assets }; +function reset() { + base = initial.base; + assets = initial.assets; +} +function set_assets(path) { + assets = initial.assets = path; +} +function afterUpdate() { +} +function set_building() { +} +const Root = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let { stores } = $$props; + let { page } = $$props; + let { constructors } = $$props; + let { components = [] } = $$props; + let { form } = $$props; + let { data_0 = null } = $$props; + let { data_1 = null } = $$props; + { + setContext("__svelte__", stores); + } + afterUpdate(stores.page.notify); + if ($$props.stores === void 0 && $$bindings.stores && stores !== void 0) + $$bindings.stores(stores); + if ($$props.page === void 0 && $$bindings.page && page !== void 0) + $$bindings.page(page); + if ($$props.constructors === void 0 && $$bindings.constructors && constructors !== void 0) + $$bindings.constructors(constructors); + if ($$props.components === void 0 && $$bindings.components && components !== void 0) + $$bindings.components(components); + if ($$props.form === void 0 && $$bindings.form && form !== void 0) + $$bindings.form(form); + if ($$props.data_0 === void 0 && $$bindings.data_0 && data_0 !== void 0) + $$bindings.data_0(data_0); + if ($$props.data_1 === void 0 && $$bindings.data_1 && data_1 !== void 0) + $$bindings.data_1(data_1); + let $$settled; + let $$rendered; + do { + $$settled = true; + { + stores.page.set(page); + } + $$rendered = ` + + +${constructors[1] ? `${validate_component(constructors[0] || missing_component, "svelte:component").$$render( + $$result, + { data: data_0, this: components[0] }, + { + this: ($$value) => { + components[0] = $$value; + $$settled = false; + } + }, + { + default: () => { + return `${validate_component(constructors[1] || missing_component, "svelte:component").$$render( + $$result, + { data: data_1, form, this: components[1] }, + { + this: ($$value) => { + components[1] = $$value; + $$settled = false; + } + }, + {} + )}`; + } + } + )}` : `${validate_component(constructors[0] || missing_component, "svelte:component").$$render( + $$result, + { data: data_0, form, this: components[0] }, + { + this: ($$value) => { + components[0] = $$value; + $$settled = false; + } + }, + {} + )}`} + +${``}`; + } while (!$$settled); + return $$rendered; +}); +const options = { + app_template_contains_nonce: false, + csp: { "mode": "auto", "directives": { "upgrade-insecure-requests": false, "block-all-mixed-content": false }, "reportOnly": { "upgrade-insecure-requests": false, "block-all-mixed-content": false } }, + csrf_check_origin: true, + embedded: false, + env_public_prefix: "PUBLIC_", + hooks: null, + // added lazily, via `get_hooks` + preload_strategy: "modulepreload", + root: Root, + service_worker: false, + templates: { + app: ({ head, body, assets: assets2, nonce, env }) => '\n\n \n \n \n \n ' + head + '\n \n \n
' + body + "
\n \n\n", + error: ({ status, message }) => '\n\n \n \n ' + message + ` + + + + +
+ ` + status + '\n
\n

' + message + "

\n
\n
\n \n\n" + }, + version_hash: "1vmmy0u" +}; +function get_hooks() { + return {}; +} +export { + assets as a, + base as b, + set_building as c, + get_hooks as g, + options as o, + reset as r, + set_assets as s +}; diff --git a/frontend/.netlify/server/chunks/shared-server.js b/frontend/.netlify/server/chunks/shared-server.js new file mode 100644 index 0000000..13941ee --- /dev/null +++ b/frontend/.netlify/server/chunks/shared-server.js @@ -0,0 +1,11 @@ +let public_env = {}; +function set_private_env(environment) { +} +function set_public_env(environment) { + public_env = environment; +} +export { + set_private_env as a, + public_env as p, + set_public_env as s +}; diff --git a/frontend/.netlify/server/entries/fallbacks/error.svelte.js b/frontend/.netlify/server/entries/fallbacks/error.svelte.js new file mode 100644 index 0000000..aa7ae7c --- /dev/null +++ b/frontend/.netlify/server/entries/fallbacks/error.svelte.js @@ -0,0 +1,30 @@ +import { j as getContext, c as create_ssr_component, h as subscribe, e as escape } from "../../chunks/index3.js"; +const getStores = () => { + const stores = getContext("__svelte__"); + return { + page: { + subscribe: stores.page.subscribe + }, + navigating: { + subscribe: stores.navigating.subscribe + }, + updated: stores.updated + }; +}; +const page = { + /** @param {(value: any) => void} fn */ + subscribe(fn) { + const store = getStores().page; + return store.subscribe(fn); + } +}; +const Error$1 = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let $page, $$unsubscribe_page; + $$unsubscribe_page = subscribe(page, (value) => $page = value); + $$unsubscribe_page(); + return `

${escape($page.status)}

+

${escape($page.error?.message)}

`; +}); +export { + Error$1 as default +}; diff --git a/frontend/.netlify/server/entries/pages/_layout.svelte.js b/frontend/.netlify/server/entries/pages/_layout.svelte.js new file mode 100644 index 0000000..758258e --- /dev/null +++ b/frontend/.netlify/server/entries/pages/_layout.svelte.js @@ -0,0 +1,289 @@ +import { o as onDestroy, c as create_ssr_component, a as add_styles, e as escape, v as validate_component, m as missing_component, b as spread, d as escape_object, f as merge_ssr_styles, g as add_attribute, h as subscribe, i as each } from "../../chunks/index3.js"; +import { u as useToasterStore, t as toast, s as startPause, e as endPause, a as update, p as prefersReducedMotion } from "../../chunks/Toaster.svelte_svelte_type_style_lang.js"; +const app = ""; +function calculateOffset(toast2, $toasts, opts) { + const { reverseOrder, gutter = 8, defaultPosition } = opts || {}; + const relevantToasts = $toasts.filter((t) => (t.position || defaultPosition) === (toast2.position || defaultPosition) && t.height); + const toastIndex = relevantToasts.findIndex((t) => t.id === toast2.id); + const toastsBefore = relevantToasts.filter((toast3, i) => i < toastIndex && toast3.visible).length; + const offset = relevantToasts.filter((t) => t.visible).slice(...reverseOrder ? [toastsBefore + 1] : [0, toastsBefore]).reduce((acc, t) => acc + (t.height || 0) + gutter, 0); + return offset; +} +const handlers = { + startPause() { + startPause(Date.now()); + }, + endPause() { + endPause(Date.now()); + }, + updateHeight: (toastId, height) => { + update({ id: toastId, height }); + }, + calculateOffset +}; +function useToaster(toastOptions) { + const { toasts, pausedAt } = useToasterStore(toastOptions); + const timeouts = /* @__PURE__ */ new Map(); + let _pausedAt; + const unsubscribes = [ + pausedAt.subscribe(($pausedAt) => { + if ($pausedAt) { + for (const [, timeoutId] of timeouts) { + clearTimeout(timeoutId); + } + timeouts.clear(); + } + _pausedAt = $pausedAt; + }), + toasts.subscribe(($toasts) => { + if (_pausedAt) { + return; + } + const now = Date.now(); + for (const t of $toasts) { + if (timeouts.has(t.id)) { + continue; + } + if (t.duration === Infinity) { + continue; + } + const durationLeft = (t.duration || 0) + t.pauseDuration - (now - t.createdAt); + if (durationLeft < 0) { + if (t.visible) { + toast.dismiss(t.id); + } + return null; + } + timeouts.set(t.id, setTimeout(() => toast.dismiss(t.id), durationLeft)); + } + }) + ]; + onDestroy(() => { + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + }); + return { toasts, handlers }; +} +const css$7 = { + code: "div.svelte-lzwg39{width:20px;opacity:0;height:20px;border-radius:10px;background:var(--primary, #61d345);position:relative;transform:rotate(45deg);animation:svelte-lzwg39-circleAnimation 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;animation-delay:100ms}div.svelte-lzwg39::after{content:'';box-sizing:border-box;animation:svelte-lzwg39-checkmarkAnimation 0.2s ease-out forwards;opacity:0;animation-delay:200ms;position:absolute;border-right:2px solid;border-bottom:2px solid;border-color:var(--secondary, #fff);bottom:6px;left:6px;height:10px;width:6px}@keyframes svelte-lzwg39-circleAnimation{from{transform:scale(0) rotate(45deg);opacity:0}to{transform:scale(1) rotate(45deg);opacity:1}}@keyframes svelte-lzwg39-checkmarkAnimation{0%{height:0;width:0;opacity:0}40%{height:0;width:6px;opacity:1}100%{opacity:1;height:10px}}", + map: null +}; +const CheckmarkIcon = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let { primary = "#61d345" } = $$props; + let { secondary = "#fff" } = $$props; + if ($$props.primary === void 0 && $$bindings.primary && primary !== void 0) + $$bindings.primary(primary); + if ($$props.secondary === void 0 && $$bindings.secondary && secondary !== void 0) + $$bindings.secondary(secondary); + $$result.css.add(css$7); + return ` + + +
`; +}); +const css$6 = { + code: "div.svelte-10jnndo{width:20px;opacity:0;height:20px;border-radius:10px;background:var(--primary, #ff4b4b);position:relative;transform:rotate(45deg);animation:svelte-10jnndo-circleAnimation 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;animation-delay:100ms}div.svelte-10jnndo::after,div.svelte-10jnndo::before{content:'';animation:svelte-10jnndo-firstLineAnimation 0.15s ease-out forwards;animation-delay:150ms;position:absolute;border-radius:3px;opacity:0;background:var(--secondary, #fff);bottom:9px;left:4px;height:2px;width:12px}div.svelte-10jnndo:before{animation:svelte-10jnndo-secondLineAnimation 0.15s ease-out forwards;animation-delay:180ms;transform:rotate(90deg)}@keyframes svelte-10jnndo-circleAnimation{from{transform:scale(0) rotate(45deg);opacity:0}to{transform:scale(1) rotate(45deg);opacity:1}}@keyframes svelte-10jnndo-firstLineAnimation{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}@keyframes svelte-10jnndo-secondLineAnimation{from{transform:scale(0) rotate(90deg);opacity:0}to{transform:scale(1) rotate(90deg);opacity:1}}", + map: null +}; +const ErrorIcon = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let { primary = "#ff4b4b" } = $$props; + let { secondary = "#fff" } = $$props; + if ($$props.primary === void 0 && $$bindings.primary && primary !== void 0) + $$bindings.primary(primary); + if ($$props.secondary === void 0 && $$bindings.secondary && secondary !== void 0) + $$bindings.secondary(secondary); + $$result.css.add(css$6); + return ` + + +
`; +}); +const css$5 = { + code: "div.svelte-bj4lu8{width:12px;height:12px;box-sizing:border-box;border:2px solid;border-radius:100%;border-color:var(--secondary, #e0e0e0);border-right-color:var(--primary, #616161);animation:svelte-bj4lu8-rotate 1s linear infinite}@keyframes svelte-bj4lu8-rotate{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}", + map: null +}; +const LoaderIcon = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let { primary = "#616161" } = $$props; + let { secondary = "#e0e0e0" } = $$props; + if ($$props.primary === void 0 && $$bindings.primary && primary !== void 0) + $$bindings.primary(primary); + if ($$props.secondary === void 0 && $$bindings.secondary && secondary !== void 0) + $$bindings.secondary(secondary); + $$result.css.add(css$5); + return ` + + +
`; +}); +const css$4 = { + code: ".indicator.svelte-1c92bpz{position:relative;display:flex;justify-content:center;align-items:center;min-width:20px;min-height:20px}.status.svelte-1c92bpz{position:absolute}.animated.svelte-1c92bpz{position:relative;transform:scale(0.6);opacity:0.4;min-width:20px;animation:svelte-1c92bpz-enter 0.3s 0.12s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards}@keyframes svelte-1c92bpz-enter{from{transform:scale(0.6);opacity:0.4}to{transform:scale(1);opacity:1}}", + map: null +}; +const ToastIcon = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let type; + let icon; + let iconTheme; + let { toast: toast2 } = $$props; + if ($$props.toast === void 0 && $$bindings.toast && toast2 !== void 0) + $$bindings.toast(toast2); + $$result.css.add(css$4); + ({ type, icon, iconTheme } = toast2); + return `${typeof icon === "string" ? `
${escape(icon)}
` : `${typeof icon !== "undefined" ? `${validate_component(icon || missing_component, "svelte:component").$$render($$result, {}, {}, {})}` : `${type !== "blank" ? `
${validate_component(LoaderIcon, "LoaderIcon").$$render($$result, Object.assign({}, iconTheme), {}, {})} + ${type !== "loading" ? `
${type === "error" ? `${validate_component(ErrorIcon, "ErrorIcon").$$render($$result, Object.assign({}, iconTheme), {}, {})}` : `${validate_component(CheckmarkIcon, "CheckmarkIcon").$$render($$result, Object.assign({}, iconTheme), {}, {})}`}
` : ``}
` : ``}`}`}`; +}); +const css$3 = { + code: ".message.svelte-o805t1{display:flex;justify-content:center;margin:4px 10px;color:inherit;flex:1 1 auto;white-space:pre-line}", + map: null +}; +const ToastMessage = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let { toast: toast2 } = $$props; + if ($$props.toast === void 0 && $$bindings.toast && toast2 !== void 0) + $$bindings.toast(toast2); + $$result.css.add(css$3); + return `${typeof toast2.message === "string" ? `${escape(toast2.message)}` : `${validate_component(toast2.message || missing_component, "svelte:component").$$render($$result, { toast: toast2 }, {}, {})}`} +`; +}); +const css$2 = { + code: "@keyframes svelte-15lyehg-enterAnimation{0%{transform:translate3d(0, calc(var(--factor) * -200%), 0) scale(0.6);opacity:0.5}100%{transform:translate3d(0, 0, 0) scale(1);opacity:1}}@keyframes svelte-15lyehg-exitAnimation{0%{transform:translate3d(0, 0, -1px) scale(1);opacity:1}100%{transform:translate3d(0, calc(var(--factor) * -150%), -1px) scale(0.6);opacity:0}}@keyframes svelte-15lyehg-fadeInAnimation{0%{opacity:0}100%{opacity:1}}@keyframes svelte-15lyehg-fadeOutAnimation{0%{opacity:1}100%{opacity:0}}.base.svelte-15lyehg{display:flex;align-items:center;background:#fff;color:#363636;line-height:1.3;will-change:transform;box-shadow:0 3px 10px rgba(0, 0, 0, 0.1), 0 3px 3px rgba(0, 0, 0, 0.05);max-width:350px;pointer-events:auto;padding:8px 10px;border-radius:8px}.transparent.svelte-15lyehg{opacity:0}.enter.svelte-15lyehg{animation:svelte-15lyehg-enterAnimation 0.35s cubic-bezier(0.21, 1.02, 0.73, 1) forwards}.exit.svelte-15lyehg{animation:svelte-15lyehg-exitAnimation 0.4s cubic-bezier(0.06, 0.71, 0.55, 1) forwards}.fadeIn.svelte-15lyehg{animation:svelte-15lyehg-fadeInAnimation 0.35s cubic-bezier(0.21, 1.02, 0.73, 1) forwards}.fadeOut.svelte-15lyehg{animation:svelte-15lyehg-fadeOutAnimation 0.4s cubic-bezier(0.06, 0.71, 0.55, 1) forwards}", + map: null +}; +const ToastBar = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let { toast: toast2 } = $$props; + let { position = void 0 } = $$props; + let { style = "" } = $$props; + let { Component = void 0 } = $$props; + let factor; + let animation; + if ($$props.toast === void 0 && $$bindings.toast && toast2 !== void 0) + $$bindings.toast(toast2); + if ($$props.position === void 0 && $$bindings.position && position !== void 0) + $$bindings.position(position); + if ($$props.style === void 0 && $$bindings.style && style !== void 0) + $$bindings.style(style); + if ($$props.Component === void 0 && $$bindings.Component && Component !== void 0) + $$bindings.Component(Component); + $$result.css.add(css$2); + { + { + const top = (toast2.position || position || "top-center").includes("top"); + factor = top ? 1 : -1; + const [enter, exit] = prefersReducedMotion() ? ["fadeIn", "fadeOut"] : ["enter", "exit"]; + animation = toast2.visible ? enter : exit; + } + } + return `
${Component ? `${validate_component(Component || missing_component, "svelte:component").$$render($$result, {}, {}, { + message: () => { + return `${validate_component(ToastMessage, "ToastMessage").$$render($$result, { toast: toast2, slot: "message" }, {}, {})}`; + }, + icon: () => { + return `${validate_component(ToastIcon, "ToastIcon").$$render($$result, { toast: toast2, slot: "icon" }, {}, {})}`; + } + })}` : `${slots.default ? slots.default({ ToastIcon, ToastMessage, toast: toast2 }) : ` + ${validate_component(ToastIcon, "ToastIcon").$$render($$result, { toast: toast2 }, {}, {})} + ${validate_component(ToastMessage, "ToastMessage").$$render($$result, { toast: toast2 }, {}, {})} + `}`} +
`; +}); +const css$1 = { + code: ".wrapper.svelte-1pakgpd{left:0;right:0;display:flex;position:absolute;transform:translateY(calc(var(--offset, 16px) * var(--factor) * 1px))}.transition.svelte-1pakgpd{transition:all 230ms cubic-bezier(0.21, 1.02, 0.73, 1)}.active.svelte-1pakgpd{z-index:9999}.active.svelte-1pakgpd>*{pointer-events:auto}", + map: null +}; +const ToastWrapper = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let top; + let bottom; + let factor; + let justifyContent; + let { toast: toast2 } = $$props; + let { setHeight } = $$props; + let wrapperEl; + if ($$props.toast === void 0 && $$bindings.toast && toast2 !== void 0) + $$bindings.toast(toast2); + if ($$props.setHeight === void 0 && $$bindings.setHeight && setHeight !== void 0) + $$bindings.setHeight(setHeight); + $$result.css.add(css$1); + top = toast2.position?.includes("top") ? 0 : null; + bottom = toast2.position?.includes("bottom") ? 0 : null; + factor = toast2.position?.includes("top") ? 1 : -1; + justifyContent = toast2.position?.includes("center") && "center" || toast2.position?.includes("right") && "flex-end" || null; + return `
${toast2.type === "custom" ? `${validate_component(ToastMessage, "ToastMessage").$$render($$result, { toast: toast2 }, {}, {})}` : `${slots.default ? slots.default({ toast: toast2 }) : ` + ${validate_component(ToastBar, "ToastBar").$$render($$result, { toast: toast2, position: toast2.position }, {}, {})} + `}`} +
`; +}); +const css = { + code: ".toaster.svelte-jyff3d{--default-offset:16px;position:fixed;z-index:9999;top:var(--default-offset);left:var(--default-offset);right:var(--default-offset);bottom:var(--default-offset);pointer-events:none}", + map: null +}; +const Toaster = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let $toasts, $$unsubscribe_toasts; + let { reverseOrder = false } = $$props; + let { position = "top-center" } = $$props; + let { toastOptions = void 0 } = $$props; + let { gutter = 8 } = $$props; + let { containerStyle = void 0 } = $$props; + let { containerClassName = void 0 } = $$props; + const { toasts, handlers: handlers2 } = useToaster(toastOptions); + $$unsubscribe_toasts = subscribe(toasts, (value) => $toasts = value); + let _toasts; + if ($$props.reverseOrder === void 0 && $$bindings.reverseOrder && reverseOrder !== void 0) + $$bindings.reverseOrder(reverseOrder); + if ($$props.position === void 0 && $$bindings.position && position !== void 0) + $$bindings.position(position); + if ($$props.toastOptions === void 0 && $$bindings.toastOptions && toastOptions !== void 0) + $$bindings.toastOptions(toastOptions); + if ($$props.gutter === void 0 && $$bindings.gutter && gutter !== void 0) + $$bindings.gutter(gutter); + if ($$props.containerStyle === void 0 && $$bindings.containerStyle && containerStyle !== void 0) + $$bindings.containerStyle(containerStyle); + if ($$props.containerClassName === void 0 && $$bindings.containerClassName && containerClassName !== void 0) + $$bindings.containerClassName(containerClassName); + $$result.css.add(css); + _toasts = $toasts.map((toast2) => ({ + ...toast2, + position: toast2.position || position, + offset: handlers2.calculateOffset(toast2, $toasts, { + reverseOrder, + gutter, + defaultPosition: position + }) + })); + $$unsubscribe_toasts(); + return `
${each(_toasts, (toast2) => { + return `${validate_component(ToastWrapper, "ToastWrapper").$$render( + $$result, + { + toast: toast2, + setHeight: (height) => handlers2.updateHeight(toast2.id, height) + }, + {}, + {} + )}`; + })} +
`; +}); +const Layout = create_ssr_component(($$result, $$props, $$bindings, slots) => { + return `${validate_component(Toaster, "Toaster").$$render($$result, {}, {}, {})} +
${slots.default ? slots.default({}) : ``}
`; +}); +export { + Layout as default +}; diff --git a/frontend/.netlify/server/entries/pages/_page.svelte.js b/frontend/.netlify/server/entries/pages/_page.svelte.js new file mode 100644 index 0000000..5fe697b --- /dev/null +++ b/frontend/.netlify/server/entries/pages/_page.svelte.js @@ -0,0 +1,39 @@ +import { c as create_ssr_component, e as escape, i as each, g as add_attribute } from "../../chunks/index3.js"; +import "../../chunks/Toaster.svelte_svelte_type_style_lang.js"; +const Page = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let status, rooms; + let { data } = $$props; + let eth_pk = ""; + let room = ""; + let create_new_room = false; + const filled_in = () => { + return !(eth_pk.length > 0 && room.length > 0); + }; + if ($$props.data === void 0 && $$bindings.data && data !== void 0) + $$bindings.data(data); + ({ status, rooms } = data); + return `

Chatr: a Websocket chatroom

+
+

List of active chatroom's +

+
+ ${status && rooms.length < 1 ? `

${escape(status)}

` : ``} + ${rooms ? `${each(rooms, (room2) => { + return ` +
+ `; + })}` : ``} +
+
+
+
+
+
+

Check out Chatr, to view the source code! +

`; +}); +export { + Page as default +}; diff --git a/frontend/.netlify/server/entries/pages/_page.ts.js b/frontend/.netlify/server/entries/pages/_page.ts.js new file mode 100644 index 0000000..80ee122 --- /dev/null +++ b/frontend/.netlify/server/entries/pages/_page.ts.js @@ -0,0 +1,19 @@ +import { p as public_env } from "../../chunks/shared-server.js"; +const load = async ({ fetch }) => { + try { + let url = `${public_env.PUBLIC_API_URL}`; + if (url.endsWith("/")) { + url = url.slice(0, -1); + } + const res = await fetch(`${url}/rooms`); + return await res.json(); + } catch (e) { + return { + status: "API offline (try again in a min)", + rooms: [] + }; + } +}; +export { + load +}; diff --git a/frontend/.netlify/server/entries/pages/chat/_page.svelte.js b/frontend/.netlify/server/entries/pages/chat/_page.svelte.js new file mode 100644 index 0000000..c282505 --- /dev/null +++ b/frontend/.netlify/server/entries/pages/chat/_page.svelte.js @@ -0,0 +1,128 @@ +import { c as create_ssr_component, h as subscribe, o as onDestroy, g as add_attribute, e as escape, i as each } from "../../../chunks/index3.js"; +import { w as writable } from "../../../chunks/index2.js"; +import { p as public_env } from "../../../chunks/shared-server.js"; +import { t as toast } from "../../../chunks/Toaster.svelte_svelte_type_style_lang.js"; +import "../../../chunks/index.js"; +const eth_private_key = writable(""); +const group = writable(""); +const createNewRoom = writable(false); +function guard(name) { + return () => { + throw new Error(`Cannot call ${name}(...) on the server`); + }; +} +const goto = guard("goto"); +const Page = create_ssr_component(($$result, $$props, $$bindings, slots) => { + let $group, $$unsubscribe_group; + let $eth_private_key, $$unsubscribe_eth_private_key; + let $createNewRoom, $$unsubscribe_createNewRoom; + $$unsubscribe_group = subscribe(group, (value) => $group = value); + $$unsubscribe_eth_private_key = subscribe(eth_private_key, (value) => $eth_private_key = value); + $$unsubscribe_createNewRoom = subscribe(createNewRoom, (value) => $createNewRoom = value); + let status = "🔴"; + let statusTip = "Disconnected"; + let message = ""; + let messages = []; + let socket; + let interval; + let delay = 2e3; + let timeout = false; + let currentVotingProposal = null; + let showVotingUI = false; + function connect() { + socket = new WebSocket(`${public_env.PUBLIC_WEBSOCKET_URL}/ws`); + socket.addEventListener("open", () => { + status = "🟢"; + statusTip = "Connected"; + timeout = false; + socket.send(JSON.stringify({ + eth_private_key: $eth_private_key, + group_id: $group, + should_create: $createNewRoom + })); + }); + socket.addEventListener("close", () => { + status = "🔴"; + statusTip = "Disconnected"; + if (timeout == false) { + delay = 2e3; + timeout = true; + } + }); + socket.addEventListener("message", function(event) { + if (event.data == "Username already taken.") { + toast.error(event.data); + goto("/"); + } else { + try { + const data = JSON.parse(event.data); + if (data.type === "voting_proposal") { + currentVotingProposal = data.proposal; + showVotingUI = true; + toast.success("New voting proposal received!"); + } else { + messages = [...messages, event.data]; + } + } catch (e) { + messages = [...messages, event.data]; + } + } + }); + } + onDestroy(() => { + if (socket) { + socket.close(); + } + if (interval) { + clearInterval(interval); + } + timeout = false; + }); + { + { + if (interval || !timeout && interval) { + clearInterval(interval); + } + if (timeout == true) { + interval = setInterval( + () => { + if (delay < 3e4) + delay = delay * 2; + console.log("reconnecting in:", delay); + connect(); + }, + delay + ); + } + } + } + $$unsubscribe_group(); + $$unsubscribe_eth_private_key(); + $$unsubscribe_createNewRoom(); + return `

MLS Chat ${escape(status)}

+
+ + +
+ + + ${showVotingUI && currentVotingProposal ? `

Voting Proposal

+

Group Name: ${escape(currentVotingProposal.group_name)}

+

Proposal ID: ${escape(currentVotingProposal.proposal_id)}

+

Proposal Payload:

+
${escape(currentVotingProposal.payload)}
+
+ +
` : ``} + +
${each(messages, (msg) => { + return `
${escape(msg)}
`; + })}
+ +
+
`; +}); +export { + Page as default +}; diff --git a/frontend/.netlify/server/index.js b/frontend/.netlify/server/index.js new file mode 100644 index 0000000..7607bf7 --- /dev/null +++ b/frontend/.netlify/server/index.js @@ -0,0 +1,2674 @@ +import { b as base, a as assets, r as reset, o as options, g as get_hooks } from "./chunks/internal.js"; +import { H as HttpError, j as json, t as text, R as Redirect, e as error, A as ActionFailure } from "./chunks/index.js"; +import * as devalue from "devalue"; +import { w as writable, r as readable } from "./chunks/index2.js"; +import { p as public_env, s as set_public_env } from "./chunks/shared-server.js"; +import { parse, serialize } from "cookie"; +import * as set_cookie_parser from "set-cookie-parser"; +const DEV = false; +function negotiate(accept, types) { + const parts = []; + accept.split(",").forEach((str, i) => { + const match = /([^/]+)\/([^;]+)(?:;q=([0-9.]+))?/.exec(str); + if (match) { + const [, type, subtype, q = "1"] = match; + parts.push({ type, subtype, q: +q, i }); + } + }); + parts.sort((a, b) => { + if (a.q !== b.q) { + return b.q - a.q; + } + if (a.subtype === "*" !== (b.subtype === "*")) { + return a.subtype === "*" ? 1 : -1; + } + if (a.type === "*" !== (b.type === "*")) { + return a.type === "*" ? 1 : -1; + } + return a.i - b.i; + }); + let accepted; + let min_priority = Infinity; + for (const mimetype of types) { + const [type, subtype] = mimetype.split("/"); + const priority = parts.findIndex( + (part) => (part.type === type || part.type === "*") && (part.subtype === subtype || part.subtype === "*") + ); + if (priority !== -1 && priority < min_priority) { + accepted = mimetype; + min_priority = priority; + } + } + return accepted; +} +function is_content_type(request, ...types) { + const type = request.headers.get("content-type")?.split(";", 1)[0].trim() ?? ""; + return types.includes(type.toLowerCase()); +} +function is_form_content_type(request) { + return is_content_type( + request, + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain" + ); +} +function coalesce_to_error(err) { + return err instanceof Error || err && /** @type {any} */ + err.name && /** @type {any} */ + err.message ? ( + /** @type {Error} */ + err + ) : new Error(JSON.stringify(err)); +} +function normalize_error(error2) { + return ( + /** @type {Redirect | HttpError | Error} */ + error2 + ); +} +function method_not_allowed(mod, method) { + return text(`${method} method not allowed`, { + status: 405, + headers: { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + // "The server must generate an Allow header field in a 405 status code response" + allow: allowed_methods(mod).join(", ") + } + }); +} +function allowed_methods(mod) { + const allowed = []; + for (const method in ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) { + if (method in mod) + allowed.push(method); + } + if (mod.GET || mod.HEAD) + allowed.push("HEAD"); + return allowed; +} +function static_error_page(options2, status, message) { + let page = options2.templates.error({ status, message }); + return text(page, { + headers: { "content-type": "text/html; charset=utf-8" }, + status + }); +} +async function handle_fatal_error(event, options2, error2) { + error2 = error2 instanceof HttpError ? error2 : coalesce_to_error(error2); + const status = error2 instanceof HttpError ? error2.status : 500; + const body = await handle_error_and_jsonify(event, options2, error2); + const type = negotiate(event.request.headers.get("accept") || "text/html", [ + "application/json", + "text/html" + ]); + if (event.isDataRequest || type === "application/json") { + return json(body, { + status + }); + } + return static_error_page(options2, status, body.message); +} +async function handle_error_and_jsonify(event, options2, error2) { + if (error2 instanceof HttpError) { + return error2.body; + } else { + return await options2.hooks.handleError({ error: error2, event }) ?? { + message: event.route.id != null ? "Internal Error" : "Not Found" + }; + } +} +function redirect_response(status, location) { + const response = new Response(void 0, { + status, + headers: { location } + }); + return response; +} +function clarify_devalue_error(event, error2) { + if (error2.path) { + return `Data returned from \`load\` while rendering ${event.route.id} is not serializable: ${error2.message} (data${error2.path})`; + } + if (error2.path === "") { + return `Data returned from \`load\` while rendering ${event.route.id} is not a plain object`; + } + return error2.message; +} +function stringify_uses(node) { + const uses = []; + if (node.uses && node.uses.dependencies.size > 0) { + uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`); + } + if (node.uses && node.uses.params.size > 0) { + uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`); + } + if (node.uses?.parent) + uses.push(`"parent":1`); + if (node.uses?.route) + uses.push(`"route":1`); + if (node.uses?.url) + uses.push(`"url":1`); + return `"uses":{${uses.join(",")}}`; +} +async function render_endpoint(event, mod, state) { + const method = ( + /** @type {import('types').HttpMethod} */ + event.request.method + ); + let handler = mod[method]; + if (!handler && method === "HEAD") { + handler = mod.GET; + } + if (!handler) { + return method_not_allowed(mod, method); + } + const prerender = mod.prerender ?? state.prerender_default; + if (prerender && (mod.POST || mod.PATCH || mod.PUT || mod.DELETE)) { + throw new Error("Cannot prerender endpoints that have mutative methods"); + } + if (state.prerendering && !prerender) { + if (state.depth > 0) { + throw new Error(`${event.route.id} is not prerenderable`); + } else { + return new Response(void 0, { status: 204 }); + } + } + try { + const response = await handler( + /** @type {import('types').RequestEvent>} */ + event + ); + if (!(response instanceof Response)) { + throw new Error( + `Invalid response from route ${event.url.pathname}: handler should return a Response object` + ); + } + if (state.prerendering) { + response.headers.set("x-sveltekit-prerender", String(prerender)); + } + return response; + } catch (e) { + if (e instanceof Redirect) { + return new Response(void 0, { + status: e.status, + headers: { location: e.location } + }); + } + throw e; + } +} +function is_endpoint_request(event) { + const { method, headers } = event.request; + if (method === "PUT" || method === "PATCH" || method === "DELETE" || method === "OPTIONS") { + return true; + } + if (method === "POST" && headers.get("x-sveltekit-action") === "true") + return false; + const accept = event.request.headers.get("accept") ?? "*/*"; + return negotiate(accept, ["*", "text/html"]) !== "text/html"; +} +function compact(arr) { + return arr.filter( + /** @returns {val is NonNullable} */ + (val) => val != null + ); +} +function normalize_path(path, trailing_slash) { + if (path === "/" || trailing_slash === "ignore") + return path; + if (trailing_slash === "never") { + return path.endsWith("/") ? path.slice(0, -1) : path; + } else if (trailing_slash === "always" && !path.endsWith("/")) { + return path + "/"; + } + return path; +} +function decode_pathname(pathname) { + return pathname.split("%25").map(decodeURI).join("%25"); +} +function decode_params(params) { + for (const key2 in params) { + params[key2] = decodeURIComponent(params[key2]); + } + return params; +} +const tracked_url_properties = ["href", "pathname", "search", "searchParams", "toString", "toJSON"]; +function make_trackable(url, callback) { + const tracked = new URL(url); + for (const property of tracked_url_properties) { + let value = tracked[property]; + Object.defineProperty(tracked, property, { + get() { + callback(); + return value; + }, + enumerable: true, + configurable: true + }); + } + { + tracked[Symbol.for("nodejs.util.inspect.custom")] = (depth, opts, inspect) => { + return inspect(url, opts); + }; + } + disable_hash(tracked); + return tracked; +} +function disable_hash(url) { + Object.defineProperty(url, "hash", { + get() { + throw new Error( + "Cannot access event.url.hash. Consider using `$page.url.hash` inside a component instead" + ); + } + }); +} +function disable_search(url) { + for (const property of ["search", "searchParams"]) { + Object.defineProperty(url, property, { + get() { + throw new Error(`Cannot access url.${property} on a page with prerendering enabled`); + } + }); + } +} +const DATA_SUFFIX = "/__data.json"; +function has_data_suffix(pathname) { + return pathname.endsWith(DATA_SUFFIX); +} +function add_data_suffix(pathname) { + return pathname.replace(/\/$/, "") + DATA_SUFFIX; +} +function strip_data_suffix(pathname) { + return pathname.slice(0, -DATA_SUFFIX.length); +} +function is_action_json_request(event) { + const accept = negotiate(event.request.headers.get("accept") ?? "*/*", [ + "application/json", + "text/html" + ]); + return accept === "application/json" && event.request.method === "POST"; +} +async function handle_action_json_request(event, options2, server) { + const actions = server?.actions; + if (!actions) { + const no_actions_error = error(405, "POST method not allowed. No actions exist for this page"); + return action_json( + { + type: "error", + error: await handle_error_and_jsonify(event, options2, no_actions_error) + }, + { + status: no_actions_error.status, + headers: { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + // "The server must generate an Allow header field in a 405 status code response" + allow: "GET" + } + } + ); + } + check_named_default_separate(actions); + try { + const data = await call_action(event, actions); + if (false) + ; + if (data instanceof ActionFailure) { + return action_json({ + type: "failure", + status: data.status, + // @ts-expect-error we assign a string to what is supposed to be an object. That's ok + // because we don't use the object outside, and this way we have better code navigation + // through knowing where the related interface is used. + data: stringify_action_response( + data.data, + /** @type {string} */ + event.route.id + ) + }); + } else { + return action_json({ + type: "success", + status: data ? 200 : 204, + // @ts-expect-error see comment above + data: stringify_action_response( + data, + /** @type {string} */ + event.route.id + ) + }); + } + } catch (e) { + const err = normalize_error(e); + if (err instanceof Redirect) { + return action_json({ + type: "redirect", + status: err.status, + location: err.location + }); + } + return action_json( + { + type: "error", + error: await handle_error_and_jsonify(event, options2, check_incorrect_fail_use(err)) + }, + { + status: err instanceof HttpError ? err.status : 500 + } + ); + } +} +function check_incorrect_fail_use(error2) { + return error2 instanceof ActionFailure ? new Error(`Cannot "throw fail()". Use "return fail()"`) : error2; +} +function action_json(data, init2) { + return json(data, init2); +} +function is_action_request(event) { + return event.request.method === "POST"; +} +async function handle_action_request(event, server) { + const actions = server?.actions; + if (!actions) { + event.setHeaders({ + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + // "The server must generate an Allow header field in a 405 status code response" + allow: "GET" + }); + return { + type: "error", + error: error(405, "POST method not allowed. No actions exist for this page") + }; + } + check_named_default_separate(actions); + try { + const data = await call_action(event, actions); + if (false) + ; + if (data instanceof ActionFailure) { + return { + type: "failure", + status: data.status, + data: data.data + }; + } else { + return { + type: "success", + status: 200, + // @ts-expect-error this will be removed upon serialization, so `undefined` is the same as omission + data + }; + } + } catch (e) { + const err = normalize_error(e); + if (err instanceof Redirect) { + return { + type: "redirect", + status: err.status, + location: err.location + }; + } + return { + type: "error", + error: check_incorrect_fail_use(err) + }; + } +} +function check_named_default_separate(actions) { + if (actions.default && Object.keys(actions).length > 1) { + throw new Error( + `When using named actions, the default action cannot be used. See the docs for more info: https://kit.svelte.dev/docs/form-actions#named-actions` + ); + } +} +async function call_action(event, actions) { + const url = new URL(event.request.url); + let name = "default"; + for (const param of url.searchParams) { + if (param[0].startsWith("/")) { + name = param[0].slice(1); + if (name === "default") { + throw new Error('Cannot use reserved action name "default"'); + } + break; + } + } + const action = actions[name]; + if (!action) { + throw new Error(`No action with name '${name}' found`); + } + if (!is_form_content_type(event.request)) { + throw new Error( + `Actions expect form-encoded data (received ${event.request.headers.get("content-type")})` + ); + } + return action(event); +} +function validate_action_return(data) { + if (data instanceof Redirect) { + throw new Error(`Cannot \`return redirect(...)\` — use \`throw redirect(...)\` instead`); + } + if (data instanceof HttpError) { + throw new Error( + `Cannot \`return error(...)\` — use \`throw error(...)\` or \`return fail(...)\` instead` + ); + } +} +function uneval_action_response(data, route_id) { + return try_deserialize(data, devalue.uneval, route_id); +} +function stringify_action_response(data, route_id) { + return try_deserialize(data, devalue.stringify, route_id); +} +function try_deserialize(data, fn, route_id) { + try { + return fn(data); + } catch (e) { + const error2 = ( + /** @type {any} */ + e + ); + if ("path" in error2) { + let message = `Data returned from action inside ${route_id} is not serializable: ${error2.message}`; + if (error2.path !== "") + message += ` (data.${error2.path})`; + throw new Error(message); + } + throw error2; + } +} +async function unwrap_promises(object) { + for (const key2 in object) { + if (typeof object[key2]?.then === "function") { + return Object.fromEntries( + await Promise.all(Object.entries(object).map(async ([key3, value]) => [key3, await value])) + ); + } + } + return object; +} +async function load_server_data({ event, state, node, parent }) { + if (!node?.server) + return null; + const uses = { + dependencies: /* @__PURE__ */ new Set(), + params: /* @__PURE__ */ new Set(), + parent: false, + route: false, + url: false + }; + const url = make_trackable(event.url, () => { + uses.url = true; + }); + if (state.prerendering) { + disable_search(url); + } + const result = await node.server.load?.call(null, { + ...event, + fetch: (info, init2) => { + const url2 = new URL(info instanceof Request ? info.url : info, event.url); + uses.dependencies.add(url2.href); + return event.fetch(info, init2); + }, + /** @param {string[]} deps */ + depends: (...deps) => { + for (const dep of deps) { + const { href } = new URL(dep, event.url); + uses.dependencies.add(href); + } + }, + params: new Proxy(event.params, { + get: (target, key2) => { + uses.params.add(key2); + return target[ + /** @type {string} */ + key2 + ]; + } + }), + parent: async () => { + uses.parent = true; + return parent(); + }, + route: new Proxy(event.route, { + get: (target, key2) => { + uses.route = true; + return target[ + /** @type {'id'} */ + key2 + ]; + } + }), + url + }); + const data = result ? await unwrap_promises(result) : null; + return { + type: "data", + data, + uses, + slash: node.server.trailingSlash + }; +} +async function load_data({ + event, + fetched, + node, + parent, + server_data_promise, + state, + resolve_opts, + csr +}) { + const server_data_node = await server_data_promise; + if (!node?.universal?.load) { + return server_data_node?.data ?? null; + } + const result = await node.universal.load.call(null, { + url: event.url, + params: event.params, + data: server_data_node?.data ?? null, + route: event.route, + fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts), + setHeaders: event.setHeaders, + depends: () => { + }, + parent + }); + const data = result ? await unwrap_promises(result) : null; + return data; +} +function create_universal_fetch(event, state, fetched, csr, resolve_opts) { + return async (input, init2) => { + const cloned_body = input instanceof Request && input.body ? input.clone().body : null; + let response = await event.fetch(input, init2); + const url = new URL(input instanceof Request ? input.url : input, event.url); + const same_origin = url.origin === event.url.origin; + let dependency; + if (same_origin) { + if (state.prerendering) { + dependency = { response, body: null }; + state.prerendering.dependencies.set(url.pathname, dependency); + } + } else { + const mode = input instanceof Request ? input.mode : init2?.mode ?? "cors"; + if (mode === "no-cors") { + response = new Response("", { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } else { + const acao = response.headers.get("access-control-allow-origin"); + if (!acao || acao !== event.url.origin && acao !== "*") { + throw new Error( + `CORS error: ${acao ? "Incorrect" : "No"} 'Access-Control-Allow-Origin' header is present on the requested resource` + ); + } + } + } + const proxy = new Proxy(response, { + get(response2, key2, _receiver) { + async function text2() { + const body = await response2.text(); + if (!body || typeof body === "string") { + const status_number = Number(response2.status); + if (isNaN(status_number)) { + throw new Error( + `response.status is not a number. value: "${response2.status}" type: ${typeof response2.status}` + ); + } + fetched.push({ + url: same_origin ? url.href.slice(event.url.origin.length) : url.href, + method: event.request.method, + request_body: ( + /** @type {string | ArrayBufferView | undefined} */ + input instanceof Request && cloned_body ? await stream_to_string(cloned_body) : init2?.body + ), + request_headers: init2?.headers, + response_body: body, + response: response2 + }); + } + if (dependency) { + dependency.body = body; + } + return body; + } + if (key2 === "arrayBuffer") { + return async () => { + const buffer = await response2.arrayBuffer(); + if (dependency) { + dependency.body = new Uint8Array(buffer); + } + return buffer; + }; + } + if (key2 === "text") { + return text2; + } + if (key2 === "json") { + return async () => { + return JSON.parse(await text2()); + }; + } + return Reflect.get(response2, key2, response2); + } + }); + if (csr) { + const get = response.headers.get; + response.headers.get = (key2) => { + const lower = key2.toLowerCase(); + const value = get.call(response.headers, lower); + if (value && !lower.startsWith("x-sveltekit-")) { + const included = resolve_opts.filterSerializedResponseHeaders(lower, value); + if (!included) { + throw new Error( + `Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://kit.svelte.dev/docs/hooks#server-hooks-handle (at ${event.route.id})` + ); + } + } + return value; + }; + } + return proxy; + }; +} +async function stream_to_string(stream) { + let result = ""; + const reader = stream.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + result += decoder.decode(value); + } + return result; +} +function hash(...values) { + let hash2 = 5381; + for (const value of values) { + if (typeof value === "string") { + let i = value.length; + while (i) + hash2 = hash2 * 33 ^ value.charCodeAt(--i); + } else if (ArrayBuffer.isView(value)) { + const buffer = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + let i = buffer.length; + while (i) + hash2 = hash2 * 33 ^ buffer[--i]; + } else { + throw new TypeError("value must be a string or TypedArray"); + } + } + return (hash2 >>> 0).toString(36); +} +const escape_html_attr_dict = { + "&": "&", + '"': """ +}; +const escape_html_attr_regex = new RegExp( + // special characters + `[${Object.keys(escape_html_attr_dict).join("")}]|[\\ud800-\\udbff](?![\\udc00-\\udfff])|[\\ud800-\\udbff][\\udc00-\\udfff]|[\\udc00-\\udfff]`, + "g" +); +function escape_html_attr(str) { + const escaped_str = str.replace(escape_html_attr_regex, (match) => { + if (match.length === 2) { + return match; + } + return escape_html_attr_dict[match] ?? `&#${match.charCodeAt(0)};`; + }); + return `"${escaped_str}"`; +} +const replacements = { + "<": "\\u003C", + "\u2028": "\\u2028", + "\u2029": "\\u2029" +}; +const pattern = new RegExp(`[${Object.keys(replacements).join("")}]`, "g"); +function serialize_data(fetched, filter, prerendering = false) { + const headers = {}; + let cache_control = null; + let age = null; + let vary = false; + for (const [key2, value] of fetched.response.headers) { + if (filter(key2, value)) { + headers[key2] = value; + } + if (key2 === "cache-control") + cache_control = value; + if (key2 === "age") + age = value; + if (key2 === "vary") + vary = true; + } + const payload = { + status: fetched.response.status, + statusText: fetched.response.statusText, + headers, + body: fetched.response_body + }; + const safe_payload = JSON.stringify(payload).replace(pattern, (match) => replacements[match]); + const attrs = [ + 'type="application/json"', + "data-sveltekit-fetched", + `data-url=${escape_html_attr(fetched.url)}` + ]; + if (fetched.request_headers || fetched.request_body) { + const values = []; + if (fetched.request_headers) { + values.push([...new Headers(fetched.request_headers)].join(",")); + } + if (fetched.request_body) { + values.push(fetched.request_body); + } + attrs.push(`data-hash="${hash(...values)}"`); + } + if (!prerendering && fetched.method === "GET" && cache_control && !vary) { + const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control); + if (match) { + const ttl = +match[1] - +(age ?? "0"); + attrs.push(`data-ttl="${ttl}"`); + } + } + return ` +
-

Chat Room {status} -

- +

+ Chat Room {status} +

+
-
+ + +{#if showVotingUI && currentVotingProposal} +
-
- {#each messages as msg} -
{msg}
- {/each} +

Voting Proposal

+
+

Group Name: {currentVotingProposal.group_name}

+

Proposal ID: {currentVotingProposal.proposal_id}

+

Proposal Payload:

+
+ {currentVotingProposal.payload}
+
+
+ + + +
+
+{/if} + +
+
+
+ {#each messages as msg} +
{msg}
+ {/each} +
+
-
- - -
+
+ + +
diff --git a/mls_crypto/src/identity.rs b/mls_crypto/src/identity.rs index 011920a..8434d99 100644 --- a/mls_crypto/src/identity.rs +++ b/mls_crypto/src/identity.rs @@ -68,7 +68,7 @@ impl Identity { } pub fn identity_string(&self) -> String { - address_string(self.credential_with_key.credential.serialized_content()) + Address::from_slice(self.credential_with_key.credential.serialized_content()).to_string() } pub fn signer(&self) -> &SignatureKeyPair { @@ -90,18 +90,10 @@ impl Identity { impl Display for Identity { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - Address::from_slice(self.credential_with_key.credential.serialized_content()) - ) + write!(f, "{}", self.identity_string()) } } -pub fn address_string(identity: &[u8]) -> String { - Address::from_slice(identity).to_string() -} - pub fn random_identity() -> Result { let signer = PrivateKeySigner::random(); let user_address = signer.address(); diff --git a/src/action_handlers.rs b/src/action_handlers.rs index fb78dc4..cbe9102 100644 --- a/src/action_handlers.rs +++ b/src/action_handlers.rs @@ -6,9 +6,9 @@ use tokio_util::sync::CancellationToken; use waku_bindings::WakuMessage; use crate::{ - message::wrap_conversation_message_into_application_msg, + protos::messages::v1::{AppMessage, BanRequest, ConversationMessage}, user::{User, UserAction}, - user_actor::{LeaveGroupRequest, RemoveUserRequest, SendGroupMessage}, + user_actor::{BuildBanMessage, LeaveGroupRequest, SendGroupMessage, UserVoteRequest}, ws_actor::{RawWsMessage, WsAction, WsActor}, AppState, }; @@ -39,15 +39,16 @@ pub async fn handle_user_actions( app_state .content_topics - .lock() - .unwrap() + .write() + .await .retain(|topic| topic.application_name != group_name); info!("Leave group: {:?}", &group_name); - let app_message = wrap_conversation_message_into_application_msg( - format!("You're removed from the group {group_name}").into_bytes(), - "system".to_string(), - group_name.clone(), - ); + let app_message: AppMessage = ConversationMessage { + message: format!("You're removed from the group {group_name}").into_bytes(), + sender: "SYSTEM".to_string(), + group_name: group_name.clone(), + } + .into(); ws_actor.ask(app_message).await?; cancel_token.cancel(); } @@ -68,11 +69,12 @@ pub async fn handle_ws_action( info!("Got unexpected connect: {:?}", &connect); } WsAction::UserMessage(msg) => { - let app_message = wrap_conversation_message_into_application_msg( - msg.message.clone().into_bytes(), - "me".to_string(), - msg.group_id.clone(), - ); + let app_message: AppMessage = ConversationMessage { + message: msg.message.clone(), + sender: "me".to_string(), + group_name: msg.group_id.clone(), + } + .into(); ws_actor.ask(app_message).await?; let pmt = user_actor @@ -85,21 +87,69 @@ pub async fn handle_ws_action( } WsAction::RemoveUser(user_to_ban, group_name) => { info!("Got remove user: {:?}", &user_to_ban); - user_actor - .ask(RemoveUserRequest { - user_to_ban: user_to_ban.clone(), + + // Create a ban request message to send to the group + let ban_request_msg = BanRequest { + user_to_ban: user_to_ban.clone(), + requester: "someone".to_string(), // The current user is the requester + group_name: group_name.clone(), + }; + + // Send the ban request directly via Waku if the user is not the steward + // If steward, need to add remove proposal to the group and sent notification to the group + let waku_msg = user_actor + .ask(BuildBanMessage { + ban_request: ban_request_msg, group_name: group_name.clone(), }) .await?; + waku_node.send(waku_msg).await?; - let app_message = wrap_conversation_message_into_application_msg( - format!("Remove proposal for user {user_to_ban} added to steward queue") - .into_bytes(), - "system".to_string(), - group_name.clone(), - ); + // Send a local confirmation message + let app_message: AppMessage = ConversationMessage { + message: format!("Ban request for user {user_to_ban} sent to group").into_bytes(), + sender: "system".to_string(), + group_name: group_name.clone(), + } + .into(); ws_actor.ask(app_message).await?; } + WsAction::UserVote { + proposal_id, + vote, + group_id, + } => { + info!("Got user vote: proposal_id={proposal_id}, vote={vote}, group={group_id}"); + + // Process the user vote: + // if it come from the user, send the vote result to Waku + // if it come from the steward, just process it and return None + let user_vote_result = user_actor + .ask(UserVoteRequest { + group_name: group_id.clone(), + proposal_id, + vote, + }) + .await?; + + // Send a local confirmation message + let app_message: AppMessage = ConversationMessage { + message: format!( + "Your vote ({}) has been submitted for proposal {proposal_id}", + if vote { "YES" } else { "NO" }, + ) + .into_bytes(), + sender: "SYSTEM".to_string(), + group_name: group_id.clone(), + } + .into(); + ws_actor.ask(app_message).await?; + + // Send the vote result to Waku + if let Some(waku_msg) = user_vote_result { + waku_node.send(waku_msg).await?; + } + } WsAction::DoNothing => {} } diff --git a/src/consensus/mod.rs b/src/consensus/mod.rs new file mode 100644 index 0000000..9954f95 --- /dev/null +++ b/src/consensus/mod.rs @@ -0,0 +1,305 @@ +//! Consensus module implementing HashGraph-like consensus for distributed voting +//! +//! This module implements the consensus protocol described in the [RFC](https://github.com/vacp2p/rfc-index/blob/consensus-hashgraph-like/vac/raw/consensus-hashgraphlike.md) +//! +//! The consensus is designed to work with GossipSub-like networks and provides: +//! - Proposal management +//! - Vote collection and validation +//! - Consensus reached detection + +use crate::error::ConsensusError; +use crate::protos::messages::v1::consensus::v1::{Proposal, Vote}; +use crate::LocalSigner; +use log::info; +use prost::Message; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::broadcast; +use uuid::Uuid; + +pub mod service; + +// Re-export protobuf types for compatibility with generated code +pub mod v1 { + pub use crate::protos::messages::v1::consensus::v1::{Proposal, Vote}; +} + +pub use service::ConsensusService; + +/// Consensus events emitted when consensus state changes +#[derive(Debug, Clone)] +pub enum ConsensusEvent { + /// Consensus has been reached for a proposal + ConsensusReached { proposal_id: u32, result: bool }, + /// Consensus failed due to timeout or other reasons + ConsensusFailed { proposal_id: u32, reason: String }, +} + +/// Consensus configuration +#[derive(Debug, Clone)] +pub struct ConsensusConfig { + /// Minimum number of votes required for consensus (as percentage of expected voters) + pub consensus_threshold: f64, + /// Timeout for consensus rounds in seconds + pub consensus_timeout: u64, + /// Maximum number of rounds before consensus is considered failed + pub max_rounds: u32, + /// Whether to use liveness criteria for silent peers + pub liveness_criteria: bool, +} + +impl Default for ConsensusConfig { + fn default() -> Self { + Self { + consensus_threshold: 0.67, // 67% supermajority + consensus_timeout: 10, // 10 seconds + max_rounds: 3, // Maximum 3 rounds + liveness_criteria: true, + } + } +} + +/// Consensus state for a proposal +#[derive(Debug, Clone)] +pub enum ConsensusState { + /// Proposal is active and accepting votes + Active, + /// Consensus has been reached + ConsensusReached(bool), // true for yes, false for no + /// Consensus failed (timeout or insufficient votes) + Failed, + /// Proposal has expired + Expired, +} + +/// Consensus session for a specific proposal +#[derive(Debug)] +pub struct ConsensusSession { + pub proposal: Proposal, + pub state: ConsensusState, + pub votes: HashMap, Vote>, // vote_owner -> Vote + pub created_at: u64, + pub config: ConsensusConfig, + pub event_sender: Option>, + pub group_name: String, +} + +impl ConsensusSession { + pub fn new( + proposal: Proposal, + config: ConsensusConfig, + event_sender: Option>, + group_name: &str, + ) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Failed to get current time") + .as_secs(); + + Self { + proposal, + state: ConsensusState::Active, + votes: HashMap::new(), + created_at: now, + config, + event_sender, + group_name: group_name.to_string(), + } + } + + pub fn set_consensus_threshold(&mut self, consensus_threshold: f64) { + self.config.consensus_threshold = consensus_threshold + } + + /// Add a vote to the session + pub fn add_vote(&mut self, vote: Vote) -> Result<(), ConsensusError> { + match self.state { + ConsensusState::Active => { + // Check if voter already voted + if self.votes.contains_key(&vote.vote_owner) { + return Err(ConsensusError::DuplicateVote); + } + + // Add vote into the session and proposal + self.votes.insert(vote.vote_owner.clone(), vote.clone()); + self.proposal.votes.push(vote.clone()); + + // Check if consensus can be reached after adding the vote + self.check_consensus(); + Ok(()) + } + ConsensusState::ConsensusReached(_) => { + info!( + "[consensus::mod::add_vote]: Consensus already reached for proposal {}, skipping vote", + self.proposal.proposal_id + ); + Ok(()) + } + _ => Err(ConsensusError::SessionNotActive), + } + } + + /// Count the number of required votes to reach consensus + fn count_required_votes(&self) -> usize { + let expected_voters = self.proposal.expected_voters_count as usize; + if expected_voters <= 2 { + expected_voters + } else { + ((expected_voters as f64) * self.config.consensus_threshold) as usize + } + } + + /// Check if consensus has been reached + /// + /// - `ConsensusReached(true)` if yes votes > no votes + /// - `ConsensusReached(false)` + /// - if no votes > yes votes + /// - if no votes == yes votes and we have all votes + /// - `Active` + /// - if no votes == yes votes and we don't have all votes + /// - if total votes < required votes (we wait for more votes) + fn check_consensus(&mut self) { + let total_votes = self.votes.len(); + let yes_votes = self.votes.values().filter(|v| v.vote).count(); + let no_votes = total_votes - yes_votes; + + // Check if we have all expected votes (only calculate consensus immediately if ALL votes received) + let expected_voters = self.proposal.expected_voters_count as usize; + let required_votes = self.count_required_votes(); + // For <= 2 voters, we require all votes to reach consensus + if total_votes >= required_votes { + // All votes received - calculate consensus immediately + if yes_votes > no_votes { + self.state = ConsensusState::ConsensusReached(true); + info!( + "[consensus::mod::check_consensus]: Enough votes received {yes_votes}-{no_votes} - consensus reached: YES" + ); + self.emit_consensus_event(ConsensusEvent::ConsensusReached { + proposal_id: self.proposal.proposal_id, + result: true, + }); + } else if no_votes > yes_votes { + self.state = ConsensusState::ConsensusReached(false); + info!( + "[consensus::mod::check_consensus]: Enough votes received {yes_votes}-{no_votes} - consensus reached: NO" + ); + self.emit_consensus_event(ConsensusEvent::ConsensusReached { + proposal_id: self.proposal.proposal_id, + result: false, + }); + } else { + // Tie - if it's all votes, we reject the proposal + if total_votes == expected_voters { + self.state = ConsensusState::ConsensusReached(false); + info!( + "[consensus::mod::check_consensus]: All votes received and tie - consensus not reached" + ); + self.emit_consensus_event(ConsensusEvent::ConsensusReached { + proposal_id: self.proposal.proposal_id, + result: false, + }); + } else { + // Tie - if it's not all votes, we wait for more votes + self.state = ConsensusState::Active; + info!( + "[consensus::mod::check_consensus]: Not enough votes received - consensus not reached" + ); + } + } + } + } + + /// Emit a consensus event + fn emit_consensus_event(&self, event: ConsensusEvent) { + if let Some(sender) = &self.event_sender { + info!( + "[consensus::mod::emit_consensus_event]: Emitting consensus event: {event:?} for proposal {}", + self.proposal.proposal_id + ); + let _ = sender.send((self.group_name.clone(), event)); + } + } + + /// Check if the session is still active + pub fn is_active(&self) -> bool { + matches!(self.state, ConsensusState::Active) + } +} + +/// Compute the hash of a vote +pub fn compute_vote_hash(vote: &Vote) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(vote.vote_id.to_le_bytes()); + hasher.update(&vote.vote_owner); + hasher.update(vote.proposal_id.to_le_bytes()); + hasher.update(vote.timestamp.to_le_bytes()); + hasher.update([vote.vote as u8]); + hasher.update(&vote.parent_hash); + hasher.update(&vote.received_hash); + hasher.finalize().to_vec() +} + +/// Create a vote for an incoming proposal based on user's vote +async fn create_vote_for_proposal( + proposal: &Proposal, + user_vote: bool, + signer: S, +) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + + // Get the latest vote as parent and received hash + let (parent_hash, received_hash) = if let Some(latest_vote) = proposal.votes.last() { + // Check if we already voted (same voter) + let is_same_voter = latest_vote.vote_owner == signer.address_bytes(); + if is_same_voter { + // Same voter: parent_hash should be the hash of our previous vote + (latest_vote.vote_hash.clone(), Vec::new()) + } else { + // Different voter: parent_hash is empty, received_hash is the hash of the latest vote + (Vec::new(), latest_vote.vote_hash.clone()) + } + } else { + (Vec::new(), Vec::new()) + }; + + // Create our vote with user's choice + let mut vote = Vote { + vote_id: Uuid::new_v4().as_u128() as u32, + vote_owner: signer.address_bytes(), + proposal_id: proposal.proposal_id, + timestamp: now, + vote: user_vote, // Use the user's actual vote choice + parent_hash, + received_hash, + vote_hash: Vec::new(), // Will be computed below + signature: Vec::new(), // Will be signed below + }; + + // Compute vote hash and signature + vote.vote_hash = compute_vote_hash(&vote); + let vote_bytes = vote.encode_to_vec(); + vote.signature = signer + .local_sign_message(&vote_bytes) + .await + .map_err(|e| ConsensusError::InvalidSignature(e.to_string()))?; + + Ok(vote) +} + +/// Statistics about consensus sessions +#[derive(Debug, Clone)] +pub struct ConsensusStats { + pub total_sessions: usize, + pub active_sessions: usize, + pub consensus_reached: usize, + pub failed_sessions: usize, +} + +impl Default for ConsensusService { + fn default() -> Self { + Self::new() + } +} diff --git a/src/consensus/service.rs b/src/consensus/service.rs new file mode 100644 index 0000000..5c6d858 --- /dev/null +++ b/src/consensus/service.rs @@ -0,0 +1,690 @@ +//! Consensus service for managing consensus sessions and HashGraph integration + +use crate::consensus::{ + compute_vote_hash, create_vote_for_proposal, ConsensusConfig, ConsensusEvent, ConsensusSession, + ConsensusState, ConsensusStats, +}; +use crate::error::ConsensusError; +use crate::protos::messages::v1::consensus::v1::{Proposal, Vote}; +use crate::{verify_vote_hash, LocalSigner}; +use log::info; +use prost::Message; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::{broadcast, RwLock}; +use uuid::Uuid; + +/// Consensus service that manages multiple consensus sessions for multiple groups +#[derive(Clone)] +pub struct ConsensusService { + /// Active consensus sessions organized by group: group_name -> proposal_id -> session + sessions: Arc>>>, + /// Maximum number of voting sessions to keep per group + max_sessions_per_group: usize, + /// Event sender for consensus events + event_sender: broadcast::Sender<(String, ConsensusEvent)>, +} + +impl ConsensusService { + /// Create a new consensus service + pub fn new() -> Self { + let (event_sender, _) = broadcast::channel(1000); + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + max_sessions_per_group: 10, + event_sender, + } + } + + /// Create a new consensus service with custom max sessions per group + pub fn new_with_max_sessions(max_sessions_per_group: usize) -> Self { + let (event_sender, _) = broadcast::channel(1000); + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + max_sessions_per_group, + event_sender, + } + } + + /// Subscribe to consensus events + pub fn subscribe_to_events(&self) -> broadcast::Receiver<(String, ConsensusEvent)> { + self.event_sender.subscribe() + } + + pub async fn set_consensus_threshold_for_group_session( + &mut self, + group_name: &str, + proposal_id: u32, + consensus_threshold: f64, + ) -> Result<(), ConsensusError> { + let mut sessions = self.sessions.write().await; + let group_sessions = sessions + .entry(group_name.to_string()) + .or_insert_with(HashMap::new); + + let session = group_sessions + .get_mut(&proposal_id) + .ok_or(ConsensusError::SessionNotFound)?; + + session.set_consensus_threshold(consensus_threshold); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn create_proposal( + &self, + group_name: &str, + name: String, + payload: String, + proposal_owner: Vec, + expected_voters_count: u32, + expiration_time: u64, + liveness_criteria_yes: bool, + ) -> Result { + let proposal_id = Uuid::new_v4().as_u128() as u32; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let config = ConsensusConfig::default(); + + // Create proposal with steward's vote + let proposal = Proposal { + name, + payload, + proposal_id, + proposal_owner, + votes: vec![], + expected_voters_count, + round: 1, + timestamp: now, + expiration_time: now + expiration_time, + liveness_criteria_yes, + }; + + // Create consensus session + + let session = ConsensusSession::new( + proposal.clone(), + config.clone(), + Some(self.event_sender.clone()), + group_name, + ); + + // Get timeout from session config before adding to sessions + let timeout_seconds = config.consensus_timeout; + + // Add session to group and handle cleanup in a single lock operation + { + let mut sessions = self.sessions.write().await; + let group_sessions = sessions + .entry(group_name.to_string()) + .or_insert_with(HashMap::new); + group_sessions.insert(proposal_id, session); + + // Clean up old sessions if we exceed the limit (within the same lock) + if group_sessions.len() > self.max_sessions_per_group { + // Sort sessions by creation time and keep the most recent ones + let mut session_entries: Vec<_> = group_sessions.drain().collect(); + session_entries.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at)); + + // Keep only the most recent sessions + for (proposal_id, session) in session_entries + .into_iter() + .take(self.max_sessions_per_group) + { + group_sessions.insert(proposal_id, session); + } + } + } + + // Start automatic timeout handling for this proposal using session config + let self_clone = self.clone(); + let group_name_owned = group_name.to_string(); + tokio::spawn(async move { + let timeout_duration = std::time::Duration::from_secs(timeout_seconds); + tokio::time::sleep(timeout_duration).await; + + if self_clone + .get_consensus_result(&group_name_owned, proposal_id) + .await + .is_some() + { + info!( + "Consensus result already exists for proposal {proposal_id}, skipping timeout" + ); + return; + } + + // Apply timeout consensus if still active + if self_clone + .handle_consensus_timeout(&group_name_owned, proposal_id) + .await + .is_ok() + { + info!( + "Automatic timeout applied for proposal {proposal_id} after {timeout_seconds}s" + ); + } + }); + + Ok(proposal) + } + + /// Create a new proposal with steward's vote attached + pub async fn vote_on_proposal( + &self, + group_name: &str, + proposal_id: u32, + steward_vote: bool, + signer: S, + ) -> Result { + let vote_id = Uuid::new_v4().as_u128() as u32; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + + // Create steward's vote first + let steward_vote_obj = Vote { + vote_id, + vote_owner: signer.address_bytes(), + proposal_id, + timestamp: now, + vote: steward_vote, + parent_hash: Vec::new(), // First vote, no parent + received_hash: Vec::new(), // First vote, no received + vote_hash: Vec::new(), // Will be computed below + signature: Vec::new(), // Will be signed below + }; + + // Compute vote hash and signature for steward's vote + let mut steward_vote_obj = steward_vote_obj; + steward_vote_obj.vote_hash = compute_vote_hash(&steward_vote_obj); + let vote_bytes = steward_vote_obj.encode_to_vec(); + steward_vote_obj.signature = signer + .local_sign_message(&vote_bytes) + .await + .map_err(|e| ConsensusError::InvalidSignature(e.to_string()))?; + + let mut sessions = self.sessions.write().await; + let group_sessions = sessions + .entry(group_name.to_string()) + .or_insert_with(HashMap::new); + let session = group_sessions + .get_mut(&proposal_id) + .ok_or(ConsensusError::SessionNotFound)?; + + session.add_vote(steward_vote_obj.clone())?; + + Ok(session.proposal.clone()) + } + + /// 1. Check the signatures of the each votes in proposal, in particular for proposal P_1, + /// verify the signature of V_1 where V_1 = P_1.votes\[0\] with V_1.signature and V_1.vote_owner + /// 2. Do parent_hash check: If there are repeated votes from the same sender, + /// check that the hash of the former vote is equal to the parent_hash of the later vote. + /// 3. Do received_hash check: If there are multiple votes in a proposal, + /// check that the hash of a vote is equal to the received_hash of the next one. + pub fn validate_proposal(&self, proposal: &Proposal) -> Result<(), ConsensusError> { + // Validate each vote individually first + for vote in proposal.votes.iter() { + self.validate_vote(vote, proposal.expiration_time)?; + } + + // Validate vote chain integrity according to RFC + self.validate_vote_chain(&proposal.votes)?; + + Ok(()) + } + + fn validate_vote(&self, vote: &Vote, expiration_time: u64) -> Result<(), ConsensusError> { + if vote.vote_owner.is_empty() { + return Err(ConsensusError::EmptyVoteOwner); + } + + if vote.vote_hash.is_empty() { + return Err(ConsensusError::EmptyVoteHash); + } + + if vote.signature.is_empty() { + return Err(ConsensusError::EmptySignature); + } + + let expected_hash = compute_vote_hash(vote); + if vote.vote_hash != expected_hash { + return Err(ConsensusError::InvalidVoteHash); + } + + // Encode vote without signature to verify signature + let mut vote_copy = vote.clone(); + vote_copy.signature = Vec::new(); + let vote_copy_bytes = vote_copy.encode_to_vec(); + + // Validate signature + let verified = verify_vote_hash(&vote.signature, &vote.vote_owner, &vote_copy_bytes)?; + + if !verified { + return Err(ConsensusError::InvalidVoteSignature); + } + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + // Check that vote timestamp is not in the future + if vote.timestamp > now { + return Err(ConsensusError::InvalidVoteTimestamp); + } + + // Check that vote timestamp is within expiration threshold + if now - vote.timestamp > expiration_time { + return Err(ConsensusError::VoteExpired); + } + + Ok(()) + } + + /// Validate vote chain integrity according to RFC specification + fn validate_vote_chain(&self, votes: &[Vote]) -> Result<(), ConsensusError> { + if votes.len() <= 1 { + return Ok(()); + } + + for i in 0..votes.len() - 1 { + let current_vote = &votes[i]; + let next_vote = &votes[i + 1]; + + // RFC requirement: received_hash of next vote should equal hash of current vote + if current_vote.vote_hash != next_vote.received_hash { + return Err(ConsensusError::ReceivedHashMismatch); + } + + // RFC requirement: if same voter, parent_hash should equal hash of previous vote + if current_vote.vote_owner == next_vote.vote_owner + && current_vote.vote_hash != next_vote.parent_hash + { + return Err(ConsensusError::ParentHashMismatch); + } + } + + Ok(()) + } + + /// Process incoming proposal message + pub async fn process_incoming_proposal( + &self, + group_name: &str, + proposal: Proposal, + ) -> Result<(), ConsensusError> { + info!( + "[consensus::service::process_incoming_proposal]: Processing incoming proposal for group {group_name}" + ); + let mut sessions = self.sessions.write().await; + let group_sessions = sessions + .entry(group_name.to_string()) + .or_insert_with(HashMap::new); + + // Check if proposal already exists + if group_sessions.contains_key(&proposal.proposal_id) { + return Err(ConsensusError::ProposalAlreadyExist); + } + + // Validate proposal including vote chain integrity + self.validate_proposal(&proposal)?; + + // Create new session without our vote - user will vote later + let mut session = ConsensusSession::new( + proposal.clone(), + ConsensusConfig::default(), + Some(self.event_sender.clone()), + group_name, + ); + + session.add_vote(proposal.votes[0].clone())?; + group_sessions.insert(proposal.proposal_id, session); + + // Clean up old sessions if we exceed the limit (within the same lock) + if group_sessions.len() > self.max_sessions_per_group { + // Sort sessions by creation time and keep the most recent ones + let mut session_entries: Vec<_> = group_sessions.drain().collect(); + session_entries.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at)); + + // Keep only the most recent sessions + for (proposal_id, session) in session_entries + .into_iter() + .take(self.max_sessions_per_group) + { + group_sessions.insert(proposal_id, session); + } + } + + info!("[consensus::service::process_incoming_proposal]: Proposal stored, waiting for user vote"); + Ok(()) + } + + /// Process user vote for a proposal + pub async fn process_user_vote( + &self, + group_name: &str, + proposal_id: u32, + user_vote: bool, + signer: S, + ) -> Result { + let mut sessions = self.sessions.write().await; + let group_sessions = sessions + .get_mut(group_name) + .ok_or(ConsensusError::GroupNotFound)?; + + let session = group_sessions + .get_mut(&proposal_id) + .ok_or(ConsensusError::SessionNotFound)?; + + // Check if user already voted + let user_address = signer.address_bytes(); + if session.votes.values().any(|v| v.vote_owner == user_address) { + return Err(ConsensusError::UserAlreadyVoted); + } + + // Create our vote based on the user's choice + let our_vote = create_vote_for_proposal(&session.proposal, user_vote, signer).await?; + + session.add_vote(our_vote.clone())?; + + Ok(our_vote) + } + + /// Process incoming vote + pub async fn process_incoming_vote( + &self, + group_name: &str, + vote: Vote, + ) -> Result<(), ConsensusError> { + info!( + "[consensus::service::process_incoming_vote]: Processing incoming vote for group {group_name}" + ); + let mut sessions = self.sessions.write().await; + let group_sessions = sessions + .get_mut(group_name) + .ok_or(ConsensusError::GroupNotFound)?; + + let session = group_sessions + .get_mut(&vote.proposal_id) + .ok_or(ConsensusError::SessionNotFound)?; + + self.validate_vote(&vote, session.proposal.expiration_time)?; + + // Add vote to session + session.add_vote(vote.clone())?; + + Ok(()) + } + + /// Get liveness criteria for a proposal + pub async fn get_proposal_liveness_criteria( + &self, + group_name: &str, + proposal_id: u32, + ) -> Option { + let sessions = self.sessions.read().await; + if let Some(group_sessions) = sessions.get(group_name) { + if let Some(session) = group_sessions.get(&proposal_id) { + return Some(session.proposal.liveness_criteria_yes); + } + } + None + } + + /// Get consensus result for a proposal + pub async fn get_consensus_result(&self, group_name: &str, proposal_id: u32) -> Option { + let sessions = self.sessions.read().await; + if let Some(group_sessions) = sessions.get(group_name) { + if let Some(session) = group_sessions.get(&proposal_id) { + match session.state { + ConsensusState::ConsensusReached(result) => Some(result), + _ => None, + } + } else { + None + } + } else { + None + } + } + + /// Get active proposals for a specific group + pub async fn get_active_proposals(&self, group_name: &str) -> Vec { + let sessions = self.sessions.read().await; + if let Some(group_sessions) = sessions.get(group_name) { + group_sessions + .values() + .filter(|session| session.is_active()) + .map(|session| session.proposal.clone()) + .collect() + } else { + Vec::new() + } + } + + /// Clean up expired sessions for all groups + pub async fn cleanup_expired_sessions(&self) { + let mut sessions = self.sessions.write().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get current time") + .as_secs(); + + let group_names: Vec = sessions.keys().cloned().collect(); + + for group_name in group_names { + if let Some(group_sessions) = sessions.get_mut(&group_name) { + group_sessions.retain(|_, session| { + now <= session.proposal.expiration_time && session.is_active() + }); + + // Clean up old sessions if we exceed the limit + if group_sessions.len() > self.max_sessions_per_group { + // Sort sessions by creation time and keep the most recent ones + let mut session_entries: Vec<_> = group_sessions.drain().collect(); + session_entries.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at)); + + // Keep only the most recent sessions + for (proposal_id, session) in session_entries + .into_iter() + .take(self.max_sessions_per_group) + { + group_sessions.insert(proposal_id, session); + } + } + } + } + } + + /// Get session statistics for a specific group + pub async fn get_group_stats(&self, group_name: &str) -> ConsensusStats { + let sessions = self.sessions.read().await; + if let Some(group_sessions) = sessions.get(group_name) { + let total_sessions = group_sessions.len(); + let active_sessions = group_sessions.values().filter(|s| s.is_active()).count(); + let consensus_reached = group_sessions + .values() + .filter(|s| matches!(s.state, ConsensusState::ConsensusReached(_))) + .count(); + + ConsensusStats { + total_sessions, + active_sessions, + consensus_reached, + failed_sessions: total_sessions - active_sessions - consensus_reached, + } + } else { + ConsensusStats { + total_sessions: 0, + active_sessions: 0, + consensus_reached: 0, + failed_sessions: 0, + } + } + } + + /// Get overall session statistics across all groups + pub async fn get_overall_stats(&self) -> ConsensusStats { + let sessions = self.sessions.read().await; + let mut total_sessions = 0; + let mut active_sessions = 0; + let mut consensus_reached = 0; + + for group_sessions in sessions.values() { + total_sessions += group_sessions.len(); + active_sessions += group_sessions.values().filter(|s| s.is_active()).count(); + consensus_reached += group_sessions + .values() + .filter(|s| matches!(s.state, ConsensusState::ConsensusReached(_))) + .count(); + } + + ConsensusStats { + total_sessions, + active_sessions, + consensus_reached, + failed_sessions: total_sessions - active_sessions - consensus_reached, + } + } + + /// Get all group names that have active sessions + pub async fn get_active_groups(&self) -> Vec { + let sessions = self.sessions.read().await; + sessions + .iter() + .filter(|(_, group_sessions)| { + group_sessions.values().any(|session| session.is_active()) + }) + .map(|(group_name, _)| group_name.clone()) + .collect() + } + + /// Remove all sessions for a specific group + pub async fn remove_group_sessions(&self, group_name: &str) { + let mut sessions = self.sessions.write().await; + sessions.remove(group_name); + } + + /// Check if we have enough votes for consensus (2n/3 threshold) + pub async fn has_sufficient_votes(&self, group_name: &str, proposal_id: u32) -> bool { + let sessions = self.sessions.read().await; + + if let Some(group_sessions) = sessions.get(group_name) { + if let Some(session) = group_sessions.get(&proposal_id) { + let total_votes = session.votes.len() as u32; + let expected_voters = session.proposal.expected_voters_count; + self.check_sufficient_votes( + total_votes, + expected_voters, + session.config.consensus_threshold, + ) + } else { + false + } + } else { + false + } + } + + /// Handle consensus when timeout is reached + pub async fn handle_consensus_timeout( + &self, + group_name: &str, + proposal_id: u32, + ) -> Result { + // First, check if consensus was already reached to avoid unnecessary work + let mut sessions = self.sessions.write().await; + if let Some(group_sessions) = sessions.get_mut(group_name) { + if let Some(session) = group_sessions.get_mut(&proposal_id) { + // Check if consensus was already reached + match session.state { + crate::consensus::ConsensusState::ConsensusReached(result) => { + info!("Consensus already reached for proposal {proposal_id}, skipping timeout"); + Ok(result) + } + _ => { + // Calculate consensus result + let total_votes = session.votes.len() as u32; + let expected_voters = session.proposal.expected_voters_count; + let result = if self.check_sufficient_votes( + total_votes, + expected_voters, + session.config.consensus_threshold, + ) { + // We have sufficient votes (2n/3) - calculate result based on votes + self.calculate_consensus_result( + &session.votes, + session.proposal.liveness_criteria_yes, + ) + } else { + // Insufficient votes - apply liveness criteria + session.proposal.liveness_criteria_yes + }; + + // Apply timeout consensus + session.state = crate::consensus::ConsensusState::ConsensusReached(result); + info!("Timeout consensus applied for proposal {proposal_id}: {result} (liveness criteria)"); + + // Emit consensus event + session.emit_consensus_event( + crate::consensus::ConsensusEvent::ConsensusReached { + proposal_id, + result, + }, + ); + + Ok(result) + } + } + } else { + Err(ConsensusError::SessionNotFound) + } + } else { + Err(ConsensusError::SessionNotFound) + } + } + + /// Helper method to calculate required votes for consensus + fn calculate_required_votes(&self, expected_voters: u32, consensus_threshold: f64) -> u32 { + if expected_voters == 1 || expected_voters == 2 { + expected_voters + } else { + ((expected_voters as f64) * consensus_threshold) as u32 + } + } + + /// Helper method to check if sufficient votes exist for consensus + fn check_sufficient_votes( + &self, + total_votes: u32, + expected_voters: u32, + consensus_threshold: f64, + ) -> bool { + let required_votes = self.calculate_required_votes(expected_voters, consensus_threshold); + println!( + "[consensus::service::check_sufficient_votes]: Total votes: {total_votes}, Expected voters: {expected_voters}, Consensus threshold: {consensus_threshold}, Required votes: {required_votes}" + ); + total_votes >= required_votes + } + + /// Helper method to calculate consensus result based on votes + fn calculate_consensus_result( + &self, + votes: &HashMap, Vote>, + liveness_criteria_yes: bool, + ) -> bool { + let total_votes = votes.len() as u32; + let yes_votes = votes.values().filter(|v| v.vote).count() as u32; + let no_votes = total_votes - yes_votes; + + if yes_votes > no_votes { + true + } else if no_votes > yes_votes { + false + } else { + // Tie - apply liveness criteria + liveness_criteria_yes + } + } +} diff --git a/src/error.rs b/src/error.rs index 69e1894..bbd2178 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,4 @@ use alloy::signers::local::LocalSignerError; -use kameo::error::SendError; use openmls::group::WelcomeError; use openmls::{ framing::errors::MlsMessageError, @@ -7,41 +6,58 @@ use openmls::{ prelude::{ CommitToPendingProposalsError, CreateMessageError, MergeCommitError, MergePendingCommitError, NewGroupError, ProcessMessageError, ProposeAddMemberError, - RemoveMembersError, }, }; use openmls_rust_crypto::MemoryStorageError; -use std::{str::Utf8Error, string::FromUtf8Error}; +use std::string::FromUtf8Error; use ds::{waku_actor::WakuMessageToSend, DeliveryServiceError}; use mls_crypto::error::IdentityError; #[derive(Debug, thiserror::Error)] pub enum ConsensusError { - #[error("Invalid vote ID")] - InvalidVoteId, - #[error("Invalid vote hash")] - InvalidVoteHash, + #[error(transparent)] + MessageError(#[from] MessageError), + + #[error("Verification failed")] + InvalidVoteSignature, #[error("Duplicate vote")] DuplicateVote, + #[error("Empty vote owner")] + EmptyVoteOwner, + #[error("Vote expired")] + VoteExpired, + #[error("Invalid vote hash")] + InvalidVoteHash, + #[error("Empty vote hash")] + EmptyVoteHash, + #[error("Received hash mismatch")] + ReceivedHashMismatch, + #[error("Parent hash mismatch")] + ParentHashMismatch, + #[error("Invalid vote timestamp")] + InvalidVoteTimestamp, + #[error("Session not active")] SessionNotActive, - #[error("Invalid message")] - InvalidMessage, - #[error("Proposal not found")] - ProposalNotFound, - #[error("Vote validation failed")] - VoteValidationFailed, - #[error("Consensus timeout")] - ConsensusTimeout, - #[error("Insufficient votes for consensus")] - InsufficientVotes, - #[error("Invalid signature")] - InvalidSignature, - #[error("Proposal expired")] - ProposalExpired, - #[error("An unknown consensus error occurred: {0}")] - Other(String), + #[error("Group not found")] + GroupNotFound, + #[error("Session not found")] + SessionNotFound, + + #[error("User already voted")] + UserAlreadyVoted, + + #[error("Proposal already exist in consensus service")] + ProposalAlreadyExist, + + #[error("Empty signature")] + EmptySignature, + #[error("Invalid signature: {0}")] + InvalidSignature(String), + + #[error("Failed to get current time")] + FailedToGetCurrentTime(#[from] std::time::SystemTimeError), } #[derive(Debug, thiserror::Error)] @@ -52,6 +68,10 @@ pub enum MessageError { InvalidJson(#[from] serde_json::Error), #[error("Failed to serialize or deserialize MLS message: {0}")] InvalidMlsMessage(#[from] MlsMessageError), + #[error("Invalid alloy signature: {0}")] + InvalidAlloySignature(#[from] alloy::primitives::SignatureError), + #[error("Mismatched length: expected {expect}, got {actual}")] + MismatchedLength { expect: usize, actual: usize }, } #[derive(Debug, thiserror::Error)] @@ -65,15 +85,15 @@ pub enum GroupError { MlsGroupNotSet, #[error("Group still active")] GroupStillActive, - #[error("Empty welcome message")] - EmptyWelcomeMessage, - #[error("Member not found")] - MemberNotFound, - #[error("Invalid state transition")] - InvalidStateTransition, + #[error("Invalid state transition from {from} to {to}")] + InvalidStateTransition { from: String, to: String }, #[error("Empty proposals for current epoch")] EmptyProposals, + #[error("Invalid state [{state}] to send message [{message_type}]")] + InvalidStateToMessageSend { state: String, message_type: String }, + #[error("Failed to decode hex address: {0}")] + HexDecodeError(#[from] alloy::hex::FromHexError), #[error("Unable to create MLS group: {0}")] UnableToCreateGroup(#[from] NewGroupError), #[error("Unable to merge pending commit in MLS group: {0}")] @@ -84,8 +104,6 @@ pub enum GroupError { InvalidProcessMessage(#[from] ProcessMessageError), #[error("Unable to encrypt MLS message: {0}")] UnableToEncryptMlsMessage(#[from] CreateMessageError), - #[error("Unable to remove members: {0}")] - UnableToRemoveMembers(#[from] RemoveMembersError), #[error("Unable to create proposal to add members: {0}")] UnableToCreateProposal(#[from] ProposeAddMemberError), #[error("Unable to create proposal to remove members: {0}")] @@ -96,21 +114,10 @@ pub enum GroupError { ), #[error("Unable to store pending proposal: {0}")] UnableToStorePendingProposal(#[from] MemoryStorageError), - #[error("Failed to serialize mls message: {0}")] MlsMessageError(#[from] MlsMessageError), - - #[error("UTF-8 parsing error: {0}")] - Utf8ParsingError(#[from] FromUtf8Error), - #[error("JSON processing error: {0}")] - JsonError(#[from] serde_json::Error), - #[error("Serialization error: {0}")] - SerializationError(#[from] tls_codec::Error), #[error("Failed to decode app message: {0}")] - AppMessageDecodeError(String), - - #[error("An unknown error occurred: {0}")] - Other(anyhow::Error), + AppMessageDecodeError(#[from] prost::DecodeError), } #[derive(Debug, thiserror::Error)] @@ -123,63 +130,56 @@ pub enum UserError { GroupError(#[from] GroupError), #[error(transparent)] MessageError(#[from] MessageError), + #[error(transparent)] + ConsensusError(#[from] ConsensusError), - #[error("Group already exists: {0}")] - GroupAlreadyExistsError(String), - #[error("Group not found: {0}")] - GroupNotFoundError(String), - - #[error("Unsupported message type.")] - UnsupportedMessageType, + #[error("Group already exists")] + GroupAlreadyExistsError, + #[error("Group not found")] + GroupNotFoundError, + #[error("MLS group not initialized")] + MlsGroupNotInitialized, #[error("Welcome message cannot be empty.")] EmptyWelcomeMessageError, + #[error("Failed to extract welcome message")] + FailedToExtractWelcomeMessage, #[error("Message verification failed")] MessageVerificationFailed, - + #[error("Failed to create group: {0}")] + UnableToCreateGroup(String), + #[error("Failed to send message to user: {0}")] + UnableToHandleStewardEpoch(String), + #[error("Failed to process steward message: {0}")] + ProcessStewardMessageError(String), + #[error("Failed to get group update requests: {0}")] + GetGroupUpdateRequestsError(String), + #[error("Invalid user action: {0}")] + InvalidUserAction(String), + #[error("Failed to start voting: {0}")] + UnableToStartVoting(String), #[error("Unknown content topic type: {0}")] UnknownContentTopicType(String), + #[error("Failed to send message to ws: {0}")] + UnableToSendMessageToWs(String), + #[error("Invalid group state: {0}")] + InvalidGroupState(String), + #[error("No proposals found")] + NoProposalsFound, + #[error("Invalid app message type")] + InvalidAppMessageType, #[error("Failed to create staged join: {0}")] MlsWelcomeError(#[from] WelcomeError), - #[error("UTF-8 parsing error: {0}")] Utf8ParsingError(#[from] FromUtf8Error), - #[error("UTF-8 string parsing error: {0}")] - Utf8StringParsingError(#[from] Utf8Error), - #[error("JSON processing error: {0}")] - JsonError(#[from] serde_json::Error), - #[error("Serialization error: {0}")] - SerializationError(#[from] tls_codec::Error), #[error("Failed to parse signer: {0}")] SignerParsingError(#[from] LocalSignerError), - - #[error("Failed to publish message: {0}")] - KameoPublishMessageError(#[from] SendError), - #[error("Failed to create group: {0}")] - KameoCreateGroupError(String), - #[error("Failed to send message to user: {0}")] - KameoSendMessageError(String), - #[error("Failed to get income key packages: {0}")] - GetIncomeKeyPackagesError(String), - #[error("Failed to process steward message: {0}")] - ProcessStewardMessageError(String), - #[error("Failed to process proposals: {0}")] - ProcessProposalsError(String), - #[error("Unsupported mls message type")] - UnsupportedMlsMessageType, #[error("Failed to decode welcome message: {0}")] WelcomeMessageDecodeError(#[from] prost::DecodeError), - #[error("Failed to apply proposals: {0}")] - ApplyProposalsError(String), #[error("Failed to deserialize mls message in: {0}")] - MlsMessageInDeserializeError(String), - #[error("Failed to create invite proposal: {0}")] - CreateInviteProposalError(String), + MlsMessageInDeserializeError(#[from] openmls::prelude::Error), #[error("Failed to try into protocol message: {0}")] - TryIntoProtocolMessageError(String), - #[error("Failed to get group update requests: {0}")] - GetGroupUpdateRequestsError(String), - + TryIntoProtocolMessageError(#[from] openmls::framing::errors::ProtocolMessageError), #[error("Failed to send message to waku: {0}")] WakuSendMessageError(#[from] tokio::sync::mpsc::error::SendError), } diff --git a/src/group.rs b/src/group.rs index b74468d..2ce9cb4 100644 --- a/src/group.rs +++ b/src/group.rs @@ -1,51 +1,79 @@ use alloy::hex; use kameo::Actor; -use log::info; +use log::{error, info}; use openmls::{ group::{GroupEpoch, GroupId, MlsGroup, MlsGroupCreateConfig}, prelude::{ - Credential, CredentialWithKey, KeyPackage, LeafNodeIndex, OpenMlsProvider, + ApplicationMessage, CredentialWithKey, KeyPackage, LeafNodeIndex, OpenMlsProvider, ProcessedMessageContent, ProtocolMessage, }, }; use openmls_basic_credential::SignatureKeyPair; use prost::Message; use std::{fmt::Display, sync::Arc}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use uuid; use crate::{ + consensus::v1::{Proposal, Vote}, error::GroupError, - message::{ - wrap_batch_proposals_into_application_msg, wrap_conversation_message_into_application_msg, - wrap_group_announcement_in_welcome_msg, wrap_invitation_into_welcome_msg, - }, - protos::messages::v1::{app_message, AppMessage}, + message::{message_types, MessageType}, + protos::messages::v1::{app_message, AppMessage, BatchProposalsMessage, WelcomeMessage}, state_machine::{GroupState, GroupStateMachine}, steward::GroupUpdateRequest, }; use ds::{waku_actor::WakuMessageToSend, APP_MSG_SUBTOPIC, WELCOME_SUBTOPIC}; use mls_crypto::openmls_provider::MlsProvider; +/// Represents the action to take after processing a group message or event. +/// +/// This enum defines the possible outcomes when processing group-related operations, +/// allowing the caller to determine the appropriate next steps. #[derive(Clone, Debug)] pub enum GroupAction { GroupAppMsg(AppMessage), + GroupProposal(Proposal), + GroupVote(Vote), LeaveGroup, DoNothing, } +impl Display for GroupAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GroupAction::GroupAppMsg(_) => write!(f, "Message will be printed to the app"), + GroupAction::GroupProposal(_) => write!(f, "Get proposal for voting"), + GroupAction::GroupVote(_) => write!(f, "Get vote for proposal"), + GroupAction::LeaveGroup => write!(f, "User will leave the group"), + GroupAction::DoNothing => write!(f, "Do Nothing"), + } + } +} + +/// Represents a group in the MLS-based messaging system. +/// +/// The Group struct manages the lifecycle of an MLS group, including member management, +/// proposal handling, and state transitions. It integrates with the state machine +/// to enforce proper group operations and steward epoch management. +/// +/// ## Key Features: +/// - MLS group management and message processing +/// - Steward epoch coordination and proposal handling +/// - State machine integration for proper workflow enforcement +/// - Member addition/removal through proposals +/// - Message validation and permission checking #[derive(Clone, Debug, Actor)] pub struct Group { group_name: String, mls_group: Option>>, is_kp_shared: bool, app_id: Vec, - state_machine: GroupStateMachine, + state_machine: Arc>, } impl Group { pub fn new( - group_name: String, + group_name: &str, is_creation: bool, provider: Option<&MlsProvider>, signer: Option<&SignatureKeyPair>, @@ -53,14 +81,14 @@ impl Group { ) -> Result { let uuid = uuid::Uuid::new_v4().as_bytes().to_vec(); let mut group = Group { - group_name: group_name.clone(), + group_name: group_name.to_string(), mls_group: None, is_kp_shared: false, app_id: uuid.clone(), state_machine: if is_creation { - GroupStateMachine::new_with_steward() + Arc::new(RwLock::new(GroupStateMachine::new_with_steward())) } else { - GroupStateMachine::new() + Arc::new(RwLock::new(GroupStateMachine::new())) }, }; @@ -87,11 +115,20 @@ impl Group { Ok(group) } + /// Get the identities of all current group members. + /// + /// ## Returns: + /// - Vector of member identity bytes + /// + /// ## Errors: + /// - `GroupError::MlsGroupNotSet` if MLS group is not initialized pub async fn members_identity(&self) -> Result>, GroupError> { - if !self.is_mls_group_initialized() { - return Err(GroupError::MlsGroupNotSet); - } - let mls_group = self.mls_group.as_ref().unwrap().lock().await; + let mls_group = self + .mls_group + .as_ref() + .ok_or_else(|| GroupError::MlsGroupNotSet)? + .lock() + .await; let x = mls_group .members() .map(|m| m.credential.serialized_content().to_vec()) @@ -99,14 +136,26 @@ impl Group { Ok(x) } + /// Find the leaf node index of a member by their identity. + /// + /// ## Parameters: + /// - `identity`: The member's identity bytes + /// + /// ## Returns: + /// - `Some(LeafNodeIndex)` if member is found, `None` otherwise + /// + /// ## Errors: + /// - `GroupError::MlsGroupNotSet` if MLS group is not initialized pub async fn find_member_index( &self, identity: Vec, ) -> Result, GroupError> { - if !self.is_mls_group_initialized() { - return Err(GroupError::MlsGroupNotSet); - } - let mls_group = self.mls_group.as_ref().unwrap().lock().await; + let mls_group = self + .mls_group + .as_ref() + .ok_or_else(|| GroupError::MlsGroupNotSet)? + .lock() + .await; let x = mls_group.members().find_map(|m| { if m.credential.serialized_content() == identity { Some(m.index) @@ -117,137 +166,282 @@ impl Group { Ok(x) } + /// Get the current epoch of the MLS group. + /// + /// ## Returns: + /// - Current group epoch + /// + /// ## Errors: + /// - `GroupError::MlsGroupNotSet` if MLS group is not initialized pub async fn epoch(&self) -> Result { - if !self.is_mls_group_initialized() { - return Err(GroupError::MlsGroupNotSet); - } - let mls_group = self.mls_group.as_ref().unwrap().lock().await; + let mls_group = self + .mls_group + .as_ref() + .ok_or_else(|| GroupError::MlsGroupNotSet)? + .lock() + .await; Ok(mls_group.epoch()) } + /// Set the MLS group instance for this group. + /// + /// ## Parameters: + /// - `mls_group`: The MLS group instance to set + /// + /// ## Effects: + /// - Sets `is_kp_shared` to `true` + /// - Stores the MLS group in an `Arc>` pub fn set_mls_group(&mut self, mls_group: MlsGroup) -> Result<(), GroupError> { self.is_kp_shared = true; self.mls_group = Some(Arc::new(Mutex::new(mls_group))); Ok(()) } + /// Check if the MLS group is initialized. + /// + /// ## Returns: + /// - `true` if MLS group is set, `false` otherwise pub fn is_mls_group_initialized(&self) -> bool { self.mls_group.is_some() } + /// Check if the key package has been shared. + /// + /// ## Returns: + /// - `true` if key package is shared, `false` otherwise pub fn is_kp_shared(&self) -> bool { self.is_kp_shared } + /// Set the key package shared status. + /// + /// ## Parameters: + /// - `is_kp_shared`: Whether the key package is shared pub fn set_kp_shared(&mut self, is_kp_shared: bool) { self.is_kp_shared = is_kp_shared; } - pub fn is_steward(&self) -> bool { - self.state_machine.has_steward() + /// Check if this group has a steward configured. + /// + /// ## Returns: + /// - `true` if steward is configured, `false` otherwise + pub async fn is_steward(&self) -> bool { + self.state_machine.read().await.has_steward() } - pub fn app_id(&self) -> Vec { - self.app_id.clone() + /// Get the application ID for this group. + /// + /// ## Returns: + /// - Reference to the application ID bytes + pub fn app_id(&self) -> &[u8] { + &self.app_id } - pub fn decrypt_steward_msg(&self, message: Vec) -> Result { - if !self.is_steward() { - return Err(GroupError::StewardNotSet); - } - let steward = self.state_machine.get_steward().unwrap(); - let msg: KeyPackage = steward.decrypt_message(message)?; + /// Get the group name as bytes. + /// + /// ## Returns: + /// - Reference to the group name bytes + pub fn group_name_bytes(&self) -> &[u8] { + self.group_name.as_bytes() + } + + /// Generate a steward announcement message for this group. + /// + /// ## Returns: + /// - Waku message containing the steward announcement + /// + /// ## Errors: + /// - `GroupError::StewardNotSet` if no steward is configured + /// + /// ## Effects: + /// - Refreshes the steward's key pair + /// - Creates a new group announcement + pub async fn generate_steward_message(&mut self) -> Result { + let mut state_machine = self.state_machine.write().await; + let steward = state_machine + .get_steward_mut() + .ok_or(GroupError::StewardNotSet)?; + steward.refresh_key_pair().await; + + let welcome_msg: WelcomeMessage = steward.create_announcement().await.into(); + let msg_to_send = WakuMessageToSend::new( + welcome_msg.encode_to_vec(), + WELCOME_SUBTOPIC, + &self.group_name, + self.app_id(), + ); + Ok(msg_to_send) + } + + /// Decrypt a steward message using the group's steward key. + /// + /// ## Parameters: + /// - `message`: The encrypted message bytes + /// + /// ## Returns: + /// - Decrypted KeyPackage + /// + /// ## Errors: + /// - `GroupError::StewardNotSet` if no steward is configured + /// - Various decryption errors from the steward + pub async fn decrypt_steward_msg( + &mut self, + message: Vec, + ) -> Result { + let state_machine = self.state_machine.read().await; + let steward = state_machine + .get_steward() + .ok_or(GroupError::StewardNotSet)?; + let msg: KeyPackage = steward.decrypt_message(message).await?; Ok(msg) } - // Functions to store proposals in steward queue - + /// Store an invite proposal in the steward queue for the current epoch. + /// + /// ## Parameters: + /// - `key_package`: The key package of the member to add + /// + /// ## Effects: + /// - Adds an AddMember proposal to the current epoch + /// - Proposal will be processed in the next steward epoch pub async fn store_invite_proposal( &mut self, key_package: Box, ) -> Result<(), GroupError> { - self.state_machine + let mut state_machine = self.state_machine.write().await; + state_machine .add_proposal(GroupUpdateRequest::AddMember(key_package)) .await; Ok(()) } - pub async fn store_remove_proposal(&mut self, identity: Vec) -> Result<(), GroupError> { - self.state_machine + /// Store a remove proposal in the steward queue for the current epoch. + /// + /// ## Parameters: + /// - `identity`: The identity string of the member to remove + /// + /// ## Effects: + /// - Adds a RemoveMember proposal to the current epoch + /// - Proposal will be processed in the next steward epoch + pub async fn store_remove_proposal(&mut self, identity: String) -> Result<(), GroupError> { + let mut state_machine = self.state_machine.write().await; + state_machine .add_proposal(GroupUpdateRequest::RemoveMember(identity)) .await; Ok(()) } - // Fucntions to process protocol messages + /// Process an application message and determine the appropriate action. + /// + /// ## Parameters: + /// - `message`: The application message to process + /// + /// ## Returns: + /// - `GroupAction` indicating what action should be taken + /// + /// ## Effects: + /// - For ban requests from stewards: automatically adds remove proposals + /// - For other messages: processes normally + /// + /// ## Supported Message Types: + /// - Conversation messages + /// - Proposals + /// - Votes + /// - Ban requests + pub async fn process_application_message( + &mut self, + message: ApplicationMessage, + ) -> Result { + let app_msg = AppMessage::decode(message.into_bytes().as_slice())?; + match app_msg.payload { + Some(app_message::Payload::ConversationMessage(conversation_message)) => { + info!("[group::process_application_message]: Processing conversation message"); + Ok(GroupAction::GroupAppMsg(conversation_message.into())) + } + Some(app_message::Payload::Proposal(proposal)) => { + info!("[group::process_application_message]: Processing proposal message"); + Ok(GroupAction::GroupProposal(proposal)) + } + Some(app_message::Payload::Vote(vote)) => { + info!("[group::process_application_message]: Processing vote message"); + Ok(GroupAction::GroupVote(vote)) + } + Some(app_message::Payload::BanRequest(ban_request)) => { + info!("[group::process_application_message]: Processing ban request message"); + if self.is_steward().await { + info!( + "[group::process_application_message]: Steward adding remove proposal for user {}", + ban_request.user_to_ban.clone() + ); + self.store_remove_proposal(ban_request.user_to_ban.clone()) + .await?; + } else { + info!( + "[group::process_application_message]: Non-steward received ban request message" + ); + } + + Ok(GroupAction::GroupAppMsg(ban_request.into())) + } + _ => Ok(GroupAction::DoNothing), + } + } + + /// Process a protocol message from the MLS group. + /// + /// ## Parameters: + /// - `message`: The protocol message to process + /// - `provider`: The MLS provider for processing + /// + /// ## Returns: + /// - `GroupAction` indicating what action should be taken + /// + /// ## Effects: + /// - Processes MLS group messages + /// - Handles member removal scenarios + /// - Stores pending proposals + /// + /// ## Supported Message Types: + /// - Application messages + /// - Proposal messages + /// - External join proposals + /// - Staged commit messages pub async fn process_protocol_msg( &mut self, message: ProtocolMessage, provider: &MlsProvider, - signature_key: Vec, ) -> Result { - let group_id = message.group_id().as_slice().to_vec(); - if group_id != self.group_name.as_bytes().to_vec() { + let group_id = message.group_id().as_slice(); + if group_id != self.group_name_bytes() { return Ok(GroupAction::DoNothing); } - if !self.is_mls_group_initialized() { - return Err(GroupError::MlsGroupNotSet); - } - let mut mls_group = self.mls_group.as_mut().unwrap().lock().await; - + let mut mls_group = self + .mls_group + .as_ref() + .ok_or_else(|| GroupError::MlsGroupNotSet)? + .lock() + .await; // If the message is from a previous epoch, we don't need to process it and it's a commit for welcome message if message.epoch() < mls_group.epoch() && message.epoch() == 0.into() { return Ok(GroupAction::DoNothing); } let processed_message = mls_group.process_message(provider, message)?; - let processed_message_credential: Credential = processed_message.credential().clone(); match processed_message.into_content() { ProcessedMessageContent::ApplicationMessage(application_message) => { - let sender_name = { - let user_id = mls_group.members().find_map(|m| { - if m.credential.serialized_content() - == processed_message_credential.serialized_content() - && (signature_key != m.signature_key.as_slice()) - { - Some(hex::encode(m.credential.serialized_content())) - } else { - None - } - }); - if user_id.is_none() { - return Ok(GroupAction::DoNothing); - } - user_id.unwrap() - }; - - let app_msg_bytes = application_message.into_bytes(); - let app_msg_bytes_slice = app_msg_bytes.as_slice(); - let app_msg = AppMessage::decode(app_msg_bytes_slice) - .map_err(|e| GroupError::AppMessageDecodeError(e.to_string()))?; - - match app_msg.payload { - Some(app_message::Payload::ConversationMessage(conversation_message)) => { - let msg_to_send = wrap_conversation_message_into_application_msg( - conversation_message.message, - sender_name, - self.group_name.clone(), - ); - return Ok(GroupAction::GroupAppMsg(msg_to_send)); - } - _ => return Ok(GroupAction::DoNothing), - } + drop(mls_group); + self.process_application_message(application_message).await } ProcessedMessageContent::ProposalMessage(proposal_ptr) => { - let res = mls_group - .store_pending_proposal(provider.storage(), proposal_ptr.as_ref().clone()); - if res.is_err() { - return Err(GroupError::UnableToStorePendingProposal(res.err().unwrap())); - } + mls_group + .store_pending_proposal(provider.storage(), proposal_ptr.as_ref().clone())?; + Ok(GroupAction::DoNothing) + } + ProcessedMessageContent::ExternalJoinProposalMessage(_external_proposal_ptr) => { + Ok(GroupAction::DoNothing) } - ProcessedMessageContent::ExternalJoinProposalMessage(_external_proposal_ptr) => (), ProcessedMessageContent::StagedCommitMessage(commit_ptr) => { let mut remove_proposal: bool = false; if commit_ptr.self_removed() { @@ -255,51 +449,62 @@ impl Group { } mls_group.merge_staged_commit(provider, *commit_ptr)?; if remove_proposal { - // here we need to remove group instance locally and - // also remove correspond key package from local storage ans sc storage if mls_group.is_active() { return Err(GroupError::GroupStillActive); } - return Ok(GroupAction::LeaveGroup); + Ok(GroupAction::LeaveGroup) + } else { + Ok(GroupAction::DoNothing) } } - }; - Ok(GroupAction::DoNothing) - } - - pub fn generate_steward_message(&mut self) -> Result { - if !self.is_steward() { - return Err(GroupError::StewardNotSet); } - let steward = self.state_machine.get_steward_mut().unwrap(); - steward.refresh_key_pair(); - - let msg_to_send = WakuMessageToSend::new( - wrap_group_announcement_in_welcome_msg(steward.create_announcement()).encode_to_vec(), - WELCOME_SUBTOPIC, - self.group_name.clone(), - self.app_id.clone(), - ); - Ok(msg_to_send) } + /// Build and validate a message for sending to the group. + /// + /// ## Parameters: + /// - `provider`: The MLS provider for message creation + /// - `signer`: The signature key pair for signing + /// - `msg`: The application message to build + /// + /// ## Returns: + /// - Waku message ready for transmission + /// + /// ## Effects: + /// - Validates message can be sent in current state + /// - Creates MLS message with proper signing + /// + /// ## Validation: + /// - Checks state machine permissions + /// - Ensures steward status and proposal availability pub async fn build_message( &mut self, provider: &MlsProvider, signer: &SignatureKeyPair, msg: &AppMessage, ) -> Result { - // Check if message can be sent in current state - let is_steward = self.is_steward(); + let is_steward = self.is_steward().await; let has_proposals = self.get_pending_proposals_count().await > 0; - if !self.can_send_message(is_steward, has_proposals) { - return Err(GroupError::InvalidStateTransition); + let message_type = msg + .payload + .as_ref() + .map(|p| p.message_type()) + .unwrap_or(message_types::UNKNOWN); + + // Check if message can be sent in current state + let state_machine = self.state_machine.read().await; + let current_state = state_machine.current_state(); + if !state_machine.can_send_message_type(is_steward, has_proposals, message_type) { + return Err(GroupError::InvalidStateToMessageSend { + state: current_state.to_string(), + message_type: message_type.to_string(), + }); } let message_out = self .mls_group .as_mut() - .unwrap() + .ok_or_else(|| GroupError::MlsGroupNotSet)? .lock() .await .create_message(provider, signer, &msg.encode_to_vec())? @@ -307,70 +512,142 @@ impl Group { Ok(WakuMessageToSend::new( message_out, APP_MSG_SUBTOPIC, - self.group_name.clone(), - self.app_id.clone(), + &self.group_name, + self.app_id(), )) } - // State management methods - pub fn get_state(&self) -> GroupState { - self.state_machine.current_state() - } - - pub fn can_send_message(&self, is_steward: bool, has_proposals: bool) -> bool { - self.state_machine - .can_send_message(is_steward, has_proposals) + /// Get the current state of the group state machine. + /// + /// ## Returns: + /// - Current `GroupState` of the group + pub async fn get_state(&self) -> GroupState { + self.state_machine.read().await.current_state() } /// Get the number of pending proposals for the current epoch pub async fn get_pending_proposals_count(&self) -> usize { - let count = self.state_machine.get_current_epoch_proposals_count().await; - info!("State machine reports {count} current epoch proposals"); - count + self.state_machine + .read() + .await + .get_current_epoch_proposals_count() + .await } /// Get the number of pending proposals for the voting epoch pub async fn get_voting_proposals_count(&self) -> usize { - let count = self.state_machine.get_voting_epoch_proposals_count().await; - info!("State machine reports {count} voting proposals"); - count + self.state_machine + .read() + .await + .get_voting_epoch_proposals_count() + .await } /// Get the proposals for the voting epoch pub async fn get_proposals_for_voting_epoch(&self) -> Vec { - self.state_machine.get_voting_epoch_proposals().await + self.state_machine + .read() + .await + .get_voting_epoch_proposals() + .await } - /// Start a new steward epoch, moving proposals from the previous epoch to the voting epoch - /// and transitioning to Waiting state. - pub async fn start_steward_epoch(&mut self) -> Result<(), GroupError> { - if !self.is_steward() { - return Err(GroupError::StewardNotSet); - } - - // Start new epoch and move proposals from current epoch to voting epoch - self.state_machine.start_steward_epoch().await?; - Ok(()) + /// Start voting on proposals for the current epoch + pub async fn start_voting(&mut self) -> Result<(), GroupError> { + self.state_machine.write().await.start_voting() } - /// Start voting on proposals for the current epoch, transitioning to Voting state. - pub fn start_voting(&mut self) -> Result<(), GroupError> { - self.state_machine.start_voting() + /// Complete voting and update state based on result + pub async fn complete_voting(&mut self, vote_result: bool) -> Result<(), GroupError> { + self.state_machine + .write() + .await + .complete_voting(vote_result) } - /// Complete voting, updating group state based on the result. - pub fn complete_voting(&mut self, vote_result: bool) -> Result<(), GroupError> { - self.state_machine.complete_voting(vote_result) + /// Start working state (for non-steward peers after consensus or edge case recovery) + pub async fn start_working(&mut self) { + self.state_machine.write().await.start_working(); + } + + /// Start consensus reached state (for non-steward peers after consensus) + pub async fn start_consensus_reached(&mut self) { + self.state_machine.write().await.start_consensus_reached(); + } + + /// Recover from consensus failure by transitioning back to Working state + pub async fn recover_from_consensus_failure(&mut self) -> Result<(), GroupError> { + self.state_machine + .write() + .await + .recover_from_consensus_failure() + } + + /// Start waiting state (for non-steward peers after consensus or edge case recovery) + pub async fn start_waiting(&mut self) { + self.state_machine.write().await.start_waiting(); + } + + /// Start steward epoch with validation + pub async fn start_steward_epoch_with_validation(&mut self) -> Result { + self.state_machine + .write() + .await + .start_steward_epoch_with_validation() + .await + } + + /// Handle successful vote for group + pub async fn handle_yes_vote(&mut self) -> Result<(), GroupError> { + self.state_machine.write().await.handle_yes_vote().await + } + + /// Handle failed vote for group + pub async fn handle_no_vote(&mut self) -> Result<(), GroupError> { + self.state_machine.write().await.handle_no_vote().await + } + + /// Start waiting state when steward sends batch proposals after consensus + pub async fn start_waiting_after_consensus(&mut self) -> Result<(), GroupError> { + self.state_machine + .write() + .await + .start_waiting_after_consensus() } /// Create a batch proposals message and welcome message for the current epoch. - /// Returns [batch_proposals_msg, welcome_msg] where welcome_msg is only included if there are new members. + /// + /// ## Parameters: + /// - `provider`: The MLS provider for proposal creation + /// - `signer`: The signature key pair for signing + /// + /// ## Returns: + /// - Vector of Waku messages: [batch_proposals_msg, welcome_msg] + /// - Welcome message is only included if there are new members to add + /// + /// ## Preconditions: + /// - Must be a steward + /// - Must have proposals in the voting epoch + /// + /// ## Effects: + /// - Creates MLS proposals for all pending group updates + /// - Commits all proposals to the MLS group + /// - Merges the commit to apply changes + /// + /// ## Supported Proposal Types: + /// - AddMember: Adds new member with key package + /// - RemoveMember: Removes member by identity + /// + /// ## Errors: + /// - `GroupError::StewardNotSet` if not a steward + /// - `GroupError::EmptyProposals` if no proposals exist + /// - Various MLS processing errors pub async fn create_batch_proposals_message( &mut self, provider: &MlsProvider, signer: &SignatureKeyPair, ) -> Result, GroupError> { - if !self.is_steward() { + if !self.is_steward().await { return Err(GroupError::StewardNotSet); } @@ -380,85 +657,95 @@ impl Group { return Err(GroupError::EmptyProposals); } - // Pre-collect member indices to avoid borrow checker issues let mut member_indices = Vec::new(); for proposal in &proposals { if let GroupUpdateRequest::RemoveMember(identity) = proposal { - let member_index = self.find_member_index(identity.clone()).await?; + // Convert the address string to bytes for proper MLS credential matching + let identity_bytes = if let Some(hex_string) = identity.strip_prefix("0x") { + // Remove 0x prefix and convert to bytes + hex::decode(hex_string)? + } else { + // Assume it's already a hex string without 0x prefix + hex::decode(identity)? + }; + + let member_index = self.find_member_index(identity_bytes).await?; member_indices.push(member_index); } else { member_indices.push(None); } } - let mut mls_proposals = Vec::new(); - let mut mls_group = self.mls_group.as_mut().unwrap().lock().await; + let (out_messages, welcome) = { + let mut mls_group = self + .mls_group + .as_mut() + .ok_or_else(|| GroupError::MlsGroupNotSet)? + .lock() + .await; - // Convert each GroupUpdateRequest to MLS proposal - for (i, proposal) in proposals.iter().enumerate() { - match proposal { - GroupUpdateRequest::AddMember(boxed_key_package) => { - let (mls_message_out, _proposal_ref) = mls_group.propose_add_member( - provider, - signer, - boxed_key_package.as_ref(), - )?; - mls_proposals.push(mls_message_out.to_bytes()?); - } - GroupUpdateRequest::RemoveMember(_identity) => { - if let Some(index) = member_indices[i] { - let (mls_message_out, _proposal_ref) = - mls_group.propose_remove_member(provider, signer, index)?; + // Convert each GroupUpdateRequest to MLS proposal + for (i, proposal) in proposals.iter().enumerate() { + match proposal { + GroupUpdateRequest::AddMember(boxed_key_package) => { + let (mls_message_out, _proposal_ref) = mls_group.propose_add_member( + provider, + signer, + boxed_key_package.as_ref(), + )?; mls_proposals.push(mls_message_out.to_bytes()?); } + GroupUpdateRequest::RemoveMember(identity) => { + if let Some(index) = member_indices[i] { + let (mls_message_out, _proposal_ref) = + mls_group.propose_remove_member(provider, signer, index)?; + mls_proposals.push(mls_message_out.to_bytes()?); + } else { + error!("[create_batch_proposals_message]: Failed to find member index for identity: {identity}"); + } + } } } - } - // Create commit with all proposals - let (out_messages, welcome, _group_info) = - mls_group.commit_to_pending_proposals(provider, signer)?; - - // Merge the commit - mls_group.merge_pending_commit(provider)?; + // Create commit with all proposals + let (out_messages, welcome, _group_info) = + mls_group.commit_to_pending_proposals(provider, signer)?; + // Merge the commit + mls_group.merge_pending_commit(provider)?; + (out_messages, welcome) + }; // Create batch proposals message (without welcome) - let batch_msg = wrap_batch_proposals_into_application_msg( - self.group_name.clone(), + let batch_msg: AppMessage = BatchProposalsMessage { + group_name: self.group_name_bytes().to_vec(), mls_proposals, - out_messages.to_bytes()?, - ); + commit_message: out_messages.to_bytes()?, + } + .into(); let batch_waku_msg = WakuMessageToSend::new( batch_msg.encode_to_vec(), APP_MSG_SUBTOPIC, - self.group_name.clone(), - self.app_id.clone(), + &self.group_name, + self.app_id(), ); let mut messages = vec![batch_waku_msg]; // Create separate welcome message if there are new members if let Some(welcome) = welcome { - let welcome_msg = wrap_invitation_into_welcome_msg(welcome)?; - + let welcome_msg: WelcomeMessage = welcome.try_into()?; let welcome_waku_msg = WakuMessageToSend::new( welcome_msg.encode_to_vec(), WELCOME_SUBTOPIC, - self.group_name.clone(), - self.app_id.clone(), + &self.group_name, + self.app_id(), ); - messages.push(welcome_waku_msg); } Ok(messages) } - - pub async fn remove_proposals_and_complete(&mut self) -> Result<(), GroupError> { - self.state_machine.remove_proposals_and_complete().await?; - Ok(()) - } } impl Display for Group { diff --git a/src/lib.rs b/src/lib.rs index 0c0f487..0707ee1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,23 +29,29 @@ //! //! ## Steward Epoch Flow //! -//! The system operates in epochs managed by a steward: +//! The system operates in epochs managed by a steward with robust state management: //! -//! 1. **Working State**: Normal operation, all users can send messages -//! 2. **Waiting State**: Steward epoch active, collecting proposals -//! 3. **Voting State**: Consensus voting on collected proposals +//! 1. **Working State**: Normal operation, all users can send any message freely +//! 2. **Waiting State**: Steward epoch active, only steward can send BATCH_PROPOSALS_MESSAGE +//! 3. **Voting State**: Consensus voting, restricted message types (VOTE/USER_VOTE for all, VOTING_PROPOSAL/PROPOSAL for steward only) //! -//! ### State Transitions +//! ### Complete State Transitions //! //! ```text //! Working --start_steward_epoch()--> Waiting (if proposals exist) -//! Working --start_steward_epoch()--> Working (if no proposals) +//! Working --start_steward_epoch()--> Working (if no proposals - no state change) //! Waiting --start_voting()---------> Voting -//! Voting --complete_voting(true)--> Waiting (vote passed) -//! Voting --complete_voting(false)-> Working (vote failed) -//! Waiting --apply_proposals_and_complete()--> Working +//! Waiting --no_proposals_found()---> Working (edge case: proposals disappear during voting) +//! Voting --complete_voting(YES)----> Waiting --apply_proposals()--> Working +//! Voting --complete_voting(NO)-----> Working //! ``` //! +//! ### Steward State Guarantees +//! +//! - **Always returns to Working**: Steward transitions back to Working state after every epoch +//! - **No proposals handling**: If no proposals exist, steward stays in Working state +//! - **Edge case coverage**: All scenarios including proposal disappearance are handled +//! - **Robust error handling**: Invalid state transitions are prevented and logged //! ## Message Flow //! //! ### Regular Messages @@ -86,6 +92,7 @@ //! - **Waku**: Decentralized messaging protocol //! - **Alloy**: Ethereum wallet and signing +use alloy::primitives::{Address, PrimitiveSignature}; use ecies::{decrypt, encrypt}; use libsecp256k1::{sign, verify, Message, PublicKey, SecretKey, Signature as libSignature}; use rand::thread_rng; @@ -94,14 +101,14 @@ use std::{ collections::HashSet, sync::{Arc, Mutex}, }; -use tokio::sync::mpsc::Sender; +use tokio::sync::{mpsc::Sender, RwLock}; use waku_bindings::{WakuContentTopic, WakuMessage}; use ds::waku_actor::WakuMessageToSend; use error::{GroupError, MessageError}; pub mod action_handlers; -// pub mod consensus; +pub mod consensus; pub mod error; pub mod group; pub mod message; @@ -115,16 +122,19 @@ pub mod ws_actor; pub mod protos { pub mod messages { pub mod v1 { + pub mod consensus { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/consensus.v1.rs")); + } + } include!(concat!(env!("OUT_DIR"), "/de_mls.messages.v1.rs")); - include!(concat!(env!("OUT_DIR"), "/consensus.v1.rs")); } } } - pub struct AppState { pub waku_node: Sender, pub rooms: Mutex>, - pub content_topics: Arc>>, + pub content_topics: Arc>>, pub pubsub: tokio::sync::broadcast::Sender, } @@ -153,10 +163,13 @@ pub fn verify_message( signature: &[u8], public_key: &[u8], ) -> Result { + const COMPRESSED_PUBLIC_KEY_SIZE: usize = 33; + let digest = sha256::Hash::hash(message); let msg = Message::parse(&digest.to_byte_array()); let signature = libSignature::parse_der(signature)?; - let mut pub_key_bytes: [u8; 33] = [0; 33]; + + let mut pub_key_bytes: [u8; COMPRESSED_PUBLIC_KEY_SIZE] = [0; COMPRESSED_PUBLIC_KEY_SIZE]; pub_key_bytes[..].copy_from_slice(public_key); let public_key = PublicKey::parse_compressed(&pub_key_bytes)?; Ok(verify(&msg, &signature, &public_key)) @@ -174,16 +187,49 @@ pub fn decrypt_message(message: &[u8], secret_key: SecretKey) -> Result, } /// Check if a content topic exists in a list of topics or if the list is empty -pub fn match_content_topic( - content_topics: &Arc>>, +pub async fn match_content_topic( + content_topics: &Arc>>, topic: &WakuContentTopic, ) -> bool { - let locked_topics = content_topics.lock().unwrap(); + let locked_topics = content_topics.read().await; locked_topics.is_empty() || locked_topics.iter().any(|t| t == topic) } +pub trait LocalSigner { + fn local_sign_message( + &self, + message: &[u8], + ) -> impl std::future::Future, anyhow::Error>> + Send; + + fn address(&self) -> Address; + fn address_string(&self) -> String; + fn address_bytes(&self) -> Vec; +} + +pub fn verify_vote_hash( + signature: &[u8], + public_key: &[u8], + message: &[u8], +) -> Result { + let signature_bytes: [u8; 65] = + signature + .try_into() + .map_err(|_| MessageError::MismatchedLength { + expect: 65, + actual: signature.len(), + })?; + let signature = PrimitiveSignature::from_raw_array(&signature_bytes)?; + let address = signature.recover_address_from_msg(message)?; + let address_bytes = address.as_slice().to_vec(); + Ok(address_bytes == public_key) +} + #[cfg(test)] mod tests { + use alloy::signers::local::PrivateKeySigner; + + use crate::{verify_vote_hash, LocalSigner}; + use super::{decrypt_message, encrypt_message, generate_keypair, sign_message, verify_message}; #[test] @@ -191,17 +237,32 @@ mod tests { let message = b"Hello, world!"; let (public_key, secret_key) = generate_keypair(); let signature = sign_message(message, &secret_key); - let verified = verify_message(message, &signature, &public_key.serialize_compressed()); - assert!(verified.is_ok()); - assert!(verified.unwrap()); + let verified = verify_message(message, &signature, &public_key.serialize_compressed()) + .expect("Failed to verify message"); + assert!(verified); } #[test] fn test_encrypt_decrypt_message() { let message = b"Hello, world!"; let (public_key, secret_key) = generate_keypair(); - let encrypted = encrypt_message(message, &public_key.serialize_compressed()); - let decrypted = decrypt_message(&encrypted.unwrap(), secret_key); - assert_eq!(message, decrypted.unwrap().as_slice()); + let encrypted = encrypt_message(message, &public_key.serialize_compressed()) + .expect("Failed to encrypt message"); + let decrypted = decrypt_message(&encrypted, secret_key).expect("Failed to decrypt message"); + assert_eq!(message, decrypted.as_slice()); + } + + #[tokio::test] + async fn test_local_signer() { + let signer = PrivateKeySigner::random(); + let message = b"Hello, world!"; + let signature = signer + .local_sign_message(message) + .await + .expect("Failed to sign message"); + + let verified = verify_vote_hash(&signature, &signer.address_bytes(), message) + .expect("Failed to verify vote hash"); + assert!(verified); } } diff --git a/src/main.rs b/src/main.rs index ed0bd04..261b9ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use std::{ net::SocketAddr, sync::{Arc, Mutex}, }; -use tokio::sync::mpsc::channel; +use tokio::sync::{mpsc::channel, RwLock}; use tokio_util::sync::CancellationToken; use tower_http::cors::{Any, CorsLayer}; use waku_bindings::{Multiaddr, WakuMessage}; @@ -39,15 +39,18 @@ async fn main() -> Result<(), Box> { let peer_addresses = std::env::var("PEER_ADDRESSES") .map(|val| { val.split(",") - .map(|addr| addr.parse::().unwrap()) + .map(|addr| { + addr.parse::() + .expect("Failed to parse peer address") + }) .collect() }) .expect("PEER_ADDRESSES is not set"); - let content_topics = Arc::new(Mutex::new(Vec::new())); + let content_topics = Arc::new(RwLock::new(Vec::new())); let (waku_sender, mut waku_receiver) = channel::(100); - let (sender, mut reciever) = channel::(100); + let (sender, mut receiver) = channel::(100); let (tx, _) = tokio::sync::broadcast::channel(100); let app_state = Arc::new(AppState { @@ -76,7 +79,7 @@ async fn main() -> Result<(), Box> { info!("Starting waku node"); tokio::task::block_in_place(move || { tokio::runtime::Handle::current().block_on(async move { - run_waku_node(node_port, Some(peer_addresses), waku_sender, &mut reciever).await + run_waku_node(node_port, Some(peer_addresses), waku_sender, &mut receiver).await }) })?; @@ -138,7 +141,13 @@ async fn handle_socket(socket: WebSocket, state: Arc) { group_id: connect.group_id.clone(), should_create_group: connect.should_create, }); - let mut rooms = state.rooms.lock().unwrap(); + let mut rooms = match state.rooms.lock() { + Ok(rooms) => rooms, + Err(e) => { + log::error!("Failed to acquire rooms lock: {e}"); + continue; + } + }; if !rooms.contains(&connect.group_id.clone()) { rooms.insert(connect.group_id.clone()); } @@ -153,9 +162,13 @@ async fn handle_socket(socket: WebSocket, state: Arc) { } } - let user_actor = create_user_instance(main_loop_connection.unwrap().clone(), state.clone()) - .await - .expect("Failed to start main loop"); + let user_actor = create_user_instance( + main_loop_connection.unwrap().clone(), + state.clone(), + ws_actor.clone(), + ) + .await + .expect("Failed to create user instance"); let user_actor_clone = user_actor.clone(); let state_clone = state.clone(); @@ -168,11 +181,11 @@ async fn handle_socket(socket: WebSocket, state: Arc) { while let Ok(msg) = user_waku_receiver.recv().await { let content_topic = msg.content_topic.clone(); // Check if message belongs to a relevant topic - if !match_content_topic(&state_clone.content_topics, &content_topic) { + if !match_content_topic(&state_clone.content_topics, &content_topic).await { error!("Content topic not match: {content_topic:?}"); return; }; - info!("Received message from waku that matches content topic"); + info!("[handle_socket]: Received message from waku that matches content topic"); let res = handle_user_actions( msg, state_clone.waku_node.clone(), @@ -191,7 +204,7 @@ async fn handle_socket(socket: WebSocket, state: Arc) { let user_ref_clone = user_actor.clone(); let mut recv_messages_ws = { tokio::spawn(async move { - info!("Running recieve messages from websocket"); + info!("Running receive messages from websocket"); while let Some(Ok(Message::Text(text))) = ws_receiver.next().await { let res = handle_ws_action( RawWsMessage { message: text }, @@ -213,7 +226,7 @@ async fn handle_socket(socket: WebSocket, state: Arc) { recv_messages_ws.abort(); } _ = (&mut recv_messages_ws) => { - info!("recieve messages from websocket finished"); + info!("receive messages from websocket finished"); recv_messages_ws.abort(); } _ = cancel_token.cancelled() => { @@ -227,7 +240,17 @@ async fn handle_socket(socket: WebSocket, state: Arc) { } async fn get_rooms(State(state): State>) -> String { - let rooms = state.rooms.lock().unwrap(); + let rooms = match state.rooms.lock() { + Ok(rooms) => rooms, + Err(e) => { + log::error!("Failed to acquire rooms lock: {e}"); + return json!({ + "status": "Error acquiring rooms lock", + "rooms": [] + }) + .to_string(); + } + }; let vec = rooms.iter().collect::>(); match vec.len() { 0 => json!({ diff --git a/src/message.rs b/src/message.rs index 8c7f56c..f523dce 100644 --- a/src/message.rs +++ b/src/message.rs @@ -16,63 +16,59 @@ //! - [`AppMessage`] //! - [`ConversationMessage`] //! - [`BatchProposalsMessage`] +//! - [`BanRequest`] +//! - [`VotingProposal`] +//! - [`UserVote`] //! use crate::{ + consensus::v1::{Proposal, Vote}, encrypt_message, - protos::messages::v1::{app_message, UserKeyPackage}, + protos::messages::v1::{app_message, UserKeyPackage, UserVote, VotingProposal}, verify_message, MessageError, }; -// use log::info; use openmls::prelude::{KeyPackage, MlsMessageOut}; use serde::{Deserialize, Serialize}; use std::fmt::Display; -// use crate::protos::messages::v1::{ -// welcome_message, GroupAnnouncement, InvitationToJoin, WelcomeMessage, AppMessage, ConversationMessage, UserKeyPackage, -// }; use crate::protos::messages::v1::{ - welcome_message, AppMessage, BatchProposalsMessage, ConversationMessage, GroupAnnouncement, - InvitationToJoin, WelcomeMessage, + welcome_message, AppMessage, BanRequest, BatchProposalsMessage, ConversationMessage, + GroupAnnouncement, InvitationToJoin, WelcomeMessage, }; -// WELCOME MESSAGE SUBTOPIC +// Message type constants for consistency and type safety +pub mod message_types { + pub const CONVERSATION_MESSAGE: &str = "ConversationMessage"; + pub const BATCH_PROPOSALS_MESSAGE: &str = "BatchProposalsMessage"; + pub const BAN_REQUEST: &str = "BanRequest"; + pub const PROPOSAL: &str = "Proposal"; + pub const VOTE: &str = "Vote"; + pub const VOTING_PROPOSAL: &str = "VotingProposal"; + pub const USER_VOTE: &str = "UserVote"; + pub const UNKNOWN: &str = "Unknown"; +} -pub fn wrap_group_announcement_in_welcome_msg( - group_announcement: GroupAnnouncement, -) -> WelcomeMessage { - WelcomeMessage { - payload: Some(welcome_message::Payload::GroupAnnouncement( - group_announcement, - )), +/// Trait for getting message type as a string constant +pub trait MessageType { + fn message_type(&self) -> &'static str; +} + +impl MessageType for app_message::Payload { + fn message_type(&self) -> &'static str { + use message_types::*; + match self { + app_message::Payload::ConversationMessage(_) => CONVERSATION_MESSAGE, + app_message::Payload::BatchProposalsMessage(_) => BATCH_PROPOSALS_MESSAGE, + app_message::Payload::BanRequest(_) => BAN_REQUEST, + app_message::Payload::Proposal(_) => PROPOSAL, + app_message::Payload::Vote(_) => VOTE, + app_message::Payload::VotingProposal(_) => VOTING_PROPOSAL, + app_message::Payload::UserVote(_) => USER_VOTE, + } } } -pub fn wrap_user_kp_into_welcome_msg( - encrypted_kp: Vec, -) -> Result { - let user_key_package = UserKeyPackage { - encrypt_kp: encrypted_kp, - }; - let welcome_message = WelcomeMessage { - payload: Some(welcome_message::Payload::UserKeyPackage(user_key_package)), - }; - Ok(welcome_message) -} -pub fn wrap_invitation_into_welcome_msg( - mls_message: MlsMessageOut, -) -> Result { - let mls_bytes = mls_message.to_bytes()?; - let invitation = InvitationToJoin { - mls_message_out_bytes: mls_bytes, - }; - - let welcome_message = WelcomeMessage { - payload: Some(welcome_message::Payload::InvitationToJoin(invitation)), - }; - Ok(welcome_message) -} - +// WELCOME MESSAGE SUBTOPIC impl GroupAnnouncement { pub fn new(pub_key: Vec, signature: Vec) -> Self { GroupAnnouncement { @@ -87,47 +83,45 @@ impl GroupAnnouncement { } pub fn encrypt(&self, kp: KeyPackage) -> Result, MessageError> { - // TODO: replace json in encryption and decryption let key_package = serde_json::to_vec(&kp)?; let encrypted = encrypt_message(&key_package, &self.eth_pub_key)?; Ok(encrypted) } } -// APPLICATION MESSAGE SUBTOPIC - -pub fn wrap_conversation_message_into_application_msg( - message: Vec, - sender: String, - group_name: String, -) -> AppMessage { - AppMessage { - payload: Some(app_message::Payload::ConversationMessage( - ConversationMessage { - message, - sender, - group_name, - }, - )), +impl From for WelcomeMessage { + fn from(group_announcement: GroupAnnouncement) -> Self { + WelcomeMessage { + payload: Some(welcome_message::Payload::GroupAnnouncement( + group_announcement, + )), + } } } -pub fn wrap_batch_proposals_into_application_msg( - group_name: String, - mls_proposals: Vec>, - commit_message: Vec, -) -> AppMessage { - AppMessage { - payload: Some(app_message::Payload::BatchProposalsMessage( - BatchProposalsMessage { - group_name: group_name.into_bytes(), - mls_proposals, - commit_message, - }, - )), +impl TryFrom for WelcomeMessage { + type Error = MessageError; + fn try_from(mls_message: MlsMessageOut) -> Result { + let mls_bytes = mls_message.to_bytes()?; + let invitation = InvitationToJoin { + mls_message_out_bytes: mls_bytes, + }; + + Ok(WelcomeMessage { + payload: Some(welcome_message::Payload::InvitationToJoin(invitation)), + }) } } +impl From for WelcomeMessage { + fn from(user_key_package: UserKeyPackage) -> Self { + WelcomeMessage { + payload: Some(welcome_message::Payload::UserKeyPackage(user_key_package)), + } + } +} + +// APP MESSAGE SUBTOPIC impl Display for AppMessage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.payload { @@ -139,15 +133,123 @@ impl Display for AppMessage { String::from_utf8_lossy(&conversation_message.message) ) } - _ => write!(f, "Invalid message"), + Some(app_message::Payload::BatchProposalsMessage(batch_msg)) => { + write!( + f, + "BatchProposalsMessage: {} proposals for group {}", + batch_msg.mls_proposals.len(), + String::from_utf8_lossy(&batch_msg.group_name) + ) + } + Some(app_message::Payload::BanRequest(ban_request)) => { + write!( + f, + "SYSTEM: {} wants to ban {}", + ban_request.requester, ban_request.user_to_ban + ) + } + Some(app_message::Payload::Proposal(proposal)) => { + write!( + f, + "Proposal: ID {} with {} votes for {} voters", + proposal.proposal_id, + proposal.votes.len(), + proposal.expected_voters_count + ) + } + Some(app_message::Payload::Vote(vote)) => { + write!( + f, + "Vote: {} for proposal {} ({})", + if vote.vote { "YES" } else { "NO" }, + vote.proposal_id, + vote.vote_id + ) + } + Some(app_message::Payload::VotingProposal(voting_proposal)) => { + write!( + f, + "VotingProposal: ID {} for group {}", + voting_proposal.proposal_id, voting_proposal.group_name + ) + } + Some(app_message::Payload::UserVote(user_vote)) => { + write!( + f, + "UserVote: {} for proposal {} in group {}", + if user_vote.vote { "YES" } else { "NO" }, + user_vote.proposal_id, + user_vote.group_name + ) + } + None => write!(f, "Empty message"), } } } +impl From for AppMessage { + fn from(voting_proposal: VotingProposal) -> Self { + AppMessage { + payload: Some(app_message::Payload::VotingProposal(voting_proposal)), + } + } +} + +impl From for AppMessage { + fn from(user_vote: UserVote) -> Self { + AppMessage { + payload: Some(app_message::Payload::UserVote(user_vote)), + } + } +} + +impl From for AppMessage { + fn from(conversation_message: ConversationMessage) -> Self { + AppMessage { + payload: Some(app_message::Payload::ConversationMessage( + conversation_message, + )), + } + } +} + +impl From for AppMessage { + fn from(batch_proposals_message: BatchProposalsMessage) -> Self { + AppMessage { + payload: Some(app_message::Payload::BatchProposalsMessage( + batch_proposals_message, + )), + } + } +} + +impl From for AppMessage { + fn from(ban_request: BanRequest) -> Self { + AppMessage { + payload: Some(app_message::Payload::BanRequest(ban_request)), + } + } +} + +impl From for AppMessage { + fn from(proposal: Proposal) -> Self { + AppMessage { + payload: Some(app_message::Payload::Proposal(proposal)), + } + } +} + +impl From for AppMessage { + fn from(vote: Vote) -> Self { + AppMessage { + payload: Some(app_message::Payload::Vote(vote)), + } + } +} /// This struct is used to represent the message from the user that we got from web socket #[derive(Deserialize, Debug, PartialEq, Serialize)] pub struct UserMessage { - pub message: String, + pub message: Vec, pub group_id: String, } diff --git a/src/protos/messages/v1/application.proto b/src/protos/messages/v1/application.proto index 8a2b341..4f946e4 100644 --- a/src/protos/messages/v1/application.proto +++ b/src/protos/messages/v1/application.proto @@ -4,13 +4,26 @@ syntax = "proto3"; package de_mls.messages.v1; +import "messages/v1/consensus.proto"; + message AppMessage { oneof payload { ConversationMessage conversation_message = 1; BatchProposalsMessage batch_proposals_message = 2; + BanRequest ban_request = 3; + consensus.v1.Proposal proposal = 4; + consensus.v1.Vote vote = 5; + VotingProposal voting_proposal = 6; + UserVote user_vote = 7; } } +message BanRequest { + string user_to_ban = 1; + string requester = 2; + string group_name = 3; +} + message ConversationMessage { bytes message = 1; string sender = 2; @@ -21,4 +34,17 @@ message BatchProposalsMessage { bytes group_name = 1; repeated bytes mls_proposals = 2; // Individual MLS proposal messages bytes commit_message = 3; // MLS commit message -} \ No newline at end of file +} + +// New message types for voting +message VotingProposal { + uint32 proposal_id = 1; + string group_name = 2; + string payload = 3; +} + +message UserVote { + uint32 proposal_id = 1; + bool vote = 2; + string group_name = 3; +} diff --git a/src/protos/messages/v1/consensus.proto b/src/protos/messages/v1/consensus.proto index 59c8316..56519f2 100644 --- a/src/protos/messages/v1/consensus.proto +++ b/src/protos/messages/v1/consensus.proto @@ -5,45 +5,26 @@ package consensus.v1; // Proposal represents a consensus proposal that needs voting message Proposal { string name = 10; // Proposal name - int32 proposal_id = 11; // Unique identifier of the proposal - bytes proposal_owner = 12; // Public key of the creator - repeated Vote votes = 13; // Vote list in the proposal - int32 expected_voters_count = 14; // Maximum number of distinct voters - int32 round = 15; // Number of Votes - int64 timestamp = 16; // Creation time of proposal - int64 expiration_time = 17; // The time interval that the proposal is active. - bool liveness_criteria_yes = 18; // Shows how managing the silent peers vote + string payload = 11; // Payload of the proposal + uint32 proposal_id = 12; // Unique identifier of the proposal + bytes proposal_owner = 13; // Public key of the creator + repeated Vote votes = 14; // Vote list in the proposal + uint32 expected_voters_count = 15; // Maximum number of distinct voters + uint32 round = 16; // Number of Votes + uint64 timestamp = 17; // Creation time of proposal + uint64 expiration_time = 18; // The time interval that the proposal is active. + bool liveness_criteria_yes = 19; // Shows how managing the silent peers vote } // Vote represents a single vote in a consensus proposal message Vote { - int32 vote_id = 20; // Unique identifier of the vote + uint32 vote_id = 20; // Unique identifier of the vote bytes vote_owner = 21; // Voter's public key - int64 timestamp = 22; // Time when the vote was cast - bool vote = 23; // Vote bool value (true/false) - bytes parent_hash = 24; // Hash of previous owner's Vote - bytes received_hash = 25; // Hash of previous received Vote - bytes vote_hash = 26; // Hash of all previously defined fields in Vote - bytes signature = 27; // Signature of vote_hash + uint32 proposal_id = 22; // Proposal ID (for the vote) + uint64 timestamp = 23; // Time when the vote was cast + bool vote = 24; // Vote bool value (true/false) + bytes parent_hash = 25; // Hash of previous owner's Vote + bytes received_hash = 26; // Hash of previous received Vote + bytes vote_hash = 27; // Hash of all previously defined fields in Vote + bytes signature = 28; // Signature of vote_hash } - -// ConsensusMessage wraps consensus-related messages -message ConsensusMessage { - oneof payload { - Proposal proposal = 1; - Vote vote = 2; - ConsensusResult result = 3; - } -} - -// ConsensusResult represents the final result of a consensus round -message ConsensusResult { - int32 proposal_id = 1; // ID of the proposal this result belongs to - bool consensus_reached = 2; // Whether consensus was reached - bool final_decision = 3; // The final decision (true/false) - int32 total_votes = 4; // Total number of votes received - int32 yes_votes = 5; // Number of yes votes - int32 no_votes = 6; // Number of no votes - int64 consensus_time = 7; // Time when consensus was reached - repeated bytes participants = 8; // List of participant public keys -} \ No newline at end of file diff --git a/src/state_machine.rs b/src/state_machine.rs index e702662..ff2bf27 100644 --- a/src/state_machine.rs +++ b/src/state_machine.rs @@ -6,43 +6,126 @@ //! //! # States //! -//! - **Working**: Normal operation state where users can send messages freely -//! - **Waiting**: Steward epoch state where only steward can send messages with proposals -//! - **Voting**: Transitional state during voting process where no messages are allowed +//! - **Working**: Normal operation state where users can send any message freely +//! - **Waiting**: Steward epoch state where only steward can send BATCH_PROPOSALS_MESSAGE (if proposals exist) +//! - **Voting**: Voting state where everyone can send VOTE/USER_VOTE, only steward can send VOTING_PROPOSAL/PROPOSAL +//! - **ConsensusReached**: Consensus achieved, waiting for steward to send batch proposals +//! - **ConsensusFailed**: Consensus failed due to timeout or other reasons //! //! # State Transitions //! //! ```text -//! Working --start_steward_epoch()--> Waiting (if proposals exist) -//! Working --start_steward_epoch()--> Working (if no proposals) -//! Waiting --start_voting()---------> Voting -//! Voting --complete_voting(true)--> Waiting (vote passed) -//! Voting --complete_voting(false)-> Working (vote failed) -//! Waiting --apply_proposals_and_complete()--> Working +//! Working -- start_steward_epoch_with_validation() --> Waiting (if proposals exist) +//! Working -- start_steward_epoch_with_validation() --> Working (if no proposals, returns 0) +//! Waiting -- start_voting() --> Voting +//! Voting -- complete_voting(true) --> ConsensusReached (vote passed) +//! Voting -- complete_voting(false) --> Working (vote failed) +//! ConsensusReached -- start_waiting_after_consensus() --> Waiting (steward sends batch proposals) +//! Waiting -- handle_yes_vote() --> Working (after successful vote and proposal application) +//! ConsensusFailed -- recover_from_consensus_failure() --> Working (recovery) //! ``` +//! +//! # Message Type Permissions by State +//! +//! ## Working State +//! - **All users**: Can send any message type +//! +//! ## Waiting State +//! - **Steward with proposals**: Can send BATCH_PROPOSALS_MESSAGE +//! - **All users**: All other message types blocked +//! +//! ## Voting State +//! - **All users**: Can send VOTE and USER_VOTE +//! - **Steward only**: Can send VOTING_PROPOSAL and PROPOSAL +//! - **All users**: All other message types blocked +//! +//! ## ConsensusReached State +//! - **Steward with proposals**: Can send BATCH_PROPOSALS_MESSAGE +//! - **All users**: All other message types blocked +//! +//! ## ConsensusFailed State +//! - **All users**: No messages allowed +//! +//! # Steward Flow Scenarios +//! +//! ## Scenario 1: No Proposals Initially +//! ```text +//! Working --start_steward_epoch_with_validation()--> Working (stays in Working, returns 0) +//! ``` +//! +//! ## Scenario 2: Successful Vote with Proposals +//! **Steward:** +//! ```text +//! Working --start_steward_epoch_with_validation()--> Waiting --start_voting()--> Voting +//! --complete_voting(true)--> ConsensusReached --start_waiting_after_consensus()--> Waiting +//! --handle_yes_vote()--> Working +//! ``` +//! **Non-Steward:** +//! ```text +//! Working --steward_starts_epoch()--> Waiting --start_voting()--> Voting +//! --start_consensus_reached()--> ConsensusReached --start_waiting()--> Waiting +//! --handle_yes_vote()--> Working +//! ``` +//! +//! ## Scenario 3: Failed Vote +//! **Steward:** +//! ```text +//! Working --start_steward_epoch_with_validation()--> Waiting --start_voting()--> Voting +//! --complete_voting(false)--> Working +//! ``` +//! **Non-Steward:** +//! ```text +//! Working --steward_starts_epoch()--> Waiting --start_voting()--> Voting +//! --start_consensus_reached()--> ConsensusReached --start_consensus_failed()--> ConsensusFailed +//! --recover_from_consensus_failure()--> Working +//! ``` +//! +//! # Key Methods +//! +//! - `start_steward_epoch_with_validation()`: Main entry point for starting steward epochs with proposal validation +//! - `start_voting()`: Transitions to voting state from any non-voting state +//! - `complete_voting(vote_result)`: Handles voting completion and transitions based on result +//! - `handle_yes_vote()`: Applies proposals and returns to working state after successful vote +//! - `start_waiting_after_consensus()`: Transitions from ConsensusReached to Waiting for batch proposal processing +//! - `recover_from_consensus_failure()`: Recovers from consensus failure back to Working state +//! +//! # Proposal Management +//! +//! - Proposals are collected in the current epoch and moved to voting epoch when steward epoch starts +//! - After successful voting, proposals are applied and cleared from voting epoch +//! - Failed votes result in proposals being discarded and return to working state use std::fmt::Display; +use log::info; + +use crate::message::message_types; use crate::steward::Steward; use crate::{steward::GroupUpdateRequest, GroupError}; /// Represents the different states a group can be in during the steward epoch flow #[derive(Debug, Clone, PartialEq)] pub enum GroupState { - /// Normal operation state - users can send messages freely + /// Normal operation state - users can send any message freely Working, - /// Waiting state during steward epoch - only steward can send messages with proposals + /// Waiting state during steward epoch - only steward can send BATCH_PROPOSALS_MESSAGE Waiting, - /// Transitional state during voting process + /// Voting state - everyone can send VOTE/USER_VOTE, only steward can send VOTING_PROPOSAL/PROPOSAL Voting, + /// Consensus reached state - consensus achieved, waiting for steward to send batch proposals + ConsensusReached, + /// Consensus failed state - consensus failed due to timeout or other reasons + ConsensusFailed, } impl Display for GroupState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let state = match self { - GroupState::Working => "Working - Normal operation", - GroupState::Waiting => "Waiting - Steward epoch active", - GroupState::Voting => "Voting - Vote in progress", + GroupState::Working => "Working", + GroupState::Waiting => "Waiting", + GroupState::Voting => "Voting", + GroupState::ConsensusReached => "ConsensusReached", + GroupState::ConsensusFailed => "ConsensusFailed", }; write!(f, "{state}") } @@ -79,113 +162,190 @@ impl GroupStateMachine { self.state.clone() } - /// Check if a message can be sent in the current state - pub fn can_send_message(&self, is_steward: bool, has_proposals: bool) -> bool { + /// Check if a specific message type can be sent in the current state. + /// + /// ## Parameters: + /// - `is_steward`: Whether the sender is a steward + /// - `has_proposals`: Whether there are proposals available (for steward operations) + /// - `message_type`: The type of message to check + /// + /// ## Returns: + /// - `true` if the message can be sent, `false` otherwise + /// + /// ## Usage: + /// Used to enforce message type permissions based on current state and sender role. + /// This ensures proper state machine behavior and prevents invalid operations. + pub fn can_send_message_type( + &self, + is_steward: bool, + has_proposals: bool, + message_type: &str, + ) -> bool { match self.state { - GroupState::Working => true, // Anyone can send messages in working state - GroupState::Waiting => is_steward && has_proposals, // Only steward with proposals can send - GroupState::Voting => false, // No one can send messages during voting + GroupState::Working => true, // Anyone can send any message in working state + GroupState::Waiting => { + // In waiting state, only steward can send BATCH_PROPOSALS_MESSAGE + match message_type { + message_types::BATCH_PROPOSALS_MESSAGE => is_steward && has_proposals, + _ => false, // All other messages blocked during waiting + } + } + GroupState::Voting => { + // In voting state, only voting-related messages allowed + match message_type { + message_types::VOTE => true, // Everyone can send votes + message_types::USER_VOTE => true, // Everyone can send user votes + message_types::VOTING_PROPOSAL => is_steward, // Only steward can send voting proposals + message_types::PROPOSAL => is_steward, // Only steward can send proposals + _ => false, // All other messages blocked during voting + } + } + GroupState::ConsensusReached => { + // In ConsensusReached state, only steward can send BATCH_PROPOSALS_MESSAGE + match message_type { + message_types::BATCH_PROPOSALS_MESSAGE => is_steward && has_proposals, + _ => false, // All other messages blocked during ConsensusReached + } + } + GroupState::ConsensusFailed => { + // In ConsensusFailed state, no messages are allowed + false + } } } - /// Start a new steward epoch, transitioning to Waiting state - pub async fn start_steward_epoch(&mut self) -> Result<(), GroupError> { - println!( - "State machine: start_steward_epoch called, current state: {:?}", - self.state - ); - if self.state != GroupState::Working { - println!( - "State machine: Invalid state transition from {:?} to Waiting", - self.state - ); - return Err(GroupError::InvalidStateTransition); - } - - self.state = GroupState::Waiting; - println!("State machine: Transitioned from Working to Waiting"); - - self.steward.as_mut().unwrap().start_new_epoch().await; - println!("State machine: Started new epoch"); - - Ok(()) - } - - /// Start voting on proposals for the current epoch, transitioning to Voting state + /// Start voting on proposals for the current epoch, transitioning to Voting state. + /// + /// ## Preconditions: + /// - Can be called from any state except Voting (prevents double voting) + /// + /// ## State Transition: + /// Any State (except Voting) → Voting pub fn start_voting(&mut self) -> Result<(), GroupError> { - println!( - "State machine: start_voting called, current state: {:?}", - self.state - ); - if self.state != GroupState::Waiting { - println!( - "State machine: Invalid state transition from {:?} to Voting", - self.state - ); - return Err(GroupError::InvalidStateTransition); + if self.state == GroupState::Voting { + return Err(GroupError::InvalidStateTransition { + from: self.state.to_string(), + to: "Voting".to_string(), + }); } - self.state = GroupState::Voting; - println!("State machine: Transitioned from Waiting to Voting"); Ok(()) } - /// Complete voting and update state based on result + /// Complete voting and update state based on result. + /// + /// ## Preconditions: + /// - Must be in Voting state + /// + /// ## State Transitions: + /// - Vote YES: Voting → ConsensusReached (consensus achieved, waiting for batch proposals) + /// - Vote NO: Voting → Working (proposals discarded) pub fn complete_voting(&mut self, vote_result: bool) -> Result<(), GroupError> { - println!( - "State machine: complete_voting called with result {}, current state: {:?}", - vote_result, self.state - ); if self.state != GroupState::Voting { - println!( - "State machine: Invalid state transition from {:?} to {}", - self.state, - if vote_result { "Waiting" } else { "Working" } - ); - return Err(GroupError::InvalidStateTransition); + return Err(GroupError::InvalidStateTransition { + from: self.state.to_string(), + to: if vote_result { + "ConsensusReached" + } else { + "Working" + } + .to_string(), + }); } if vote_result { - // Vote passed - stay in waiting state for proposal application - self.state = GroupState::Waiting; - println!("State machine: Vote passed, staying in Waiting state"); + // Vote YES - go to ConsensusReached state to wait for steward to send batch proposals + info!("[complete_voting]: Vote YES, transitioning to ConsensusReached state"); + self.start_consensus_reached(); } else { - // Vote failed - return to working state - self.state = GroupState::Working; - println!("State machine: Vote failed, returning to Working state"); + // Vote NO - return to working state + info!("[complete_voting]: Vote NO, transitioning to Working state"); + self.start_working(); } Ok(()) } - /// Apply proposals and complete the steward epoch - pub async fn remove_proposals_and_complete(&mut self) -> Result<(), GroupError> { - println!( - "State machine: remove_proposals_and_complete called, current state: {:?}", - self.state - ); - if self.state != GroupState::Waiting { - println!( - "State machine: Invalid state transition from {:?} to Working", - self.state - ); - return Err(GroupError::InvalidStateTransition); - } + /// Start consensus reached state (for non-steward peers after consensus). + /// + /// ## State Transition: + /// Any State → ConsensusReached + /// + /// ## Usage: + /// Called by non-steward peers when consensus is reached during voting. + /// This allows them to transition to the appropriate state for waiting + /// for the steward to process and send batch proposals. + pub fn start_consensus_reached(&mut self) { + self.state = GroupState::ConsensusReached; + info!("[start_consensus_reached] Transitioning to ConsensusReached state"); + } - // Apply proposals for current epoch from steward - if let Some(steward) = &mut self.steward { - steward.empty_voting_epoch_proposals().await; - } else { - return Err(GroupError::StewardNotSet); + /// Start consensus failed state (for peers after consensus failure). + /// + /// ## State Transition: + /// Any State → ConsensusFailed + /// + /// ## Usage: + /// Called when consensus fails due to timeout or other reasons. + /// This state blocks all message types until recovery is initiated. + pub fn start_consensus_failed(&mut self) { + self.state = GroupState::ConsensusFailed; + info!("[start_consensus_failed] Transitioning to ConsensusFailed state"); + } + + /// Recover from consensus failure by transitioning back to Working state + pub fn recover_from_consensus_failure(&mut self) -> Result<(), GroupError> { + if self.state != GroupState::ConsensusFailed { + return Err(GroupError::InvalidStateTransition { + from: self.state.to_string(), + to: "Working".to_string(), + }); } self.state = GroupState::Working; - println!("State machine: Transitioned from Waiting to Working"); - + info!("[recover_from_consensus_failure] Recovering from consensus failure, transitioning to Working state"); Ok(()) } - /// Get the count of proposals in the current epoch + /// Start working state (for non-steward peers after consensus or edge case recovery). + /// + /// ## State Transition: + /// Any State → Working + /// + /// ## Usage: + /// - Non-steward peers: Called after receiving consensus results + /// - Edge case recovery: Called when proposals disappear during voting phase + /// - General recovery: Can be used to reset to normal operation from any state + /// + /// ## Note: + /// This method provides a safe way to transition back to normal operation + /// and is commonly used for recovery scenarios. + pub fn start_working(&mut self) { + self.state = GroupState::Working; + info!("[start_working] Transitioning to Working state"); + } + + /// Start waiting state (for non-steward peers after consensus). + /// + /// ## State Transition: + /// Any State → Waiting + /// + /// ## Usage: + /// Called by non-steward peers to transition to waiting state, + /// typically after consensus is reached and they need to wait for + /// the steward to process and send batch proposals. + pub fn start_waiting(&mut self) { + self.state = GroupState::Waiting; + info!("[start_waiting] Transitioning to Waiting state"); + } + + /// Get the count of proposals in the current epoch. + /// + /// ## Returns: + /// - Number of proposals currently collected for the next steward epoch + /// + /// ## Usage: + /// Used to check if there are proposals to vote on before starting a steward epoch. pub async fn get_current_epoch_proposals_count(&self) -> usize { if let Some(steward) = &self.steward { steward.get_current_epoch_proposals_count().await @@ -194,7 +354,13 @@ impl GroupStateMachine { } } - /// Get the count of proposals in the voting epoch + /// Get the count of proposals in the voting epoch. + /// + /// ## Returns: + /// - Number of proposals currently being voted on + /// + /// ## Usage: + /// Used during voting to track how many proposals are being considered. pub async fn get_voting_epoch_proposals_count(&self) -> usize { if let Some(steward) = &self.steward { steward.get_voting_epoch_proposals_count().await @@ -203,7 +369,13 @@ impl GroupStateMachine { } } - /// Get the proposals in the voting epoch + /// Get the proposals in the voting epoch. + /// + /// ## Returns: + /// - Vector of proposals currently being voted on + /// + /// ## Usage: + /// Used during voting to access the actual proposal details for processing. pub async fn get_voting_epoch_proposals(&self) -> Vec { if let Some(steward) = &self.steward { steward.get_voting_epoch_proposals().await @@ -212,27 +384,179 @@ impl GroupStateMachine { } } - /// Add a proposal to the current epoch + /// Add a proposal to the current epoch. + /// + /// ## Parameters: + /// - `proposal`: The group update request to add + /// + /// ## Usage: + /// Called to submit new proposals for consideration in the next steward epoch. + /// Proposals are collected and will be moved to the voting epoch when + /// `start_steward_epoch_with_validation()` is called. pub async fn add_proposal(&mut self, proposal: GroupUpdateRequest) { if let Some(steward) = &mut self.steward { steward.add_proposal(proposal).await; } } - /// Check if this state machine has a steward + /// Check if this state machine has a steward configured. + /// + /// ## Returns: + /// - `true` if a steward is configured, `false` otherwise + /// + /// ## Usage: + /// Used to verify steward availability before attempting steward epoch operations. pub fn has_steward(&self) -> bool { self.steward.is_some() } - /// Get a reference to the steward (if available) + /// Get a reference to the steward (if available). + /// + /// ## Returns: + /// - `Some(&Steward)` if steward is configured, `None` otherwise + /// + /// ## Usage: + /// Used to access steward functionality for read-only operations. pub fn get_steward(&self) -> Option<&Steward> { self.steward.as_ref() } - /// Get a mutable reference to the steward (if available) + /// Get a mutable reference to the steward (if available). + /// + /// ## Returns: + /// - `Some(&mut Steward)` if steward is configured, `None` otherwise + /// + /// ## Usage: + /// Used to access steward functionality for read-write operations. pub fn get_steward_mut(&mut self) -> Option<&mut Steward> { self.steward.as_mut() } + + /// Handle steward epoch start with proposal validation. + /// This is the main entry point for starting steward epochs. + /// + /// ## Preconditions: + /// - Must be in Working state + /// - Must have a steward configured + /// + /// ## State Transitions: + /// - **With proposals**: Working → Waiting (returns proposal count) + /// - **No proposals**: Working → Working (stays in Working, returns 0) + /// + /// ## Returns: + /// - Number of proposals collected for voting (0 if no proposals) + /// + /// ## Usage: + /// This method should be used instead of `start_steward_epoch()` for external calls + /// as it provides proper proposal validation and state management. + pub async fn start_steward_epoch_with_validation(&mut self) -> Result { + if self.state != GroupState::Working { + return Err(GroupError::InvalidStateTransition { + from: self.state.to_string(), + to: "Waiting".to_string(), + }); + } + + // Always check if steward is set - required for steward epoch operations + if !self.has_steward() { + return Err(GroupError::StewardNotSet); + } + + // Check if there are proposals to vote on + let proposal_count = self.get_current_epoch_proposals_count().await; + + if proposal_count == 0 { + // No proposals, stay in Working state but still return 0 + // This indicates a successful steward epoch start with no proposals + Ok(0) + } else { + // Start steward epoch and transition to Waiting + self.state = GroupState::Waiting; + self.steward + .as_mut() + .ok_or(GroupError::StewardNotSet)? + .start_new_epoch() + .await; + Ok(proposal_count) + } + } + + /// Handle proposal application and completion after successful voting. + /// + /// ## Preconditions: + /// - Must be in ConsensusReached or Waiting state + /// - Must have a steward configured + /// + /// ## State Transition: + /// ConsensusReached/Waiting → Working + /// + /// ## Actions: + /// - Clears voting epoch proposals + /// - Transitions to Working state + /// + /// ## Usage: + /// Called after successful voting to empty the voting epoch proposals and transition to Working state. + pub async fn handle_yes_vote(&mut self) -> Result<(), GroupError> { + // Check state transition validity - can be called from ConsensusReached or Waiting state + if self.state != GroupState::ConsensusReached && self.state != GroupState::Waiting { + return Err(GroupError::InvalidStateTransition { + from: self.state.to_string(), + to: "Working".to_string(), + }); + } + + let steward = self.steward.as_mut().ok_or(GroupError::StewardNotSet)?; + steward.empty_voting_epoch_proposals().await; + + self.state = GroupState::Working; + + Ok(()) + } + + /// Start waiting state when steward sends batch proposals after consensus. + /// This transitions from ConsensusReached to Waiting state. + /// + /// ## Preconditions: + /// - Must be in ConsensusReached state + /// + /// ## State Transition: + /// ConsensusReached → Waiting + /// + /// ## Usage: + /// Called when steward needs to send batch proposals after consensus is reached. + /// This allows the steward to process and send proposals while maintaining proper state flow. + pub fn start_waiting_after_consensus(&mut self) -> Result<(), GroupError> { + if self.state != GroupState::ConsensusReached { + return Err(GroupError::InvalidStateTransition { + from: self.state.to_string(), + to: "Waiting".to_string(), + }); + } + + self.state = GroupState::Waiting; + info!( + "[start_waiting_after_consensus] Transitioning from ConsensusReached to Waiting state" + ); + Ok(()) + } + + /// Handle failed vote cleanup. + /// + /// ## Preconditions: + /// - Must have a steward configured + /// + /// ## Actions: + /// - Clears voting epoch proposals + /// - Does not change state + /// + /// ## Usage: + /// Called after failed votes to clean up proposals. The caller is responsible + /// for transitioning to the appropriate state (typically Working). + pub async fn handle_no_vote(&mut self) -> Result<(), GroupError> { + let steward = self.steward.as_mut().ok_or(GroupError::StewardNotSet)?; + steward.empty_voting_epoch_proposals().await; + Ok(()) + } } impl Default for GroupStateMachine { @@ -266,9 +590,16 @@ mod tests { // Initial state should be Working assert_eq!(state_machine.current_state(), GroupState::Working); + // Add a proposal to switch to waiting state + state_machine + .add_proposal(GroupUpdateRequest::RemoveMember( + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc".to_string(), + )) + .await; + // Test start_steward_epoch state_machine - .start_steward_epoch() + .start_steward_epoch_with_validation() .await .expect("Failed to start steward epoch"); assert_eq!(state_machine.current_state(), GroupState::Waiting); @@ -283,64 +614,129 @@ mod tests { state_machine .complete_voting(true) .expect("Failed to complete voting"); + assert_eq!(state_machine.current_state(), GroupState::ConsensusReached); + + // Test start_waiting_after_consensus + state_machine + .start_waiting_after_consensus() + .expect("Failed to start waiting after consensus"); assert_eq!(state_machine.current_state(), GroupState::Waiting); // Test apply_proposals_and_complete state_machine - .remove_proposals_and_complete() + .handle_yes_vote() .await .expect("Failed to apply proposals"); assert_eq!(state_machine.current_state(), GroupState::Working); } #[tokio::test] - async fn test_message_permissions() { + async fn test_message_type_permissions() { let mut state_machine = GroupStateMachine::new_with_steward(); - // Working state - anyone can send messages - assert!(state_machine.can_send_message(false, false)); // Regular user, no proposals - assert!(state_machine.can_send_message(true, false)); // Steward, no proposals - assert!(state_machine.can_send_message(true, true)); // Steward, with proposals + // Working state - all message types allowed + assert!(state_machine.can_send_message_type(false, false, message_types::BAN_REQUEST)); + assert!(state_machine.can_send_message_type( + false, + false, + message_types::CONVERSATION_MESSAGE + )); + assert!(state_machine.can_send_message_type( + true, + false, + message_types::BATCH_PROPOSALS_MESSAGE + )); + + // Add a proposal to switch to waiting state + state_machine + .add_proposal(GroupUpdateRequest::RemoveMember( + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc".to_string(), + )) + .await; // Start steward epoch state_machine - .start_steward_epoch() + .start_steward_epoch_with_validation() .await .expect("Failed to start steward epoch"); - // Waiting state - only steward with proposals can send messages - assert!(!state_machine.can_send_message(false, false)); // Regular user, no proposals - assert!(!state_machine.can_send_message(false, true)); // Regular user, with proposals - assert!(!state_machine.can_send_message(true, false)); // Steward, no proposals - assert!(state_machine.can_send_message(true, true)); // Steward, with proposals + // Waiting state - test specific message types + // All messages not allowed from anyone EXCEPT BATCH_PROPOSALS_MESSAGE + assert!(!state_machine.can_send_message_type(false, false, message_types::BAN_REQUEST)); + assert!(!state_machine.can_send_message_type( + false, + false, + message_types::CONVERSATION_MESSAGE + )); + assert!(!state_machine.can_send_message_type(false, false, message_types::VOTE)); + assert!(!state_machine.can_send_message_type(false, false, message_types::USER_VOTE)); + assert!(!state_machine.can_send_message_type(false, false, message_types::VOTING_PROPOSAL)); + assert!(!state_machine.can_send_message_type(false, false, message_types::PROPOSAL)); + + // BatchProposalsMessage should only be allowed from steward with proposals + assert!(!state_machine.can_send_message_type( + false, + false, + message_types::BATCH_PROPOSALS_MESSAGE + )); + assert!(!state_machine.can_send_message_type( + true, + false, + message_types::BATCH_PROPOSALS_MESSAGE + )); + assert!(state_machine.can_send_message_type( + true, + true, + message_types::BATCH_PROPOSALS_MESSAGE + )); // Start voting state_machine .start_voting() .expect("Failed to start voting"); - // Voting state - no one can send messages - assert!(!state_machine.can_send_message(false, false)); - assert!(!state_machine.can_send_message(false, true)); - assert!(!state_machine.can_send_message(true, false)); - assert!(!state_machine.can_send_message(true, true)); + // Voting state - only voting-related messages allowed + // Everyone can send votes and user votes + assert!(state_machine.can_send_message_type(false, false, message_types::VOTE)); + assert!(state_machine.can_send_message_type(false, false, message_types::USER_VOTE)); + + // Only steward can send voting proposals and proposals + assert!(!state_machine.can_send_message_type(false, false, message_types::VOTING_PROPOSAL)); + assert!(state_machine.can_send_message_type(true, false, message_types::VOTING_PROPOSAL)); + assert!(!state_machine.can_send_message_type(false, false, message_types::PROPOSAL)); + assert!(state_machine.can_send_message_type(true, false, message_types::PROPOSAL)); + + // All other message types blocked during voting + assert!(!state_machine.can_send_message_type( + false, + false, + message_types::CONVERSATION_MESSAGE + )); + assert!(!state_machine.can_send_message_type(false, false, message_types::BAN_REQUEST)); + assert!(!state_machine.can_send_message_type( + false, + false, + message_types::BATCH_PROPOSALS_MESSAGE + )); } #[tokio::test] async fn test_invalid_state_transitions() { let mut state_machine = GroupStateMachine::new(); - // Cannot start voting from Working state - let result = state_machine.start_voting(); - assert!(matches!(result, Err(GroupError::InvalidStateTransition))); - // Cannot complete voting from Working state let result = state_machine.complete_voting(true); - assert!(matches!(result, Err(GroupError::InvalidStateTransition))); + assert!(matches!( + result, + Err(GroupError::InvalidStateTransition { .. }) + )); // Cannot apply proposals from Working state - let result = state_machine.remove_proposals_and_complete().await; - assert!(matches!(result, Err(GroupError::InvalidStateTransition))); + let result = state_machine.handle_yes_vote().await; + assert!(matches!( + result, + Err(GroupError::InvalidStateTransition { .. }) + )); } #[tokio::test] @@ -349,12 +745,14 @@ mod tests { // Add some proposals state_machine - .add_proposal(GroupUpdateRequest::RemoveMember(vec![1, 2, 3])) + .add_proposal(GroupUpdateRequest::RemoveMember( + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc".to_string(), + )) .await; // Start steward epoch - should collect proposals state_machine - .start_steward_epoch() + .start_steward_epoch_with_validation() .await .expect("Failed to start steward epoch"); assert_eq!(state_machine.get_voting_epoch_proposals_count().await, 1); @@ -367,11 +765,41 @@ mod tests { .complete_voting(true) .expect("Failed to complete voting"); state_machine - .remove_proposals_and_complete() + .handle_yes_vote() .await .expect("Failed to apply proposals"); // Proposals should be applied and count should be reset assert_eq!(state_machine.get_current_epoch_proposals_count().await, 0); } + + #[tokio::test] + async fn test_state_snapshot_consistency() { + let mut state_machine = GroupStateMachine::new_with_steward(); + + // Add some proposals + state_machine + .add_proposal(GroupUpdateRequest::RemoveMember( + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc".to_string(), + )) + .await; + + // Get a snapshot before state transition + let snapshot1 = state_machine.get_current_epoch_proposals_count().await; + assert_eq!(snapshot1, 1); + + // Start steward epoch + state_machine + .start_steward_epoch_with_validation() + .await + .expect("Failed to start steward epoch"); + + // Get a snapshot after state transition + let snapshot2 = state_machine.get_current_epoch_proposals_count().await; + assert_eq!(snapshot2, 0); + + // Verify that the snapshots are consistent within themselves + assert!(snapshot1 > 0); + assert_ne!(snapshot1, snapshot2); + } } diff --git a/src/steward.rs b/src/steward.rs index 1de5a1a..cdeb680 100644 --- a/src/steward.rs +++ b/src/steward.rs @@ -1,14 +1,15 @@ +use alloy::primitives::Address; use libsecp256k1::{PublicKey, SecretKey}; use openmls::prelude::KeyPackage; -use std::sync::Arc; +use std::{fmt::Display, str::FromStr, sync::Arc}; use tokio::sync::Mutex; use crate::{protos::messages::v1::GroupAnnouncement, *}; #[derive(Clone, Debug)] pub struct Steward { - eth_pub: PublicKey, - eth_secr: SecretKey, + eth_pub: Arc>, + eth_secr: Arc>, current_epoch_proposals: Arc>>, voting_epoch_proposals: Arc>>, } @@ -16,7 +17,22 @@ pub struct Steward { #[derive(Clone, Debug, PartialEq)] pub enum GroupUpdateRequest { AddMember(Box), - RemoveMember(Vec), + RemoveMember(String), +} + +impl Display for GroupUpdateRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GroupUpdateRequest::AddMember(kp) => { + let id = Address::from_slice(kp.leaf_node().credential().serialized_content()); + writeln!(f, "Add Member: {id:#?}") + } + GroupUpdateRequest::RemoveMember(id) => { + let id = Address::from_str(id).unwrap(); + writeln!(f, "Remove Member: {id:#?}") + } + } + } } impl Default for Steward { @@ -29,54 +45,48 @@ impl Steward { pub fn new() -> Self { let (public_key, private_key) = generate_keypair(); Steward { - eth_pub: public_key, - eth_secr: private_key, + eth_pub: Arc::new(Mutex::new(public_key)), + eth_secr: Arc::new(Mutex::new(private_key)), current_epoch_proposals: Arc::new(Mutex::new(Vec::new())), voting_epoch_proposals: Arc::new(Mutex::new(Vec::new())), } } - pub fn refresh_key_pair(&mut self) { + pub async fn refresh_key_pair(&mut self) { let (public_key, private_key) = generate_keypair(); - self.eth_pub = public_key; - self.eth_secr = private_key; + *self.eth_pub.lock().await = public_key; + *self.eth_secr.lock().await = private_key; } - pub fn create_announcement(&self) -> GroupAnnouncement { - let signature = sign_message(&self.eth_pub.serialize_compressed(), &self.eth_secr); - GroupAnnouncement::new(self.eth_pub.serialize_compressed().to_vec(), signature) + pub async fn create_announcement(&self) -> GroupAnnouncement { + let pub_key = self.eth_pub.lock().await; + let sec_key = self.eth_secr.lock().await; + let signature = sign_message(&pub_key.serialize_compressed(), &sec_key); + GroupAnnouncement::new(pub_key.serialize_compressed().to_vec(), signature) } - pub fn decrypt_message(&self, message: Vec) -> Result { - let msg: Vec = decrypt_message(&message, self.eth_secr)?; - // TODO: replace json in encryption and decryption + pub async fn decrypt_message(&self, message: Vec) -> Result { + let sec_key = self.eth_secr.lock().await; + let msg: Vec = decrypt_message(&message, *sec_key)?; let key_package: KeyPackage = serde_json::from_slice(&msg)?; Ok(key_package) } - /// Start a new steward epoch, moving current proposals to the epoch proposals map and incrementing the epoch. + /// Start a new steward epoch, moving current proposals to the epoch proposals map. pub async fn start_new_epoch(&mut self) { - // Get proposals from current epoch and store them for this epoch - let proposals = self - .current_epoch_proposals - .lock() - .await - .drain(0..) - .collect::>(); + // Use a single atomic operation to move proposals between epochs + let proposals = { + let mut current = self.current_epoch_proposals.lock().await; + current.drain(0..).collect::>() + }; // Store proposals for this epoch (for voting and application) if !proposals.is_empty() { - self.voting_epoch_proposals - .lock() - .await - .extend(proposals.clone()); + let mut voting = self.voting_epoch_proposals.lock().await; + voting.extend(proposals); } } - pub async fn get_current_epoch_proposals(&self) -> Vec { - self.current_epoch_proposals.lock().await.clone() - } - pub async fn get_current_epoch_proposals_count(&self) -> usize { self.current_epoch_proposals.lock().await.len() } @@ -101,3 +111,50 @@ impl Steward { self.current_epoch_proposals.lock().await.push(proposal); } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use alloy::signers::local::PrivateKeySigner; + use mls_crypto::openmls_provider::{MlsProvider, CIPHERSUITE}; + use openmls::prelude::{BasicCredential, CredentialWithKey, KeyPackage}; + use openmls_basic_credential::SignatureKeyPair; + + use crate::steward::GroupUpdateRequest; + #[tokio::test] + async fn test_display_group_update_request() { + let user_eth_priv_key = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + let signer = + PrivateKeySigner::from_str(user_eth_priv_key).expect("Failed to create signer"); + let user_address = signer.address(); + + let ciphersuite = CIPHERSUITE; + let provider = MlsProvider::default(); + + let credential = BasicCredential::new(user_address.as_slice().to_vec()); + let signer = SignatureKeyPair::new(ciphersuite.signature_algorithm()) + .expect("Error generating a signature key pair."); + let credential_with_key = CredentialWithKey { + credential: credential.into(), + signature_key: signer.public().into(), + }; + let key_package_bundle = KeyPackage::builder() + .build(ciphersuite, &provider, &signer, credential_with_key) + .expect("Error building key package bundle."); + let key_package = key_package_bundle.key_package(); + + let proposal_add_member = GroupUpdateRequest::AddMember(Box::new(key_package.clone())); + assert_eq!( + proposal_add_member.to_string(), + "Add Member: 0x70997970c51812dc3a010c7d01b50e0d17dc79c8\n" + ); + + let proposal_remove_member = GroupUpdateRequest::RemoveMember(user_address.to_string()); + assert_eq!( + proposal_remove_member.to_string(), + "Remove Member: 0x70997970c51812dc3a010c7d01b50e0d17dc79c8\n" + ); + } +} diff --git a/src/user.rs b/src/user.rs index 3fe64fc..99f3d44 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,12 +1,16 @@ -use alloy::signers::local::PrivateKeySigner; +use alloy::{ + primitives::Address, + signers::{local::PrivateKeySigner, Signer}, +}; use kameo::Actor; -use log::info; +use log::{debug, error, info}; use openmls::{ group::MlsGroupJoinConfig, prelude::{DeserializeBytes, MlsMessageBodyIn, MlsMessageIn, StagedWelcome, Welcome}, }; use prost::Message; -use std::{collections::HashMap, str::FromStr}; +use std::{collections::HashMap, fmt::Display, str::FromStr, sync::Arc}; +use tokio::sync::{broadcast, RwLock}; use waku_bindings::WakuMessage; use ds::{waku_actor::WakuMessageToSend, APP_MSG_SUBTOPIC, WELCOME_SUBTOPIC}; @@ -16,14 +20,22 @@ use mls_crypto::{ }; use crate::{ + consensus::{v1::Vote, ConsensusEvent, ConsensusService}, error::UserError, group::{Group, GroupAction}, - message::{wrap_conversation_message_into_application_msg, wrap_user_kp_into_welcome_msg}, protos::messages::v1::{ - app_message, welcome_message, AppMessage, BatchProposalsMessage, WelcomeMessage, + app_message, consensus::v1::Proposal, welcome_message, AppMessage, BanRequest, + BatchProposalsMessage, ConversationMessage, UserKeyPackage, VotingProposal, WelcomeMessage, }, + state_machine::GroupState, + LocalSigner, }; +/// Represents the action to take after processing a user message or event. +/// +/// This enum defines the possible outcomes when processing user-related operations, +/// allowing the caller to determine the appropriate next steps for message handling, +/// group management, and network communication. #[derive(Debug, Clone, PartialEq)] pub enum UserAction { SendToWaku(WakuMessageToSend), @@ -32,15 +44,52 @@ pub enum UserAction { DoNothing, } +impl Display for UserAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UserAction::SendToWaku(_) => write!(f, "SendToWaku"), + UserAction::SendToApp(_) => write!(f, "SendToApp"), + UserAction::LeaveGroup(group_name) => write!(f, "LeaveGroup({group_name})"), + UserAction::DoNothing => write!(f, "DoNothing"), + } + } +} + +/// Represents a user in the MLS-based messaging system. +/// +/// The User struct manages the lifecycle of multiple groups, handles consensus operations, +/// and coordinates communication between the application layer and the Waku network. +/// It integrates with the consensus service for proposal management and voting. +/// +/// ## Key Features: +/// - Multi-group management and coordination +/// - Consensus service integration for proposal handling +/// - Waku message processing and routing +/// - Steward epoch coordination +/// - Member management through proposals #[derive(Actor)] pub struct User { identity: Identity, - groups: HashMap, + // Each group has its own lock for better concurrency + groups: Arc>>>>, provider: MlsProvider, - _eth_signer: PrivateKeySigner, + consensus_service: ConsensusService, + eth_signer: PrivateKeySigner, + // Queue for batch proposals that arrive before consensus is reached + pending_batch_proposals: Arc>>, } impl User { + /// Create a new user instance with the specified Ethereum private key. + /// + /// ## Parameters: + /// - `user_eth_priv_key`: The user's Ethereum private key as a hex string + /// + /// ## Returns: + /// - New User instance with initialized identity and services + /// + /// ## Errors: + /// - `UserError` if private key parsing or identity creation fails pub fn new(user_eth_priv_key: &str) -> Result { let signer = PrivateKeySigner::from_str(user_eth_priv_key)?; let user_address = signer.address(); @@ -49,72 +98,238 @@ impl User { let id = Identity::new(CIPHERSUITE, &crypto, user_address.as_slice())?; let user = User { - groups: HashMap::new(), + groups: Arc::new(RwLock::new(HashMap::new())), identity: id, - _eth_signer: signer, + eth_signer: signer, provider: crypto, + consensus_service: ConsensusService::new(), + pending_batch_proposals: Arc::new(RwLock::new(HashMap::new())), }; Ok(user) } + /// Get a subscription to consensus events + pub fn subscribe_to_consensus_events(&self) -> broadcast::Receiver<(String, ConsensusEvent)> { + self.consensus_service.subscribe_to_events() + } + + pub async fn set_up_consensus_threshold_for_group( + &mut self, + group_name: &str, + proposal_id: u32, + consensus_threshold: f64, + ) -> Result<(), UserError> { + self.consensus_service + .set_consensus_threshold_for_group_session(group_name, proposal_id, consensus_threshold) + .await?; + Ok(()) + } + + /// Create a new group for this user. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to create + /// - `is_creation`: Whether this is a group creation (true) or joining (false) + /// + /// ## Effects: + /// - If `is_creation` is true: Creates MLS group with steward capabilities + /// - If `is_creation` is false: Creates empty group for later joining + /// - Adds group to user's groups map + /// + /// ## Errors: + /// - `UserError::GroupAlreadyExistsError` if group already exists + /// - Various MLS group creation errors pub async fn create_group( &mut self, - group_name: String, + group_name: &str, is_creation: bool, ) -> Result<(), UserError> { - if self.if_group_exists(group_name.clone()) { - return Err(UserError::GroupAlreadyExistsError(group_name)); + let mut groups = self.groups.write().await; + if groups.contains_key(group_name) { + return Err(UserError::GroupAlreadyExistsError); } let group = if is_creation { Group::new( - group_name.clone(), + group_name, true, Some(&self.provider), Some(self.identity.signer()), Some(&self.identity.credential_with_key()), )? } else { - Group::new(group_name.clone(), false, None, None, None)? + Group::new(group_name, false, None, None, None)? }; - self.groups.insert(group_name.clone(), group); + groups.insert(group_name.to_string(), Arc::new(RwLock::new(group))); Ok(()) } - pub fn get_group(&self, group_name: String) -> Result { - match self.groups.get(&group_name) { - Some(g) => Ok(g.clone()), - None => Err(UserError::GroupNotFoundError(group_name)), - } + /// Check if a group exists for this user. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to check + /// + /// ## Returns: + /// - `true` if group exists, `false` otherwise + pub async fn if_group_exists(&self, group_name: &str) -> bool { + let groups = self.groups.read().await; + groups.contains_key(group_name) } - pub fn if_group_exists(&self, group_name: String) -> bool { - self.groups.contains_key(&group_name) + /// Get the state of a group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to get the state of + /// + /// ## Returns: + /// - `GroupState` of the group + pub async fn get_group_state(&self, group_name: &str) -> Result { + let groups = self.groups.read().await; + let state = groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .read() + .await + .get_state() + .await; + + Ok(state) } + /// Get the number of members in a group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to get the number of members of + /// + /// ## Returns: + /// - The number of members in the group + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + pub async fn get_group_number_of_members(&self, group_name: &str) -> Result { + let groups = self.groups.read().await; + let members = groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .read() + .await + .members_identity() + .await?; + Ok(members.len()) + } + + /// Get the MLS epoch of a group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to get the MLS epoch of + /// + /// ## Returns: + /// - The MLS epoch of the group + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + pub async fn get_group_mls_epoch(&self, group_name: &str) -> Result { + let groups = self.groups.read().await; + let epoch = groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .read() + .await + .epoch() + .await?; + Ok(epoch.as_u64()) + } + + /// Check if a user is in a group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to check if the user is in + /// - `user_address`: The address of the user to check if they are in the group + /// + /// ## Returns: + /// - `true` if the user is in the group, `false` otherwise + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + pub async fn check_if_user_in_group( + &self, + group_name: &str, + user_address: &str, + ) -> Result { + let groups = self.groups.read().await; + let members = groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .read() + .await + .members_identity() + .await?; + Ok(members.contains(&user_address.as_bytes().to_vec())) + } + + /// Process messages from the welcome subtopic. + /// + /// ## Parameters: + /// - `msg`: The Waku message to process + /// - `group_name`: The name of the group this message is for + /// + /// ## Returns: + /// - `UserAction` indicating what action should be taken + /// + /// ## Message Types Handled: + /// - **GroupAnnouncement**: Steward announcements for group joining + /// - **UserKeyPackage**: Encrypted key packages from new members + /// - **InvitationToJoin**: MLS welcome messages for group joining + /// + /// ## Effects: + /// - For group announcements: Generates and sends key package + /// - For user key packages: Decrypts and stores invite proposals (steward only) + /// - For invitations: Processes MLS welcome and joins group + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::MessageVerificationFailed` if announcement verification fails + /// - Various MLS and encryption errors pub async fn process_welcome_subtopic( &mut self, msg: WakuMessage, - group_name: String, + group_name: &str, ) -> Result { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), + // Get the group lock first + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + let is_steward = { + let group = group.read().await; + group.is_steward().await + }; + let is_kp_shared = { + let group = group.read().await; + group.is_kp_shared() + }; + let is_mls_group_initialized = { + let group = group.read().await; + group.is_mls_group_initialized() }; let received_msg = WelcomeMessage::decode(msg.payload())?; if let Some(payload) = &received_msg.payload { match payload { welcome_message::Payload::GroupAnnouncement(group_announcement) => { - let app_id = group.app_id(); - if group.is_steward() || group.is_kp_shared() { - info!("Its steward or key package already shared"); + if is_steward || is_kp_shared { Ok(UserAction::DoNothing) } else { info!( - "User {:?} received group announcement message for group {:?}", - self.identity.identity_string(), - group_name + "[user::process_welcome_subtopic]: User received group announcement message for group {group_name}" ); if !group_announcement.verify()? { return Err(UserError::MessageVerificationFailed); @@ -122,67 +337,71 @@ impl User { let new_kp = self.identity.generate_key_package(&self.provider)?; let encrypted_key_package = group_announcement.encrypt(new_kp)?; - group.set_kp_shared(true); + group.write().await.set_kp_shared(true); + let welcome_msg: WelcomeMessage = UserKeyPackage { + encrypt_kp: encrypted_key_package, + } + .into(); Ok(UserAction::SendToWaku(WakuMessageToSend::new( - wrap_user_kp_into_welcome_msg(encrypted_key_package)?.encode_to_vec(), + welcome_msg.encode_to_vec(), WELCOME_SUBTOPIC, - group_name.clone(), - app_id.clone(), + group_name, + group.read().await.app_id(), ))) } } welcome_message::Payload::UserKeyPackage(user_key_package) => { - if group.is_steward() { + if is_steward { info!( - "Steward {:?} received key package for the group {:?}", - self.identity.identity_string(), - group_name + "[user::process_welcome_subtopic]: Steward received key package for the group {group_name}" ); - let key_package = - group.decrypt_steward_msg(user_key_package.encrypt_kp.clone())?; + let key_package = group + .write() + .await + .decrypt_steward_msg(user_key_package.encrypt_kp.clone()) + .await?; - group.store_invite_proposal(Box::new(key_package)).await?; + group + .write() + .await + .store_invite_proposal(Box::new(key_package)) + .await?; Ok(UserAction::DoNothing) } else { Ok(UserAction::DoNothing) } } welcome_message::Payload::InvitationToJoin(invitation_to_join) => { - if group.is_steward() { + if is_steward || is_mls_group_initialized { Ok(UserAction::DoNothing) } else { - info!( - "User {:?} received invitation to join group {:?}", - self.identity.identity_string(), - group_name - ); + // Release the lock before calling join_group + drop(group); + // Parse the MLS message to get the welcome let (mls_in, _) = MlsMessageIn::tls_deserialize_bytes( &invitation_to_join.mls_message_out_bytes, - ) - .map_err(|e| UserError::MlsMessageInDeserializeError(e.to_string()))?; + )?; let welcome = match mls_in.extract() { MlsMessageBodyIn::Welcome(welcome) => welcome, - _ => return Err(UserError::EmptyWelcomeMessageError), + _ => return Err(UserError::FailedToExtractWelcomeMessage), }; if welcome.secrets().iter().any(|egs| { let hash_ref = egs.new_member().as_slice().to_vec(); self.identity.is_key_package_exists(&hash_ref) }) { - self.join_group(welcome)?; + self.join_group(welcome).await?; let msg = self - .build_group_message("User joined to the group", group_name) + .build_group_message( + "User joined to the group".as_bytes().to_vec(), + group_name, + ) .await?; Ok(UserAction::SendToWaku(msg)) } else { - info!( - "User {:?} received invitation to join group {:?}, but key package is not shared", - self.identity.identity_string(), - group_name - ); Ok(UserAction::DoNothing) } } @@ -193,155 +412,262 @@ impl User { } } + /// Process messages from the application message subtopic. + /// + /// ## Parameters: + /// - `msg`: The Waku message to process + /// - `group_name`: The name of the group this message is for + /// + /// ## Returns: + /// - `UserAction` indicating what action should be taken + /// + /// ## Message Types Handled: + /// - **BatchProposalsMessage**: Batch proposals from steward + /// - **MLS Protocol Messages**: Encrypted group messages + /// - **Application Messages**: Various app-level messages + /// + /// ## Effects: + /// - Processes batch proposals and applies them to the group + /// - Handles MLS protocol messages through the group + /// - Routes consensus proposals and votes to appropriate handlers + /// + /// ## Preconditions: + /// - Group must be initialized with MLS group + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various MLS processing errors pub async fn process_app_subtopic( &mut self, msg: WakuMessage, - group_name: String, + group_name: &str, ) -> Result { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? }; - if !group.is_mls_group_initialized() { + + if !group.read().await.is_mls_group_initialized() { return Ok(UserAction::DoNothing); } - info!( - "[process_app_subtopic]: User {:?} received app message for group {:?}", - self.identity.identity_string(), - group_name - ); // Try to parse as AppMessage first + // This one required for commit messages as they are sent as AppMessage + // without group encryption if let Ok(app_message) = AppMessage::decode(msg.payload()) { - return self.process_app_message(app_message, group_name).await; + match app_message.payload { + Some(app_message::Payload::BatchProposalsMessage(batch_msg)) => { + info!( + "[user::process_app_subtopic]: Processing batch proposals message for group {group_name}" + ); + // Release the lock before calling self methods + return self + .process_batch_proposals_message(batch_msg, group_name) + .await; + } + _ => { + error!( + "[user::process_app_subtopic]: Cannot process another app message here: {:?}", + app_message.to_string() + ); + return Err(UserError::InvalidAppMessageType); + } + } } // Fall back to MLS protocol message - let (mls_message_in, _) = MlsMessageIn::tls_deserialize_bytes(msg.payload()) - .map_err(|e| UserError::MlsMessageInDeserializeError(e.to_string()))?; - let mls_message = mls_message_in - .try_into_protocol_message() - .map_err(|e| UserError::TryIntoProtocolMessageError(e.to_string()))?; + let (mls_message_in, _) = MlsMessageIn::tls_deserialize_bytes(msg.payload())?; + let mls_message = mls_message_in.try_into_protocol_message()?; - let res = group - .process_protocol_msg(mls_message, &self.provider, self.identity.signature_key()) - .await?; + let res = { + info!("[user::process_app_subtopic]: processing encrypted protocol message"); + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .write() + .await + .process_protocol_msg(mls_message, &self.provider) + .await + }?; + // Handle the result outside of any lock scope match res { - GroupAction::GroupAppMsg(msg) => Ok(UserAction::SendToApp(msg)), - GroupAction::LeaveGroup => Ok(UserAction::LeaveGroup(group_name)), - GroupAction::DoNothing => Ok(UserAction::DoNothing), + GroupAction::GroupAppMsg(msg) => { + info!("[user::process_app_subtopic]: sending to app"); + Ok(UserAction::SendToApp(msg)) + } + GroupAction::LeaveGroup => { + info!("[user::process_app_subtopic]: leaving group"); + Ok(UserAction::LeaveGroup(group_name.to_string())) + } + GroupAction::DoNothing => { + info!("[user::process_app_subtopic]: doing nothing"); + Ok(UserAction::DoNothing) + } + GroupAction::GroupProposal(proposal) => { + info!("[user::process_app_subtopic]: processing consensus proposal"); + self.process_consensus_proposal(proposal, group_name).await + } + GroupAction::GroupVote(vote) => { + info!("[user::process_app_subtopic]: processing consensus vote"); + self.process_consensus_vote(vote, group_name).await + } } } - /// Process AppMessage payload - async fn process_app_message( - &mut self, - app_message: AppMessage, - group_name: String, - ) -> Result { - match app_message.payload { - Some(app_message::Payload::BatchProposalsMessage(batch_msg)) => { - self.process_batch_proposals_message(batch_msg, group_name) - .await - } - Some(app_message::Payload::ConversationMessage(conv_msg)) => { - Ok(UserAction::SendToApp(AppMessage { - payload: Some(app_message::Payload::ConversationMessage(conv_msg)), - })) - } - _ => Ok(UserAction::DoNothing), - } - } - - /// Process batch proposals message + /// Process batch proposals message from the steward. + /// + /// ## Parameters: + /// - `batch_msg`: The batch proposals message to process + /// - `group_name`: The name of the group these proposals are for + /// + /// ## Returns: + /// - `UserAction` indicating what action should be taken + /// + /// ## Effects: + /// - Processes all MLS proposals in the batch + /// - Applies the commit message to complete the group update + /// - Transitions group to Working state after successful processing + /// + /// ## State Requirements: + /// - Group must be in Waiting state to process batch proposals + /// - If not in correct state, stores proposals for later processing + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various MLS processing errors async fn process_batch_proposals_message( &mut self, batch_msg: BatchProposalsMessage, - group_name: String, + group_name: &str, ) -> Result { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), + // Get the group lock + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? }; - // First, process all proposals before the commit + let initial_state = group.read().await.get_state().await; + if initial_state != GroupState::Waiting { + info!( + "[user::process_batch_proposals_message]: Cannot process batch proposals in {initial_state} state, storing for later processing" + ); + // Store the batch proposals for later processing + self.store_pending_batch_proposals(group_name, batch_msg) + .await; + return Ok(UserAction::DoNothing); + } + + // Process all proposals before the commit for proposal_bytes in batch_msg.mls_proposals { - let (mls_message_in, _) = MlsMessageIn::tls_deserialize_bytes(&proposal_bytes) - .map_err(|e| UserError::MlsMessageInDeserializeError(e.to_string()))?; + let (mls_message_in, _) = MlsMessageIn::tls_deserialize_bytes(&proposal_bytes)?; + let protocol_message = mls_message_in.try_into_protocol_message()?; - let protocol_message = mls_message_in - .try_into_protocol_message() - .map_err(|e| UserError::TryIntoProtocolMessageError(e.to_string()))?; - - // Process the proposal let _res = group - .process_protocol_msg( - protocol_message, - &self.provider, - self.identity.signature_key(), - ) + .write() + .await + .process_protocol_msg(protocol_message, &self.provider) .await?; } // Then process the commit message - let (mls_message_in, _) = MlsMessageIn::tls_deserialize_bytes(&batch_msg.commit_message) - .map_err(|e| UserError::MlsMessageInDeserializeError(e.to_string()))?; - - let protocol_message = mls_message_in - .try_into_protocol_message() - .map_err(|e| UserError::TryIntoProtocolMessageError(e.to_string()))?; + let (mls_message_in, _) = MlsMessageIn::tls_deserialize_bytes(&batch_msg.commit_message)?; + let protocol_message = mls_message_in.try_into_protocol_message()?; let res = group - .process_protocol_msg( - protocol_message, - &self.provider, - self.identity.signature_key(), - ) + .write() + .await + .process_protocol_msg(protocol_message, &self.provider) .await?; + group.write().await.start_working().await; + match res { GroupAction::GroupAppMsg(msg) => Ok(UserAction::SendToApp(msg)), - GroupAction::LeaveGroup => Ok(UserAction::LeaveGroup(group_name)), + GroupAction::LeaveGroup => Ok(UserAction::LeaveGroup(group_name.to_string())), GroupAction::DoNothing => Ok(UserAction::DoNothing), + GroupAction::GroupProposal(proposal) => { + self.process_consensus_proposal(proposal, group_name).await + } + GroupAction::GroupVote(vote) => self.process_consensus_vote(vote, group_name).await, } } - /// This function is used to process waku messages - /// After processing the message, it returns an action to be performed by the user - /// - SendToWaku - send a message to the waku network - /// - SendToGroup - send a message to the group (print to the application) - /// - LeaveGroup - leave a group - /// - DoNothing - do nothing + /// Process incoming Waku messages and route them to appropriate handlers. + /// + /// ## Parameters: + /// - `msg`: The Waku message to process + /// + /// ## Returns: + /// - `UserAction` indicating what action should be taken + /// + /// ## Message Routing: + /// - **Welcome Subtopic**: Routes to `process_welcome_subtopic()` + /// - **App Message Subtopic**: Routes to `process_app_subtopic()` + /// - **Unknown Topics**: Returns error + /// + /// ## Effects: + /// - Processes messages based on content topic + /// - Skips messages from the same app instance + /// - Routes to appropriate subtopic handlers + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::UnknownContentTopicType` for unsupported topics + /// - Various processing errors from subtopic handlers pub async fn process_waku_message( &mut self, msg: WakuMessage, ) -> Result { let group_name = msg.content_topic.application_name.to_string(); - let group = match self.groups.get(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), + let group = { + let groups = self.groups.read().await; + groups + .get(&group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? }; - let app_id = group.app_id(); - if msg.meta == app_id { - info!("Message is from the same app, skipping"); + + if msg.meta == group.read().await.app_id() { + debug!("Message is from the same app, skipping"); return Ok(UserAction::DoNothing); } + let ct_name = msg.content_topic.content_topic_name.to_string(); - info!("Processing waku message from content topic: {ct_name:?}"); match ct_name.as_str() { - WELCOME_SUBTOPIC => self.process_welcome_subtopic(msg, group_name).await, - APP_MSG_SUBTOPIC => self.process_app_subtopic(msg, group_name).await, + WELCOME_SUBTOPIC => self.process_welcome_subtopic(msg, &group_name).await, + APP_MSG_SUBTOPIC => self.process_app_subtopic(msg, &group_name).await, _ => Err(UserError::UnknownContentTopicType(ct_name)), } } - /// This function is used to join a group after receiving a welcome message - fn join_group(&mut self, welcome: Welcome) -> Result<(), UserError> { - let group_config = MlsGroupJoinConfig::builder() - .use_ratchet_tree_extension(true) - .build(); - + /// Join a group after receiving a welcome message. + /// + /// ## Parameters: + /// - `welcome`: The MLS welcome message containing group information + /// + /// ## Effects: + /// - Creates new MLS group from welcome message + /// - Sets the MLS group in the user's group instance + /// - Updates group state to reflect successful joining + /// + /// ## Preconditions: + /// - Group must already exist in user's groups map + /// - Welcome message must be valid and contain proper group data + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various MLS group creation errors + async fn join_group(&mut self, welcome: Welcome) -> Result<(), UserError> { + let group_config = MlsGroupJoinConfig::builder().build(); let mls_group = StagedWelcome::new_from_welcome(&self.provider, &group_config, welcome, None)? .into_group(&self.provider)?; @@ -349,206 +675,992 @@ impl User { let group_id = mls_group.group_id().to_vec(); let group_name = String::from_utf8(group_id)?; - if !self.if_group_exists(group_name.clone()) { - return Err(UserError::GroupNotFoundError(group_name)); - } - - self.groups - .get_mut(&group_name) - .unwrap() + let groups = self.groups.read().await; + groups + .get(&group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .write() + .await .set_mls_group(mls_group)?; - info!( - "User {:?} joined group {:?}", - self.identity.identity_string(), - group_name - ); + info!("[user::join_group]: User joined group {group_name}"); Ok(()) } - /// This function is used to leave a group after receiving a commit message - pub async fn leave_group(&mut self, group_name: String) -> Result<(), UserError> { - if !self.if_group_exists(group_name.clone()) { - return Err(UserError::GroupNotFoundError(group_name)); + /// Leave a group and clean up associated resources. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to leave + /// + /// ## Effects: + /// - Removes group from user's groups map + /// - Cleans up all group-related resources + /// + /// ## Preconditions: + /// - Group must exist in user's groups map + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + pub async fn leave_group(&mut self, group_name: &str) -> Result<(), UserError> { + info!("[user::leave_group]: Leaving group {group_name}"); + if !self.if_group_exists(group_name).await { + return Err(UserError::GroupNotFoundError); } - self.groups.remove(&group_name); + let mut groups = self.groups.write().await; + groups.remove(group_name); Ok(()) } + /// Prepare a steward announcement message for a group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to prepare the message for + /// + /// ## Returns: + /// - Waku message containing the steward announcement + /// + /// ## Preconditions: + /// - Group must exist and be initialized + /// + /// ## Effects: + /// - Generates new steward announcement with refreshed keys + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various steward message generation errors pub async fn prepare_steward_msg( &mut self, - group_name: String, + group_name: &str, ) -> Result { - if !self.if_group_exists(group_name.clone()) { - return Err(UserError::GroupNotFoundError(group_name)); - } - let msg_to_send = self - .groups - .get_mut(&group_name) - .unwrap() - .generate_steward_message()?; - Ok(msg_to_send) - } - - pub async fn build_group_message( - &mut self, - msg: &str, - group_name: String, - ) -> Result { - info!("Start building group message"); - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? }; - if !group.is_mls_group_initialized() { - return Err(UserError::GroupNotFoundError(group_name)); - } - let app_msg = wrap_conversation_message_into_application_msg( - msg.as_bytes().to_vec(), - self.identity.identity_string(), - group_name.clone(), - ); - let msg_to_send = group - .build_message(&self.provider, self.identity.signer(), &app_msg) - .await?; - info!("End building group message"); + let msg_to_send = group.write().await.generate_steward_message().await?; Ok(msg_to_send) } - /// Get the identity string for debugging purposes + /// Build a group message for sending to the group. + /// + /// ## Parameters: + /// - `msg`: The message content as bytes + /// - `group_name`: The name of the group to send the message to + /// + /// ## Returns: + /// - Waku message ready for transmission + /// + /// ## Preconditions: + /// - Group must be initialized with MLS group + /// + /// ## Effects: + /// - Creates conversation message with sender identity + /// - Builds MLS message through the group + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::MlsGroupNotInitialized` if MLS group not initialized + /// - Various MLS message building errors + pub async fn build_group_message( + &mut self, + msg: Vec, + group_name: &str, + ) -> Result { + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + if !group.read().await.is_mls_group_initialized() { + return Err(UserError::MlsGroupNotInitialized); + } + + let app_msg = ConversationMessage { + message: msg, + sender: self.identity.identity_string(), + group_name: group_name.to_string(), + } + .into(); + + let msg_to_send = group + .write() + .await + .build_message(&self.provider, self.identity.signer(), &app_msg) + .await?; + + Ok(msg_to_send) + } + + /// Build a system message for sending to the group. + /// + /// ## Parameters: + /// - `system_message`: The message content as bytes + /// - `group_name`: The name of the group to send the message to + /// + /// ## Returns: + /// - Waku message ready for transmission + /// + /// ## Preconditions: + /// - Group must be initialized with MLS group + /// + /// ## Effects: + /// - Creates encrypted system message + /// - Builds MLS message through the group + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::MlsGroupNotInitialized` if MLS group not initialized + /// - Various MLS message building errors + pub async fn build_system_message( + &mut self, + system_message: Vec, + group_name: &str, + ) -> Result { + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + if !group.read().await.is_mls_group_initialized() { + return Err(UserError::MlsGroupNotInitialized); + } + + let app_msg = ConversationMessage { + message: system_message, + sender: "SYSTEM".to_string(), + group_name: group_name.to_string(), + } + .into(); + + let msg_to_send = group + .write() + .await + .build_message(&self.provider, self.identity.signer(), &app_msg) + .await?; + + Ok(msg_to_send) + } + + /// Build a special message (ban request/vote) preserving the original AppMessage structure. + /// + /// ## Parameters: + /// - `app_message`: The application message to build + /// - `group_name`: The name of the group to send the message to + /// + /// ## Returns: + /// - Waku message ready for transmission + /// + /// ## Preconditions: + /// - Group must be initialized with MLS group + /// + /// ## Effects: + /// - Preserves original AppMessage structure without wrapping + /// - Builds MLS message through the group + /// + /// ## Usage: + /// Used for consensus-related messages like proposals and votes + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::MlsGroupNotInitialized` if MLS group not initialized + /// - Various MLS message building errors + pub async fn build_changer_message( + &mut self, + app_message: AppMessage, + group_name: &str, + ) -> Result { + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + if !group.read().await.is_mls_group_initialized() { + return Err(UserError::MlsGroupNotInitialized); + } + + let msg_to_send = group + .write() + .await + .build_message(&self.provider, self.identity.signer(), &app_message) + .await?; + + Ok(msg_to_send) + } + + /// Get the identity string for debugging and identification purposes. + /// + /// ## Returns: + /// - String representation of the user's identity + /// + /// ## Usage: + /// Primarily used for debugging, logging, and user identification pub fn identity_string(&self) -> String { self.identity.identity_string() } /// Apply proposals for the given group, returning the batch message(s). + /// + /// ## Parameters: + /// - `group_name`: The name of the group to apply proposals for + /// + /// ## Returns: + /// - Vector of Waku messages containing batch proposals and welcome messages + /// + /// ## Preconditions: + /// - Group must be initialized with MLS group + /// - User must be steward for the group + /// + /// ## Effects: + /// - Creates MLS proposals for all pending group updates + /// - Commits all proposals to the MLS group + /// - Generates batch proposals message and welcome message if needed + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::MlsGroupNotInitialized` if MLS group not initialized + /// - Various MLS proposal creation errors pub async fn apply_proposals( &mut self, - group_name: String, + group_name: &str, ) -> Result, UserError> { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? }; + if !group.read().await.is_mls_group_initialized() { + return Err(UserError::MlsGroupNotInitialized); + } + let messages = group + .write() + .await .create_batch_proposals_message(&self.provider, self.identity.signer()) .await?; + info!("[user::apply_proposals]: Applied proposals for group {group_name}"); Ok(messages) } - /// Remove proposals and complete the steward epoch for the given group. - pub async fn remove_proposals_and_complete( - &mut self, - group_name: String, - ) -> Result<(), UserError> { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), - }; - group.remove_proposals_and_complete().await?; - Ok(()) - } - /// Start a new steward epoch for the given group. - /// Returns the number of proposals that will be voted on. - /// If there are no proposals, returns 0 and doesn't change state. - pub async fn start_steward_epoch(&mut self, group_name: String) -> Result { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), - }; - - // Check if there are proposals in the current epoch - let proposal_count = group.get_pending_proposals_count().await; + /// + /// ## Parameters: + /// - `group_name`: The name of the group to start steward epoch for + /// + /// ## Returns: + /// - Number of proposals that will be voted on (0 if no proposals) + /// + /// ## Effects: + /// - Starts steward epoch through the group's state machine + /// - Collects proposals for voting + /// - Transitions group to appropriate state based on proposal count + /// + /// ## State Transitions: + /// - **With proposals**: Working → Waiting (returns proposal count) + /// - **No proposals**: Working → Working (stays in Working, returns 0) + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various state machine errors + pub async fn start_steward_epoch(&mut self, group_name: &str) -> Result { + let groups = self.groups.read().await; + let proposal_count = groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .write() + .await + .start_steward_epoch_with_validation() + .await?; if proposal_count == 0 { - // No proposals to vote on, return 0 and don't change state - info!("No proposals to vote on, skipping steward epoch"); - return Ok(0); + info!("[user::start_steward_epoch]: No proposals to vote on, skipping steward epoch"); + } else { + info!("[user::start_steward_epoch]: Started steward epoch with {proposal_count} proposals"); } - // There are proposals, start the steward epoch - info!("Starting steward epoch with {proposal_count} proposals"); - group.start_steward_epoch().await?; Ok(proposal_count) } - /// Start voting for the given group, returning the vote ID. - pub async fn start_voting(&mut self, group_name: String) -> Result, UserError> { - let current_identity = self.identity.identity_string(); - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), - }; - group.start_voting()?; - let members = group.members_identity().await?; - let participant_ids: Vec> = members.into_iter().collect(); - println!( - "User: start_voting - participants: {:?}", - participant_ids - .iter() - .map(alloy::hex::encode) - .collect::>() - ); - println!("User: start_voting - current user identity: {current_identity}"); - let vote_id = uuid::Uuid::new_v4().as_bytes().to_vec(); - - Ok(vote_id) - } - - /// Complete voting for the given group and vote ID, returning the result. - pub async fn complete_voting( + /// Start voting for the given group, returning the proposal ID. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to start voting for + /// + /// ## Returns: + /// - Tuple of (proposal_id, UserAction) for steward actions + /// + /// ## Effects: + /// - Starts voting phase in the group + /// - Creates consensus proposal for voting + /// - Sends voting proposal to frontend + /// + /// ## State Transitions: + /// - **Waiting → Voting**: If proposals found and steward starts voting + /// - **Waiting → Working**: If no proposals found (edge case fix) + /// + /// ## Edge Case Handling: + /// If no proposals are found during voting phase (rare edge case where proposals + /// disappear between epoch start and voting), transitions back to Working state + /// to prevent getting stuck in Waiting state. + /// + /// ## Preconditions: + /// - User must be steward for the group + /// - Group must have proposals in voting epoch + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::NoProposalsFound` if no proposals exist + /// - Various consensus service errors + pub async fn get_proposals_for_steward_voting( &mut self, - group_name: String, - _vote_id: Vec, - ) -> Result { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), + group_name: &str, + ) -> Result<(u32, UserAction), UserError> { + info!( + "[user::get_proposals_for_steward_voting]: Getting proposals for steward voting in group {group_name}" + ); + + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? }; - // Get vote result from HashGraph consensus - let vote_result = true; + // If this is the steward, create proposal with vote and send to group + if group.read().await.is_steward().await { + let proposals = group.read().await.get_proposals_for_voting_epoch().await; + if !proposals.is_empty() { + group.write().await.start_voting().await?; - // Update group state based on vote result - group.complete_voting(vote_result)?; - Ok(vote_result) - } + // Get group members for expected voters count + let members = group.read().await.members_identity().await?; + let participant_ids: Vec> = members.into_iter().collect(); + let expected_voters_count = participant_ids.len() as u32; - /// Submit a vote for the given vote ID. - pub async fn submit_vote(&mut self, vote_id: Vec, vote: bool) -> Result<(), UserError> { - let current_identity = self.identity.identity_string(); - info!("User: submit_vote - vote_id: {vote_id:?}, vote: {vote}"); - info!("User: submit_vote - current user identity: {current_identity}"); - Ok(()) + // Create consensus proposal + let proposal = self + .consensus_service + .create_proposal( + group_name, + "Group Update Proposal".to_string(), + proposals.iter().map(|p| p.to_string()).collect(), + self.identity.identity_string().into(), + expected_voters_count, + 3600, // 1 hour expiration + true, // liveness criteria + ) + .await?; + + info!( + "[user::get_proposals_for_steward_voting]: Created consensus proposal with ID {} and {} expected voters", + proposal.proposal_id, expected_voters_count + ); + + // Send voting proposal to frontend + let voting_proposal: AppMessage = VotingProposal { + proposal_id: proposal.proposal_id, + payload: proposal.payload, + group_name: group_name.to_string(), + } + .into(); + + Ok((proposal.proposal_id, UserAction::SendToApp(voting_proposal))) + } else { + error!("[user::get_proposals_for_steward_voting]: No proposals found"); + Err(UserError::NoProposalsFound) + } + } else { + // Not steward, do nothing + info!("[user::get_proposals_for_steward_voting]: Not steward, doing nothing"); + Ok((0, UserAction::DoNothing)) + } } /// Add a remove proposal to the steward for the given group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to add the proposal to + /// - `identity`: The identity string of the member to remove + /// + /// ## Effects: + /// - Stores remove proposal in the group's steward queue + /// - Proposal will be processed in the next steward epoch + /// + /// ## Preconditions: + /// - Group must exist + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various proposal storage errors pub async fn add_remove_proposal( &mut self, - group_name: String, + group_name: &str, identity: String, ) -> Result<(), UserError> { - let group = match self.groups.get_mut(&group_name) { - Some(g) => g, - None => return Err(UserError::GroupNotFoundError(group_name)), - }; - let identity_bytes = alloy::hex::decode(&identity) - .map_err(|e| UserError::ApplyProposalsError(format!("Invalid hex string: {e}")))?; - group.store_remove_proposal(identity_bytes).await?; + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + .write() + .await + .store_remove_proposal(identity) + .await?; Ok(()) } - /// Get the number of pending proposals for the given group. - pub async fn get_pending_proposals_count( + /// Handle consensus result after it's determined. + /// + /// ## Parameters: + /// - `group_name`: The name of the group the consensus is for + /// - `vote_result`: Whether the consensus passed (true) or failed (false) + /// + /// ## Returns: + /// - Vector of Waku messages to send (if any) + /// + /// ## State Transitions: + /// **Steward:** + /// - **Vote YES**: Voting → ConsensusReached → Waiting → Working (creates and sends batch proposals, then applies them) + /// - **Vote NO**: Voting → Working (discards proposals) + /// + /// **Non-Steward:** + /// - **Vote YES**: Voting → ConsensusReached → Waiting → Working (waits for consensus + batch proposals, then applies them) + /// - **Vote NO**: Voting → Working (no proposals to apply) + /// + /// ## Effects: + /// - Completes voting in the group + /// - Handles proposal application or cleanup based on result + /// - Manages state transitions for both steward and non-steward users + /// - Processes pending batch proposals if available + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various state machine and proposal processing errors + async fn handle_consensus_result( + &mut self, + group_name: &str, + vote_result: bool, + ) -> Result, UserError> { + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + group.write().await.complete_voting(vote_result).await?; + + // Handle vote result based on steward status + if group.read().await.is_steward().await { + if vote_result { + // Vote YES: Apply proposals and send commit messages + info!("[user::complete_voting_for_steward]: Vote YES, sending commit message"); + + // Apply proposals and complete (state must be ConsensusReached for this) + let messages = self.apply_proposals(group_name).await?; + group.write().await.handle_yes_vote().await?; + Ok(messages) + } else { + // Vote NO: Empty proposal queue without applying, no commit messages + info!("[user::complete_voting_for_steward]: Vote NO, emptying proposal queue without applying"); + + // Empty proposals without state requirement (direct steward call) + group.write().await.handle_no_vote().await?; + + Ok(vec![]) + } + } else if vote_result { + // Vote YES: Transition to ConsensusReached state to await batch proposals + group.write().await.start_consensus_reached().await; + info!("[user::handle_consensus_result]: Non-steward user transitioning to ConsensusReached state to await batch proposals"); + + // Now transition to Waiting state to follow complete state machine flow + group.write().await.start_waiting().await; + info!("[user::handle_consensus_result]: Non-steward user transitioning to Waiting state to await batch proposals"); + + // Check if there are pending batch proposals that can now be processed + if self.has_pending_batch_proposals(group_name).await { + info!("[user::handle_consensus_result]: Non-steward user has pending batch proposals, processing them now"); + let action = self.process_pending_batch_proposals(group_name).await?; + info!("[user::handle_consensus_result]: Successfully processed pending batch proposals"); + if let Some(action) = action { + match action { + UserAction::SendToWaku(waku_message) => { + info!( + "[user::handle_consensus_result]: Sending waku message to backend" + ); + Ok(vec![waku_message]) + } + UserAction::LeaveGroup(group_name) => { + self.leave_group(group_name.as_str()).await?; + info!("[user::handle_consensus_result]: Non-steward user left group {group_name}"); + Ok(vec![]) + } + UserAction::DoNothing => { + info!("[user::handle_consensus_result]: No action to process"); + Ok(vec![]) + } + _ => { + error!("[user::handle_consensus_result]: Invalid action to process"); + Err(UserError::InvalidUserAction(action.to_string())) + } + } + } else { + info!("[user::handle_consensus_result]: No action to process"); + Ok(vec![]) + } + } else { + info!("[user::handle_consensus_result]: No pending batch proposals to process"); + Ok(vec![]) + } + } else { + // Vote NO: Transition to Working state + group.write().await.start_working().await; + info!("[user::handle_consensus_result]: Non-steward user transitioning to Working state after failed vote"); + Ok(vec![]) + } + } + + /// Handle incoming consensus events and return commit messages if needed. + /// + /// ## Parameters: + /// - `group_name`: The name of the group the consensus event is for + /// - `event`: The consensus event to handle + /// + /// ## Returns: + /// - Vector of Waku messages to send (if any) + /// + /// ## Event Types Handled: + /// - **ConsensusReached**: Handles successful consensus with result + /// - **ConsensusFailed**: Handles consensus failure with liveness criteria + /// + /// ## Effects: + /// - Routes consensus events to appropriate handlers + /// - Manages state transitions based on consensus results + /// - Applies liveness criteria for failed consensus + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - `UserError::InvalidGroupState` if group is in invalid state + /// - Various consensus handling errors + pub async fn handle_consensus_event( + &mut self, + group_name: &str, + event: ConsensusEvent, + ) -> Result, UserError> { + match event { + ConsensusEvent::ConsensusReached { + proposal_id, + result, + } => { + info!( + "[user::handle_consensus_event]: Consensus reached for proposal {proposal_id} in group {group_name}: {result}" + ); + + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + let current_state = group.read().await.get_state().await; + info!("[user::handle_consensus_event]: Current state: {:?} for proposal {proposal_id}", current_state); + + // Handle the consensus result and return commit messages + let messages = self.handle_consensus_result(group_name, result).await?; + Ok(messages) + } + ConsensusEvent::ConsensusFailed { + proposal_id, + reason, + } => { + info!( + "[user::handle_consensus_event]: Consensus failed for proposal {proposal_id} in group {group_name}: {reason}" + ); + + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + let current_state = group.read().await.get_state().await; + + info!("[user::handle_consensus_event]: Handling consensus failure in {:?} state for proposal {proposal_id}", current_state); + + // Handle consensus failure based on current state + match current_state { + GroupState::Voting => { + // If we're in Voting state, complete voting with liveness criteria + // Get liveness criteria from the actual proposal + let liveness_result = self + .consensus_service + .get_proposal_liveness_criteria(group_name, proposal_id) + .await + .unwrap_or(false); // Default to false if proposal not found + + info!("Applying liveness criteria for failed proposal {proposal_id}: {liveness_result}"); + let messages = self + .handle_consensus_result(group_name, liveness_result) + .await?; + Ok(messages) + } + _ => Err(UserError::InvalidGroupState(current_state.to_string())), + } + } + } + } + + /// Process incoming consensus proposal. + /// + /// ## Parameters: + /// - `proposal`: The consensus proposal to process + /// - `group_name`: The name of the group the proposal is for + /// + /// ## Returns: + /// - `UserAction` indicating what action should be taken + /// + /// ## Effects: + /// - Stores proposal in consensus service + /// - Starts voting phase in the group + /// - Creates voting proposal for frontend + /// + /// ## State Transitions: + /// - Any state → Voting (starts voting phase) + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various consensus service errors + pub async fn process_consensus_proposal( + &mut self, + proposal: Proposal, + group_name: &str, + ) -> Result { + self.consensus_service + .process_incoming_proposal(group_name, proposal.clone()) + .await?; + + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + group.write().await.start_voting().await?; + info!( + "[user::process_consensus_proposal]: Starting voting for proposal {}", + proposal.proposal_id + ); + + // Send voting proposal to frontend + let voting_proposal: AppMessage = VotingProposal { + proposal_id: proposal.proposal_id, + payload: proposal.payload.clone(), + group_name: group_name.to_string(), + } + .into(); + + Ok(UserAction::SendToApp(voting_proposal)) + } + + /// Process user vote from frontend. + /// + /// ## Parameters: + /// - `proposal_id`: The ID of the proposal to vote on + /// - `user_vote`: The user's vote (true for yes, false for no) + /// - `group_name`: The name of the group the vote is for + /// + /// ## Returns: + /// - `UserAction` indicating what action should be taken + /// + /// ## Effects: + /// - For stewards: Creates consensus vote and sends to group + /// - For regular users: Processes user vote in consensus service + /// - Builds and sends appropriate message to group + /// + /// ## Message Types: + /// - **Steward**: Sends consensus vote message + /// - **Regular User**: Sends user vote message + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various consensus service and message building errors + pub async fn process_user_vote( + &mut self, + proposal_id: u32, + user_vote: bool, + group_name: &str, + ) -> Result { + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + let app_message = if group.read().await.is_steward().await { + info!( + "[user::process_user_vote]: Steward voting for proposal {proposal_id} in group {group_name}" + ); + let proposal = self + .consensus_service + .vote_on_proposal(group_name, proposal_id, user_vote, self.eth_signer.clone()) + .await?; + proposal.into() + } else { + info!( + "[user::process_user_vote]: User voting for proposal {proposal_id} in group {group_name}" + ); + let vote = self + .consensus_service + .process_user_vote(group_name, proposal_id, user_vote, self.eth_signer.clone()) + .await?; + vote.into() + }; + + let waku_msg = self.build_changer_message(app_message, group_name).await?; + + Ok(UserAction::SendToWaku(waku_msg)) + } + + /// Process incoming consensus vote and handle immediate state transitions. + /// + /// ## Parameters: + /// - `vote`: The consensus vote to process + /// - `group_name`: The name of the group the vote is for + /// + /// ## Returns: + /// - `UserAction` indicating what action should be taken + /// + /// ## Effects: + /// - Stores vote in consensus service + /// - Handles immediate state transitions if consensus is reached + /// + /// ## State Transitions: + /// When consensus is reached immediately after processing a vote: + /// - **Vote YES**: Non-steward transitions to Waiting state to await batch proposals + /// - **Vote NO**: Non-steward transitions to Working state immediately + /// - **Steward**: Relies on event-driven system for full proposal management + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various consensus service errors + async fn process_consensus_vote( + &mut self, + vote: Vote, + group_name: &str, + ) -> Result { + self.consensus_service + .process_incoming_vote(group_name, vote.clone()) + .await?; + + Ok(UserAction::DoNothing) + } + + /// Process incoming ban request. + /// + /// ## Parameters: + /// - `ban_request`: The ban request to process + /// - `group_name`: The name of the group the ban request is for + /// + /// ## Returns: + /// - Waku message to send to the group + /// + /// ## Effects: + /// - **For stewards**: Adds remove proposal to steward queue and sends system message + /// - **For regular users**: Forwards ban request to the group + /// + /// ## Message Types: + /// - **Steward**: Sends system message about proposal addition + /// - **Regular User**: Sends ban request message to group + /// + /// ## Errors: + /// - `UserError::GroupNotFoundError` if group doesn't exist + /// - Various message building errors + pub async fn process_ban_request( + &mut self, + ban_request: BanRequest, + group_name: &str, + ) -> Result { + let user_to_ban = ban_request.user_to_ban.clone(); + info!( + "[user::process_ban_request]: Processing ban request for user {user_to_ban} in group {group_name}" + ); + + let group = { + let groups = self.groups.read().await; + groups + .get(group_name) + .cloned() + .ok_or_else(|| UserError::GroupNotFoundError)? + }; + + let is_steward = { + let group = group.read().await; + group.is_steward().await + }; + if is_steward { + // Steward: add the remove proposal to the queue + info!( + "[user::process_ban_request]: Steward adding remove proposal for user {user_to_ban}" + ); + self.add_remove_proposal(group_name, user_to_ban.to_string()) + .await?; + + let msg_to_send = self + .build_system_message( + format!("Remove proposal for user {user_to_ban} added to steward queue") + .into_bytes(), + group_name, + ) + .await?; + + Ok(msg_to_send) + } else { + // Regular user: send the ban request to the group + let updated_ban_request = BanRequest { + user_to_ban: ban_request.user_to_ban, + requester: self.identity_string(), + group_name: ban_request.group_name, + }; + self.build_changer_message(updated_ban_request.into(), group_name) + .await + } + } + + /// Store batch proposals for later processing when state becomes correct. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to store proposals for + /// - `batch_msg`: The batch proposals message to store + /// + /// ## Effects: + /// - Stores batch proposals in pending queue for later processing + /// - Used when proposals arrive before group is in correct state + /// + /// ## Usage: + /// Called when batch proposals cannot be processed immediately + /// due to incorrect group state + async fn store_pending_batch_proposals( &self, - group_name: String, - ) -> Result { - let group = self - .groups - .get(&group_name) - .ok_or(UserError::GroupNotFoundError(group_name))?; - Ok(group.get_pending_proposals_count().await) + group_name: &str, + batch_msg: BatchProposalsMessage, + ) { + let mut pending = self.pending_batch_proposals.write().await; + pending.insert(group_name.to_string(), batch_msg); + info!( + "[user::store_pending_batch_proposals]: Stored batch proposals for group {} to be processed later", + group_name + ); + } + + /// Check if there are pending batch proposals for a group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to check + /// + /// ## Returns: + /// - `true` if pending proposals exist, `false` otherwise + async fn has_pending_batch_proposals(&self, group_name: &str) -> bool { + let pending = self.pending_batch_proposals.read().await; + pending.contains_key(group_name) + } + + /// Retrieve and remove pending batch proposals for a group. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to retrieve proposals for + /// + /// ## Returns: + /// - `Some(BatchProposalsMessage)` if proposals exist, `None` otherwise + /// + /// ## Effects: + /// - Removes proposals from pending queue after retrieval + async fn retrieve_pending_batch_proposals( + &self, + group_name: &str, + ) -> Option { + let mut pending = self.pending_batch_proposals.write().await; + pending.remove(group_name) + } + + /// Process any pending batch proposals that can now be processed. + /// + /// ## Parameters: + /// - `group_name`: The name of the group to process proposals for + /// + /// ## Returns: + /// - `Some(UserAction)` if proposals were processed, `None` otherwise + /// + /// ## Effects: + /// - Processes any stored batch proposals that were waiting for correct state + /// - Removes processed proposals from pending queue + /// + /// ## Usage: + /// Called when group state changes to allow processing of previously stored proposals + async fn process_pending_batch_proposals( + &mut self, + group_name: &str, + ) -> Result, UserError> { + if self.has_pending_batch_proposals(group_name).await { + if let Some(batch_msg) = self.retrieve_pending_batch_proposals(group_name).await { + info!( + "[user::process_pending_batch_proposals]: Processing pending batch proposals for group {}", + group_name + ); + let action = self + .process_batch_proposals_message(batch_msg, group_name) + .await?; + Ok(Some(action)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } +} + +impl LocalSigner for PrivateKeySigner { + async fn local_sign_message(&self, message: &[u8]) -> Result, anyhow::Error> { + let signature = self.sign_message(message).await?; + let signature_bytes = signature.as_bytes().to_vec(); + Ok(signature_bytes) + } + + fn address(&self) -> Address { + self.address() + } + + fn address_string(&self) -> String { + self.address().to_string() + } + + fn address_bytes(&self) -> Vec { + self.address().as_slice().to_vec() } } diff --git a/src/user_actor.rs b/src/user_actor.rs index eda0344..debc10c 100644 --- a/src/user_actor.rs +++ b/src/user_actor.rs @@ -4,7 +4,9 @@ use waku_bindings::WakuMessage; use ds::waku_actor::WakuMessageToSend; use crate::{ + consensus::ConsensusEvent, error::UserError, + protos::messages::v1::BanRequest, user::{User, UserAction}, }; @@ -33,8 +35,7 @@ impl Message for User { msg: CreateGroupRequest, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.create_group(msg.group_name.clone(), msg.is_creation) - .await?; + self.create_group(&msg.group_name, msg.is_creation).await?; Ok(()) } } @@ -51,7 +52,7 @@ impl Message for User { msg: StewardMessageRequest, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.prepare_steward_msg(msg.group_name.clone()).await + self.prepare_steward_msg(&msg.group_name).await } } @@ -67,32 +68,13 @@ impl Message for User { msg: LeaveGroupRequest, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.leave_group(msg.group_name.clone()).await?; + self.leave_group(&msg.group_name).await?; Ok(()) } } -pub struct RemoveUserRequest { - pub user_to_ban: String, - pub group_name: String, -} - -impl Message for User { - type Reply = Result<(), UserError>; - - async fn handle( - &mut self, - msg: RemoveUserRequest, - _ctx: Context<'_, Self, Self::Reply>, - ) -> Self::Reply { - // Add remove proposal to steward instead of direct removal - self.add_remove_proposal(msg.group_name, msg.user_to_ban) - .await - } -} - pub struct SendGroupMessage { - pub message: String, + pub message: Vec, pub group_name: String, } @@ -104,9 +86,28 @@ impl Message for User { msg: SendGroupMessage, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.build_group_message(&msg.message, msg.group_name).await + self.build_group_message(msg.message, &msg.group_name).await } } + +pub struct BuildBanMessage { + pub ban_request: BanRequest, + pub group_name: String, +} + +impl Message for User { + type Reply = Result; + + async fn handle( + &mut self, + msg: BuildBanMessage, + _ctx: Context<'_, Self, Self::Reply>, + ) -> Self::Reply { + self.process_ban_request(msg.ban_request, &msg.group_name) + .await + } +} + // New state machine message types pub struct StartStewardEpochRequest { pub group_name: String, @@ -120,71 +121,71 @@ impl Message for User { msg: StartStewardEpochRequest, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.start_steward_epoch(msg.group_name).await + self.start_steward_epoch(&msg.group_name).await } } -pub struct StartVotingRequest { +pub struct GetProposalsForStewardVotingRequest { pub group_name: String, } -impl Message for User { - type Reply = Result, UserError>; // Returns vote_id +impl Message for User { + type Reply = Result; // Returns proposal_id async fn handle( &mut self, - msg: StartVotingRequest, + msg: GetProposalsForStewardVotingRequest, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.start_voting(msg.group_name).await + let (_, action) = self + .get_proposals_for_steward_voting(&msg.group_name) + .await?; + Ok(action) } } -pub struct CompleteVotingRequest { +pub struct UserVoteRequest { pub group_name: String, - pub vote_id: Vec, + pub proposal_id: u32, + pub vote: bool, } -impl Message for User { - type Reply = Result; // Returns vote result +impl Message for User { + type Reply = Result, UserError>; async fn handle( &mut self, - msg: CompleteVotingRequest, + msg: UserVoteRequest, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.complete_voting(msg.group_name, msg.vote_id).await + let action = self + .process_user_vote(msg.proposal_id, msg.vote, &msg.group_name) + .await?; + match action { + UserAction::SendToWaku(waku_msg) => Ok(Some(waku_msg)), + UserAction::DoNothing => Ok(None), + _ => Err(UserError::InvalidUserAction( + "Vote action must result in Waku message".to_string(), + )), + } } } -pub struct ApplyProposalsAndCompleteRequest { +// Consensus event message handler +pub struct ConsensusEventMessage { pub group_name: String, + pub event: ConsensusEvent, } -impl Message for User { +impl Message for User { type Reply = Result, UserError>; async fn handle( &mut self, - msg: ApplyProposalsAndCompleteRequest, + msg: ConsensusEventMessage, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.apply_proposals(msg.group_name).await - } -} - -pub struct RemoveProposalsAndCompleteRequest { - pub group_name: String, -} - -impl Message for User { - type Reply = Result<(), UserError>; - - async fn handle( - &mut self, - msg: RemoveProposalsAndCompleteRequest, - _ctx: Context<'_, Self, Self::Reply>, - ) -> Self::Reply { - self.remove_proposals_and_complete(msg.group_name).await + self.handle_consensus_event(&msg.group_name, msg.event) + .await } } diff --git a/src/user_app_instance.rs b/src/user_app_instance.rs index bc45682..79a7433 100644 --- a/src/user_app_instance.rs +++ b/src/user_app_instance.rs @@ -4,25 +4,31 @@ use kameo::actor::ActorRef; use log::{error, info}; use std::{str::FromStr, sync::Arc, time::Duration}; -use crate::user::User; +use crate::user::{User, UserAction}; use crate::user_actor::{ - ApplyProposalsAndCompleteRequest, CompleteVotingRequest, CreateGroupRequest, - RemoveProposalsAndCompleteRequest, StartStewardEpochRequest, StartVotingRequest, - StewardMessageRequest, + ConsensusEventMessage, CreateGroupRequest, GetProposalsForStewardVotingRequest, + StartStewardEpochRequest, StewardMessageRequest, }; +use crate::ws_actor::WsActor; +use crate::LocalSigner; use crate::{error::UserError, AppState, Connection}; -pub const STEWARD_EPOCH: u64 = 60; +pub const STEWARD_EPOCH: u64 = 15; pub async fn create_user_instance( connection: Connection, app_state: Arc, + ws_actor: ActorRef, ) -> Result, UserError> { let signer = PrivateKeySigner::from_str(&connection.eth_private_key)?; - let user_address = signer.address().to_string(); + let user_address = signer.address_string(); let group_name: String = connection.group_id.clone(); // Create user let user = User::new(&connection.eth_private_key)?; + + // Set up consensus event forwarding before spawning the actor + let consensus_events = user.subscribe_to_consensus_events(); + let user_ref = kameo::spawn(user); user_ref .ask(CreateGroupRequest { @@ -30,20 +36,21 @@ pub async fn create_user_instance( is_creation: connection.should_create_group, }) .await - .map_err(|e| UserError::KameoCreateGroupError(e.to_string()))?; + .map_err(|e| UserError::UnableToCreateGroup(e.to_string()))?; let mut content_topics = build_content_topics(&group_name); info!("Building content topics: {content_topics:?}"); app_state .content_topics - .lock() - .unwrap() + .write() + .await .append(&mut content_topics); if connection.should_create_group { info!("User {user_address:?} start sending steward message for group {group_name:?}"); let user_clone = user_ref.clone(); let group_name_clone = group_name.clone(); + let app_state_steward = app_state.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(STEWARD_EPOCH)); loop { @@ -52,10 +59,11 @@ pub async fn create_user_instance( handle_steward_flow_per_epoch( user_clone.clone(), group_name_clone.clone(), - app_state.clone(), + app_state_steward.clone(), + ws_actor.clone(), ) .await - .map_err(|e| UserError::KameoSendMessageError(e.to_string()))?; + .map_err(|e| UserError::UnableToHandleStewardEpoch(e.to_string()))?; Ok::<(), UserError>(()) } .await @@ -64,19 +72,73 @@ pub async fn create_user_instance( }); }; + // Set up consensus event forwarding loop + let user_ref_consensus = user_ref.clone(); + let mut consensus_events_receiver = consensus_events; + let app_state_consensus = app_state.clone(); + tokio::spawn(async move { + info!("Starting consensus event forwarding loop"); + while let Ok((group_name, event)) = consensus_events_receiver.recv().await { + info!("Forwarding consensus event for group {group_name}: {event:?}"); + let result = user_ref_consensus + .ask(ConsensusEventMessage { + group_name: group_name.clone(), + event, + }) + .await; + + match result { + Ok(commit_messages) => { + // Send commit messages to Waku if any + if !commit_messages.is_empty() { + info!( + "Sending {} commit messages to Waku for group {}", + commit_messages.len(), + group_name + ); + for msg in commit_messages { + if let Err(e) = app_state_consensus.waku_node.send(msg).await { + error!("Error sending commit message to Waku: {e}"); + } + } + } + } + Err(e) => { + error!("Error forwarding consensus event: {e}"); + } + } + } + info!("Consensus event forwarding loop ended"); + }); + Ok(user_ref) } /// Enhanced steward epoch flow with state machine: -/// 1. Start steward epoch (collect pending proposals, change state to Waiting if there are proposals) -/// 2. Send new steward key to the waku node -/// 3. If there are proposals, start voting process (change state to Voting) -/// 4. Complete voting (change state based on result) -/// 5. If vote passed, apply proposals and complete (change state back to Working) +/// +/// ## Complete Flow Steps: +/// 1. **Start steward epoch**: Collect pending proposals +/// - If no proposals: Stay in Working state, complete epoch without voting +/// - If proposals exist: Transition Working → Waiting +/// 2. **Send steward key**: Broadcast new steward key to waku network +/// 3. **Voting phase** (only if proposals exist): +/// - Get proposals for voting: Transition Waiting → Voting +/// - If no proposals found (edge case): Transition Waiting → Working, complete epoch +/// - If proposals found: Send voting proposal to group members +/// 4. **Complete voting**: Handle consensus result +/// - Vote YES: Transition Voting → Waiting → Working (after applying proposals) +/// - Vote NO: Transition Voting → Working (proposals discarded) +/// 5. **Apply proposals** (only if vote passed): Execute group changes and return to Working +/// +/// ## State Guarantees: +/// - Steward always returns to Working state after epoch completion +/// - No proposals scenario never leaves Working state +/// - All edge cases properly handled with state transitions pub async fn handle_steward_flow_per_epoch( user: ActorRef, group_name: String, app_state: Arc, + ws_actor: ActorRef, ) -> Result<(), UserError> { info!("Starting steward epoch for group: {group_name}"); @@ -99,59 +161,35 @@ pub async fn handle_steward_flow_per_epoch( if proposals_count == 0 { info!("No proposals to vote on for group: {group_name}, completing epoch without voting"); - info!("Steward epoch completed for group: {group_name} (no proposals)"); - return Ok(()); - } + } else { + info!("Found {proposals_count} proposals to vote on for group: {group_name}"); - info!("Found {proposals_count} proposals to vote on for group: {group_name}"); - - // Step 3: Start voting process - let vote_id = user - .ask(StartVotingRequest { - group_name: group_name.clone(), - }) - .await - .map_err(|e| UserError::ProcessProposalsError(e.to_string()))?; - - info!("Started voting with vote_id: {vote_id:?} for group: {group_name}"); - - // Step 4: Complete voting (in a real implementation, this would wait for actual votes) - // For now, we'll simulate the voting process - let vote_result = user - .ask(CompleteVotingRequest { - group_name: group_name.clone(), - vote_id: vote_id.clone(), - }) - .await - .map_err(|e| UserError::ApplyProposalsError(e.to_string()))?; - - info!("Voting completed with result: {vote_result} for group: {group_name}"); - - // Step 5: If vote passed, apply proposals and complete - if vote_result { - let msgs = user - .ask(ApplyProposalsAndCompleteRequest { + // Step 3: Start voting process - steward gets proposals for voting + let action = user + .ask(GetProposalsForStewardVotingRequest { group_name: group_name.clone(), }) .await - .map_err(|e| UserError::ApplyProposalsError(e.to_string()))?; + .map_err(|e| UserError::UnableToStartVoting(e.to_string()))?; - // Only send messages if there are any (when there are proposals) - for msg in msgs { - app_state.waku_node.send(msg).await?; + // Step 4: Send proposals to ws to steward to vote or do nothing if no proposals + // After voting, steward sends vote and proposal to waku node and start consensus process + match action { + UserAction::SendToApp(app_msg) => { + info!("Sending app message to ws"); + ws_actor.ask(app_msg).await.map_err(|e| { + UserError::UnableToSendMessageToWs(format!("Failed to send message to ws: {e}")) + })?; + } + UserAction::DoNothing => { + info!("No action to take for group: {group_name}"); + return Ok(()); + } + _ => { + return Err(UserError::InvalidUserAction(action.to_string())); + } } - - info!("Proposals applied and steward epoch completed for group: {group_name}"); - } else { - info!("Vote failed, returning to working state for group: {group_name}"); } - user.ask(RemoveProposalsAndCompleteRequest { - group_name: group_name.clone(), - }) - .await - .map_err(|e| UserError::ApplyProposalsError(e.to_string()))?; - - info!("Removing proposals and completing steward epoch for group: {group_name}"); Ok(()) } diff --git a/src/ws_actor.rs b/src/ws_actor.rs index ea29b1d..5359a9e 100644 --- a/src/ws_actor.rs +++ b/src/ws_actor.rs @@ -4,10 +4,12 @@ use kameo::{ message::{Context, Message}, Actor, }; +use log::info; +use serde_json::Value; use crate::{ message::{ConnectMessage, UserMessage}, - protos::messages::v1::AppMessage, + protos::messages::v1::{app_message, AppMessage}, }; /// This actor is used to handle messages from web socket @@ -15,7 +17,8 @@ use crate::{ pub struct WsActor { /// This is the sender of the open web socket connection pub ws_sender: SplitSink, - /// This variable is used to check if the user has connected to the ws, if not, we parce message as ConnectMessage + /// This variable is used to check if the user has connected to the ws, + /// if not, we parse message as ConnectMessage pub is_initialized: bool, } @@ -31,20 +34,23 @@ impl WsActor { /// This enum is used to represent the actions that can be performed on the web socket /// Connect - this action is used to return connection data to the user /// UserMessage - this action is used to handle message from web socket and return it to the user -/// RemoveUser - this action is used to remove a user from the group /// DoNothing - this action is used for test purposes (return empty action if message is not valid) #[derive(Debug, PartialEq)] pub enum WsAction { Connect(ConnectMessage), UserMessage(UserMessage), RemoveUser(String, String), + UserVote { + proposal_id: u32, + vote: bool, + group_id: String, + }, DoNothing, } /// This struct is used to represent the raw message from the web socket. /// It is used to handle the message from the web socket and return it to the user /// We can parse it to the ConnectMessage or UserMessage -/// if it starts with "/ban" it will be parsed to RemoveUser, otherwise it will be parsed to UserMessage #[derive(Debug, PartialEq)] pub struct RawWsMessage { pub message: String, @@ -63,29 +69,65 @@ impl Message for WsActor { self.is_initialized = true; return Ok(WsAction::Connect(connect_message)); } - match serde_json::from_str(&msg.message) { - Ok(UserMessage { message, group_id }) => { - if message.starts_with("/") { - let mut tokens = message.split_whitespace(); - match tokens.next() { - Some("/ban") => { - let user_to_ban = tokens.next(); - if user_to_ban.is_none() { - return Err(WsError::InvalidMessage); - } else { - let user_to_ban = user_to_ban.unwrap().to_lowercase(); - return Ok(WsAction::RemoveUser( - user_to_ban.to_string(), - group_id.clone(), - )); - } + match serde_json::from_str::(&msg.message) { + Ok(json_data) => { + // Handle different JSON message types + if let Some(type_field) = json_data.get("type") { + if let Some("user_vote") = type_field.as_str() { + if let (Some(proposal_id), Some(vote), Some(group_id)) = ( + json_data.get("proposal_id").and_then(|v| v.as_u64()), + json_data.get("vote").and_then(|v| v.as_bool()), + json_data.get("group_id").and_then(|v| v.as_str()), + ) { + return Ok(WsAction::UserVote { + proposal_id: proposal_id as u32, + vote, + group_id: group_id.to_string(), + }); } - _ => return Err(WsError::InvalidMessage), } } - Ok(WsAction::UserMessage(UserMessage { message, group_id })) + + // Check if it's a UserMessage format + if let (Some(message), Some(group_id)) = ( + json_data.get("message").and_then(|v| v.as_str()), + json_data.get("group_id").and_then(|v| v.as_str()), + ) { + // Handle commands + if message.starts_with("/") { + let mut tokens = message.split_whitespace(); + match tokens.next() { + Some("/ban") => { + let user_to_ban = tokens.next(); + if let Some(user_to_ban) = user_to_ban { + let user_to_ban = user_to_ban.to_lowercase(); + return Ok(WsAction::RemoveUser( + user_to_ban.to_string(), + group_id.to_string(), + )); + } else { + return Err(WsError::InvalidMessage); + } + } + _ => return Err(WsError::InvalidMessage), + } + } + + return Ok(WsAction::UserMessage(UserMessage { + message: message.as_bytes().to_vec(), + group_id: group_id.to_string(), + })); + } + + Err(WsError::InvalidMessage) + } + Err(_) => { + // Try to parse as UserMessage as fallback + match serde_json::from_str::(&msg.message) { + Ok(user_msg) => Ok(WsAction::UserMessage(user_msg)), + Err(_) => Err(WsError::InvalidMessage), + } } - Err(_) => Err(WsError::InvalidMessage), } } } @@ -99,9 +141,24 @@ impl Message for WsActor { msg: AppMessage, _ctx: Context<'_, Self, Self::Reply>, ) -> Self::Reply { - self.ws_sender - .send(WsMessage::Text(msg.to_string())) - .await?; + // Check if this is a voting proposal and format it specially for the frontend + let message_text = + if let Some(app_message::Payload::VotingProposal(voting_proposal)) = &msg.payload { + // Format as JSON for the frontend to parse + info!("[ws_actor::handle]: Sending voting proposal to ws"); + serde_json::json!({ + "type": "voting_proposal", + "proposal": { + "proposal_id": voting_proposal.proposal_id, + "group_name": voting_proposal.group_name, + "payload": voting_proposal.payload + } + }) + .to_string() + } else { + msg.to_string() + }; + self.ws_sender.send(WsMessage::Text(message_text)).await?; Ok(()) } } diff --git a/tests/consensus_multi_group_test.rs b/tests/consensus_multi_group_test.rs new file mode 100644 index 0000000..a90623f --- /dev/null +++ b/tests/consensus_multi_group_test.rs @@ -0,0 +1,359 @@ +use alloy::signers::local::PrivateKeySigner; +use de_mls::consensus::{compute_vote_hash, ConsensusEvent, ConsensusService}; +use de_mls::protos::messages::v1::consensus::v1::Vote; +use de_mls::LocalSigner; +use prost::Message; +use std::time::Duration; +use uuid::Uuid; + +#[tokio::test] +async fn test_basic_consensus_service() { + // Create consensus service + let consensus_service = ConsensusService::new(); + + let group_name = "test_group"; + let expected_voters_count = 3; + + let signer = PrivateKeySigner::random(); + let proposal_owner_address = signer.address(); + let proposal_owner = proposal_owner_address.to_string().as_bytes().to_vec(); + + // Create a proposal + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer) + .await + .expect("Failed to vote on proposal"); + + // Verify proposal was created + let active_proposals = consensus_service.get_active_proposals(group_name).await; + assert_eq!(active_proposals.len(), 1); + assert_eq!(active_proposals[0].proposal_id, proposal.proposal_id); + + // Verify group statistics + let group_stats = consensus_service.get_group_stats(group_name).await; + assert_eq!(group_stats.total_sessions, 1); + assert_eq!(group_stats.active_sessions, 1); + + // Verify consensus threshold calculation + // With 3 expected voters, we need 2n/3 = 2 votes for consensus + // Initially we have 1 vote (steward), so we don't have sufficient votes + assert!( + !consensus_service + .has_sufficient_votes(group_name, proposal.proposal_id) + .await + ); + + let signer_2 = PrivateKeySigner::random(); + let proposal_owner_2 = signer_2.address_bytes(); + // Add 1 more vote (total 2 votes) + let mut vote = Vote { + vote_id: Uuid::new_v4().as_u128() as u32, + vote_owner: proposal_owner_2, + proposal_id: proposal.proposal_id, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get current time") + .as_secs(), + vote: true, + parent_hash: Vec::new(), + received_hash: proposal.votes[0].vote_hash.clone(), // Reference steward's vote hash + vote_hash: Vec::new(), + signature: Vec::new(), + }; + + // Compute vote hash + vote.vote_hash = compute_vote_hash(&vote); + let vote_bytes = vote.encode_to_vec(); + vote.signature = signer_2 + .local_sign_message(&vote_bytes) + .await + .expect("Failed to sign vote"); + + consensus_service + .process_incoming_vote(group_name, vote) + .await + .expect("Failed to process vote"); + + // Now we should have sufficient votes (2 out of 3 expected voters) + assert!( + consensus_service + .has_sufficient_votes(group_name, proposal.proposal_id) + .await + ); +} + +#[tokio::test] +async fn test_multi_group_consensus_service() { + // Create consensus service with max 10 sessions per group + let consensus_service = ConsensusService::new_with_max_sessions(10); + + // Test group 1 + let group1_name = "test_group_1"; + let group1_members_count = 3; + let signer_1 = PrivateKeySigner::random(); + let proposal_owner_1 = signer_1.address_bytes(); + + // Test group 2 + let group2_name = "test_group_2"; + let group2_members_count = 3; + let signer_2 = PrivateKeySigner::random(); + let proposal_owner_2 = signer_2.address_bytes(); + + // Create proposals for group 1 + let proposal_1 = consensus_service + .create_proposal( + group1_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner_1, + group1_members_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let _proposal_1 = consensus_service + .vote_on_proposal(group1_name, proposal_1.proposal_id, true, signer_1) + .await + .expect("Failed to vote on proposal"); + + let proposal_2 = consensus_service + .create_proposal( + group2_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner_2.clone(), + group2_members_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let _proposal_2 = consensus_service + .vote_on_proposal(group2_name, proposal_2.proposal_id, true, signer_2.clone()) + .await + .expect("Failed to vote on proposal"); + + // Create proposal for group 2 + let proposal_3 = consensus_service + .create_proposal( + group2_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner_2, + group2_members_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let _proposal_3 = consensus_service + .vote_on_proposal(group2_name, proposal_3.proposal_id, true, signer_2) + .await + .expect("Failed to vote on proposal"); + + // Verify proposals are created for both groups + let group1_proposals = consensus_service.get_active_proposals(group1_name).await; + let group2_proposals = consensus_service.get_active_proposals(group2_name).await; + + assert_eq!(group1_proposals.len(), 1); + assert_eq!(group2_proposals.len(), 2); + + // Verify group statistics + let group1_stats = consensus_service.get_group_stats(group1_name).await; + let group2_stats = consensus_service.get_group_stats(group2_name).await; + + assert_eq!(group1_stats.total_sessions, 1); + assert_eq!(group1_stats.active_sessions, 1); + assert_eq!(group2_stats.total_sessions, 2); + assert_eq!(group2_stats.active_sessions, 2); + + // Verify overall statistics + let overall_stats = consensus_service.get_overall_stats().await; + assert_eq!(overall_stats.total_sessions, 3); + assert_eq!(overall_stats.active_sessions, 3); + + // Verify active groups + let active_groups = consensus_service.get_active_groups().await; + assert_eq!(active_groups.len(), 2); + assert!(active_groups.contains(&group1_name.to_string())); + assert!(active_groups.contains(&group2_name.to_string())); +} + +#[tokio::test] +async fn test_consensus_threshold_calculation() { + let consensus_service = ConsensusService::new(); + let mut consensus_events = consensus_service.subscribe_to_events(); + + let group_name = "test_group_threshold"; + let expected_voters_count = 5; + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + + // Create a proposal + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer) + .await + .expect("Failed to vote on proposal"); + + // With 5 expected voters, we need 2n/3 = 3.33... -> 4 votes for consensus + // Initially we have 1 vote (steward), so we don't have sufficient votes + assert!( + !consensus_service + .has_sufficient_votes(group_name, proposal.proposal_id) + .await + ); + + for _ in 0..4 { + let signer = PrivateKeySigner::random(); + let vote_owner = signer.address_bytes(); + let mut vote = Vote { + vote_id: Uuid::new_v4().as_u128() as u32, + vote_owner: vote_owner.clone(), + proposal_id: proposal.proposal_id, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get current time") + .as_secs(), + vote: true, + parent_hash: Vec::new(), + received_hash: proposal.votes[0].vote_hash.clone(), // Reference previous vote's hash + vote_hash: Vec::new(), + signature: Vec::new(), + }; + + // Compute vote hash + vote.vote_hash = compute_vote_hash(&vote); + let vote_bytes = vote.encode_to_vec(); + vote.signature = signer + .local_sign_message(&vote_bytes) + .await + .expect("Failed to sign vote"); + + let result = consensus_service + .process_incoming_vote(group_name, vote.clone()) + .await; + + result.expect("Failed to process vote"); + } + + // With 4 out of 5 votes, we should have sufficient votes for consensus + assert!( + consensus_service + .has_sufficient_votes(group_name, proposal.proposal_id) + .await + ); + + // Subscribe to consensus events and wait for natural consensus + let proposal_id = proposal.proposal_id; + let group_name_clone = group_name; + + // Wait for consensus event with timeout + let timeout_duration = Duration::from_secs(15); + let consensus_result = tokio::time::timeout(timeout_duration, async { + while let Ok((event_group_name, event)) = consensus_events.recv().await { + if event_group_name == group_name_clone { + match event { + ConsensusEvent::ConsensusReached { + proposal_id: event_proposal_id, + result, + } => { + if event_proposal_id == proposal_id { + println!("Consensus reached for proposal {proposal_id}: {result}"); + return Ok(result); + } + } + ConsensusEvent::ConsensusFailed { + proposal_id: event_proposal_id, + reason, + } => { + if event_proposal_id == proposal_id { + println!("Consensus failed for proposal {proposal_id}: {reason}"); + return Err(format!("Consensus failed: {reason}")); + } + } + } + } + } + Err("Event channel closed".to_string()) + }) + .await + .expect("Timeout waiting for consensus event") + .expect("Consensus should succeed"); + + // Should have consensus result based on 2n/3 threshold + assert!(consensus_result); // All votes were true, so result should be true +} + +#[tokio::test] +async fn test_remove_group_sessions() { + let consensus_service = ConsensusService::new(); + + let group_name = "test_group_remove"; + let expected_voters_count = 2; + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + + // Create a proposal + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let _proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer) + .await + .expect("Failed to vote on proposal"); + + // Verify proposal exists + let group_stats = consensus_service.get_group_stats(group_name).await; + assert_eq!(group_stats.total_sessions, 1); + + // Remove group sessions + consensus_service.remove_group_sessions(group_name).await; + + // Verify group sessions are removed + let group_stats_after = consensus_service.get_group_stats(group_name).await; + assert_eq!(group_stats_after.total_sessions, 0); + + // Verify group is not in active groups + let active_groups = consensus_service.get_active_groups().await; + assert!(!active_groups.contains(&group_name.to_string())); +} diff --git a/tests/consensus_realtime_test.rs b/tests/consensus_realtime_test.rs new file mode 100644 index 0000000..63b910c --- /dev/null +++ b/tests/consensus_realtime_test.rs @@ -0,0 +1,599 @@ +use alloy::signers::local::PrivateKeySigner; +use de_mls::consensus::{compute_vote_hash, ConsensusEvent, ConsensusService}; +use de_mls::protos::messages::v1::consensus::v1::Vote; +use de_mls::LocalSigner; +use prost::Message; +use std::time::Duration; +use uuid::Uuid; + +#[tokio::test] +async fn test_realtime_consensus_waiting() { + // Create consensus service + let consensus_service = ConsensusService::new(); + + let group_name = "test_group_realtime"; + let expected_voters_count = 3; + + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + + // Create a proposal + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer) + .await + .expect("Failed to vote on proposal"); + + println!("Created proposal with ID: {}", proposal.proposal_id); + + // Subscribe to consensus events + let mut consensus_events = consensus_service.subscribe_to_events(); + let proposal_id = proposal.proposal_id; + + // Start a background task that waits for consensus events + let group_name_clone = group_name; + let consensus_waiter = tokio::spawn(async move { + println!("Starting consensus event waiter for proposal {proposal_id:?}"); + + // Wait for consensus event with timeout + let timeout_duration = Duration::from_secs(10); + match tokio::time::timeout(timeout_duration, async { + while let Ok((event_group_name, event)) = consensus_events.recv().await { + if event_group_name == group_name_clone { + match event { + ConsensusEvent::ConsensusReached { + proposal_id: event_proposal_id, + result, + } => { + if event_proposal_id == proposal_id { + println!("Consensus reached for proposal {proposal_id}: {result}"); + return Ok(result); + } + } + ConsensusEvent::ConsensusFailed { + proposal_id: event_proposal_id, + reason, + } => { + if event_proposal_id == proposal_id { + println!("Consensus failed for proposal {proposal_id}: {reason}"); + return Err(format!("Consensus failed: {reason}")); + } + } + } + } + } + Err("Event channel closed".to_string()) + }) + .await + { + Ok(result) => { + println!("Consensus event waiter result: {result:?}"); + result + } + Err(_) => { + println!("Consensus event waiter timed out"); + Err("Timeout waiting for consensus".to_string()) + } + } + }); + + // Wait a bit to ensure the waiter is running + tokio::time::sleep(Duration::from_millis(100)).await; + + // Add votes to reach consensus + let mut previous_vote_hash = proposal.votes[0].vote_hash.clone(); // Start with steward's vote hash + + for i in 1..expected_voters_count { + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + let mut vote = Vote { + vote_id: Uuid::new_v4().as_u128() as u32, + vote_owner: proposal_owner, + proposal_id: proposal.proposal_id, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get current time") + .as_secs(), + vote: true, + parent_hash: Vec::new(), + received_hash: previous_vote_hash.clone(), // Reference previous vote's hash + vote_hash: Vec::new(), + signature: Vec::new(), + }; + + // Compute vote hash + vote.vote_hash = compute_vote_hash(&vote); + let vote_bytes = vote.encode_to_vec(); + vote.signature = signer + .local_sign_message(&vote_bytes) + .await + .expect("Failed to sign vote"); + + println!("Adding vote {} for proposal {}", i, proposal.proposal_id); + consensus_service + .process_incoming_vote(group_name, vote.clone()) + .await + .expect("Failed to process vote"); + + // Update previous vote hash for next iteration + previous_vote_hash = vote.vote_hash.clone(); + + // Small delay between votes + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Wait for consensus result + let consensus_result = consensus_waiter + .await + .expect("Consensus waiter task failed"); + + // Verify consensus was reached + assert!(consensus_result.is_ok()); + let result = consensus_result.unwrap(); + assert!(result); // Should be true (yes votes) + + println!("Test completed successfully - consensus reached!"); +} + +#[tokio::test] +async fn test_consensus_timeout() { + // Create consensus service + let consensus_service = ConsensusService::new(); + + let group_name = "test_group_timeout"; + let expected_voters_count = 5; + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + + // Need 4 votes for consensus + // Create a proposal + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer) + .await + .expect("Failed to vote on proposal"); + + println!("Created proposal with ID: {}", proposal.proposal_id); + + // Subscribe to consensus events for timeout test + let mut consensus_events = consensus_service.subscribe_to_events(); + let proposal_id = proposal.proposal_id; + + // Start consensus event waiter with timeout + let group_name_clone = group_name; + let consensus_waiter = tokio::spawn(async move { + println!("Starting consensus event waiter with timeout for proposal {proposal_id:?}"); + + // Wait for consensus event - should timeout and trigger liveness criteria + let timeout_duration = Duration::from_secs(12); // Wait longer than consensus timeout (10s) + match tokio::time::timeout(timeout_duration, async { + while let Ok((event_group_name, event)) = consensus_events.recv().await { + if event_group_name == group_name_clone { + match event { + ConsensusEvent::ConsensusReached { proposal_id: event_proposal_id, result } => { + if event_proposal_id == proposal_id { + println!("Consensus reached for proposal {proposal_id}: {result} (via timeout/liveness criteria)"); + return Ok(result); + } + } + ConsensusEvent::ConsensusFailed { proposal_id: event_proposal_id, reason } => { + if event_proposal_id == proposal_id { + println!("Consensus failed for proposal {proposal_id}: {reason}"); + return Err(format!("Consensus failed: {reason}")); + } + } + } + } + } + Err("Event channel closed".to_string()) + }).await { + Ok(result) => result, + Err(_) => Err("Test timeout waiting for consensus event".to_string()) + } + }); + + // Don't add any additional votes - should timeout and apply liveness criteria + + // Wait for consensus result + let consensus_result = consensus_waiter + .await + .expect("Consensus waiter task failed"); + + // Verify timeout occurred and liveness criteria was applied + // With liveness_criteria_yes = true, should return Ok(true) + assert!(consensus_result.is_ok()); + let result = consensus_result.unwrap(); + assert!(result); // Should be true due to liveness criteria + + println!("Test completed successfully - timeout occurred and liveness criteria applied!"); +} + +#[tokio::test] +async fn test_consensus_with_mixed_votes() { + // Create consensus service + let consensus_service = ConsensusService::new(); + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + + let group_name = "test_group_mixed"; + let expected_voters_count = 3; + + // Create a proposal + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer) + .await + .expect("Failed to vote on proposal"); + + println!("Created proposal with ID: {}", proposal.proposal_id); + + // Subscribe to consensus events + let mut consensus_events = consensus_service.subscribe_to_events(); + let proposal_id = proposal.proposal_id; + + // Start a background task that waits for consensus events + let group_name_clone = group_name; + let consensus_waiter = tokio::spawn(async move { + println!("Starting consensus event waiter for proposal {proposal_id:?}"); + + // Wait for consensus event with timeout + let timeout_duration = Duration::from_secs(15); // Allow time for votes to be processed + match tokio::time::timeout(timeout_duration, async { + while let Ok((event_group_name, event)) = consensus_events.recv().await { + if event_group_name == group_name_clone { + match event { + ConsensusEvent::ConsensusReached { + proposal_id: event_proposal_id, + result, + } => { + if event_proposal_id == proposal_id { + println!("Consensus reached for proposal {proposal_id}: {result}"); + return Ok(result); + } + } + ConsensusEvent::ConsensusFailed { + proposal_id: event_proposal_id, + reason, + } => { + if event_proposal_id == proposal_id { + println!("Consensus failed for proposal {proposal_id}: {reason}"); + return Err(format!("Consensus failed: {reason}")); + } + } + } + } + } + Err("Event channel closed".to_string()) + }) + .await + { + Ok(result) => { + println!("Consensus event waiter result: {result:?}"); + result + } + Err(_) => { + println!("Consensus event waiter timed out"); + Err("Timeout waiting for consensus".to_string()) + } + } + }); + + // Wait a bit to ensure the waiter is running + tokio::time::sleep(Duration::from_millis(100)).await; + + // Add mixed votes: one yes, one no + let votes = vec![(2, false), (3, false)]; + let mut previous_vote_hash = proposal.votes[0].vote_hash.clone(); // Start with steward's vote hash + + for (i, vote_value) in votes { + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + let mut vote = Vote { + vote_id: Uuid::new_v4().as_u128() as u32, + vote_owner: proposal_owner, + proposal_id: proposal.proposal_id, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get current time") + .as_secs(), + vote: vote_value, + parent_hash: Vec::new(), + received_hash: previous_vote_hash.clone(), // Reference previous vote's hash + vote_hash: Vec::new(), + signature: Vec::new(), + }; + + // Compute vote hash + vote.vote_hash = compute_vote_hash(&vote); + let vote_bytes = vote.encode_to_vec(); + vote.signature = signer + .local_sign_message(&vote_bytes) + .await + .expect("Failed to sign vote"); + + println!( + "Adding vote {} (value: {}) for proposal {}", + i, vote_value, proposal.proposal_id + ); + consensus_service + .process_incoming_vote(group_name, vote.clone()) + .await + .expect("Failed to process vote"); + + // Update previous vote hash for next iteration + previous_vote_hash = vote.vote_hash.clone(); + + // Small delay between votes + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Wait for consensus result + let consensus_result = consensus_waiter + .await + .expect("Consensus waiter task failed"); + + // Verify consensus was reached + assert!(consensus_result.is_ok()); + let result = consensus_result.unwrap(); + // With 2 no votes and 1 yes vote, consensus should be no (false) + // However, if it times out, liveness criteria (true) will be applied + println!("Mixed votes test result: {result}"); + // Don't assert specific result since it depends on timing vs. liveness criteria + + println!("Test completed successfully - consensus reached with mixed votes!"); +} + +#[tokio::test] +async fn test_rfc_vote_chain_validation() { + use de_mls::consensus::compute_vote_hash; + use de_mls::LocalSigner; + + // Create consensus service + let consensus_service = ConsensusService::new(); + + let group_name = "test_rfc_validation"; + let expected_voters_count = 3; + + let signer1 = PrivateKeySigner::random(); + let signer2 = PrivateKeySigner::random(); + let _signer3 = PrivateKeySigner::random(); + + // Create first proposal with steward vote + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + signer1.address_bytes(), + expected_voters_count, + 300, + true, + ) + .await + .expect("Failed to create proposal"); + + let proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer1) + .await + .expect("Failed to vote on proposal"); + + println!("Created proposal with ID: {}", proposal.proposal_id); + + // Create second vote from different voter + let mut vote2 = Vote { + vote_id: Uuid::new_v4().as_u128() as u32, + vote_owner: signer2.address_bytes(), + proposal_id: proposal.proposal_id, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get current time") + .as_secs(), + vote: true, + parent_hash: Vec::new(), // Different voter, no parent + received_hash: proposal.votes[0].vote_hash.clone(), // Should be hash of first vote + vote_hash: Vec::new(), + signature: Vec::new(), + }; + + // Compute vote hash and signature + vote2.vote_hash = compute_vote_hash(&vote2); + let vote2_bytes = vote2.encode_to_vec(); + vote2.signature = signer2 + .local_sign_message(&vote2_bytes) + .await + .expect("Failed to sign vote"); + + // Create proposal with two votes from different voters + let mut test_proposal = proposal.clone(); + test_proposal.votes.push(vote2.clone()); + + // Validate the proposal - should pass RFC validation + let validation_result = consensus_service.validate_proposal(&test_proposal); + assert!( + validation_result.is_ok(), + "RFC validation should pass: {validation_result:?}" + ); + + // Test invalid vote chain (wrong received_hash) + let mut invalid_proposal = test_proposal.clone(); + invalid_proposal.votes[1].received_hash = vec![0; 32]; // Wrong hash + + let invalid_result = consensus_service.validate_proposal(&invalid_proposal); + assert!( + invalid_result.is_err(), + "Invalid vote chain should be rejected" + ); + + println!("RFC vote chain validation test completed successfully!"); +} + +#[tokio::test] +async fn test_event_driven_timeout() { + // Create consensus service + let consensus_service = ConsensusService::new(); + + let group_name = "test_group_event_timeout"; + let expected_voters_count = 3; + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + + // Create a proposal with only one vote (steward vote) - should timeout and apply liveness criteria + let proposal = consensus_service + .create_proposal( + group_name, + "Test Proposal".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, // liveness criteria = true + ) + .await + .expect("Failed to create proposal"); + + let proposal = consensus_service + .vote_on_proposal(group_name, proposal.proposal_id, true, signer) + .await + .expect("Failed to vote on proposal"); + + println!( + "Created proposal with ID: {} - waiting for timeout", + proposal.proposal_id + ); + + // Subscribe to consensus events + let mut consensus_events = consensus_service.subscribe_to_events(); + let proposal_id = proposal.proposal_id; + let group_name_clone = group_name; + + // Wait for consensus event (should timeout after 10 seconds and apply liveness criteria) + let timeout_duration = Duration::from_secs(12); // Wait longer than consensus timeout (10s) + let consensus_result = tokio::time::timeout(timeout_duration, async { + while let Ok((event_group_name, event)) = consensus_events.recv().await { + if event_group_name == group_name_clone { + match event { + ConsensusEvent::ConsensusReached { + proposal_id: event_proposal_id, + result, + } => { + if event_proposal_id == proposal_id { + println!("Consensus reached for proposal {proposal_id}: {result} (via timeout/liveness criteria)"); + return result; + } + } + ConsensusEvent::ConsensusFailed { + proposal_id: event_proposal_id, + reason, + } => { + if event_proposal_id == proposal_id { + panic!("Consensus failed for proposal {proposal_id}: {reason}"); + } + } + } + } + } + panic!("Event channel closed unexpectedly"); + }) + .await + .expect("Timeout waiting for consensus event"); + + // Should be true due to liveness criteria + assert!(consensus_result); + + println!("Test completed successfully - event-driven timeout worked!"); +} + +#[tokio::test] +async fn test_liveness_criteria_functionality() { + // Create consensus service + let consensus_service = ConsensusService::new(); + + let group_name = "test_group_liveness"; + let expected_voters_count = 3; + let signer = PrivateKeySigner::random(); + let proposal_owner = signer.address_bytes(); + + // Test liveness criteria = false + let proposal_false = consensus_service + .create_proposal( + group_name, + "Test Proposal False".to_string(), + "Test payload".to_string(), + proposal_owner.clone(), + expected_voters_count, + 300, + false, // liveness criteria = false + ) + .await + .expect("Failed to create proposal with liveness_criteria_yes = false"); + + // Test liveness criteria getter + let liveness_false = consensus_service + .get_proposal_liveness_criteria(group_name, proposal_false.proposal_id) + .await; + assert_eq!(liveness_false, Some(false)); + + // Test liveness criteria = true + let proposal_true = consensus_service + .create_proposal( + group_name, + "Test Proposal True".to_string(), + "Test payload".to_string(), + proposal_owner, + expected_voters_count, + 300, + true, // liveness criteria = true + ) + .await + .expect("Failed to create proposal with liveness_criteria_yes = true"); + + // Test liveness criteria getter + let liveness_true = consensus_service + .get_proposal_liveness_criteria(group_name, proposal_true.proposal_id) + .await; + assert_eq!(liveness_true, Some(true)); + + // Test non-existent proposal + let liveness_none = consensus_service + .get_proposal_liveness_criteria("nonexistent", 99999) + .await; + assert_eq!(liveness_none, None); + + println!("Test completed successfully - liveness criteria functionality verified!"); +} diff --git a/tests/state_machine_test.rs b/tests/state_machine_test.rs index fdd9edf..7caabd9 100644 --- a/tests/state_machine_test.rs +++ b/tests/state_machine_test.rs @@ -5,10 +5,10 @@ use mls_crypto::openmls_provider::MlsProvider; #[tokio::test] async fn test_state_machine_transitions() { let crypto = MlsProvider::default(); - let id_steward = random_identity().expect("Failed to create identity"); + let mut id_steward = random_identity().expect("Failed to create identity"); let mut group = Group::new( - "test_group".to_string(), + "test_group", true, Some(&crypto), Some(id_steward.signer()), @@ -17,82 +17,67 @@ async fn test_state_machine_transitions() { .expect("Failed to create group"); // Initial state should be Working - assert_eq!(group.get_state(), GroupState::Working); + assert_eq!(group.get_state().await, GroupState::Working); - // Test start_steward_epoch - group - .start_steward_epoch() + // Test start_steward_epoch_with_validation + let proposal_count = group + .start_steward_epoch_with_validation() .await .expect("Failed to start steward epoch"); - assert_eq!(group.get_state(), GroupState::Waiting); + assert_eq!(proposal_count, 0); // No proposals initially + assert_eq!(group.get_state().await, GroupState::Working); // Should stay in Working - // Test start_voting - group.start_voting().expect("Failed to start voting"); - assert_eq!(group.get_state(), GroupState::Voting); + // Add some proposals + let kp_user = id_steward + .generate_key_package(&crypto) + .expect("Failed to generate key package"); + group + .store_invite_proposal(Box::new(kp_user)) + .await + .expect("Failed to store proposal"); + + // Now start steward epoch with proposals + let proposal_count = group + .start_steward_epoch_with_validation() + .await + .expect("Failed to start steward epoch"); + assert_eq!(proposal_count, 1); // Should have 1 proposal + assert_eq!(group.get_state().await, GroupState::Waiting); + + // Test start_voting_with_validation + group.start_voting().await.expect("Failed to start voting"); + assert_eq!(group.get_state().await, GroupState::Voting); // Test complete_voting with success group .complete_voting(true) + .await .expect("Failed to complete voting"); - assert_eq!(group.get_state(), GroupState::Waiting); + assert_eq!(group.get_state().await, GroupState::ConsensusReached); - // Test apply_proposals + // Test start_waiting_after_consensus group - .remove_proposals_and_complete() + .start_waiting_after_consensus() .await - .expect("Failed to remove proposals"); - assert_eq!(group.get_state(), GroupState::Working); + .expect("Failed to start waiting after consensus"); + assert_eq!(group.get_state().await, GroupState::Waiting); + + // Test apply_proposals_and_complete + group + .handle_yes_vote() + .await + .expect("Failed to apply proposals"); + assert_eq!(group.get_state().await, GroupState::Working); assert_eq!(group.get_pending_proposals_count().await, 0); } -#[tokio::test] -async fn test_state_machine_permissions() { - let crypto = MlsProvider::default(); - let id_steward = random_identity().expect("Failed to create identity"); - - let mut group = Group::new( - "test_group".to_string(), - true, - Some(&crypto), - Some(id_steward.signer()), - Some(&id_steward.credential_with_key()), - ) - .expect("Failed to create group"); - - // Working state - anyone can send messages - assert!(group.can_send_message(false, false)); // Regular user, no proposals - assert!(group.can_send_message(true, false)); // Steward, no proposals - assert!(group.can_send_message(true, true)); // Steward, with proposals - - // Start steward epoch - group - .start_steward_epoch() - .await - .expect("Failed to start steward epoch"); - - // Waiting state - only steward with proposals can send messages - assert!(!group.can_send_message(false, false)); // Regular user, no proposals - assert!(!group.can_send_message(false, true)); // Regular user, with proposals - assert!(!group.can_send_message(true, false)); // Steward, no proposals - assert!(group.can_send_message(true, true)); // Steward, with proposals - - // Start voting - group.start_voting().expect("Failed to start voting"); - - // Voting state - no one can send messages - assert!(!group.can_send_message(false, false)); - assert!(!group.can_send_message(false, true)); - assert!(!group.can_send_message(true, false)); - assert!(!group.can_send_message(true, true)); -} - #[tokio::test] async fn test_invalid_state_transitions() { let crypto = MlsProvider::default(); - let id_steward = random_identity().expect("Failed to create identity"); + let mut id_steward = random_identity().expect("Failed to create identity"); let mut group = Group::new( - "test_group".to_string(), + "test_group", true, Some(&crypto), Some(id_steward.signer()), @@ -100,26 +85,54 @@ async fn test_invalid_state_transitions() { ) .expect("Failed to create group"); - // Cannot start voting from Working state - let result = group.start_voting(); - assert!(matches!(result, Err(GroupError::InvalidStateTransition))); - // Cannot complete voting from Working state - let result = group.complete_voting(true); - assert!(matches!(result, Err(GroupError::InvalidStateTransition))); + let result = group.complete_voting(true).await; + assert!(matches!( + result, + Err(GroupError::InvalidStateTransition { .. }) + )); // Cannot apply proposals from Working state - let result = group.remove_proposals_and_complete().await; - assert!(matches!(result, Err(GroupError::InvalidStateTransition))); + let result = group.handle_yes_vote().await; + assert!(matches!( + result, + Err(GroupError::InvalidStateTransition { .. }) + )); - // Start steward epoch - group - .start_steward_epoch() + // Start steward epoch - but there are no proposals, so it should stay in Working state + let proposal_count = group + .start_steward_epoch_with_validation() .await .expect("Failed to start steward epoch"); + assert_eq!(proposal_count, 0); // No proposals + assert_eq!(group.get_state().await, GroupState::Working); // Should still be in Working state + + // Cannot apply proposals from Working state (even after steward epoch start with no proposals) + let result = group.handle_yes_vote().await; + assert!(matches!( + result, + Err(GroupError::InvalidStateTransition { .. }) + )); + + // Add a proposal to actually transition to Waiting state + let kp_user = id_steward + .generate_key_package(&crypto) + .expect("Failed to generate key package"); + group + .store_invite_proposal(Box::new(kp_user)) + .await + .expect("Failed to store proposal"); + + // Now start steward epoch with proposals - should transition to Waiting + let proposal_count = group + .start_steward_epoch_with_validation() + .await + .expect("Failed to start steward epoch"); + assert_eq!(proposal_count, 1); // Should have 1 proposal + assert_eq!(group.get_state().await, GroupState::Waiting); // Should now be in Waiting state // Can apply proposals from Waiting state (even with no proposals) - let result = group.remove_proposals_and_complete().await; + let result = group.handle_yes_vote().await; assert!(result.is_ok()); } @@ -130,7 +143,7 @@ async fn test_proposal_counting() { let mut id_user = random_identity().expect("Failed to create identity"); let mut group = Group::new( - "test_group".to_string(), + "test_group", true, Some(&crypto), Some(id_steward.signer()), @@ -148,28 +161,111 @@ async fn test_proposal_counting() { .await .expect("Failed to store proposal"); group - .store_remove_proposal(vec![1, 2, 3]) + .store_remove_proposal("test_user".to_string()) .await .expect("Failed to put remove proposal"); // Start steward epoch - should collect proposals - group - .start_steward_epoch() + let proposal_count = group + .start_steward_epoch_with_validation() .await .expect("Failed to start steward epoch"); + assert_eq!(proposal_count, 2); // Should have 2 proposals + assert_eq!(group.get_state().await, GroupState::Waiting); assert_eq!(group.get_voting_proposals_count().await, 2); // Complete the flow - group.start_voting().expect("Failed to start voting"); + group.start_voting().await.expect("Failed to start voting"); group .complete_voting(true) + .await .expect("Failed to complete voting"); group - .remove_proposals_and_complete() + .handle_yes_vote() .await - .expect("Failed to remove proposals"); + .expect("Failed to apply proposals"); // Proposals count should be reset assert_eq!(group.get_voting_proposals_count().await, 0); assert_eq!(group.get_pending_proposals_count().await, 0); } + +#[tokio::test] +async fn test_steward_validation() { + let _crypto = MlsProvider::default(); + let _id_steward = random_identity().expect("Failed to create identity"); + + // Create group without steward + let mut group = Group::new( + "test_group", + false, // No steward + None, + None, + None, + ) + .expect("Failed to create group"); + + // Should fail to start steward epoch without steward + let result = group.start_steward_epoch_with_validation().await; + assert!(matches!(result, Err(GroupError::StewardNotSet))); +} + +#[tokio::test] +async fn test_consensus_result_handling() { + let crypto = MlsProvider::default(); + let id_steward = random_identity().expect("Failed to create identity"); + + let mut group = Group::new( + "test_group", + true, + Some(&crypto), + Some(id_steward.signer()), + Some(&id_steward.credential_with_key()), + ) + .expect("Failed to create group"); + + // Start steward epoch and voting + group + .start_steward_epoch_with_validation() + .await + .expect("Failed to start steward epoch"); + group.start_voting().await.expect("Failed to start voting"); + + // Test consensus result handling for steward + let result = group.complete_voting(true).await; + assert!(result.is_ok()); + assert_eq!(group.get_state().await, GroupState::ConsensusReached); + + // Test invalid consensus result handling (not in voting state) + let result = group.complete_voting(true).await; + assert!(matches!( + result, + Err(GroupError::InvalidStateTransition { .. }) + )); +} + +#[tokio::test] +async fn test_voting_validation_edge_cases() { + let _crypto = MlsProvider::default(); + let _id_steward = random_identity().expect("Failed to create identity"); + + let mut group = Group::new( + "test_group", + true, + Some(&_crypto), + Some(_id_steward.signer()), + Some(&_id_steward.credential_with_key()), + ) + .expect("Failed to create group"); + + // Test starting voting from Working state (should transition to Waiting first) + group.start_voting().await.expect("Failed to start voting"); + assert_eq!(group.get_state().await, GroupState::Voting); + + // Test starting voting from Voting state (should fail) + let result = group.start_voting().await; + assert!(matches!( + result, + Err(GroupError::InvalidStateTransition { .. }) + )); +} diff --git a/tests/user_test.rs b/tests/user_test.rs index 4dffaa9..f5d5c15 100644 --- a/tests/user_test.rs +++ b/tests/user_test.rs @@ -1,859 +1,714 @@ use de_mls::{ - message::UserMessage, protos::messages::v1::app_message, state_machine::GroupState, user::{User, UserAction}, - ws_actor::{RawWsMessage, WsAction}, }; -use log::info; +use ds::waku_actor::WakuMessageToSend; +use waku_bindings::WakuMessage; -#[tokio::test] -async fn test_invite_users_flow() { - let group_name = "new_group".to_string(); +const EXPECTED_EPOCH_1: u64 = 1; +const EXPECTED_EPOCH_2: u64 = 2; - let alice_priv_key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; - let mut alice = User::new(alice_priv_key).expect("Failed to create user"); +const EXPECTED_MEMBERS_2: usize = 2; +const EXPECTED_MEMBERS_3: usize = 3; + +const ALICE_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +// const ALICE_WALLET_ADDRESS: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const BOB_PRIVATE_KEY: &str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +// const BOB_WALLET_ADDRESS: &str = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; +const CAROL_PRIVATE_KEY: &str = + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; +// const CAROL_WALLET_ADDRESS: &str = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"; + +const GROUP_NAME: &str = "new_group"; + +async fn create_two_test_user_with_group(group_name: &str) -> (User, User) { + let mut alice = User::new(ALICE_PRIVATE_KEY).expect("Failed to create user for Alice"); alice - .create_group(group_name.clone(), true) - .await - .expect("Failed to create group"); - - let bob_priv_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - let mut bob = User::new(bob_priv_key).expect("Failed to create user"); - bob.create_group(group_name.clone(), false) - .await - .expect("Failed to create group"); - - let carol_priv_key = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; - let mut carol = User::new(carol_priv_key).expect("Failed to create user"); - carol - .create_group(group_name.clone(), false) - .await - .expect("Failed to create group"); - - let group_announcement_message = alice - .prepare_steward_msg(group_name.clone()) - .await - .expect("Failed to prepare steward message") - .build_waku_message() - .expect("Failed to build waku message"); - - // Bob parce GA message and share his KP to Alice - let bob_kp_message = match bob - .process_waku_message(group_announcement_message.clone()) - .await - .expect("Failed to process waku message") - { - UserAction::SendToWaku(msg) => msg, - _ => panic!("User action is not SendToWaku"), - }; - let bob_kp_waku_message = bob_kp_message - .build_waku_message() - .expect("Failed to build waku message"); - - // Alice parce Bob's KP and add it to the queue of income key packages - let _alice_action = alice - .process_waku_message(bob_kp_waku_message) - .await - .expect("Failed to process waku message"); - - // Carol parce GA message and share her KP to Alice - let carol_kp_message = match carol - .process_waku_message(group_announcement_message.clone()) - .await - .expect("Failed to process waku message") - { - UserAction::SendToWaku(msg) => msg, - _ => panic!("User action is not SendToWaku"), - }; - let carol_kp_waku_message = carol_kp_message - .build_waku_message() - .expect("Failed to build waku message"); - - // Alice parce Carol's KP and add it to the queue of income key packages - let _alice_action = alice - .process_waku_message(carol_kp_waku_message) - .await - .expect("Failed to process waku message"); - - // Debug: Check how many proposals we have - let proposal_count_before = alice - .get_pending_proposals_count(group_name.clone()) - .await - .expect("Failed to get proposal count"); - println!("Debug: Proposal count before steward epoch: {proposal_count_before}"); - - // Add Bob and Carol to the group initially using steward epoch flow - // State machine: start steward epoch, voting, complete voting - let steward_epoch_proposals = alice - .start_steward_epoch(group_name.clone()) - .await - .expect("Failed to start steward epoch"); - - println!("Debug: Steward epoch returned {steward_epoch_proposals} proposals"); - - let vote_id = alice - .start_voting(group_name.clone()) - .await - .expect("Failed to start voting"); - - // Submit a vote (Alice votes yes for her own proposals) - alice - .submit_vote(vote_id.clone(), true) - .await - .expect("Failed to submit vote"); - - alice - .complete_voting(group_name.clone(), vote_id) - .await - .expect("Failed to complete voting"); - - let res = alice - .apply_proposals(group_name.clone()) - .await - .expect("Failed to apply proposals"); - - // 4. Remove proposals and complete the steward epoch - alice - .remove_proposals_and_complete(group_name.clone()) - .await - .expect("Failed to remove proposals and complete the steward epoch"); - - // Bob processes the welcome message to join the group - bob.process_waku_message( - res[1] - .build_waku_message() - .expect("Failed to build waku welcome message for Bob"), - ) - .await - .expect("Failed to process waku welcome message for Bob"); - - // Bob sends a message after joining - let bob_res_waku_message = bob - .build_group_message("User joined to the group", group_name.clone()) - .await - .expect("Failed to build group message") - .build_waku_message() - .expect("Failed to build waku message"); - - let res_alice = alice - .process_waku_message(bob_res_waku_message.clone()) - .await - .expect("Failed to process waku message"); - println!("Alice result: {res_alice:?}"); - let res_alice_msg = match res_alice { - UserAction::SendToApp(msg) => msg, - _ => panic!("User action is not SendToApp"), - }; - - let inside_msg = match res_alice_msg.payload.unwrap() { - app_message::Payload::ConversationMessage(msg) => msg, - _ => panic!("User action is not SendToApp"), - }; - println!( - "Alice message: {:?}", - String::from_utf8(inside_msg.message).unwrap() - ); - - // Carol processes the welcome message to join the group - let res_carol = carol - .process_waku_message( - res[1] - .build_waku_message() - .expect("Failed to build waku welcome message for Carol"), - ) - .await - .expect("Failed to process waku message"); - println!("Carol result: {res_carol:?}"); - - let carol_group = carol - .get_group(group_name.clone()) - .expect("Failed to get group"); - let carol_members = carol_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - carol_members.len() == 3, - "Wrong number of members in the group for Carol" - ); - let carol_group_epoch = carol_group.epoch().await.expect("Failed to get epoch"); - assert_eq!(carol_group_epoch.as_u64(), 1, "Carol group epoch is not 1"); - - let bob_group = bob - .get_group(group_name.clone()) - .expect("Failed to get group"); - let bob_members = bob_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - bob_members.len() == 3, - "Wrong number of members in the group for Bob" - ); - let bob_group_epoch = bob_group.epoch().await.expect("Failed to get epoch"); - assert_eq!(bob_group_epoch.as_u64(), 1, "Bob group epoch is not 1"); - - let alice_group = alice - .get_group(group_name.clone()) - .expect("Failed to get group"); - let alice_members = alice_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - alice_members.len() == 3, - "Wrong number of members in the group for Alice" - ); - let alice_group_epoch = alice_group.epoch().await.expect("Failed to get epoch"); - assert_eq!(alice_group_epoch.as_u64(), 1, "Alice group epoch is not 1"); - - assert_eq!( - alice_members, bob_members, - "Alice and Bob have different members" - ); - assert_eq!( - alice_members, carol_members, - "Alice and Carol have different members" - ); - assert_eq!( - bob_members, carol_members, - "Bob and Carol have different members" - ); -} - -#[tokio::test] -async fn test_add_user_in_different_epoch() { - let group_name = "new_group".to_string(); - - let alice_priv_key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; - let mut alice = User::new(alice_priv_key).expect("Failed to create user"); - alice - .create_group(group_name.clone(), true) + .create_group(group_name, true) .await .expect("Failed to create group for Alice"); - let bob_priv_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - let mut bob = User::new(bob_priv_key).expect("Failed to create user"); - bob.create_group(group_name.clone(), false) + let mut bob = User::new(BOB_PRIVATE_KEY).expect("Failed to create user for Bob"); + bob.create_group(group_name, false) .await .expect("Failed to create group for Bob"); - // First group announcement to add Bob to the group - let group_announcement_message = alice - .prepare_steward_msg(group_name.clone()) - .await - .expect("Failed to prepare steward message for Bob") - .build_waku_message() - .expect("Failed to build waku message with group announcement for Bob"); - - // Bob parce GA message and share his KP to Alice - let bob_kp_message = match bob - .process_waku_message(group_announcement_message.clone()) - .await - .expect("Failed to process waku message with group announcement for Bob") - { - UserAction::SendToWaku(msg) => msg, - _ => panic!("Bob action is not SendToWaku"), - }; - - let bob_kp_waku_message = bob_kp_message - .build_waku_message() - .expect("Failed to build waku message with Bob's KP"); - - // Alice parce Bob's KP and add it to the queue of income key packages - let _alice_action = alice - .process_waku_message(bob_kp_waku_message) - .await - .expect("Failed to process waku message with Bob's KP"); - - // Alice start adding Bob into group - // State machine: start steward epoch, voting, complete voting (Bob) - println!( - "Test: Before start_steward_epoch (Bob), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - alice - .start_steward_epoch(group_name.clone()) - .await - .expect("Failed to start steward epoch (Bob)"); - println!( - "Test: After start_steward_epoch (Bob), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - - let vote_id = alice - .start_voting(group_name.clone()) - .await - .expect("Failed to start voting (Bob)"); - println!( - "Test: After start_voting (Bob), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - - // Submit a vote (Alice votes yes for her own proposals) - println!("Test: Submitting vote with ID: {vote_id:?}"); - println!("Test: Alice's identity: {}", alice.identity_string()); - alice - .submit_vote(vote_id.clone(), true) - .await - .expect("Failed to submit vote (Bob)"); - - alice - .complete_voting(group_name.clone(), vote_id) - .await - .expect("Failed to complete voting (Bob)"); - println!( - "Test: After complete_voting (Bob), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - - // 3. Apply proposals to add Bob to the group - let _out = alice - .apply_proposals(group_name.clone()) - .await - .expect("Failed to apply proposals while adding Bob to the group"); - - // 4. Remove proposals and complete the steward epoch - alice - .remove_proposals_and_complete(group_name.clone()) - .await - .expect("Failed to remove proposals and complete the steward epoch"); - - // Bob processes the welcome message to join the group - bob.process_waku_message( - _out[1] - .build_waku_message() - .expect("Failed to build waku welcome message for Bob"), - ) - .await - .expect("Failed to process waku welcome message for Bob"); - - // Adding Carol to the group in different epoch - let carol_priv_key = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; - let mut carol = User::new(carol_priv_key).expect("Failed to create user"); - carol - .create_group(group_name.clone(), false) - .await - .expect("Failed to create group"); - - // Second group announcement to add Carol to the group - let group_announcement_message_2 = alice - .prepare_steward_msg(group_name.clone()) - .await - .expect("Failed to prepare steward message for Carol") - .build_waku_message() - .expect("Failed to build waku message with group announcement for Carol"); - - // Carol parce GA message and share her KP to Alice - let carol_kp_message = match carol - .process_waku_message(group_announcement_message_2.clone()) - .await - .expect("Failed to process waku message with group announcement for Carol") - { - UserAction::SendToWaku(msg) => msg, - _ => panic!("Carol action is not SendToWaku"), - }; - - let carol_kp_waku_message = carol_kp_message - .build_waku_message() - .expect("Failed to build waku message with Carol's KP"); - - // Alice parce Carol's KP and add it to the queue of income key packages - alice - .process_waku_message(carol_kp_waku_message) - .await - .expect("Failed to process waku message with Carol's KP"); - - // Alice start adding Carol into group - - // State machine: start steward epoch, voting, complete voting (Carol) - println!( - "Test: Before start_steward_epoch (Carol), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - alice - .start_steward_epoch(group_name.clone()) - .await - .expect("Failed to start steward epoch (Carol)"); - println!( - "Test: After start_steward_epoch (Carol), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - - let vote_id = alice - .start_voting(group_name.clone()) - .await - .expect("Failed to start voting (Carol)"); - println!( - "Test: After start_voting (Carol), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - - // Submit a vote (Alice votes yes for her own proposals) - println!("Test: Submitting vote with ID: {vote_id:?}"); - println!("Test: Alice's identity: {}", alice.identity_string()); - alice - .submit_vote(vote_id.clone(), true) - .await - .expect("Failed to submit vote (Carol)"); - - alice - .complete_voting(group_name.clone(), vote_id) - .await - .expect("Failed to complete voting (Carol)"); - println!( - "Test: After complete_voting (Carol), group state: {:?}", - alice.get_group(group_name.clone()).unwrap().get_state() - ); - - // 3. Apply proposals to add Carol to the group - let _out = alice - .apply_proposals(group_name.clone()) - .await - .expect("Failed to apply proposals while adding Bob to the group"); - - // 4. Remove proposals and complete the steward epoch - alice - .remove_proposals_and_complete(group_name.clone()) - .await - .expect("Failed to remove proposals and complete the steward epoch"); - - // Carol process join message - carol - .process_waku_message( - _out[1] - .build_waku_message() - .expect("Failed to build waku message for Carol to join to the group"), - ) - .await - .expect("Failed to process waku message for Carol to join to the group"); - - // 5. Bob process commit message - match bob - .process_waku_message( - _out[0] - .build_waku_message() - .expect("Failed to build waku message apply commit to the Bob"), - ) - .await - .expect("Failed to process waku message apply commit to the Bob") - { - UserAction::SendToWaku(msg) => { - println!("Bob action is SendToWaku: {msg:?}"); - } - UserAction::DoNothing => { - println!("Bob action is DoNothing"); - } - _ => panic!("Bob action is not SendToWaku"), - }; - - let carol_group = carol - .get_group(group_name.clone()) - .expect("Failed to get group"); - let carol_members = carol_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - carol_members.len() == 3, - "Wrong number of members in the group for Carol" - ); - let carol_group_epoch = carol_group.epoch().await.expect("Failed to get epoch"); - assert_eq!(carol_group_epoch.as_u64(), 2, "Carol group epoch is not 2"); - - let bob_group = bob - .get_group(group_name.clone()) - .expect("Failed to get group"); - let bob_members = bob_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - bob_members.len() == 3, - "Wrong number of members in the group for Bob" - ); - let bob_group_epoch = bob_group.epoch().await.expect("Failed to get epoch"); - assert_eq!(bob_group_epoch.as_u64(), 2, "Bob group epoch is not 2"); - - let alice_group = alice - .get_group(group_name.clone()) - .expect("Failed to get group"); - let alice_members = alice_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - alice_members.len() == 3, - "Wrong number of members in the group for Alice" - ); - let alice_group_epoch = alice_group.epoch().await.expect("Failed to get epoch"); - assert_eq!(alice_group_epoch.as_u64(), 2, "Alice group epoch is not 2"); - - assert_eq!( - alice_members, bob_members, - "Alice and Bob have different members" - ); - assert_eq!( - alice_members, carol_members, - "Alice and Carol have different members" - ); - assert_eq!( - bob_members, carol_members, - "Bob and Carol have different members" - ); + (alice, bob) } -#[tokio::test] -async fn test_remove_user_flow() { - let group_name = "new_group".to_string(); - - let alice_priv_key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; - let mut alice = User::new(alice_priv_key).expect("Failed to create user"); +async fn create_three_test_user_with_group(group_name: &str) -> (User, User, User) { + let mut alice = User::new(ALICE_PRIVATE_KEY).expect("Failed to create user"); alice - .create_group(group_name.clone(), true) + .create_group(group_name, true) .await - .expect("Failed to create group"); + .expect("Failed to create group for Alice"); - let bob_priv_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - let mut bob = User::new(bob_priv_key).expect("Failed to create user"); - bob.create_group(group_name.clone(), false) + let mut bob = User::new(BOB_PRIVATE_KEY).expect("Failed to create user"); + bob.create_group(group_name, false) .await - .expect("Failed to create group"); + .expect("Failed to create group for Bob"); - let carol_priv_key = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; - let mut carol = User::new(carol_priv_key).expect("Failed to create user"); + let mut carol = User::new(CAROL_PRIVATE_KEY).expect("Failed to create user"); carol - .create_group(group_name.clone(), false) + .create_group(group_name, false) .await - .expect("Failed to create group"); + .expect("Failed to create group for Carol"); - let group_announcement = alice - .prepare_steward_msg(group_name.clone()) + (alice, bob, carol) +} + +async fn get_group_announcement_message(steward: &mut User, group_name: &str) -> WakuMessage { + steward + .prepare_steward_msg(group_name) .await - .expect("Failed to prepare steward message"); - let group_announcement_message = group_announcement + .expect("Failed to prepare steward message") .build_waku_message() - .expect("Failed to build waku message"); + .expect("Failed to build waku message with group announcement") +} - let bob_action = bob +async fn share_group_announcement_for_one_user( + steward: &mut User, + invite_user: &mut User, + group_announcement_message: WakuMessage, +) { + // Newcomer parse GA message and share his KP to Steward + let invite_user_kp_message = match invite_user .process_waku_message(group_announcement_message.clone()) .await - .expect("Failed to process waku message"); - let bob_kp_message = match bob_action { + .expect("Failed to process waku message with group announcement") + { UserAction::SendToWaku(msg) => msg, _ => panic!("User action is not SendToWaku"), }; - let bob_kp_waku_message = bob_kp_message + let invite_user_kp_waku_message = invite_user_kp_message .build_waku_message() - .expect("Failed to build waku message"); + .expect("Failed to build waku message with invite user's KP"); - let _alice_action = alice - .process_waku_message(bob_kp_waku_message) + // Steward parse invite user's KP and add it to the queue of income key packages + let _steward_action = steward + .process_waku_message(invite_user_kp_waku_message) .await - .expect("Failed to process waku message"); + .expect("Failed to process waku message with invite user's KP"); +} - let carol_action = carol - .process_waku_message(group_announcement_message.clone()) - .await - .expect("Failed to process waku message"); - let carol_kp_message = match carol_action { - UserAction::SendToWaku(msg) => msg, - _ => panic!("User action is not SendToWaku"), - }; - let carol_kp_waku_message = carol_kp_message - .build_waku_message() - .expect("Failed to build waku message"); +// In this function, we are starting steward epoch without any user in the group +// So as result we don't expect another vote from another user +// and have `GroupState::Working` after processing steward vote +async fn steward_epoch_without_user_in_group( + steward: &mut User, + group_name: &str, +) -> Vec { + // Set up consensus event subscription before voting + let mut consensus_events = steward.subscribe_to_consensus_events(); - let _alice_action = alice - .process_waku_message(carol_kp_waku_message) - .await - .expect("Failed to process waku message"); - - // Debug: Check how many proposals we have - let proposal_count_before = alice - .get_pending_proposals_count(group_name.clone()) - .await - .expect("Failed to get proposal count"); - println!("Debug: Proposal count before steward epoch: {proposal_count_before}"); - - // Add Bob and Carol to the group initially using steward epoch flow // State machine: start steward epoch, voting, complete voting - let steward_epoch_proposals = alice - .start_steward_epoch(group_name.clone()) + let steward_epoch_proposals = steward + .start_steward_epoch(group_name) .await .expect("Failed to start steward epoch"); println!("Debug: Steward epoch returned {steward_epoch_proposals} proposals"); - let vote_id = alice - .start_voting(group_name.clone()) + let (proposal_id, action) = steward + .get_proposals_for_steward_voting(group_name) .await .expect("Failed to start voting"); - // Submit a vote (Alice votes yes for her own proposals) - alice - .submit_vote(vote_id.clone(), true) - .await - .expect("Failed to submit vote"); + println!("Debug: Proposal ID: {proposal_id}"); - alice - .complete_voting(group_name.clone(), vote_id) - .await - .expect("Failed to complete voting"); + // This message will be printed to the app and allow steward to vote + let _steward_voting_proposal_app_message = match action { + UserAction::SendToApp(app_msg) => app_msg, + _ => panic!("User action is not SendToWaku"), + }; - let res = alice - .apply_proposals(group_name.clone()) + let steward_state = steward + .get_group_state(group_name) .await - .expect("Failed to apply proposals"); + .expect("Failed to get group state for steward after making voting proposal"); + println!("Debug: Steward state after making voting proposal: {steward_state:?}"); + assert_eq!(steward_state, GroupState::Voting); - // 4. Remove proposals and complete the steward epoch - alice - .remove_proposals_and_complete(group_name.clone()) + // Now steward can vote + steward + .process_user_vote(proposal_id, true, group_name) .await - .expect("Failed to remove proposals and complete the steward epoch"); + .expect("Failed to process steward vote on proposal"); - // Bob processes the welcome message to join the group - bob.process_waku_message( - res[1] + let mut msgs_to_send: Vec = vec![]; + // Process any consensus events that were emitted during voting + while let Ok((group_name, ev)) = consensus_events.try_recv() { + println!( + "Debug: Processing consensus event in steward_epoch: {ev:?} for group {group_name}" + ); + + let wmts = steward + .handle_consensus_event(&group_name, ev) + .await + .expect("Failed to handle consensus event"); + msgs_to_send.extend(wmts); + } + + let steward_state = steward + .get_group_state(group_name) + .await + .expect("Failed to get group state for steward after voting"); + println!("Debug: Steward state after voting: {steward_state:?}"); + assert_eq!(steward_state, GroupState::Working); + + let mut res_msgs_to_send: Vec = vec![]; + for msg in msgs_to_send { + let waku_message = msg .build_waku_message() - .expect("Failed to build waku welcome message for Bob"), - ) - .await - .expect("Failed to process waku welcome message for Bob"); + .expect("Failed to build waku message"); + res_msgs_to_send.push(waku_message); + } - // Bob sends a message after joining - let bob_res_waku_message = bob - .build_group_message("User joined to the group", group_name.clone()) - .await - .expect("Failed to build group message") - .build_waku_message() - .expect("Failed to build waku message"); - - let res_alice = alice - .process_waku_message(bob_res_waku_message.clone()) - .await - .expect("Failed to process waku message"); - println!("Alice result: {res_alice:?}"); - let res_alice_msg = match res_alice { - UserAction::SendToApp(msg) => msg, - _ => panic!("User action is not SendToApp"), - }; - - let inside_msg = match res_alice_msg.payload.unwrap() { - app_message::Payload::ConversationMessage(msg) => msg, - _ => panic!("User action is not SendToApp"), - }; - println!( - "Alice message: {:?}", - String::from_utf8(inside_msg.message).unwrap() - ); - - // Carol processes the welcome message to join the group - let res_carol = carol - .process_waku_message( - res[1] - .build_waku_message() - .expect("Failed to build waku welcome message for Carol"), - ) - .await - .expect("Failed to process waku message"); - println!("Carol result: {res_carol:?}"); - - let carol_group = carol - .get_group(group_name.clone()) - .expect("Failed to get group"); - let carol_members = carol_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - carol_members.len() == 3, - "Wrong number of members in the group for Carol" - ); - let bob_group = bob - .get_group(group_name.clone()) - .expect("Failed to get group"); - let bob_members = bob_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - bob_members.len() == 3, - "Wrong number of members in the group for Bob" - ); - let alice_group = alice - .get_group(group_name.clone()) - .expect("Failed to get group"); - let alice_members = alice_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - alice_members.len() == 3, - "Wrong number of members in the group for Alice" - ); - - assert_eq!( - alice_members, bob_members, - "Alice and Bob have different members" - ); - assert_eq!( - alice_members, carol_members, - "Alice and Carol have different members" - ); - assert_eq!( - bob_members, carol_members, - "Bob and Carol have different members" - ); - - let raw_msg = RawWsMessage { - message: serde_json::to_string(&UserMessage { - message: "/ban f39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - group_id: group_name.clone(), - }) - .expect("Failed to serialize user message"), - }; - - let ws_action = match serde_json::from_str(&raw_msg.message) { - Ok(UserMessage { message, group_id }) => { - if message.starts_with("/") { - let mut tokens = message.split_whitespace(); - match tokens.next() { - Some("/ban") => { - let user_to_ban = tokens.next().expect("Failed to get user to ban"); - WsAction::RemoveUser(user_to_ban.to_string(), group_id.clone()) - } - _ => { - panic!("Invalid user message"); - } - } - } else { - WsAction::UserMessage(UserMessage { message, group_id }) - } - } - Err(_) => { - panic!("Failed to parse user message"); - } - }; - assert_eq!( - ws_action, - WsAction::RemoveUser( - "f39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - group_name.clone() - ) - ); - - match ws_action { - WsAction::RemoveUser(user_to_ban, group_name) => { - // Add remove proposal to steward instead of direct removal - alice - .add_remove_proposal(group_name.clone(), user_to_ban.clone()) - .await - .expect("Failed to add remove proposal to steward"); - } - _ => panic!("User action is not RemoveUser"), - }; - - // State machine: start steward epoch, voting, complete voting (removal) - alice - .start_steward_epoch(group_name.clone()) - .await - .expect("Failed to start steward epoch (removal)"); - let vote_id = alice - .start_voting(group_name.clone()) - .await - .expect("Failed to start voting (removal)"); - - // Submit a vote (Alice votes yes for the removal) - println!("Test: Submitting vote with ID: {vote_id:?}"); - println!("Test: Alice's identity: {}", alice.identity_string()); - alice - .submit_vote(vote_id.clone(), true) - .await - .expect("Failed to submit vote (removal)"); - - alice - .complete_voting(group_name.clone(), vote_id) - .await - .expect("Failed to complete voting (removal)"); - let out = alice - .apply_proposals(group_name.clone()) - .await - .expect("Failed to apply proposals (removal)"); - - // 4. Remove proposals and complete the steward epoch - alice - .remove_proposals_and_complete(group_name.clone()) - .await - .expect("Failed to remove proposals and complete the steward epoch"); - - let waku_commit_message = out[0] - .build_waku_message() - .expect("Failed to build waku message"); - - let _ = carol - .process_waku_message(waku_commit_message.clone()) - .await - .expect("Failed to process waku message"); - let carol_group = carol - .get_group(group_name.clone()) - .expect("Failed to get group"); - let carol_members = carol_group - .members_identity() - .await - .expect("Failed to get members"); - assert!( - carol_members.len() == 2, - "Bob is not removed from the group" - ); - - let bob_action = bob - .process_waku_message(waku_commit_message.clone()) - .await - .expect("Failed to process waku message"); - assert_eq!( - bob_action, - UserAction::LeaveGroup(group_name.clone()), - "User action is not RemoveGroup" - ); - bob.leave_group(group_name.clone()) - .await - .expect("Failed to leave group"); - assert!( - !bob.if_group_exists(group_name.clone()), - "Bob is still in the group" - ); + res_msgs_to_send } -#[tokio::test] -async fn test_steward_epoch_with_no_proposals() { - let group_name = "test_steward_no_proposals".to_string(); +// In this function, we are starting steward epoch with users in the group +// So as result we expect another vote from another user +// and have `GroupState::Working` after processing steward vote +async fn steward_epoch_with_user_in_group( + steward: &mut User, + group_name: &str, +) -> (Vec, u32) { + // Set up consensus event subscription before voting + let mut consensus_events = steward.subscribe_to_consensus_events(); - // Create steward group - let alice_priv_key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; - let mut alice = User::new(alice_priv_key).expect("Failed to create user"); - alice - .create_group(group_name.clone(), true) - .await - .expect("Failed to create group"); - - // Start steward epoch (should return 0 when no proposals) - let proposal_count = alice - .start_steward_epoch(group_name.clone()) + // State machine: start steward epoch, voting, complete voting + let steward_epoch_proposals = steward + .start_steward_epoch(group_name) .await .expect("Failed to start steward epoch"); - // Should return 0 when no proposals - assert_eq!(proposal_count, 0); + println!("Debug: Steward epoch returned {steward_epoch_proposals} proposals"); - // Check that group is still in Working state (no steward epoch started) - let group = alice.get_group(group_name.clone()).unwrap(); - assert_eq!(group.get_state(), GroupState::Working); + let (proposal_id, action) = steward + .get_proposals_for_steward_voting(group_name) + .await + .expect("Failed to start voting"); - // Since no steward epoch was started, we can't start voting - let vote_result = alice.start_voting(group_name.clone()).await; - assert!(vote_result.is_err()); // Should fail because we're not in Waiting state + println!("Debug: Proposal ID: {proposal_id}"); - info!("Steward epoch correctly skipped when no proposals exist"); + // This message will be printed to the app and allow steward to vote + let _steward_voting_proposal_app_message = match action { + UserAction::SendToApp(app_msg) => app_msg, + _ => panic!("User action is not SendToWaku"), + }; + + let steward_state = steward + .get_group_state(group_name) + .await + .expect("Failed to get group state for steward after making voting proposal"); + println!("Debug: Steward state after making voting proposal: {steward_state:?}"); + assert_eq!(steward_state, GroupState::Voting); + + // Now steward can vote + let steward_action = steward + .process_user_vote(proposal_id, true, group_name) + .await + .expect("Failed to process steward vote on proposal"); + + let mut msgs_to_send: Vec = vec![]; + // Process any consensus events that were emitted during voting + while let Ok((group_name, ev)) = consensus_events.try_recv() { + println!( + "Debug: Processing consensus event in steward_epoch: {ev:?} for group {group_name}" + ); + + let wmts = steward + .handle_consensus_event(&group_name, ev) + .await + .expect("Failed to handle consensus event"); + msgs_to_send.extend(wmts); + } + + let steward_state = steward + .get_group_state(group_name) + .await + .expect("Failed to get group state for steward after voting"); + println!("Debug: Steward state after voting: {steward_state:?}"); + assert_eq!(steward_state, GroupState::Voting); + + let steward_voting_proposal_waku_message = match steward_action { + UserAction::SendToWaku(msg) => msg, + _ => panic!("User action is not SendToWaku"), + }; + + // Build the voting proposal message for other users + let voting_proposal_waku_message = steward_voting_proposal_waku_message + .build_waku_message() + .expect("Failed to build waku message with steward voting proposal"); + + (vec![voting_proposal_waku_message], proposal_id) } + +async fn user_join_group(user: &mut User, welcome_message: WakuMessage) { + let user_res_action = user + .process_waku_message(welcome_message) + .await + .expect("Failed to process waku message for user to join the group"); + + match user_res_action { + UserAction::SendToWaku(_) => { + println!("Debug: user join group"); + } + _ => panic!("User action is not SendToWaku: {user_res_action:?}"), + }; +} + +async fn user_vote_on_proposal( + user: &mut User, + waku_proposal_message: WakuMessage, + vote: bool, + group_name: &str, +) -> WakuMessage { + println!("Debug: user vote on proposal: {waku_proposal_message:?}"); + let mut consensus_events = user.subscribe_to_consensus_events(); + let user_action = user + .process_waku_message(waku_proposal_message) + .await + .expect("Failed to process waku message for user to vote on proposal"); + + let msg = match user_action { + UserAction::SendToApp(msg) => { + println!("Debug: user got message: {msg:?}"); + msg + } + _ => panic!("User action is not SendToApp or DoNothing: {user_action:?}"), + }; + + let user_state = user + .get_group_state(group_name) + .await + .expect("Failed to get group state for user after making voting proposal"); + println!("Debug: User state after making voting proposal: {user_state:?}"); + assert_eq!(user_state, GroupState::Voting); + + let proposal_id = match msg.payload { + Some(app_message::Payload::VotingProposal(proposal)) => proposal.proposal_id, + _ => panic!("User got an unexpected message: {msg:?}"), + }; + + // after getting voting proposal, user actually should send it into app and get vote result + // here we mock it and start to process user vote + let user_action = user + .process_user_vote(proposal_id, vote, group_name) + .await + .expect("Failed to process steward vote on proposal"); + + let mut msgs_to_send: Vec = vec![]; + while let Ok((group_name, ev)) = consensus_events.try_recv() { + println!( + "Debug: Processing consensus event in user_vote_on_proposal: {ev:?} for group {group_name}" + ); + + let wmts = user + .handle_consensus_event(&group_name, ev) + .await + .expect("Failed to handle consensus event"); + msgs_to_send.extend(wmts); + } + + let user_state = user + .get_group_state(group_name) + .await + .expect("Failed to get group state for user after vote"); + println!("Debug: User state after vote: {user_state:?}"); + assert_eq!(user_state, GroupState::Waiting); + + let msg = match user_action { + UserAction::SendToWaku(msg) => msg, + _ => panic!("User action is not SendToWaku: {user_action:?}"), + }; + + msg.build_waku_message() + .expect("Failed to build waku message") +} + +async fn process_and_handle_trigger_event_message( + user: &mut User, + waku_message: WakuMessage, + expected_group_state: GroupState, +) -> Vec { + let mut consensus_events = user.subscribe_to_consensus_events(); + + user.process_waku_message(waku_message) + .await + .expect("Failed to process waku message"); + + let mut msgs_to_send: Vec = vec![]; + // Process any consensus events that were emitted during voting + while let Ok((group_name, ev)) = consensus_events.try_recv() { + println!( + "Debug: Processing consensus event in steward_epoch: {ev:?} for group {group_name}" + ); + + let wmts = user + .handle_consensus_event(&group_name, ev) + .await + .expect("Failed to handle consensus event"); + msgs_to_send.extend(wmts); + } + + let mut waku_msgs_to_send: Vec = vec![]; + for msg in msgs_to_send { + let waku_msg = msg + .build_waku_message() + .expect("Failed to build waku message"); + waku_msgs_to_send.push(waku_msg); + } + + let user_state = user + .get_group_state(GROUP_NAME) + .await + .expect("Failed to get group state for user after processing trigger event message"); + println!("Debug: User state after voting: {user_state:?}"); + assert_eq!(user_state, expected_group_state); + + waku_msgs_to_send +} + +async fn check_users_have_same_group_stats( + alice: &User, + bob: &User, + group_name: &str, + expected_members: usize, + expected_epoch: u64, +) { + let alice_group_state = alice + .get_group_state(group_name) + .await + .expect("Failed to get group state for Alice"); + assert_eq!(alice_group_state, GroupState::Working); + let bob_group_state = bob + .get_group_state(group_name) + .await + .expect("Failed to get group state for Bob"); + assert_eq!(bob_group_state, GroupState::Working); + + let bob_members = bob + .get_group_number_of_members(group_name) + .await + .expect("Failed to get number of members for Bob"); + let bob_epoch = bob + .get_group_mls_epoch(group_name) + .await + .expect("Failed to get MLS epoch for Bob"); + assert_eq!( + bob_members, expected_members, + "Wrong number of members in the group for Bob" + ); + assert_eq!( + bob_epoch, expected_epoch, + "Bob group epoch is not {expected_epoch}" + ); + + let alice_members = alice + .get_group_number_of_members(group_name) + .await + .expect("Failed to get number of members for Alice"); + let alice_epoch = alice + .get_group_mls_epoch(group_name) + .await + .expect("Failed to get MLS epoch for Alice"); + assert_eq!( + alice_members, expected_members, + "Wrong number of members in the group for Alice" + ); + assert_eq!( + alice_epoch, expected_epoch, + "Alice group epoch is not {expected_epoch}" + ); + + assert_eq!( + bob_members, alice_members, + "Bob and Alice have different members" + ); + assert_eq!( + bob_epoch, alice_epoch, + "Bob and Alice have different MLS epochs" + ); +} + +// async fn create_ban_request_message(user: &mut User, group_name: &str) -> WakuMessage { +// let ban_request_msg = BanRequest { +// user_to_ban: CAROL_WALLET_ADDRESS.to_string(), +// requester: ALICE_WALLET_ADDRESS.to_string(), // The current user is the requester +// group_name: group_name.to_string(), +// }; + +// let waku_msg = user +// .process_ban_request(ban_request_msg, group_name) +// .await +// .expect("Failed to process ban request"); + +// let waku_msg = waku_msg +// .build_waku_message() +// .expect("Failed to build waku message"); +// waku_msg +// } + +#[tokio::test] +async fn test_invite_user_to_group_flow() { + let (mut steward, mut user) = create_two_test_user_with_group(GROUP_NAME).await; + + let ga_message = get_group_announcement_message(&mut steward, GROUP_NAME).await; + + share_group_announcement_for_one_user(&mut steward, &mut user, ga_message.clone()).await; + + let steward_epoch_messages = + steward_epoch_without_user_in_group(&mut steward, GROUP_NAME).await; + + user_join_group(&mut user, steward_epoch_messages[1].clone()).await; + + check_users_have_same_group_stats( + &steward, + &user, + GROUP_NAME, + EXPECTED_MEMBERS_2, + EXPECTED_EPOCH_1, + ) + .await; +} + +#[tokio::test] +async fn test_invite_users_to_group_same_epoch_flow() { + let (mut steward, mut user, mut user2) = create_three_test_user_with_group(GROUP_NAME).await; + + let ga_message = get_group_announcement_message(&mut steward, GROUP_NAME).await; + + share_group_announcement_for_one_user(&mut steward, &mut user, ga_message.clone()).await; + share_group_announcement_for_one_user(&mut steward, &mut user2, ga_message.clone()).await; + + let steward_epoch_messages = + steward_epoch_without_user_in_group(&mut steward, GROUP_NAME).await; + + user_join_group(&mut user, steward_epoch_messages[1].clone()).await; + user_join_group(&mut user2, steward_epoch_messages[1].clone()).await; + + check_users_have_same_group_stats( + &steward, + &user, + GROUP_NAME, + EXPECTED_MEMBERS_3, + EXPECTED_EPOCH_1, + ) + .await; + + check_users_have_same_group_stats( + &steward, + &user2, + GROUP_NAME, + EXPECTED_MEMBERS_3, + EXPECTED_EPOCH_1, + ) + .await; + + check_users_have_same_group_stats( + &user, + &user2, + GROUP_NAME, + EXPECTED_MEMBERS_3, + EXPECTED_EPOCH_1, + ) + .await; +} + +#[tokio::test] +async fn test_invite_users_to_group_different_epoch_flow() { + let (mut steward, mut bob, mut carol) = create_three_test_user_with_group(GROUP_NAME).await; + + let ga_message = get_group_announcement_message(&mut steward, GROUP_NAME).await; + share_group_announcement_for_one_user(&mut steward, &mut bob, ga_message.clone()).await; + + let steward_epoch_messages = + steward_epoch_without_user_in_group(&mut steward, GROUP_NAME).await; + user_join_group(&mut bob, steward_epoch_messages[1].clone()).await; + + check_users_have_same_group_stats( + &steward, + &bob, + GROUP_NAME, + EXPECTED_MEMBERS_2, + EXPECTED_EPOCH_1, + ) + .await; + + println!("START NEW EPOCH"); + println!("--------------------------------"); + + let ga_message_2 = get_group_announcement_message(&mut steward, GROUP_NAME).await; + share_group_announcement_for_one_user(&mut steward, &mut carol, ga_message_2.clone()).await; + + let (steward_epoch_messages_2, _) = + steward_epoch_with_user_in_group(&mut steward, GROUP_NAME).await; + + println!("Debug: steward vote, wait for user vote"); + println!("--------------------------------"); + + let waku_vote_message = user_vote_on_proposal( + &mut bob, + steward_epoch_messages_2[0].clone(), + true, + GROUP_NAME, + ) + .await; + + let waku_msgs_to_send = process_and_handle_trigger_event_message( + &mut steward, + waku_vote_message, + GroupState::Working, + ) + .await; + + bob.process_waku_message(waku_msgs_to_send[0].clone()) + .await + .expect("Failed to process waku message"); + + user_join_group(&mut carol, waku_msgs_to_send[1].clone()).await; + + check_users_have_same_group_stats( + &steward, + &bob, + GROUP_NAME, + EXPECTED_MEMBERS_3, + EXPECTED_EPOCH_2, + ) + .await; + + check_users_have_same_group_stats( + &steward, + &carol, + GROUP_NAME, + EXPECTED_MEMBERS_3, + EXPECTED_EPOCH_2, + ) + .await; + + check_users_have_same_group_stats( + &bob, + &carol, + GROUP_NAME, + EXPECTED_MEMBERS_3, + EXPECTED_EPOCH_2, + ) + .await; +} + +// #[tokio::test] +// async fn test_remove_user_flow_request_from_steward() { +// let (mut steward, mut bob, mut carol) = create_three_test_user_with_group(GROUP_NAME).await; + +// let ga_message = get_group_announcement_message(&mut steward, GROUP_NAME).await; + +// share_group_announcement_for_one_user(&mut steward, &mut bob, ga_message.clone()).await; +// share_group_announcement_for_one_user(&mut steward, &mut carol, ga_message.clone()).await; + +// let steward_epoch_messages = +// steward_epoch_without_user_in_group(&mut steward, GROUP_NAME).await; + +// user_join_group(&mut bob, steward_epoch_messages[1].clone()).await; +// user_join_group(&mut carol, steward_epoch_messages[1].clone()).await; + +// check_users_have_same_group_stats( +// &steward, +// &bob, +// GROUP_NAME, +// EXPECTED_MEMBERS_3, +// EXPECTED_EPOCH_1, +// ) +// .await; + +// check_users_have_same_group_stats( +// &steward, +// &carol, +// GROUP_NAME, +// EXPECTED_MEMBERS_3, +// EXPECTED_EPOCH_1, +// ) +// .await; + +// check_users_have_same_group_stats( +// &bob, +// &carol, +// GROUP_NAME, +// EXPECTED_MEMBERS_3, +// EXPECTED_EPOCH_1, +// ) +// .await; + +// let ban_request_message = create_ban_request_message(&mut steward, GROUP_NAME).await; + +// let action = bob +// .process_waku_message(ban_request_message.clone()) +// .await +// .expect("Failed to process ban request"); + +// match action { +// UserAction::SendToApp(_) => { +// println!("Debug: SendToApp action"); +// } +// _ => panic!("Expected SendToApp action"), +// } + +// let action = carol +// .process_waku_message(ban_request_message.clone()) +// .await +// .expect("Failed to process ban request"); + +// match action { +// UserAction::SendToApp(_) => { +// println!("Debug: SendToApp action"); +// } +// _ => panic!("Expected SendToApp action"), +// } + +// let (steward_epoch_messages, proposal_id) = +// steward_epoch_with_user_in_group(&mut steward, GROUP_NAME).await; + +// steward +// .set_up_consensus_threshold_for_group(GROUP_NAME, proposal_id, 1f64) +// .await +// .expect("Can't setup threshold"); + +// println!("Debug: Bob vote"); +// let waku_vote_message = user_vote_on_proposal( +// &mut bob, +// steward_epoch_messages[0].clone(), +// true, +// GROUP_NAME, +// ) +// .await; + +// println!("Debug: Carol vote"); +// let waku_vote_message_2 = user_vote_on_proposal( +// &mut carol, +// steward_epoch_messages[0].clone(), +// true, +// GROUP_NAME, +// ) +// .await; + +// println!("Debug: steward process bob vote"); +// let waku_msgs_to_send = process_and_handle_trigger_event_message( +// &mut steward, +// waku_vote_message, +// GroupState::Voting, +// ) +// .await; +// println!("Debug: waku_msgs_to_send after bob vote: {waku_msgs_to_send:?}"); + +// println!("Debug: steward process carol vote"); +// let waku_msgs_to_send_2 = process_and_handle_trigger_event_message( +// &mut steward, +// waku_vote_message_2, +// GroupState::Working, +// ) +// .await; +// println!("Debug: waku_msgs_to_send_2 after carol vote: {waku_msgs_to_send_2:?}"); +// }