mirror of
https://github.com/vacp2p/de-mls.git
synced 2026-01-09 05:27:59 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae4ee902d5 | ||
|
|
4ea1136012 | ||
|
|
e37a4b435f | ||
|
|
559cc856c0 | ||
|
|
867d48730d | ||
|
|
53fab78ef6 | ||
|
|
8ebeb4d898 | ||
|
|
d93ac900ae | ||
|
|
c99eadb302 |
4
.cargo/config.toml
Normal file
4
.cargo/config.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[target.'cfg(target_os = "macos")']
|
||||
# when using osx, we need to link against some golang libraries, it did just work with this missing flags
|
||||
# from: https://github.com/golang/go/issues/42459
|
||||
rustflags = ["-C", "link-args=-framework CoreFoundation -framework Security -framework CoreServices -lresolv"]
|
||||
174
.github/workflows/ci.yml
vendored
174
.github/workflows/ci.yml
vendored
@@ -1,8 +1,3 @@
|
||||
name: "CI"
|
||||
|
||||
env:
|
||||
FOUNDRY_PROFILE: "ci"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
@@ -10,122 +5,93 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
concurrency:
|
||||
cancel-in-progress: true
|
||||
group: ${{github.workflow}}-${{github.ref}}
|
||||
name: "CI"
|
||||
|
||||
env:
|
||||
PROTOC_VERSION: "3.25.3"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: "ubuntu-latest"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Check out the repo"
|
||||
uses: "actions/checkout@v3"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Install stable toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: "Install Foundry"
|
||||
uses: "foundry-rs/foundry-toolchain@v1"
|
||||
|
||||
- name: "Install Pnpm"
|
||||
uses: "pnpm/action-setup@v4"
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
version: "8"
|
||||
|
||||
- name: "Install Node.js"
|
||||
uses: "actions/setup-node@v3"
|
||||
shared-key: "stable"
|
||||
- name: Install protoc
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: "Install the Node.js dependencies"
|
||||
run: "pnpm install"
|
||||
working-directory: "contracts"
|
||||
|
||||
- name: "Lint the contracts"
|
||||
run: "pnpm lint"
|
||||
working-directory: "contracts"
|
||||
|
||||
- name: "Add lint summary"
|
||||
tool: protoc@${{ env.PROTOC_VERSION }}
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
echo "## Lint result" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Passed" >> $GITHUB_STEP_SUMMARY
|
||||
sudo apt update
|
||||
sudo apt install libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all --check
|
||||
- name: Run clippy
|
||||
run: cargo clippy -p de_mls_desktop_ui --all-features --tests -- -D warnings
|
||||
|
||||
build:
|
||||
runs-on: "ubuntu-latest"
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Check out the repo"
|
||||
uses: "actions/checkout@v3"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Install protoc
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: "Install Foundry"
|
||||
uses: "foundry-rs/foundry-toolchain@v1"
|
||||
|
||||
- name: "Build the contracts and print their size"
|
||||
run: "forge build --sizes"
|
||||
working-directory: "contracts"
|
||||
|
||||
- name: "Add build summary"
|
||||
tool: protoc@${{ env.PROTOC_VERSION }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "stable"
|
||||
- name: Generate documentation
|
||||
run: |
|
||||
echo "## Build result" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Passed" >> $GITHUB_STEP_SUMMARY
|
||||
cargo doc --lib --no-deps --all-features --document-private-items
|
||||
env:
|
||||
RUSTDOCFLAGS: -Dwarnings
|
||||
|
||||
test:
|
||||
needs: ["lint", "build"]
|
||||
runs-on: "ubuntu-latest"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Check out the repo"
|
||||
uses: "actions/checkout@v3"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
submodules: "recursive"
|
||||
shared-key: "stable"
|
||||
- name: Install protoc
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: protoc@${{ env.PROTOC_VERSION }}
|
||||
- name: Test
|
||||
run: cargo test --release
|
||||
|
||||
- name: "Install Foundry"
|
||||
uses: "foundry-rs/foundry-toolchain@v1"
|
||||
|
||||
- name: "Show the Foundry config"
|
||||
run: "forge config"
|
||||
working-directory: "contracts"
|
||||
|
||||
- name: "Generate a fuzz seed that changes weekly to avoid burning through RPC allowance"
|
||||
run: >
|
||||
echo "FOUNDRY_FUZZ_SEED=$(
|
||||
echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800))
|
||||
)" >> $GITHUB_ENV
|
||||
|
||||
- name: "Run the tests"
|
||||
run: "forge test"
|
||||
working-directory: "contracts"
|
||||
|
||||
- name: "Add test summary"
|
||||
run: |
|
||||
echo "## Tests result" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
coverage:
|
||||
needs: ["lint", "build"]
|
||||
runs-on: "ubuntu-latest"
|
||||
unused_dependencies:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Check out the repo"
|
||||
uses: "actions/checkout@v3"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: "Install Foundry"
|
||||
uses: "foundry-rs/foundry-toolchain@v1"
|
||||
|
||||
- name: "Generate the coverage report using the unit and the integration tests"
|
||||
run: 'forge coverage --match-path "test/**/*.sol" --report lcov'
|
||||
working-directory: "contracts"
|
||||
|
||||
- name: "Upload coverage report to Codecov"
|
||||
uses: "codecov/codecov-action@v3"
|
||||
shared-key: "nightly"
|
||||
- name: Install protoc
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
files: "./contracts/lcov.info"
|
||||
|
||||
- name: "Add coverage summary"
|
||||
run: |
|
||||
echo "## Coverage result" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Uploaded to Codecov" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 16
|
||||
tool: protoc@${{ env.PROTOC_VERSION }}
|
||||
- name: Install cargo-udeps
|
||||
uses: taiki-e/install-action@cargo-udeps
|
||||
- name: Check for unused dependencies
|
||||
run: cargo +nightly udeps --all-targets
|
||||
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -3,10 +3,6 @@
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
@@ -15,12 +11,8 @@ Cargo.lock
|
||||
|
||||
.DS_Store
|
||||
src/.DS_Store
|
||||
|
||||
## contracts
|
||||
# directories
|
||||
contracts/cache/**
|
||||
contracts/node_modules/**
|
||||
contracts/out/**
|
||||
.idea
|
||||
apps/de_mls_desktop_ui/logs
|
||||
|
||||
# files
|
||||
*.env
|
||||
@@ -30,9 +22,4 @@ contracts/out/**
|
||||
lcov.info
|
||||
yarn.lock
|
||||
|
||||
# broadcasts
|
||||
contracts/!broadcast
|
||||
contracts/broadcast/*
|
||||
contracts/broadcast/*/31337/
|
||||
|
||||
.certora_internal
|
||||
|
||||
7
.gitmodules
vendored
7
.gitmodules
vendored
@@ -1,7 +0,0 @@
|
||||
[submodule "contracts/lib/forge-std"]
|
||||
branch = "v1"
|
||||
path = contracts/lib/forge-std
|
||||
url = https://github.com/foundry-rs/forge-std
|
||||
[submodule "contracts/lib/openzeppelin-contracts"]
|
||||
path = contracts/lib/openzeppelin-contracts
|
||||
url = https://github.com/OpenZeppelin/openzeppelin-contracts
|
||||
9682
Cargo.lock
generated
Normal file
9682
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
Cargo.toml
97
Cargo.toml
@@ -1,58 +1,83 @@
|
||||
[workspace]
|
||||
members = ["sc_key_store", "ds", "crates/bindings", "mls_crypto"]
|
||||
[workspace.dependencies]
|
||||
foundry-contracts = { path = "crates/bindings" }
|
||||
members = [
|
||||
"apps/de_mls_desktop_ui",
|
||||
"crates/de_mls_gateway",
|
||||
"crates/de_mls_ui_protocol",
|
||||
"crates/ui_bridge",
|
||||
"ds",
|
||||
"mls_crypto",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "de-mls"
|
||||
version = "0.1.0"
|
||||
name = "de_mls"
|
||||
version = "2.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
foundry-contracts.workspace = true
|
||||
openmls = { version = "=0.5.0", features = ["test-utils"] }
|
||||
openmls_basic_credential = "=0.2.0"
|
||||
openmls_rust_crypto = "=0.2.0"
|
||||
openmls_traits = "=0.2.0"
|
||||
# foundry-contracts.workspace = true
|
||||
openmls = { version = "0.6.0" }
|
||||
openmls_basic_credential = "0.3.0"
|
||||
openmls_rust_crypto = "0.3.0"
|
||||
openmls_traits = "0.3.0"
|
||||
|
||||
# waku-bindings = "0.6.0"
|
||||
tokio = { version = "=1.38.0", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"full",
|
||||
] }
|
||||
tokio-util = "=0.7.11"
|
||||
tokio-tungstenite = "0.15"
|
||||
tungstenite = "0.14"
|
||||
alloy = { git = "https://github.com/alloy-rs/alloy", features = [
|
||||
futures = "0.3.31"
|
||||
tower-http = { version = "0.6.6", features = ["cors"] }
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "full"] }
|
||||
tokio-util = "0.7.13"
|
||||
alloy = { version = "1.0.37", features = [
|
||||
"providers",
|
||||
"node-bindings",
|
||||
"network",
|
||||
"transports",
|
||||
"k256",
|
||||
"signer-local",
|
||||
] }
|
||||
fred = { version = "=9.0.3", features = ["subscriber-client"] }
|
||||
console-subscriber = "0.1.5"
|
||||
kameo = "0.13.0"
|
||||
|
||||
rand = "=0.8.5"
|
||||
serde_json = "=1.0"
|
||||
serde = "=1.0.204"
|
||||
url = "=2.5.2"
|
||||
tls_codec = "=0.3.0"
|
||||
hex = "=0.4.3"
|
||||
waku-bindings = { git = "https://github.com/waku-org/waku-rust-bindings.git", branch = "rln-fix-deps" }
|
||||
waku-sys = { git = "https://github.com/waku-org/waku-rust-bindings.git", branch = "rln-fix-deps" }
|
||||
|
||||
shlex = "=1.3.0"
|
||||
clap = { version = "=4.5.8", features = ["derive"] }
|
||||
rand = "0.8.5"
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0.163", features = ["derive"] }
|
||||
chrono = "0.4"
|
||||
sha2 = "0.10.8"
|
||||
|
||||
anyhow = "=1.0.81"
|
||||
thiserror = "=1.0.61"
|
||||
secp256k1 = { version = "0.30.0", features = [
|
||||
"rand",
|
||||
"std",
|
||||
"hashes",
|
||||
"global-context",
|
||||
] }
|
||||
ecies = "0.2.7"
|
||||
libsecp256k1 = "0.7.1"
|
||||
|
||||
crossterm = "=0.27.0"
|
||||
ratatui = "=0.27.0"
|
||||
textwrap = "=0.16.1"
|
||||
anyhow = "1.0.81"
|
||||
thiserror = "1.0.39"
|
||||
uuid = "1.11.0"
|
||||
|
||||
tracing = "0.1.41"
|
||||
|
||||
ds = { path = "ds" }
|
||||
sc_key_store = { path = "sc_key_store" }
|
||||
mls_crypto = { path = "mls_crypto" }
|
||||
prost = "0.13.5"
|
||||
bytes = "1.10.1"
|
||||
tower-layer = "0.3.3"
|
||||
http = "1.3.1"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13.5"
|
||||
|
||||
[profile]
|
||||
|
||||
[profile.wasm-dev]
|
||||
inherits = "dev"
|
||||
opt-level = 1
|
||||
|
||||
[profile.server-dev]
|
||||
inherits = "dev"
|
||||
|
||||
[profile.android-dev]
|
||||
inherits = "dev"
|
||||
|
||||
201
LICENSE
201
LICENSE
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
203
LICENSE-APACHE
Normal file
203
LICENSE-APACHE
Normal file
@@ -0,0 +1,203 @@
|
||||
Copyright (c) 2022 Vac Research
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
25
LICENSE-MIT
Normal file
25
LICENSE-MIT
Normal file
@@ -0,0 +1,25 @@
|
||||
Copyright (c) 2022 Vac Research
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE O THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
28
Makefile
28
Makefile
@@ -1,28 +0,0 @@
|
||||
## ref: https://gist.github.com/enil/e4af160c745057809053329df4ba1dc2
|
||||
|
||||
GIT=git
|
||||
GIT_SUBMODULES=$(shell sed -nE 's/path = +(.+)/\1\/.git/ p' .gitmodules | paste -s -)
|
||||
|
||||
.PHONY: deps
|
||||
deps: $(GIT_SUBMODULES)
|
||||
|
||||
$(GIT_SUBMODULES): %/.git: .gitmodules
|
||||
$(GIT) submodule init
|
||||
$(GIT) submodule update $*
|
||||
@touch $@
|
||||
|
||||
.EXPORT_ALL_VARIABLES:
|
||||
|
||||
REDIS_PORT=6379
|
||||
ANVIL_PORT=8545
|
||||
|
||||
start:
|
||||
docker compose up -d
|
||||
until cast chain-id --rpc-url "http://localhost:${ANVIL_PORT}" 2> /dev/null; do sleep 1; done
|
||||
cd contracts && forge script --broadcast --rpc-url "http://localhost:${ANVIL_PORT}" script/Deploy.s.sol:Deploy 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --sig 'run(address)' --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
|
||||
|
||||
stop:
|
||||
docker compose down
|
||||
|
||||
example: stop start
|
||||
cargo run --release
|
||||
123
README.md
123
README.md
@@ -1,17 +1,120 @@
|
||||
# de-mls
|
||||
Decentralized MLS PoC using a smart contract for group coordination
|
||||
# De-MLS
|
||||
|
||||
## Run Redis Server
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
`docker-compose up`
|
||||
Decentralized MLS proof-of-concept that coordinates secure group membership through
|
||||
off-chain consensus and a Waku relay.
|
||||
This repository now ships a native desktop client built with Dioxus that drives the MLS core directly.
|
||||
|
||||
## Install deps
|
||||
## What’s Included
|
||||
|
||||
1. `Foundry`
|
||||
2. `make deps`
|
||||
- **de-mls** – core library that manages MLS groups, consensus, and Waku integration
|
||||
- **crates/de_mls_gateway** – bridges UI commands (`AppCmd`) to the core runtime and streams `AppEvent`s back
|
||||
- **crates/ui_bridge** – bootstrap glue that hosts the async command loop for desktop clients
|
||||
- **apps/de_mls_desktop_ui** – Dioxus desktop UI with login, chat, stewardship, and voting flows
|
||||
- **tests/** – integration tests that exercise the MLS state machine and consensus paths
|
||||
|
||||
## Scaffold Environment
|
||||
## Quick Start
|
||||
|
||||
1. `make start`: This command will start the docker compose instance, and deploy the smart contract to the local network.
|
||||
### 1. Launch a test Waku relay
|
||||
|
||||
2. `make stop`: This command will stop the docker compose instance.
|
||||
Run a lightweight `nwaku` node that your local clients can connect to:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 8645:8645 \
|
||||
-p 60000:60000 \
|
||||
wakuorg/nwaku:v0.33.1 \
|
||||
--cluster-id=15 \
|
||||
--rest \
|
||||
--relay \
|
||||
--rln-relay=false \
|
||||
--pubsub-topic=/waku/2/rs/15/1
|
||||
```
|
||||
|
||||
Take note of the node multiaddr printed in the logs (looks like `/ip4/127.0.0.1/tcp/60000/p2p/<peer-id>`).
|
||||
|
||||
### 2. Set the runtime environment
|
||||
|
||||
The desktop app reads the same environment variables the MLS core uses:
|
||||
|
||||
```bash
|
||||
export NODE_PORT=60001 # UDP/TCP port the embedded Waku client will bind to
|
||||
export PEER_ADDRESSES=/ip4/127.0.0.1/tcp/60000/p2p/<peer-id>
|
||||
export RUST_LOG=info,de_mls_gateway=info # optional; controls UI + gateway logging
|
||||
```
|
||||
|
||||
Use a unique `NODE_PORT` per local client so the embedded Waku nodes do not collide.
|
||||
`PEER_ADDRESSES` accepts a comma-separated list if you want to bootstrap from multiple relays.
|
||||
|
||||
### 3. Launch the desktop application
|
||||
|
||||
```bash
|
||||
cargo run -p de_mls_desktop_ui
|
||||
```
|
||||
|
||||
The first run creates `apps/de_mls_desktop_ui/logs/de_mls_ui.log` and starts the event bridge
|
||||
and embedded Waku client.
|
||||
Repeat steps 2–3 in another terminal with a different `NODE_PORT` to simulate multiple users.
|
||||
|
||||
## Using the Desktop UI
|
||||
|
||||
- **Login screen** – paste an Ethereum-compatible secp256k1 private key (hex, with or without `0x`)
|
||||
and click `Enter`.
|
||||
On success the app derives your wallet address, stores it in session state,
|
||||
and navigates to the home layout.
|
||||
|
||||
- **Header bar** – shows the derived address and allows runtime log-level changes (`error`→`trace`).
|
||||
Log files rotate daily under `apps/de_mls_desktop_ui/logs/`.
|
||||
|
||||
- **Groups panel** – lists every MLS group returned by the gateway.
|
||||
Use `Create` or `Join` to open a modal, enter the group name,
|
||||
and the UI automatically refreshes the list and opens the group.
|
||||
|
||||
- **Chat panel** – displays live conversation messages for the active group.
|
||||
Compose text messages at the bottom; the UI also offers:
|
||||
- `Leave group` to request a self-ban (the backend fills in your address)
|
||||
- `Request ban` to request ban for another user
|
||||
Member lists are fetched automatically when a group is opened so you can
|
||||
pick existing members from the ban modal.
|
||||
|
||||
- **Consensus panel** – keeps stewards and members aligned:
|
||||
- Shows whether you are a steward for the active group
|
||||
- Lists pending steward requests collected during the current epoch
|
||||
- Surfaces the proposal currently open for voting with `YES`/`NO` buttons
|
||||
- Stores the latest proposal decisions with timestamps for quick auditing
|
||||
|
||||
## Steward State Machine
|
||||
|
||||
- **Working** – normal mode; all MLS messages are allowed
|
||||
- **Waiting** – a steward epoch is active; only the steward may push `BATCH_PROPOSALS_MESSAGE`
|
||||
- **Voting** – the consensus phase; everyone may submit `VOTE`/`USER_VOTE`,
|
||||
the steward can still publish proposal metadata
|
||||
|
||||
Transitions:
|
||||
|
||||
```text
|
||||
Working --start_steward_epoch()--> Waiting (if proposals exist)
|
||||
Working --start_steward_epoch()--> Working (if no proposals)
|
||||
Waiting --start_voting()---------> Voting
|
||||
Waiting --no_proposals_found()---> Working
|
||||
Voting --complete_voting(YES)----> Waiting --apply_proposals()--> Working
|
||||
Voting --complete_voting(NO)-----> Working
|
||||
```
|
||||
|
||||
Stewards always return to `Working` after an epoch finishes;
|
||||
edge cases such as missing proposals are handled defensively with detailed tracing.
|
||||
|
||||
## Development Tips
|
||||
|
||||
- `cargo test` – runs the Rust unit + integration test suite
|
||||
- `cargo fmt --all check` / `cargo clippy` – keep formatting and linting consistent with the codebase
|
||||
- `RUST_BACKTRACE=full` – helpful when debugging state-machine transitions during development
|
||||
|
||||
Logs for the desktop UI live in `apps/de_mls_desktop_ui/logs/`; core logs are emitted to stdout as well.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome. Please include reproduction steps, relevant logs,
|
||||
and test coverage where possible.
|
||||
|
||||
32
apps/de_mls_desktop_ui/Cargo.toml
Normal file
32
apps/de_mls_desktop_ui/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "de_mls_desktop_ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
de_mls_ui_protocol = { path = "../../crates/de_mls_ui_protocol" }
|
||||
de_mls_gateway = { path = "../../crates/de_mls_gateway" }
|
||||
ui_bridge = { path = "../../crates/ui_bridge" }
|
||||
de_mls = { path = "../../" }
|
||||
mls_crypto = { path = "../../mls_crypto" }
|
||||
|
||||
dioxus = { version = "0.6.2", features = ["signals", "router", "desktop"] }
|
||||
dioxus-desktop = "0.6.3"
|
||||
tokio = { version = "1.47.1", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"sync",
|
||||
"time",
|
||||
] }
|
||||
futures = "0.3.31"
|
||||
anyhow = "1.0.100"
|
||||
thiserror = "2.0.17"
|
||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||
once_cell = "1.21.3"
|
||||
parking_lot = "0.12.5"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"] }
|
||||
tracing-appender = "0.2.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
hex = "0.4"
|
||||
619
apps/de_mls_desktop_ui/assets/main.css
Normal file
619
apps/de_mls_desktop_ui/assets/main.css
Normal file
@@ -0,0 +1,619 @@
|
||||
:root {
|
||||
--bg: #0b0d10;
|
||||
--card: #14161c;
|
||||
--text: #e5e7ec;
|
||||
--muted: #9094a2;
|
||||
--primary: #00b2ff;
|
||||
--primary-2: #007ad9;
|
||||
--border: #1c1e25;
|
||||
--good: #00f5a0;
|
||||
--bad: #ff005c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#main {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans, Apple Color Emoji, Segoe UI Emoji;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.header .brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.header .user-hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.header .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header .label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header .level {
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page.login {
|
||||
max-width: 520px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: var(--bad);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--primary-2);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
button.icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
button.mini {
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 260px;
|
||||
max-width: 420px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
border-color: rgba(255, 0, 92, 0.4);
|
||||
background: rgba(255, 0, 92, 0.1);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.alert .message {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.member-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.member-picker .helper {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-item .member-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-item .member-id {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.member-item .member-choose {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 18px rgba(0, 178, 255, 0.35);
|
||||
}
|
||||
|
||||
.member-item .member-choose:hover {
|
||||
background: var(--primary-2);
|
||||
box-shadow: 0 6px 18px rgba(0, 122, 217, 0.45);
|
||||
}
|
||||
|
||||
/* Home layout */
|
||||
.page.home {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 500px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.panel.groups .group-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Group list a bit wider rows to align with long names */
|
||||
.group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.group-row .title {
|
||||
font-weight: 600;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel.groups .footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel.groups .footer .primary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.panel.chat .messages {
|
||||
min-height: 360px;
|
||||
height: 58vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel.chat .chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.msg.me {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.msg.me .body {
|
||||
background: rgba(79, 140, 255, 0.15);
|
||||
border: 1px solid rgba(79, 140, 255, 0.35);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.msg.system {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.msg.system .body {
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.msg .from {
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.msg .body {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.composer input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Consensus panel */
|
||||
.panel.consensus .status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel.consensus .status .good {
|
||||
color: var(--good);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel.consensus .status .bad {
|
||||
color: var(--bad);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel.consensus .proposal-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, max-content) 1fr;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel.consensus .proposal-item .action {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel.consensus .proposal-item .value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.panel.consensus .proposal-item.proposal-id {
|
||||
background: rgba(0, 178, 255, 0.08);
|
||||
border-color: rgba(0, 178, 255, 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 178, 255, 0.15);
|
||||
}
|
||||
|
||||
.panel.consensus .proposal-item.proposal-id .action {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.panel.consensus .proposal-item.proposal-id .value {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Consensus sections */
|
||||
.panel.consensus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel.consensus .status {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel.consensus .consensus-section {
|
||||
margin: 8px 0;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel.consensus .consensus-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: var(--primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel.consensus .no-data {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel.consensus .proposals-window {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.panel.consensus .vote-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Consensus results window */
|
||||
.panel.consensus .results-window {
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.panel.consensus .result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel.consensus .result-item .proposal-id {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel.consensus .result-item .outcome {
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.panel.consensus .result-item .outcome.accepted {
|
||||
color: var(--good);
|
||||
background: rgba(23, 201, 100, 0.1);
|
||||
}
|
||||
|
||||
.panel.consensus .result-item .outcome.rejected {
|
||||
color: var(--bad);
|
||||
background: rgba(243, 18, 96, 0.1);
|
||||
}
|
||||
|
||||
.panel.consensus .result-item .outcome.unspecified {
|
||||
color: var(--muted);
|
||||
background: rgba(163, 167, 179, 0.1);
|
||||
}
|
||||
|
||||
.panel.consensus .result-item .timestamp {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, .45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 6px var(--primary);
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
}
|
||||
63
apps/de_mls_desktop_ui/src/logging.rs
Normal file
63
apps/de_mls_desktop_ui/src/logging.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tracing_appender::rolling;
|
||||
use tracing_subscriber::{
|
||||
fmt,
|
||||
layer::SubscriberExt,
|
||||
reload::{Handle, Layer as ReloadLayer},
|
||||
util::SubscriberInitExt,
|
||||
EnvFilter, Registry,
|
||||
};
|
||||
|
||||
/// Global reload handle so the UI can change the filter at runtime.
|
||||
static RELOAD: OnceCell<Mutex<Handle<EnvFilter, Registry>>> = OnceCell::new();
|
||||
|
||||
/// Initialize logging: console + rolling daily file ("logs/de_mls_ui.log").
|
||||
/// Returns the initial level string actually applied.
|
||||
pub fn init_logging(default_level: &str) -> String {
|
||||
// Use env var if present, else the provided default
|
||||
let env_level = std::env::var("RUST_LOG").unwrap_or_else(|_| default_level.to_string());
|
||||
|
||||
// Build a reloadable EnvFilter
|
||||
let filter = EnvFilter::try_new(&env_level).unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let (reload_layer, handle) = ReloadLayer::new(filter);
|
||||
|
||||
// File sink (non-blocking)
|
||||
let file_appender = rolling::daily("logs", "de_mls_ui.log");
|
||||
let (file_writer, guard) = tracing_appender::non_blocking(file_appender);
|
||||
// Keep guard alive for the whole process to flush on drop
|
||||
Box::leak(Box::new(guard));
|
||||
|
||||
// Build the subscriber: registry + reloadable filter + console + file
|
||||
tracing_subscriber::registry()
|
||||
.with(reload_layer)
|
||||
.with(fmt::layer().with_writer(std::io::stdout)) // console
|
||||
.with(fmt::layer().with_writer(file_writer).with_ansi(false)) // file
|
||||
.init();
|
||||
|
||||
RELOAD.set(Mutex::new(handle)).ok();
|
||||
|
||||
// Return the level we consider “active” for the UI dropdown
|
||||
std::env::var("RUST_LOG").unwrap_or(env_level)
|
||||
}
|
||||
|
||||
/// Set the global log level dynamically, e.g. "error", "warn", "info", "debug", "trace",
|
||||
/// or a full filter string like "info,de_mls_gateway=debug".
|
||||
pub fn set_level(new_level: &str) -> Result<(), String> {
|
||||
let handle = RELOAD
|
||||
.get()
|
||||
.ok_or_else(|| "logger not initialized".to_string())?
|
||||
.lock()
|
||||
.map_err(|_| "reload handle poisoned".to_string())?;
|
||||
|
||||
let filter = EnvFilter::try_new(new_level)
|
||||
.map_err(|e| format!("invalid level/filter '{new_level}': {e}"))?;
|
||||
|
||||
// Replace the inner EnvFilter of the reloadable layer
|
||||
handle
|
||||
.modify(|inner: &mut EnvFilter| *inner = filter)
|
||||
.map_err(|e| format!("failed to apply filter: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
971
apps/de_mls_desktop_ui/src/main.rs
Normal file
971
apps/de_mls_desktop_ui/src/main.rs
Normal file
@@ -0,0 +1,971 @@
|
||||
// apps/de_mls_desktop_ui/src/main.rs
|
||||
#![allow(non_snake_case)]
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_desktop::{launch::launch as desktop_launch, Config, LogicalSize, WindowBuilder};
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use de_mls::{
|
||||
bootstrap_core_from_env,
|
||||
message::convert_group_requests_to_display,
|
||||
protos::{
|
||||
consensus::v1::{Outcome, ProposalResult, VotePayload},
|
||||
de_mls::messages::v1::ConversationMessage,
|
||||
},
|
||||
};
|
||||
use de_mls_gateway::GATEWAY;
|
||||
use de_mls_ui_protocol::v1::{AppCmd, AppEvent};
|
||||
use mls_crypto::normalize_wallet_address_str;
|
||||
|
||||
mod logging;
|
||||
|
||||
static CSS: Asset = asset!("/assets/main.css");
|
||||
static NEXT_ALERT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
const MAX_VISIBLE_ALERTS: usize = 5;
|
||||
|
||||
// Helper function to format timestamps
|
||||
fn format_timestamp(timestamp_ms: u64) -> String {
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
// Convert to SystemTime and format
|
||||
let timestamp = UNIX_EPOCH + std::time::Duration::from_secs(timestamp_ms);
|
||||
let datetime: chrono::DateTime<chrono::Utc> = timestamp.into();
|
||||
datetime.format("%H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
// ─────────────────────────── App state ───────────────────────────
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct SessionState {
|
||||
address: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct GroupsState {
|
||||
items: Vec<String>, // names only
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct ChatState {
|
||||
opened_group: Option<String>, // which group is “Open” in the UI
|
||||
messages: Vec<ConversationMessage>, // all messages; filtered per view
|
||||
members: Vec<String>, // cached member addresses for opened group
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct ConsensusState {
|
||||
is_steward: bool,
|
||||
pending: Option<VotePayload>, // active/pending proposal for opened group
|
||||
// Store results with timestamps for better display
|
||||
latest_results: Vec<(u32, Outcome, u64)>, // (vote_id, result, timestamp_ms)
|
||||
// Store current epoch proposals for stewards
|
||||
current_epoch_proposals: Vec<(String, String)>, // (action, address) pairs
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Alert {
|
||||
id: u64,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct AlertsState {
|
||||
errors: Vec<Alert>,
|
||||
}
|
||||
|
||||
fn record_error(alerts: &mut Signal<AlertsState>, message: impl Into<String>) {
|
||||
let raw = message.into();
|
||||
let summary = summarize_error(&raw);
|
||||
tracing::error!("ui error: {}", raw);
|
||||
let id = NEXT_ALERT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let mut state = alerts.write();
|
||||
state.errors.push(Alert {
|
||||
id,
|
||||
message: summary,
|
||||
});
|
||||
if state.errors.len() > MAX_VISIBLE_ALERTS {
|
||||
state.errors.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn dismiss_error(alerts: &mut Signal<AlertsState>, alert_id: u64) {
|
||||
alerts.write().errors.retain(|alert| alert.id != alert_id);
|
||||
}
|
||||
|
||||
fn summarize_error(raw: &str) -> String {
|
||||
let mut summary = raw
|
||||
.lines()
|
||||
.next()
|
||||
.map(|line| line.trim().to_string())
|
||||
.unwrap_or_else(|| raw.trim().to_string());
|
||||
const MAX_LEN: usize = 160;
|
||||
if summary.len() > MAX_LEN {
|
||||
summary.truncate(MAX_LEN.saturating_sub(1));
|
||||
summary.push('…');
|
||||
}
|
||||
if summary.is_empty() {
|
||||
"Unexpected error".to_string()
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── Routing ───────────────────────────
|
||||
|
||||
#[derive(Routable, Clone, PartialEq)]
|
||||
enum Route {
|
||||
#[route("/")]
|
||||
Login,
|
||||
#[route("/home")]
|
||||
Home, // unified page
|
||||
}
|
||||
|
||||
// ─────────────────────────── Entry ───────────────────────────
|
||||
|
||||
fn main() {
|
||||
let initial_level = logging::init_logging("info");
|
||||
tracing::info!("🚀 DE-MLS Desktop UI starting… level={}", initial_level);
|
||||
|
||||
// Build a small RT to run the async bootstrap before the UI
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("rt");
|
||||
|
||||
rt.block_on(async {
|
||||
let boot = bootstrap_core_from_env()
|
||||
.await
|
||||
.expect("bootstrap_core_from_env failed");
|
||||
// hand CoreCtx to the gateway via the UI bridge
|
||||
ui_bridge::start_ui_bridge(boot.core.clone());
|
||||
boot.core
|
||||
});
|
||||
|
||||
let config = Config::new().with_window(
|
||||
WindowBuilder::new()
|
||||
.with_title("DE-MLS Desktop UI")
|
||||
.with_inner_size(LogicalSize::new(1280, 820))
|
||||
.with_resizable(true),
|
||||
);
|
||||
|
||||
tracing::info!("Launching desktop application");
|
||||
desktop_launch(App, vec![], vec![Box::new(config)]);
|
||||
}
|
||||
|
||||
fn App() -> Element {
|
||||
use_context_provider(|| Signal::new(AlertsState::default()));
|
||||
use_context_provider(|| Signal::new(SessionState::default()));
|
||||
use_context_provider(|| Signal::new(GroupsState::default()));
|
||||
use_context_provider(|| Signal::new(ChatState::default()));
|
||||
use_context_provider(|| Signal::new(ConsensusState::default()));
|
||||
|
||||
rsx! {
|
||||
document::Stylesheet { href: CSS }
|
||||
HeaderBar {}
|
||||
AlertsCenter {}
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
fn HeaderBar() -> Element {
|
||||
// local signal to reflect current level in the select
|
||||
let mut level = use_signal(|| std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()));
|
||||
let session = use_context::<Signal<SessionState>>();
|
||||
let my_addr = session.read().address.clone();
|
||||
|
||||
let on_change = {
|
||||
move |evt: FormEvent| {
|
||||
let new_val = evt.value();
|
||||
if let Err(e) = crate::logging::set_level(&new_val) {
|
||||
tracing::warn!("failed to set log level: {}", e);
|
||||
} else {
|
||||
level.set(new_val);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "header",
|
||||
div { class: "brand", "DE-MLS" }
|
||||
if !my_addr.is_empty() {
|
||||
span { class: "user-hint mono ellipsis", title: "{my_addr}", "{my_addr}" }
|
||||
}
|
||||
div { class: "spacer" }
|
||||
label { class: "label", "Log level" }
|
||||
select {
|
||||
class: "level",
|
||||
value: "{level}",
|
||||
oninput: on_change,
|
||||
option { value: "error", "error" }
|
||||
option { value: "warn", "warn" }
|
||||
option { value: "info", "info" }
|
||||
option { value: "debug", "debug" }
|
||||
option { value: "trace", "trace" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── Pages ───────────────────────────
|
||||
|
||||
fn Login() -> Element {
|
||||
let nav = use_navigator();
|
||||
let mut session = use_context::<Signal<SessionState>>();
|
||||
let mut key = use_signal(String::new);
|
||||
let mut alerts = use_context::<Signal<AlertsState>>();
|
||||
|
||||
// Local single-consumer loop: only Login() steals LoggedIn events
|
||||
use_future({
|
||||
move || async move {
|
||||
loop {
|
||||
match GATEWAY.next_event().await {
|
||||
Some(AppEvent::LoggedIn(name)) => {
|
||||
session.write().address = name;
|
||||
nav.replace(Route::Home);
|
||||
break;
|
||||
}
|
||||
Some(AppEvent::Error(error)) => {
|
||||
record_error(&mut alerts, error);
|
||||
}
|
||||
Some(other) => {
|
||||
tracing::debug!("login view ignored event: {:?}", other);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let oninput_key = { move |e: FormEvent| key.set(e.value()) };
|
||||
|
||||
let mut on_submit = move |_| {
|
||||
let k = key.read().trim().to_string();
|
||||
if k.is_empty() {
|
||||
return;
|
||||
}
|
||||
session.write().key = k.clone();
|
||||
spawn(async move {
|
||||
let _ = GATEWAY.send(AppCmd::Login { private_key: k }).await;
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "page login",
|
||||
h1 { "DE-MLS — Login" }
|
||||
div { class: "form-row",
|
||||
label { "Private key" }
|
||||
input {
|
||||
r#type: "password",
|
||||
value: "{key}",
|
||||
oninput: oninput_key,
|
||||
placeholder: "0x...",
|
||||
}
|
||||
}
|
||||
button { class: "primary", onclick: move |_| { on_submit(()); }, "Enter" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn Home() -> Element {
|
||||
let mut groups = use_context::<Signal<GroupsState>>();
|
||||
let mut chat = use_context::<Signal<ChatState>>();
|
||||
let mut cons = use_context::<Signal<ConsensusState>>();
|
||||
let mut alerts = use_context::<Signal<AlertsState>>();
|
||||
|
||||
use_future({
|
||||
move || async move {
|
||||
if !groups.read().loaded {
|
||||
let _ = GATEWAY.send(AppCmd::ListGroups).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Local event loop for handling events from the gateway
|
||||
use_future({
|
||||
move || async move {
|
||||
loop {
|
||||
match GATEWAY.next_event().await {
|
||||
Some(AppEvent::StewardStatus {
|
||||
group_id,
|
||||
is_steward,
|
||||
}) => {
|
||||
// only update if it is the currently opened group
|
||||
if chat.read().opened_group.as_deref() == Some(group_id.as_str()) {
|
||||
cons.write().is_steward = is_steward;
|
||||
}
|
||||
}
|
||||
Some(AppEvent::CurrentEpochProposals {
|
||||
group_id,
|
||||
proposals,
|
||||
}) => {
|
||||
// only update if it is the currently opened group
|
||||
if chat.read().opened_group.as_deref() == Some(group_id.as_str()) {
|
||||
cons.write().current_epoch_proposals = proposals;
|
||||
}
|
||||
}
|
||||
Some(AppEvent::GroupMembers { group_id, members }) => {
|
||||
if chat.read().opened_group.as_deref() == Some(group_id.as_str()) {
|
||||
chat.write().members = members;
|
||||
}
|
||||
}
|
||||
Some(AppEvent::ProposalAdded {
|
||||
group_id,
|
||||
action,
|
||||
address,
|
||||
}) => {
|
||||
// only update if it is the currently opened group
|
||||
if chat.read().opened_group.as_deref() == Some(group_id.as_str()) {
|
||||
// Avoid duplicates: do not enqueue if the same (action, address) already exists
|
||||
let exists = {
|
||||
cons.read().current_epoch_proposals.iter().any(|(a, addr)| {
|
||||
a == &action && addr.eq_ignore_ascii_case(&address)
|
||||
})
|
||||
};
|
||||
if !exists {
|
||||
cons.write().current_epoch_proposals.push((action, address));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(AppEvent::CurrentEpochProposalsCleared { group_id }) => {
|
||||
// only update if it is the currently opened group
|
||||
if chat.read().opened_group.as_deref() == Some(group_id.as_str()) {
|
||||
cons.write().current_epoch_proposals.clear();
|
||||
}
|
||||
}
|
||||
Some(AppEvent::Groups(names)) => {
|
||||
groups.write().items = names;
|
||||
groups.write().loaded = true;
|
||||
}
|
||||
Some(AppEvent::ChatMessage(msg)) => {
|
||||
chat.write().messages.push(msg);
|
||||
}
|
||||
Some(AppEvent::VoteRequested(vp)) => {
|
||||
let opened = chat.read().opened_group.clone();
|
||||
if opened.as_deref() == Some(vp.group_id.as_str()) {
|
||||
cons.write().pending = Some(vp);
|
||||
}
|
||||
}
|
||||
Some(AppEvent::ProposalDecided(ProposalResult {
|
||||
group_id,
|
||||
proposal_id,
|
||||
outcome,
|
||||
decided_at_ms,
|
||||
})) => {
|
||||
if chat.read().opened_group.as_deref() == Some(group_id.as_str()) {
|
||||
cons.write().latest_results.push((
|
||||
proposal_id,
|
||||
Outcome::try_from(outcome).unwrap_or(Outcome::Unspecified),
|
||||
decided_at_ms,
|
||||
));
|
||||
}
|
||||
cons.write().pending = None;
|
||||
}
|
||||
Some(AppEvent::GroupRemoved(name)) => {
|
||||
let mut g = groups.write();
|
||||
g.items.retain(|n| n != &name);
|
||||
if chat.read().opened_group.as_deref() == Some(name.as_str()) {
|
||||
chat.write().opened_group = None;
|
||||
chat.write().members.clear();
|
||||
}
|
||||
}
|
||||
Some(AppEvent::Error(error)) => {
|
||||
record_error(&mut alerts, error);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page home",
|
||||
div { class: "layout",
|
||||
GroupListSection {}
|
||||
ChatSection {}
|
||||
ConsensusSection {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn AlertsCenter() -> Element {
|
||||
let alerts = use_context::<Signal<AlertsState>>();
|
||||
let items = alerts.read().errors.clone();
|
||||
rsx! {
|
||||
div { class: "alerts",
|
||||
for alert in items.iter() {
|
||||
AlertItem {
|
||||
key: "{alert.id}",
|
||||
alert_id: alert.id,
|
||||
message: alert.message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Props, PartialEq, Clone)]
|
||||
struct AlertItemProps {
|
||||
alert_id: u64,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn AlertItem(props: AlertItemProps) -> Element {
|
||||
let mut alerts = use_context::<Signal<AlertsState>>();
|
||||
let alert_id = props.alert_id;
|
||||
let message = props.message.clone();
|
||||
let dismiss = move |_| {
|
||||
dismiss_error(&mut alerts, alert_id);
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "alert error",
|
||||
span { class: "message", "{message}" }
|
||||
button { class: "ghost icon", onclick: dismiss, "✕" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── Sections ───────────────────────────
|
||||
|
||||
fn GroupListSection() -> Element {
|
||||
let groups_state = use_context::<Signal<GroupsState>>();
|
||||
let mut chat = use_context::<Signal<ChatState>>();
|
||||
let mut show_modal = use_signal(|| false);
|
||||
let mut new_name = use_signal(String::new);
|
||||
let mut create_mode = use_signal(|| true); // true=create, false=join
|
||||
|
||||
let items_snapshot: Vec<String> = groups_state.read().items.clone();
|
||||
let loaded = groups_state.read().loaded;
|
||||
|
||||
let mut open_group = {
|
||||
move |name: String| {
|
||||
chat.write().opened_group = Some(name.clone());
|
||||
chat.write().members.clear();
|
||||
let group_id = name.clone();
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::EnterGroup {
|
||||
group_id: group_id.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::LoadHistory {
|
||||
group_id: group_id.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::GetStewardStatus {
|
||||
group_id: group_id.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::GetCurrentEpochProposals {
|
||||
group_id: group_id.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::GetGroupMembers {
|
||||
group_id: group_id.clone(),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut modal_submit = {
|
||||
move |_| {
|
||||
let name = new_name.read().trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let action_name = name.clone();
|
||||
if *create_mode.read() {
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::CreateGroup {
|
||||
name: action_name.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = GATEWAY.send(AppCmd::ListGroups).await;
|
||||
});
|
||||
} else {
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::JoinGroup {
|
||||
name: action_name.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = GATEWAY.send(AppCmd::ListGroups).await;
|
||||
});
|
||||
}
|
||||
open_group(name);
|
||||
new_name.set(String::new());
|
||||
show_modal.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "panel groups",
|
||||
h2 { "Groups" }
|
||||
|
||||
if !loaded {
|
||||
div { class: "hint", "Loading groups…" }
|
||||
} else if items_snapshot.is_empty() {
|
||||
div { class: "hint", "No groups yet." }
|
||||
} else {
|
||||
ul { class: "group-list",
|
||||
for name in items_snapshot.into_iter() {
|
||||
li {
|
||||
key: "{name}",
|
||||
class: "group-row",
|
||||
div { class: "title", "{name}" }
|
||||
button {
|
||||
class: "secondary",
|
||||
onclick: move |_| { open_group(name.clone()); },
|
||||
"Open"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "footer",
|
||||
button { class: "primary", onclick: move |_| { create_mode.set(true); show_modal.set(true); }, "Create" }
|
||||
button { class: "primary", onclick: move |_| { create_mode.set(false); show_modal.set(true); }, "Join" }
|
||||
}
|
||||
|
||||
if *show_modal.read() {
|
||||
Modal {
|
||||
title: if *create_mode.read() { "Create Group".to_string() } else { "Join Group".to_string() },
|
||||
on_close: move || { show_modal.set(false); },
|
||||
div { class: "form-row",
|
||||
label { "Group name" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{new_name}",
|
||||
oninput: move |e| new_name.set(e.value()),
|
||||
placeholder: "mls-devs",
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "actions",
|
||||
button { class: "primary", onclick: move |_| { modal_submit(()); }, "Confirm" }
|
||||
button { class: "ghost", onclick: move |_| { show_modal.set(false); }, "Cancel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ChatSection() -> Element {
|
||||
let chat = use_context::<Signal<ChatState>>();
|
||||
let session = use_context::<Signal<SessionState>>();
|
||||
let mut msg_input = use_signal(String::new);
|
||||
let mut show_ban_modal = use_signal(|| false);
|
||||
let mut ban_address = use_signal(String::new);
|
||||
let mut ban_error = use_signal(|| Option::<String>::None);
|
||||
|
||||
let send_msg = {
|
||||
move |_| {
|
||||
let text = msg_input.read().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(gid) = chat.read().opened_group.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
msg_input.set(String::new());
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::SendMessage {
|
||||
group_id: gid,
|
||||
body: text,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let open_ban_modal = {
|
||||
move |_| {
|
||||
if let Some(gid) = chat.read().opened_group.clone() {
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::GetGroupMembers {
|
||||
group_id: gid.clone(),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
ban_error.set(None);
|
||||
show_ban_modal.set(true);
|
||||
}
|
||||
};
|
||||
|
||||
let submit_ban_request = {
|
||||
move |_| {
|
||||
let raw = ban_address.read().to_string();
|
||||
let target = match normalize_wallet_address_str(&raw) {
|
||||
Ok(addr) => addr,
|
||||
Err(err) => {
|
||||
ban_error.set(Some(err.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let opened = chat.read().opened_group.clone();
|
||||
let Some(group_id) = opened else {
|
||||
return;
|
||||
};
|
||||
|
||||
ban_error.set(None);
|
||||
show_ban_modal.set(false);
|
||||
ban_address.set(String::new());
|
||||
|
||||
let addr_to_ban = target.clone();
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::SendBanRequest {
|
||||
group_id: group_id.clone(),
|
||||
user_to_ban: addr_to_ban,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let oninput_ban_address = {
|
||||
move |e: FormEvent| {
|
||||
ban_error.set(None);
|
||||
ban_address.set(e.value())
|
||||
}
|
||||
};
|
||||
|
||||
let close_ban_modal = {
|
||||
move || {
|
||||
ban_address.set(String::new());
|
||||
ban_error.set(None);
|
||||
show_ban_modal.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
let cancel_ban_modal = {
|
||||
move |_| {
|
||||
ban_address.set(String::new());
|
||||
ban_error.set(None);
|
||||
show_ban_modal.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
let msgs_for_group = {
|
||||
let opened = chat.read().opened_group.clone();
|
||||
chat.read()
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| Some(m.group_name.as_str()) == opened.as_deref())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let my_name = Arc::new(session.read().address.clone());
|
||||
let my_name_for_leave = my_name.clone();
|
||||
|
||||
let members_snapshot = chat.read().members.clone();
|
||||
let my_address = (*my_name).clone();
|
||||
let selectable_members: Vec<String> = members_snapshot
|
||||
.into_iter()
|
||||
.filter(|member| !member.eq_ignore_ascii_case(&my_address))
|
||||
.collect();
|
||||
|
||||
let pick_member_handler = {
|
||||
move |member: String| {
|
||||
move |_| {
|
||||
ban_error.set(None);
|
||||
ban_address.set(member.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "panel chat",
|
||||
div { class: "chat-header",
|
||||
h2 { "Chat" }
|
||||
if let Some(gid) = chat.read().opened_group.clone() {
|
||||
button {
|
||||
class: "ghost mini",
|
||||
onclick: move |_| {
|
||||
let group_id = gid.clone();
|
||||
let addr = my_name_for_leave.clone();
|
||||
// Send a self-ban (leave) request: requester filled by backend
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::SendBanRequest { group_id: group_id.clone(), user_to_ban: (*addr).clone() })
|
||||
.await;
|
||||
});
|
||||
},
|
||||
"Leave group"
|
||||
}
|
||||
button {
|
||||
class: "ghost mini",
|
||||
onclick: open_ban_modal,
|
||||
"Request ban"
|
||||
}
|
||||
}
|
||||
}
|
||||
if chat.read().opened_group.is_none() {
|
||||
div { class: "hint", "Pick a group to chat." }
|
||||
} else {
|
||||
div { class: "messages",
|
||||
for (i, m) in msgs_for_group.iter().enumerate() {
|
||||
if (*my_name).clone() == m.sender || m.sender.eq_ignore_ascii_case("me") {
|
||||
div { key: "{i}", class: "msg me",
|
||||
span { class: "from", "{m.sender}" }
|
||||
span { class: "body", "{String::from_utf8_lossy(&m.message)}" }
|
||||
}
|
||||
} else if m.sender.eq_ignore_ascii_case("system") {
|
||||
div { key: "{i}", class: "msg system",
|
||||
span { class: "body", "{String::from_utf8_lossy(&m.message)}" }
|
||||
}
|
||||
} else {
|
||||
div { key: "{i}", class: "msg",
|
||||
span { class: "from", "{m.sender}" }
|
||||
span { class: "body", "{String::from_utf8_lossy(&m.message)}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "composer",
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{msg_input}",
|
||||
oninput: move |e| msg_input.set(e.value()),
|
||||
placeholder: "Type a message…",
|
||||
}
|
||||
button { class: "primary", onclick: send_msg, "Send" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *show_ban_modal.read() {
|
||||
Modal {
|
||||
title: "Request user ban".to_string(),
|
||||
on_close: close_ban_modal,
|
||||
div { class: "form-row",
|
||||
label { "User address" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{ban_address}",
|
||||
oninput: oninput_ban_address,
|
||||
placeholder: "0x...",
|
||||
}
|
||||
if let Some(error) = &*ban_error.read() {
|
||||
span { class: "input-error", "{error}" }
|
||||
}
|
||||
}
|
||||
if selectable_members.is_empty() {
|
||||
div { class: "hint muted", "No members loaded yet." }
|
||||
} else {
|
||||
div { class: "member-picker",
|
||||
span { class: "helper", "Or pick a member:" }
|
||||
div { class: "member-list",
|
||||
for member in selectable_members.iter() {
|
||||
div {
|
||||
key: "{member}",
|
||||
class: "member-item",
|
||||
div { class: "member-actions",
|
||||
span { class: "member-id mono", "{member}" }
|
||||
button {
|
||||
class: "member-choose",
|
||||
onclick: pick_member_handler(member.clone()),
|
||||
"Choose"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "actions",
|
||||
button { class: "primary", onclick: submit_ban_request, "Submit" }
|
||||
button {
|
||||
class: "ghost",
|
||||
onclick: cancel_ban_modal,
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ConsensusSection() -> Element {
|
||||
let chat = use_context::<Signal<ChatState>>();
|
||||
let mut cons = use_context::<Signal<ConsensusState>>();
|
||||
|
||||
let vote_yes = {
|
||||
move |_| {
|
||||
let pending_proposal = cons.read().pending.clone();
|
||||
if let Some(v) = pending_proposal {
|
||||
// Clear the pending proposal immediately to close the vote window
|
||||
cons.write().pending = None;
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::Vote {
|
||||
group_id: v.group_id.clone(),
|
||||
proposal_id: v.proposal_id,
|
||||
choice: true,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
let vote_no = {
|
||||
move |_| {
|
||||
let pending_proposal = cons.read().pending.clone();
|
||||
if let Some(v) = pending_proposal {
|
||||
// Clear the pending proposal immediately to close the vote window
|
||||
cons.write().pending = None;
|
||||
spawn(async move {
|
||||
let _ = GATEWAY
|
||||
.send(AppCmd::Vote {
|
||||
group_id: v.group_id.clone(),
|
||||
proposal_id: v.proposal_id,
|
||||
choice: false,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let opened = chat.read().opened_group.clone();
|
||||
let pending = cons
|
||||
.read()
|
||||
.pending
|
||||
.clone()
|
||||
.filter(|p| Some(p.group_id.as_str()) == opened.as_deref());
|
||||
|
||||
rsx! {
|
||||
div { class: "panel consensus",
|
||||
h2 { "Consensus" }
|
||||
|
||||
if let Some(_group) = opened {
|
||||
// Steward status
|
||||
div { class: "status",
|
||||
span { class: "muted", "You are " }
|
||||
if cons.read().is_steward {
|
||||
span { class: "good", "a steward" }
|
||||
} else {
|
||||
span { class: "bad", "not a steward" }
|
||||
}
|
||||
}
|
||||
|
||||
// Pending Requests section
|
||||
div { class: "consensus-section",
|
||||
h3 { "Pending Requests" }
|
||||
if cons.read().is_steward && !cons.read().current_epoch_proposals.is_empty() {
|
||||
div { class: "proposals-window",
|
||||
for (action, address) in &cons.read().current_epoch_proposals {
|
||||
div { class: "proposal-item",
|
||||
span { class: "action", "{action}:" }
|
||||
span { class: "value", "{address}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "no-data", "No pending requests" }
|
||||
}
|
||||
}
|
||||
|
||||
// Proposal for Vote section
|
||||
div { class: "consensus-section",
|
||||
h3 { "Proposal for Vote" }
|
||||
if let Some(v) = pending {
|
||||
div { class: "proposals-window",
|
||||
div { class: "proposal-item proposal-id",
|
||||
span { class: "action", "Proposal ID:" }
|
||||
span { class: "value", "{v.proposal_id}" }
|
||||
}
|
||||
for (action, id) in convert_group_requests_to_display(&v.group_requests) {
|
||||
div { class: "proposal-item",
|
||||
span { class: "action", "{action}:" }
|
||||
span { class: "value", "{id}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "vote-actions",
|
||||
button { class: "primary", onclick: vote_yes, "YES" }
|
||||
button { class: "ghost", onclick: vote_no, "NO" }
|
||||
}
|
||||
} else {
|
||||
div { class: "no-data", "No proposal for vote" }
|
||||
}
|
||||
}
|
||||
|
||||
// Latest Decisions section
|
||||
div { class: "consensus-section",
|
||||
h3 { "Latest Decisions" }
|
||||
if cons.read().latest_results.is_empty() {
|
||||
div { class: "no-data", "No latest decisions" }
|
||||
} else {
|
||||
div { class: "results-window",
|
||||
for (vid, res, timestamp_ms) in cons.read().latest_results.iter().rev() {
|
||||
div { class: "result-item",
|
||||
span { class: "proposal-id", "{vid}" }
|
||||
span {
|
||||
class: match res {
|
||||
Outcome::Accepted => "outcome accepted",
|
||||
Outcome::Rejected => "outcome rejected",
|
||||
Outcome::Unspecified => "outcome unspecified",
|
||||
},
|
||||
match res {
|
||||
Outcome::Accepted => "Accepted",
|
||||
Outcome::Rejected => "Rejected",
|
||||
Outcome::Unspecified => "Unspecified",
|
||||
}
|
||||
}
|
||||
span { class: "timestamp",
|
||||
"{format_timestamp(*timestamp_ms)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "hint", "Open a group to see proposals & voting." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── Modal ───────────────────────────
|
||||
|
||||
#[derive(Props, PartialEq, Clone)]
|
||||
struct ModalProps {
|
||||
title: String,
|
||||
children: Element,
|
||||
on_close: EventHandler,
|
||||
}
|
||||
fn Modal(props: ModalProps) -> Element {
|
||||
rsx! {
|
||||
div { class: "modal-backdrop", onclick: move |_| (props.on_close)(()),
|
||||
div { class: "modal", onclick: move |e| e.stop_propagation(),
|
||||
div { class: "modal-head",
|
||||
h3 { "{props.title}" }
|
||||
button { class: "icon", onclick: move |_| (props.on_close)(()), "✕" }
|
||||
}
|
||||
div { class: "modal-body", {props.children} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
build.rs
Normal file
11
build.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
fn main() -> Result<(), std::io::Error> {
|
||||
prost_build::compile_protos(
|
||||
&[
|
||||
"src/protos/messages/v1/consensus.proto",
|
||||
"src/protos/messages/v1/welcome.proto",
|
||||
"src/protos/messages/v1/application.proto",
|
||||
],
|
||||
&["src/protos/"],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# EditorConfig http://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# All files
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.sol]
|
||||
indent_size = 4
|
||||
|
||||
[*.tree]
|
||||
indent_size = 1
|
||||
@@ -1,4 +0,0 @@
|
||||
export API_KEY_INFURA="YOUR_API_KEY_INFURA"
|
||||
export API_KEY_ETHERSCAN="YOUR_API_KEY_ETHERSCAN"
|
||||
export MNEMONIC="YOUR_MNEMONIC"
|
||||
export FOUNDRY_PROFILE="default"
|
||||
@@ -1,6 +0,0 @@
|
||||
ScKeystoreTest:test__addUser__addsUser__whenUserInfoIsValid() (gas: 106200)
|
||||
ScKeystoreTest:test__addUser__reverts__whenUserAlreadyExists() (gas: 110242)
|
||||
ScKeystoreTest:test__addUser__reverts__whenUserInfoIsMalformed() (gas: 8989)
|
||||
ScKeystoreTest:test__getAllKeyPackagesForUser__returnsKeyPackages__whenUserExists() (gas: 111031)
|
||||
ScKeystoreTest:test__getUser__returnsUserInfo__whenUserExists() (gas: 108923)
|
||||
ScKeystoreTest:test__userExists__returnsFalse__whenUserDoesNotExist() (gas: 7863)
|
||||
4
contracts/.gitmodules
vendored
4
contracts/.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "lib/forge-std"]
|
||||
branch = "v1"
|
||||
path = lib/forge-std
|
||||
url = https://github.com/foundry-rs/forge-std
|
||||
@@ -1,18 +0,0 @@
|
||||
# directories
|
||||
broadcast
|
||||
cache
|
||||
lib
|
||||
node_modules
|
||||
out
|
||||
|
||||
# files
|
||||
*.env
|
||||
*.log
|
||||
.DS_Store
|
||||
.pnp.*
|
||||
lcov.info
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
slither.config.json
|
||||
@@ -1,7 +0,0 @@
|
||||
bracketSpacing: true
|
||||
printWidth: 120
|
||||
proseWrap: "always"
|
||||
singleQuote: false
|
||||
tabWidth: 2
|
||||
trailingComma: "all"
|
||||
useTabs: false
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "solhint:recommended",
|
||||
"rules": {
|
||||
"code-complexity": ["error", 8],
|
||||
"compiler-version": ["error", ">=0.8.19"],
|
||||
"func-name-mixedcase": "off",
|
||||
"func-visibility": ["error", { "ignoreConstructors": true }],
|
||||
"max-line-length": ["error", 120],
|
||||
"named-parameters-mapping": "warn",
|
||||
"no-console": "off",
|
||||
"not-rely-on-time": "off"
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
# de-mls contracts
|
||||
|
||||
[gha]: https://github.com/vacp2p/de-mls/actions
|
||||
[gha-badge]: https://github.com/vacp2p/de-mls/actions/workflows/ci.yml/badge.svg
|
||||
[foundry]: https://getfoundry.sh/
|
||||
[foundry-badge]: https://img.shields.io/badge/Built%20with-Foundry-FFDB1C.svg
|
||||
[license]: https://opensource.org/licenses/MIT
|
||||
[license-badge]: https://img.shields.io/badge/License-MIT-blue.svg
|
||||
|
||||
## What's Inside
|
||||
|
||||
- [Forge](https://github.com/foundry-rs/foundry/blob/master/forge): compile, test, fuzz, format, and deploy smart
|
||||
contracts
|
||||
- [Forge Std](https://github.com/foundry-rs/forge-std): collection of helpful contracts and cheatcodes for testing
|
||||
- [Solhint Community](https://github.com/solhint-community/solhint-community): linter for Solidity code
|
||||
|
||||
## Features
|
||||
|
||||
This template builds upon the frameworks and libraries mentioned above, so for details about their specific features,
|
||||
please consult their respective documentation.
|
||||
|
||||
For example, if you're interested in exploring Foundry in more detail, you should look at the
|
||||
[Foundry Book](https://book.getfoundry.sh/). In particular, you may be interested in reading the
|
||||
[Writing Tests](https://book.getfoundry.sh/forge/writing-tests.html) tutorial.
|
||||
|
||||
## Usage
|
||||
|
||||
This is a list of the most frequently needed commands.
|
||||
|
||||
### Build
|
||||
|
||||
Build the contracts:
|
||||
|
||||
```sh
|
||||
$ forge build
|
||||
```
|
||||
|
||||
### Clean
|
||||
|
||||
Delete the build artifacts and cache directories:
|
||||
|
||||
```sh
|
||||
$ forge clean
|
||||
```
|
||||
|
||||
### Compile
|
||||
|
||||
Compile the contracts:
|
||||
|
||||
```sh
|
||||
$ forge build
|
||||
```
|
||||
|
||||
### Coverage
|
||||
|
||||
Get a test coverage report:
|
||||
|
||||
```sh
|
||||
$ forge coverage
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
Deploy to Anvil:
|
||||
|
||||
```sh
|
||||
$ forge script script/Deploy.s.sol --broadcast --fork-url http://localhost:8545
|
||||
```
|
||||
|
||||
For this script to work, you need to have a `MNEMONIC` environment variable set to a valid
|
||||
[BIP39 mnemonic](https://iancoleman.io/bip39/).
|
||||
|
||||
For instructions on how to deploy to a testnet or mainnet, check out the
|
||||
[Solidity Scripting](https://book.getfoundry.sh/tutorials/solidity-scripting.html) tutorial.
|
||||
|
||||
### Format
|
||||
|
||||
Format the contracts:
|
||||
|
||||
```sh
|
||||
$ forge fmt
|
||||
```
|
||||
|
||||
### Gas Usage
|
||||
|
||||
Get a gas report:
|
||||
|
||||
```sh
|
||||
$ forge test --gas-report
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
Lint the contracts:
|
||||
|
||||
```sh
|
||||
$ pnpm lint
|
||||
```
|
||||
|
||||
#### Fixing linting issues
|
||||
|
||||
For any errors in solidity files, run `forge fmt`. For errors in any other file type, run `pnpm prettier:write`.
|
||||
|
||||
### Test
|
||||
|
||||
Run the tests:
|
||||
|
||||
```sh
|
||||
$ forge test
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
1. Foundry uses [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) to manage dependencies. For
|
||||
detailed instructions on working with dependencies, please refer to the
|
||||
[guide](https://book.getfoundry.sh/projects/dependencies.html) in the book
|
||||
2. You don't have to create a `.env` file, but filling in the environment variables may be useful when debugging and
|
||||
testing against a fork.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under MIT.
|
||||
@@ -1,28 +0,0 @@
|
||||
codecov:
|
||||
require_ci_to_pass: false
|
||||
comment: false
|
||||
ignore:
|
||||
- "script"
|
||||
- "test"
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
# advanced settings
|
||||
|
||||
# Prevents PR from being blocked with a reduction in coverage.
|
||||
# Note, if we want to re-enable this, a `threshold` value can be used
|
||||
# allow coverage to drop by x% while still posting a success status.
|
||||
# `informational`: https://docs.codecov.com/docs/commit-status#informational
|
||||
# `threshold`: https://docs.codecov.com/docs/commit-status#threshold
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
# advanced settings
|
||||
|
||||
# Prevents PR from being blocked with a reduction in coverage.
|
||||
# Note, if we want to re-enable this, a `threshold` value can be used
|
||||
# allow coverage to drop by x% while still posting a success status.
|
||||
# `informational`: https://docs.codecov.com/docs/commit-status#informational
|
||||
# `threshold`: https://docs.codecov.com/docs/commit-status#threshold
|
||||
informational: true
|
||||
@@ -1,38 +0,0 @@
|
||||
# Full reference https://github.com/foundry-rs/foundry/tree/master/config
|
||||
|
||||
[profile.default]
|
||||
auto_detect_solc = false
|
||||
block_timestamp = 1_680_220_800 # March 31, 2023 at 00:00 GMT
|
||||
bytecode_hash = "none"
|
||||
cbor_metadata = false
|
||||
evm_version = "paris"
|
||||
fuzz = { runs = 1_000 }
|
||||
gas_reports = ["*"]
|
||||
libs = ["lib"]
|
||||
optimizer = true
|
||||
optimizer_runs = 10_000
|
||||
out = "out"
|
||||
script = "script"
|
||||
solc = "0.8.24"
|
||||
src = "src"
|
||||
test = "test"
|
||||
|
||||
[profile.ci]
|
||||
fuzz = { runs = 10_000 }
|
||||
verbosity = 4
|
||||
|
||||
[etherscan]
|
||||
sepolia = { key = "${API_KEY_ETHERSCAN}" }
|
||||
|
||||
[fmt]
|
||||
bracket_spacing = true
|
||||
int_types = "long"
|
||||
line_length = 120
|
||||
multiline_func_header = "all"
|
||||
number_underscore = "thousands"
|
||||
quote_style = "double"
|
||||
tab_width = 4
|
||||
wrap_comments = true
|
||||
|
||||
[rpc_endpoints]
|
||||
sepolia = "https://sepolia.infura.io/v3/${API_KEY_INFURA}"
|
||||
1
contracts/lib/forge-std
vendored
1
contracts/lib/forge-std
vendored
Submodule contracts/lib/forge-std deleted from 07263d193d
1
contracts/lib/openzeppelin-contracts
vendored
1
contracts/lib/openzeppelin-contracts
vendored
Submodule contracts/lib/openzeppelin-contracts deleted from e3786e63e6
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "@vacp2p/de-mls-contracts",
|
||||
"description": "Foundry-based contracts for de-mls",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"prettier": "^3.0.0",
|
||||
"solhint-community": "^3.6.0",
|
||||
"commit-and-tag-version": "^12.2.0"
|
||||
},
|
||||
"keywords": [
|
||||
"blockchain",
|
||||
"ethereum",
|
||||
"forge",
|
||||
"foundry",
|
||||
"smart-contracts",
|
||||
"solidity",
|
||||
"template"
|
||||
],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rm -rf cache out",
|
||||
"lint": "pnpm lint:sol && pnpm prettier:check",
|
||||
"verify": "certoraRun certora/certora.conf",
|
||||
"lint:sol": "forge fmt --check && pnpm solhint {script,src,test,certora}/**/*.sol",
|
||||
"prettier:check": "prettier --check **/*.{json,md,yml} --ignore-path=.prettierignore",
|
||||
"prettier:write": "prettier --write **/*.{json,md,yml} --ignore-path=.prettierignore",
|
||||
"gas-report": "forge test --gas-report 2>&1 | (tee /dev/tty | awk '/Test result:/ {found=1; buffer=\"\"; next} found && !/Ran/ {buffer=buffer $0 ORS} /Ran/ {found=0} END {printf \"%s\", buffer}' > .gas-report)",
|
||||
"release": "commit-and-tag-version",
|
||||
"adorno": "pnpm prettier:write && forge fmt && forge snapshot && pnpm gas-report"
|
||||
}
|
||||
}
|
||||
1894
contracts/pnpm-lock.yaml
generated
1894
contracts/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
forge-std/=lib/forge-std/src/
|
||||
Openzeppelin/=lib/openzeppelin-contracts/contracts
|
||||
@@ -1,41 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.19 <=0.9.0;
|
||||
|
||||
import { Script } from "forge-std/Script.sol";
|
||||
|
||||
abstract contract BaseScript is Script {
|
||||
/// @dev Included to enable compilation of the script without a $MNEMONIC environment variable.
|
||||
string internal constant TEST_MNEMONIC = "test test test test test test test test test test test junk";
|
||||
|
||||
/// @dev Needed for the deterministic deployments.
|
||||
bytes32 internal constant ZERO_SALT = bytes32(0);
|
||||
|
||||
/// @dev The address of the transaction broadcaster.
|
||||
address internal broadcaster;
|
||||
|
||||
/// @dev Used to derive the broadcaster's address if $ETH_FROM is not defined.
|
||||
string internal mnemonic;
|
||||
|
||||
/// @dev Initializes the transaction broadcaster like this:
|
||||
///
|
||||
/// - If $ETH_FROM is defined, use it.
|
||||
/// - Otherwise, derive the broadcaster address from $MNEMONIC.
|
||||
/// - If $MNEMONIC is not defined, default to a test mnemonic.
|
||||
///
|
||||
/// The use case for $ETH_FROM is to specify the broadcaster key and its address via the command line.
|
||||
constructor() {
|
||||
address from = vm.envOr({ name: "ETH_FROM", defaultValue: address(0) });
|
||||
if (from != address(0)) {
|
||||
broadcaster = from;
|
||||
} else {
|
||||
mnemonic = vm.envOr({ name: "MNEMONIC", defaultValue: TEST_MNEMONIC });
|
||||
(broadcaster,) = deriveRememberKey({ mnemonic: mnemonic, index: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
modifier broadcast() {
|
||||
vm.startBroadcast(broadcaster);
|
||||
_;
|
||||
vm.stopBroadcast();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// SPDX-License-Identifier: UNLICENSED
|
||||
pragma solidity >=0.8.19 <=0.9.0;
|
||||
|
||||
import { ScKeystore } from "../src/ScKeystore.sol";
|
||||
import { BaseScript } from "./Base.s.sol";
|
||||
import { DeploymentConfig } from "./DeploymentConfig.s.sol";
|
||||
|
||||
contract Deploy is BaseScript {
|
||||
function run(
|
||||
address initialOwner
|
||||
)
|
||||
public
|
||||
broadcast
|
||||
returns (ScKeystore scKeystore, DeploymentConfig deploymentConfig)
|
||||
{
|
||||
deploymentConfig = new DeploymentConfig(broadcaster);
|
||||
scKeystore = new ScKeystore(initialOwner);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//// SPDX-License-Identifier: UNLICENSED
|
||||
|
||||
pragma solidity >=0.8.19 <=0.9.0;
|
||||
|
||||
import { Script } from "forge-std/Script.sol";
|
||||
|
||||
contract DeploymentConfig is Script {
|
||||
error DeploymentConfig_InvalidDeployerAddress();
|
||||
error DeploymentConfig_NoConfigForChain(uint256);
|
||||
|
||||
struct NetworkConfig {
|
||||
address deployer;
|
||||
}
|
||||
|
||||
NetworkConfig public activeNetworkConfig;
|
||||
|
||||
address private deployer;
|
||||
|
||||
constructor(address _broadcaster) {
|
||||
if (_broadcaster == address(0)) revert DeploymentConfig_InvalidDeployerAddress();
|
||||
deployer = _broadcaster;
|
||||
if (block.chainid == 31_337) {
|
||||
activeNetworkConfig = getOrCreateAnvilEthConfig();
|
||||
} else {
|
||||
revert DeploymentConfig_NoConfigForChain(block.chainid);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateAnvilEthConfig() public view returns (NetworkConfig memory) {
|
||||
return NetworkConfig({ deployer: deployer });
|
||||
}
|
||||
|
||||
// This function is a hack to have it excluded by `forge coverage` until
|
||||
// https://github.com/foundry-rs/foundry/issues/2988 is fixed.
|
||||
// See: https://github.com/foundry-rs/foundry/issues/2988#issuecomment-1437784542
|
||||
// for more info.
|
||||
// solhint-disable-next-line
|
||||
function test() public { }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"detectors_to_exclude": "naming-convention,reentrancy-events,solc-version,timestamp",
|
||||
"filter_paths": "(lib|test)",
|
||||
"solc_remaps": [
|
||||
"@openzeppelin/contracts=lib/openzeppelin-contracts/contracts/",
|
||||
"forge-std/=lib/forge-std/src/"
|
||||
]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.24;
|
||||
|
||||
interface IScKeystore {
|
||||
function userExists(address user) external view returns (bool);
|
||||
|
||||
function addUser(address user) external;
|
||||
|
||||
function removeUser(address user) external;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: UNLICENSED
|
||||
pragma solidity 0.8.24;
|
||||
|
||||
import { Ownable } from "Openzeppelin/access/Ownable.sol";
|
||||
import { IScKeystore } from "./IScKeystore.sol";
|
||||
|
||||
error UserAlreadyExists();
|
||||
error UserDoesNotExist();
|
||||
|
||||
contract ScKeystore is Ownable, IScKeystore {
|
||||
event UserAdded(address user);
|
||||
event UserRemoved(address user);
|
||||
|
||||
mapping(address user => bool exists) private users;
|
||||
|
||||
constructor(address initialOwner) Ownable(initialOwner) { }
|
||||
|
||||
function userExists(address user) public view returns (bool) {
|
||||
return users[user];
|
||||
}
|
||||
|
||||
function addUser(address user) external onlyOwner {
|
||||
if (userExists(user)) revert UserAlreadyExists();
|
||||
|
||||
users[user] = true;
|
||||
|
||||
emit UserAdded(user);
|
||||
}
|
||||
|
||||
function removeUser(address user) external onlyOwner {
|
||||
if (!userExists(user)) revert UserDoesNotExist();
|
||||
users[user] == false;
|
||||
emit UserRemoved(user);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// SPDX-License-Identifier: UNLICENSED
|
||||
pragma solidity >=0.8.19 <0.9.0;
|
||||
|
||||
import { Test } from "forge-std/Test.sol";
|
||||
import { Deploy } from "../script/Deploy.s.sol";
|
||||
import { DeploymentConfig } from "../script/DeploymentConfig.s.sol";
|
||||
import "forge-std/console.sol";
|
||||
import "../src/ScKeystore.sol"; // solhint-disable-line
|
||||
|
||||
contract ScKeystoreTest is Test {
|
||||
ScKeystore internal s;
|
||||
DeploymentConfig internal deploymentConfig;
|
||||
address internal deployer;
|
||||
|
||||
function setUp() public virtual {
|
||||
Deploy deployment = new Deploy();
|
||||
(s, deploymentConfig) = deployment.run(address(this));
|
||||
}
|
||||
|
||||
function addUser() internal {
|
||||
s.addUser(address(this));
|
||||
}
|
||||
|
||||
function test__owner() public view {
|
||||
assert(s.owner() == address(this));
|
||||
}
|
||||
|
||||
function test__userExists__returnsFalse__whenUserDoesNotExist() public view {
|
||||
assert(!s.userExists(address(this)));
|
||||
}
|
||||
|
||||
function test__addUser__reverts__whenUserAlreadyExists() public {
|
||||
addUser();
|
||||
vm.expectRevert(UserAlreadyExists.selector);
|
||||
addUser();
|
||||
}
|
||||
|
||||
function test__addUser__addsUser__whenUserInfoIsValid() public {
|
||||
addUser();
|
||||
assert(s.userExists(address(this)));
|
||||
}
|
||||
|
||||
function test__addUser__reverts__whenSenderIsNotOwner() public {
|
||||
vm.prank(address(0));
|
||||
vm.expectRevert();
|
||||
addUser();
|
||||
vm.stopPrank();
|
||||
}
|
||||
|
||||
function test__removeUser__reverts__whenUserDoesNotExist() public {
|
||||
vm.expectRevert();
|
||||
s.removeUser(address(0));
|
||||
}
|
||||
|
||||
function test__removeUser() public {
|
||||
addUser();
|
||||
s.removeUser(address(this));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
[package]
|
||||
name = "foundry-contracts"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alloy = { git = "https://github.com/alloy-rs/alloy", features = ["sol-types", "contract"] }
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
|
||||
Generated by the following Solidity interface...
|
||||
```solidity
|
||||
interface Context {}
|
||||
```
|
||||
|
||||
...which was generated by the following JSON ABI:
|
||||
```json
|
||||
[]
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
pub mod Context {
|
||||
use super::*;
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
/// The creation / init bytecode of the contract.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
/// The runtime bytecode of the contract, as deployed on the network.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static DEPLOYED_BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
use alloy::contract as alloy_contract;
|
||||
/**Creates a new wrapper around an on-chain [`Context`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`ContextInstance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
) -> ContextInstance<T, P, N> {
|
||||
ContextInstance::<T, P, N>::new(address, provider)
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub fn deploy<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> impl ::core::future::Future<Output = alloy_contract::Result<ContextInstance<T, P, N>>>
|
||||
{
|
||||
ContextInstance::<T, P, N>::deploy(provider)
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
ContextInstance::<T, P, N>::deploy_builder(provider)
|
||||
}
|
||||
/**A [`Context`](self) instance.
|
||||
|
||||
Contains type-safe methods for interacting with an on-chain instance of the
|
||||
[`Context`](self) contract located at a given `address`, using a given
|
||||
provider `P`.
|
||||
|
||||
If the contract bytecode is available (see the [`sol!`](alloy_sol_types::sol!)
|
||||
documentation on how to provide it), the `deploy` and `deploy_builder` methods can
|
||||
be used to deploy a new instance of the contract.
|
||||
|
||||
See the [module-level documentation](self) for all the available methods.*/
|
||||
#[derive(Clone)]
|
||||
pub struct ContextInstance<T, P, N = alloy_contract::private::Ethereum> {
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
_network_transport: ::core::marker::PhantomData<(N, T)>,
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl<T, P, N> ::core::fmt::Debug for ContextInstance<T, P, N> {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
|
||||
f.debug_tuple("ContextInstance")
|
||||
.field(&self.address)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Instantiation and getters/setters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> ContextInstance<T, P, N>
|
||||
{
|
||||
/**Creates a new wrapper around an on-chain [`Context`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`ContextInstance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new(address: alloy_sol_types::private::Address, provider: P) -> Self {
|
||||
Self {
|
||||
address,
|
||||
provider,
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub async fn deploy(provider: P) -> alloy_contract::Result<ContextInstance<T, P, N>> {
|
||||
let call_builder = Self::deploy_builder(provider);
|
||||
let contract_address = call_builder.deploy().await?;
|
||||
Ok(Self::new(contract_address, call_builder.provider))
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder(provider: P) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
alloy_contract::RawCallBuilder::new_raw_deploy(
|
||||
provider,
|
||||
::core::clone::Clone::clone(&BYTECODE),
|
||||
)
|
||||
}
|
||||
/// Returns a reference to the address.
|
||||
#[inline]
|
||||
pub const fn address(&self) -> &alloy_sol_types::private::Address {
|
||||
&self.address
|
||||
}
|
||||
/// Sets the address.
|
||||
#[inline]
|
||||
pub fn set_address(&mut self, address: alloy_sol_types::private::Address) {
|
||||
self.address = address;
|
||||
}
|
||||
/// Sets the address and returns `self`.
|
||||
pub fn at(mut self, address: alloy_sol_types::private::Address) -> Self {
|
||||
self.set_address(address);
|
||||
self
|
||||
}
|
||||
/// Returns a reference to the provider.
|
||||
#[inline]
|
||||
pub const fn provider(&self) -> &P {
|
||||
&self.provider
|
||||
}
|
||||
}
|
||||
impl<T, P: ::core::clone::Clone, N> ContextInstance<T, &P, N> {
|
||||
/// Clones the provider and returns a new instance with the cloned provider.
|
||||
#[inline]
|
||||
pub fn with_cloned_provider(self) -> ContextInstance<T, P, N> {
|
||||
ContextInstance {
|
||||
address: self.address,
|
||||
provider: ::core::clone::Clone::clone(&self.provider),
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Function calls.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> ContextInstance<T, P, N>
|
||||
{
|
||||
/// Creates a new call builder using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the call can be any function call, not just those defined in this
|
||||
/// contract. Prefer using the other methods for building type-safe contract calls.
|
||||
pub fn call_builder<C: alloy_sol_types::SolCall>(
|
||||
&self,
|
||||
call: &C,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, C, N> {
|
||||
alloy_contract::SolCallBuilder::new_sol(&self.provider, &self.address, call)
|
||||
}
|
||||
}
|
||||
/// Event filters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> ContextInstance<T, P, N>
|
||||
{
|
||||
/// Creates a new event filter using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the type can be any event, not just those defined in this contract.
|
||||
/// Prefer using the other methods for building type-safe event filters.
|
||||
pub fn event_filter<E: alloy_sol_types::SolEvent>(
|
||||
&self,
|
||||
) -> alloy_contract::Event<T, &P, E, N> {
|
||||
alloy_contract::Event::new_sol(&self.provider, &self.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,440 +0,0 @@
|
||||
/**
|
||||
|
||||
Generated by the following Solidity interface...
|
||||
```solidity
|
||||
interface IERC165 {
|
||||
function supportsInterface(bytes4 interfaceID) external view returns (bool);
|
||||
}
|
||||
```
|
||||
|
||||
...which was generated by the following JSON ABI:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "supportsInterface",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "interfaceID",
|
||||
"type": "bytes4",
|
||||
"internalType": "bytes4"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
}
|
||||
]
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
pub mod IERC165 {
|
||||
use super::*;
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
/// The creation / init bytecode of the contract.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
/// The runtime bytecode of the contract, as deployed on the network.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static DEPLOYED_BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
/**Function with signature `supportsInterface(bytes4)` and selector `0x01ffc9a7`.
|
||||
```solidity
|
||||
function supportsInterface(bytes4 interfaceID) external view returns (bool);
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct supportsInterfaceCall {
|
||||
pub interfaceID: alloy::sol_types::private::FixedBytes<4>,
|
||||
}
|
||||
///Container type for the return parameters of the [`supportsInterface(bytes4)`](supportsInterfaceCall) function.
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct supportsInterfaceReturn {
|
||||
pub _0: bool,
|
||||
}
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
const _: () = {
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::FixedBytes<4>,);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (alloy::sol_types::private::FixedBytes<4>,);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<supportsInterfaceCall> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: supportsInterfaceCall) -> Self {
|
||||
(value.interfaceID,)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for supportsInterfaceCall {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self {
|
||||
interfaceID: tuple.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::Bool,);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (bool,);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<supportsInterfaceReturn> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: supportsInterfaceReturn) -> Self {
|
||||
(value._0,)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for supportsInterfaceReturn {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self { _0: tuple.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolCall for supportsInterfaceCall {
|
||||
type Parameters<'a> = (alloy::sol_types::sol_data::FixedBytes<4>,);
|
||||
type Token<'a> = <Self::Parameters<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
type Return = supportsInterfaceReturn;
|
||||
type ReturnTuple<'a> = (alloy::sol_types::sol_data::Bool,);
|
||||
type ReturnToken<'a> = <Self::ReturnTuple<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
const SIGNATURE: &'static str = "supportsInterface(bytes4)";
|
||||
const SELECTOR: [u8; 4] = [1u8, 255u8, 201u8, 167u8];
|
||||
#[inline]
|
||||
fn new<'a>(
|
||||
tuple: <Self::Parameters<'a> as alloy_sol_types::SolType>::RustType,
|
||||
) -> Self {
|
||||
tuple.into()
|
||||
}
|
||||
#[inline]
|
||||
fn tokenize(&self) -> Self::Token<'_> {
|
||||
(
|
||||
<alloy::sol_types::sol_data::FixedBytes<
|
||||
4,
|
||||
> as alloy_sol_types::SolType>::tokenize(&self.interfaceID),
|
||||
)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_decode_returns(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self::Return> {
|
||||
<Self::ReturnTuple<'_> as alloy_sol_types::SolType>::abi_decode_sequence(
|
||||
data, validate,
|
||||
)
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
};
|
||||
///Container for all the [`IERC165`](self) function calls.
|
||||
pub enum IERC165Calls {
|
||||
supportsInterface(supportsInterfaceCall),
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl IERC165Calls {
|
||||
/// All the selectors of this enum.
|
||||
///
|
||||
/// Note that the selectors might not be in the same order as the variants.
|
||||
/// No guarantees are made about the order of the selectors.
|
||||
///
|
||||
/// Prefer using `SolInterface` methods instead.
|
||||
pub const SELECTORS: &'static [[u8; 4usize]] = &[[1u8, 255u8, 201u8, 167u8]];
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolInterface for IERC165Calls {
|
||||
const NAME: &'static str = "IERC165Calls";
|
||||
const MIN_DATA_LENGTH: usize = 32usize;
|
||||
const COUNT: usize = 1usize;
|
||||
#[inline]
|
||||
fn selector(&self) -> [u8; 4] {
|
||||
match self {
|
||||
Self::supportsInterface(_) => {
|
||||
<supportsInterfaceCall as alloy_sol_types::SolCall>::SELECTOR
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn selector_at(i: usize) -> ::core::option::Option<[u8; 4]> {
|
||||
Self::SELECTORS.get(i).copied()
|
||||
}
|
||||
#[inline]
|
||||
fn valid_selector(selector: [u8; 4]) -> bool {
|
||||
Self::SELECTORS.binary_search(&selector).is_ok()
|
||||
}
|
||||
#[inline]
|
||||
#[allow(unsafe_code, non_snake_case)]
|
||||
fn abi_decode_raw(
|
||||
selector: [u8; 4],
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self> {
|
||||
static DECODE_SHIMS: &[fn(&[u8], bool) -> alloy_sol_types::Result<IERC165Calls>] = &[{
|
||||
fn supportsInterface(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<IERC165Calls> {
|
||||
<supportsInterfaceCall as alloy_sol_types::SolCall>::abi_decode_raw(
|
||||
data, validate,
|
||||
)
|
||||
.map(IERC165Calls::supportsInterface)
|
||||
}
|
||||
supportsInterface
|
||||
}];
|
||||
let Ok(idx) = Self::SELECTORS.binary_search(&selector) else {
|
||||
return Err(alloy_sol_types::Error::unknown_selector(
|
||||
<Self as alloy_sol_types::SolInterface>::NAME,
|
||||
selector,
|
||||
));
|
||||
};
|
||||
(unsafe { DECODE_SHIMS.get_unchecked(idx) })(data, validate)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_encoded_size(&self) -> usize {
|
||||
match self {
|
||||
Self::supportsInterface(inner) => {
|
||||
<supportsInterfaceCall as alloy_sol_types::SolCall>::abi_encoded_size(inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn abi_encode_raw(&self, out: &mut alloy_sol_types::private::Vec<u8>) {
|
||||
match self {
|
||||
Self::supportsInterface(inner) => {
|
||||
<supportsInterfaceCall as alloy_sol_types::SolCall>::abi_encode_raw(inner, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use alloy::contract as alloy_contract;
|
||||
/**Creates a new wrapper around an on-chain [`IERC165`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`IERC165Instance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
) -> IERC165Instance<T, P, N> {
|
||||
IERC165Instance::<T, P, N>::new(address, provider)
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub fn deploy<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> impl ::core::future::Future<Output = alloy_contract::Result<IERC165Instance<T, P, N>>>
|
||||
{
|
||||
IERC165Instance::<T, P, N>::deploy(provider)
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
IERC165Instance::<T, P, N>::deploy_builder(provider)
|
||||
}
|
||||
/**A [`IERC165`](self) instance.
|
||||
|
||||
Contains type-safe methods for interacting with an on-chain instance of the
|
||||
[`IERC165`](self) contract located at a given `address`, using a given
|
||||
provider `P`.
|
||||
|
||||
If the contract bytecode is available (see the [`sol!`](alloy_sol_types::sol!)
|
||||
documentation on how to provide it), the `deploy` and `deploy_builder` methods can
|
||||
be used to deploy a new instance of the contract.
|
||||
|
||||
See the [module-level documentation](self) for all the available methods.*/
|
||||
#[derive(Clone)]
|
||||
pub struct IERC165Instance<T, P, N = alloy_contract::private::Ethereum> {
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
_network_transport: ::core::marker::PhantomData<(N, T)>,
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl<T, P, N> ::core::fmt::Debug for IERC165Instance<T, P, N> {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
|
||||
f.debug_tuple("IERC165Instance")
|
||||
.field(&self.address)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Instantiation and getters/setters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IERC165Instance<T, P, N>
|
||||
{
|
||||
/**Creates a new wrapper around an on-chain [`IERC165`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`IERC165Instance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new(address: alloy_sol_types::private::Address, provider: P) -> Self {
|
||||
Self {
|
||||
address,
|
||||
provider,
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub async fn deploy(provider: P) -> alloy_contract::Result<IERC165Instance<T, P, N>> {
|
||||
let call_builder = Self::deploy_builder(provider);
|
||||
let contract_address = call_builder.deploy().await?;
|
||||
Ok(Self::new(contract_address, call_builder.provider))
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder(provider: P) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
alloy_contract::RawCallBuilder::new_raw_deploy(
|
||||
provider,
|
||||
::core::clone::Clone::clone(&BYTECODE),
|
||||
)
|
||||
}
|
||||
/// Returns a reference to the address.
|
||||
#[inline]
|
||||
pub const fn address(&self) -> &alloy_sol_types::private::Address {
|
||||
&self.address
|
||||
}
|
||||
/// Sets the address.
|
||||
#[inline]
|
||||
pub fn set_address(&mut self, address: alloy_sol_types::private::Address) {
|
||||
self.address = address;
|
||||
}
|
||||
/// Sets the address and returns `self`.
|
||||
pub fn at(mut self, address: alloy_sol_types::private::Address) -> Self {
|
||||
self.set_address(address);
|
||||
self
|
||||
}
|
||||
/// Returns a reference to the provider.
|
||||
#[inline]
|
||||
pub const fn provider(&self) -> &P {
|
||||
&self.provider
|
||||
}
|
||||
}
|
||||
impl<T, P: ::core::clone::Clone, N> IERC165Instance<T, &P, N> {
|
||||
/// Clones the provider and returns a new instance with the cloned provider.
|
||||
#[inline]
|
||||
pub fn with_cloned_provider(self) -> IERC165Instance<T, P, N> {
|
||||
IERC165Instance {
|
||||
address: self.address,
|
||||
provider: ::core::clone::Clone::clone(&self.provider),
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Function calls.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IERC165Instance<T, P, N>
|
||||
{
|
||||
/// Creates a new call builder using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the call can be any function call, not just those defined in this
|
||||
/// contract. Prefer using the other methods for building type-safe contract calls.
|
||||
pub fn call_builder<C: alloy_sol_types::SolCall>(
|
||||
&self,
|
||||
call: &C,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, C, N> {
|
||||
alloy_contract::SolCallBuilder::new_sol(&self.provider, &self.address, call)
|
||||
}
|
||||
///Creates a new call builder for the [`supportsInterface`] function.
|
||||
pub fn supportsInterface(
|
||||
&self,
|
||||
interfaceID: alloy::sol_types::private::FixedBytes<4>,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, supportsInterfaceCall, N> {
|
||||
self.call_builder(&supportsInterfaceCall { interfaceID })
|
||||
}
|
||||
}
|
||||
/// Event filters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IERC165Instance<T, P, N>
|
||||
{
|
||||
/// Creates a new event filter using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the type can be any event, not just those defined in this contract.
|
||||
/// Prefer using the other methods for building type-safe event filters.
|
||||
pub fn event_filter<E: alloy_sol_types::SolEvent>(
|
||||
&self,
|
||||
) -> alloy_contract::Event<T, &P, E, N> {
|
||||
alloy_contract::Event::new_sol(&self.provider, &self.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,500 +0,0 @@
|
||||
/**
|
||||
|
||||
Generated by the following Solidity interface...
|
||||
```solidity
|
||||
interface IERC721TokenReceiver {
|
||||
function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes memory _data) external returns (bytes4);
|
||||
}
|
||||
```
|
||||
|
||||
...which was generated by the following JSON ABI:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "onERC721Received",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "_operator",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "_from",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "_tokenId",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "_data",
|
||||
"type": "bytes",
|
||||
"internalType": "bytes"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "bytes4",
|
||||
"internalType": "bytes4"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
}
|
||||
]
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
pub mod IERC721TokenReceiver {
|
||||
use super::*;
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
/// The creation / init bytecode of the contract.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
/// The runtime bytecode of the contract, as deployed on the network.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static DEPLOYED_BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
/**Function with signature `onERC721Received(address,address,uint256,bytes)` and selector `0x150b7a02`.
|
||||
```solidity
|
||||
function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes memory _data) external returns (bytes4);
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct onERC721ReceivedCall {
|
||||
pub _operator: alloy::sol_types::private::Address,
|
||||
pub _from: alloy::sol_types::private::Address,
|
||||
pub _tokenId: alloy::sol_types::private::U256,
|
||||
pub _data: alloy::sol_types::private::Bytes,
|
||||
}
|
||||
///Container type for the return parameters of the [`onERC721Received(address,address,uint256,bytes)`](onERC721ReceivedCall) function.
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct onERC721ReceivedReturn {
|
||||
pub _0: alloy::sol_types::private::FixedBytes<4>,
|
||||
}
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
const _: () = {
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (
|
||||
alloy::sol_types::sol_data::Address,
|
||||
alloy::sol_types::sol_data::Address,
|
||||
alloy::sol_types::sol_data::Uint<256>,
|
||||
alloy::sol_types::sol_data::Bytes,
|
||||
);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (
|
||||
alloy::sol_types::private::Address,
|
||||
alloy::sol_types::private::Address,
|
||||
alloy::sol_types::private::U256,
|
||||
alloy::sol_types::private::Bytes,
|
||||
);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<onERC721ReceivedCall> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: onERC721ReceivedCall) -> Self {
|
||||
(value._operator, value._from, value._tokenId, value._data)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for onERC721ReceivedCall {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self {
|
||||
_operator: tuple.0,
|
||||
_from: tuple.1,
|
||||
_tokenId: tuple.2,
|
||||
_data: tuple.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::FixedBytes<4>,);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (alloy::sol_types::private::FixedBytes<4>,);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<onERC721ReceivedReturn> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: onERC721ReceivedReturn) -> Self {
|
||||
(value._0,)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for onERC721ReceivedReturn {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self { _0: tuple.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolCall for onERC721ReceivedCall {
|
||||
type Parameters<'a> = (
|
||||
alloy::sol_types::sol_data::Address,
|
||||
alloy::sol_types::sol_data::Address,
|
||||
alloy::sol_types::sol_data::Uint<256>,
|
||||
alloy::sol_types::sol_data::Bytes,
|
||||
);
|
||||
type Token<'a> = <Self::Parameters<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
type Return = onERC721ReceivedReturn;
|
||||
type ReturnTuple<'a> = (alloy::sol_types::sol_data::FixedBytes<4>,);
|
||||
type ReturnToken<'a> = <Self::ReturnTuple<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
const SIGNATURE: &'static str = "onERC721Received(address,address,uint256,bytes)";
|
||||
const SELECTOR: [u8; 4] = [21u8, 11u8, 122u8, 2u8];
|
||||
#[inline]
|
||||
fn new<'a>(
|
||||
tuple: <Self::Parameters<'a> as alloy_sol_types::SolType>::RustType,
|
||||
) -> Self {
|
||||
tuple.into()
|
||||
}
|
||||
#[inline]
|
||||
fn tokenize(&self) -> Self::Token<'_> {
|
||||
(
|
||||
<alloy::sol_types::sol_data::Address as alloy_sol_types::SolType>::tokenize(
|
||||
&self._operator,
|
||||
),
|
||||
<alloy::sol_types::sol_data::Address as alloy_sol_types::SolType>::tokenize(
|
||||
&self._from,
|
||||
),
|
||||
<alloy::sol_types::sol_data::Uint<256> as alloy_sol_types::SolType>::tokenize(
|
||||
&self._tokenId,
|
||||
),
|
||||
<alloy::sol_types::sol_data::Bytes as alloy_sol_types::SolType>::tokenize(
|
||||
&self._data,
|
||||
),
|
||||
)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_decode_returns(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self::Return> {
|
||||
<Self::ReturnTuple<'_> as alloy_sol_types::SolType>::abi_decode_sequence(
|
||||
data, validate,
|
||||
)
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
};
|
||||
///Container for all the [`IERC721TokenReceiver`](self) function calls.
|
||||
pub enum IERC721TokenReceiverCalls {
|
||||
onERC721Received(onERC721ReceivedCall),
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl IERC721TokenReceiverCalls {
|
||||
/// All the selectors of this enum.
|
||||
///
|
||||
/// Note that the selectors might not be in the same order as the variants.
|
||||
/// No guarantees are made about the order of the selectors.
|
||||
///
|
||||
/// Prefer using `SolInterface` methods instead.
|
||||
pub const SELECTORS: &'static [[u8; 4usize]] = &[[21u8, 11u8, 122u8, 2u8]];
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolInterface for IERC721TokenReceiverCalls {
|
||||
const NAME: &'static str = "IERC721TokenReceiverCalls";
|
||||
const MIN_DATA_LENGTH: usize = 160usize;
|
||||
const COUNT: usize = 1usize;
|
||||
#[inline]
|
||||
fn selector(&self) -> [u8; 4] {
|
||||
match self {
|
||||
Self::onERC721Received(_) => {
|
||||
<onERC721ReceivedCall as alloy_sol_types::SolCall>::SELECTOR
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn selector_at(i: usize) -> ::core::option::Option<[u8; 4]> {
|
||||
Self::SELECTORS.get(i).copied()
|
||||
}
|
||||
#[inline]
|
||||
fn valid_selector(selector: [u8; 4]) -> bool {
|
||||
Self::SELECTORS.binary_search(&selector).is_ok()
|
||||
}
|
||||
#[inline]
|
||||
#[allow(unsafe_code, non_snake_case)]
|
||||
fn abi_decode_raw(
|
||||
selector: [u8; 4],
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self> {
|
||||
static DECODE_SHIMS: &[fn(
|
||||
&[u8],
|
||||
bool,
|
||||
)
|
||||
-> alloy_sol_types::Result<IERC721TokenReceiverCalls>] = &[{
|
||||
fn onERC721Received(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<IERC721TokenReceiverCalls> {
|
||||
<onERC721ReceivedCall as alloy_sol_types::SolCall>::abi_decode_raw(
|
||||
data, validate,
|
||||
)
|
||||
.map(IERC721TokenReceiverCalls::onERC721Received)
|
||||
}
|
||||
onERC721Received
|
||||
}];
|
||||
let Ok(idx) = Self::SELECTORS.binary_search(&selector) else {
|
||||
return Err(alloy_sol_types::Error::unknown_selector(
|
||||
<Self as alloy_sol_types::SolInterface>::NAME,
|
||||
selector,
|
||||
));
|
||||
};
|
||||
(unsafe { DECODE_SHIMS.get_unchecked(idx) })(data, validate)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_encoded_size(&self) -> usize {
|
||||
match self {
|
||||
Self::onERC721Received(inner) => {
|
||||
<onERC721ReceivedCall as alloy_sol_types::SolCall>::abi_encoded_size(inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn abi_encode_raw(&self, out: &mut alloy_sol_types::private::Vec<u8>) {
|
||||
match self {
|
||||
Self::onERC721Received(inner) => {
|
||||
<onERC721ReceivedCall as alloy_sol_types::SolCall>::abi_encode_raw(inner, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use alloy::contract as alloy_contract;
|
||||
/**Creates a new wrapper around an on-chain [`IERC721TokenReceiver`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`IERC721TokenReceiverInstance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
) -> IERC721TokenReceiverInstance<T, P, N> {
|
||||
IERC721TokenReceiverInstance::<T, P, N>::new(address, provider)
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub fn deploy<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> impl ::core::future::Future<
|
||||
Output = alloy_contract::Result<IERC721TokenReceiverInstance<T, P, N>>,
|
||||
> {
|
||||
IERC721TokenReceiverInstance::<T, P, N>::deploy(provider)
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
IERC721TokenReceiverInstance::<T, P, N>::deploy_builder(provider)
|
||||
}
|
||||
/**A [`IERC721TokenReceiver`](self) instance.
|
||||
|
||||
Contains type-safe methods for interacting with an on-chain instance of the
|
||||
[`IERC721TokenReceiver`](self) contract located at a given `address`, using a given
|
||||
provider `P`.
|
||||
|
||||
If the contract bytecode is available (see the [`sol!`](alloy_sol_types::sol!)
|
||||
documentation on how to provide it), the `deploy` and `deploy_builder` methods can
|
||||
be used to deploy a new instance of the contract.
|
||||
|
||||
See the [module-level documentation](self) for all the available methods.*/
|
||||
#[derive(Clone)]
|
||||
pub struct IERC721TokenReceiverInstance<T, P, N = alloy_contract::private::Ethereum> {
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
_network_transport: ::core::marker::PhantomData<(N, T)>,
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl<T, P, N> ::core::fmt::Debug for IERC721TokenReceiverInstance<T, P, N> {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
|
||||
f.debug_tuple("IERC721TokenReceiverInstance")
|
||||
.field(&self.address)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Instantiation and getters/setters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IERC721TokenReceiverInstance<T, P, N>
|
||||
{
|
||||
/**Creates a new wrapper around an on-chain [`IERC721TokenReceiver`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`IERC721TokenReceiverInstance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new(address: alloy_sol_types::private::Address, provider: P) -> Self {
|
||||
Self {
|
||||
address,
|
||||
provider,
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub async fn deploy(
|
||||
provider: P,
|
||||
) -> alloy_contract::Result<IERC721TokenReceiverInstance<T, P, N>> {
|
||||
let call_builder = Self::deploy_builder(provider);
|
||||
let contract_address = call_builder.deploy().await?;
|
||||
Ok(Self::new(contract_address, call_builder.provider))
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder(provider: P) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
alloy_contract::RawCallBuilder::new_raw_deploy(
|
||||
provider,
|
||||
::core::clone::Clone::clone(&BYTECODE),
|
||||
)
|
||||
}
|
||||
/// Returns a reference to the address.
|
||||
#[inline]
|
||||
pub const fn address(&self) -> &alloy_sol_types::private::Address {
|
||||
&self.address
|
||||
}
|
||||
/// Sets the address.
|
||||
#[inline]
|
||||
pub fn set_address(&mut self, address: alloy_sol_types::private::Address) {
|
||||
self.address = address;
|
||||
}
|
||||
/// Sets the address and returns `self`.
|
||||
pub fn at(mut self, address: alloy_sol_types::private::Address) -> Self {
|
||||
self.set_address(address);
|
||||
self
|
||||
}
|
||||
/// Returns a reference to the provider.
|
||||
#[inline]
|
||||
pub const fn provider(&self) -> &P {
|
||||
&self.provider
|
||||
}
|
||||
}
|
||||
impl<T, P: ::core::clone::Clone, N> IERC721TokenReceiverInstance<T, &P, N> {
|
||||
/// Clones the provider and returns a new instance with the cloned provider.
|
||||
#[inline]
|
||||
pub fn with_cloned_provider(self) -> IERC721TokenReceiverInstance<T, P, N> {
|
||||
IERC721TokenReceiverInstance {
|
||||
address: self.address,
|
||||
provider: ::core::clone::Clone::clone(&self.provider),
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Function calls.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IERC721TokenReceiverInstance<T, P, N>
|
||||
{
|
||||
/// Creates a new call builder using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the call can be any function call, not just those defined in this
|
||||
/// contract. Prefer using the other methods for building type-safe contract calls.
|
||||
pub fn call_builder<C: alloy_sol_types::SolCall>(
|
||||
&self,
|
||||
call: &C,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, C, N> {
|
||||
alloy_contract::SolCallBuilder::new_sol(&self.provider, &self.address, call)
|
||||
}
|
||||
///Creates a new call builder for the [`onERC721Received`] function.
|
||||
pub fn onERC721Received(
|
||||
&self,
|
||||
_operator: alloy::sol_types::private::Address,
|
||||
_from: alloy::sol_types::private::Address,
|
||||
_tokenId: alloy::sol_types::private::U256,
|
||||
_data: alloy::sol_types::private::Bytes,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, onERC721ReceivedCall, N> {
|
||||
self.call_builder(&onERC721ReceivedCall {
|
||||
_operator,
|
||||
_from,
|
||||
_tokenId,
|
||||
_data,
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Event filters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IERC721TokenReceiverInstance<T, P, N>
|
||||
{
|
||||
/// Creates a new event filter using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the type can be any event, not just those defined in this contract.
|
||||
/// Prefer using the other methods for building type-safe event filters.
|
||||
pub fn event_filter<E: alloy_sol_types::SolEvent>(
|
||||
&self,
|
||||
) -> alloy_contract::Event<T, &P, E, N> {
|
||||
alloy_contract::Event::new_sol(&self.provider, &self.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
/**
|
||||
|
||||
Generated by the following Solidity interface...
|
||||
```solidity
|
||||
interface IScKeystore {
|
||||
function addUser(address user) external;
|
||||
function removeUser(address user) external;
|
||||
function userExists(address user) external view returns (bool);
|
||||
}
|
||||
```
|
||||
|
||||
...which was generated by the following JSON ABI:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "addUser",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "removeUser",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "userExists",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
}
|
||||
]
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
pub mod IScKeystore {
|
||||
use super::*;
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
/// The creation / init bytecode of the contract.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
/// The runtime bytecode of the contract, as deployed on the network.
|
||||
///
|
||||
/// ```text
|
||||
///0x
|
||||
/// ```
|
||||
#[rustfmt::skip]
|
||||
#[allow(clippy::all)]
|
||||
pub static DEPLOYED_BYTECODE: alloy_sol_types::private::Bytes = alloy_sol_types::private::Bytes::from_static(
|
||||
b"",
|
||||
);
|
||||
/**Function with signature `addUser(address)` and selector `0x421b2d8b`.
|
||||
```solidity
|
||||
function addUser(address user) external;
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct addUserCall {
|
||||
pub user: alloy::sol_types::private::Address,
|
||||
}
|
||||
///Container type for the return parameters of the [`addUser(address)`](addUserCall) function.
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct addUserReturn {}
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
const _: () = {
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::Address,);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (alloy::sol_types::private::Address,);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<addUserCall> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: addUserCall) -> Self {
|
||||
(value.user,)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for addUserCall {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self { user: tuple.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = ();
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = ();
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<addUserReturn> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: addUserReturn) -> Self {
|
||||
()
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for addUserReturn {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolCall for addUserCall {
|
||||
type Parameters<'a> = (alloy::sol_types::sol_data::Address,);
|
||||
type Token<'a> = <Self::Parameters<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
type Return = addUserReturn;
|
||||
type ReturnTuple<'a> = ();
|
||||
type ReturnToken<'a> = <Self::ReturnTuple<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
const SIGNATURE: &'static str = "addUser(address)";
|
||||
const SELECTOR: [u8; 4] = [66u8, 27u8, 45u8, 139u8];
|
||||
#[inline]
|
||||
fn new<'a>(
|
||||
tuple: <Self::Parameters<'a> as alloy_sol_types::SolType>::RustType,
|
||||
) -> Self {
|
||||
tuple.into()
|
||||
}
|
||||
#[inline]
|
||||
fn tokenize(&self) -> Self::Token<'_> {
|
||||
(
|
||||
<alloy::sol_types::sol_data::Address as alloy_sol_types::SolType>::tokenize(
|
||||
&self.user,
|
||||
),
|
||||
)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_decode_returns(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self::Return> {
|
||||
<Self::ReturnTuple<'_> as alloy_sol_types::SolType>::abi_decode_sequence(
|
||||
data, validate,
|
||||
)
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
};
|
||||
/**Function with signature `removeUser(address)` and selector `0x98575188`.
|
||||
```solidity
|
||||
function removeUser(address user) external;
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct removeUserCall {
|
||||
pub user: alloy::sol_types::private::Address,
|
||||
}
|
||||
///Container type for the return parameters of the [`removeUser(address)`](removeUserCall) function.
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct removeUserReturn {}
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
const _: () = {
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::Address,);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (alloy::sol_types::private::Address,);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<removeUserCall> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: removeUserCall) -> Self {
|
||||
(value.user,)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for removeUserCall {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self { user: tuple.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = ();
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = ();
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<removeUserReturn> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: removeUserReturn) -> Self {
|
||||
()
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for removeUserReturn {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolCall for removeUserCall {
|
||||
type Parameters<'a> = (alloy::sol_types::sol_data::Address,);
|
||||
type Token<'a> = <Self::Parameters<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
type Return = removeUserReturn;
|
||||
type ReturnTuple<'a> = ();
|
||||
type ReturnToken<'a> = <Self::ReturnTuple<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
const SIGNATURE: &'static str = "removeUser(address)";
|
||||
const SELECTOR: [u8; 4] = [152u8, 87u8, 81u8, 136u8];
|
||||
#[inline]
|
||||
fn new<'a>(
|
||||
tuple: <Self::Parameters<'a> as alloy_sol_types::SolType>::RustType,
|
||||
) -> Self {
|
||||
tuple.into()
|
||||
}
|
||||
#[inline]
|
||||
fn tokenize(&self) -> Self::Token<'_> {
|
||||
(
|
||||
<alloy::sol_types::sol_data::Address as alloy_sol_types::SolType>::tokenize(
|
||||
&self.user,
|
||||
),
|
||||
)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_decode_returns(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self::Return> {
|
||||
<Self::ReturnTuple<'_> as alloy_sol_types::SolType>::abi_decode_sequence(
|
||||
data, validate,
|
||||
)
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
};
|
||||
/**Function with signature `userExists(address)` and selector `0x0e666e49`.
|
||||
```solidity
|
||||
function userExists(address user) external view returns (bool);
|
||||
```*/
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct userExistsCall {
|
||||
pub user: alloy::sol_types::private::Address,
|
||||
}
|
||||
///Container type for the return parameters of the [`userExists(address)`](userExistsCall) function.
|
||||
#[allow(non_camel_case_types, non_snake_case)]
|
||||
#[derive(Clone)]
|
||||
pub struct userExistsReturn {
|
||||
pub _0: bool,
|
||||
}
|
||||
#[allow(non_camel_case_types, non_snake_case, clippy::style)]
|
||||
const _: () = {
|
||||
use alloy::sol_types as alloy_sol_types;
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::Address,);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (alloy::sol_types::private::Address,);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<userExistsCall> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: userExistsCall) -> Self {
|
||||
(value.user,)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for userExistsCall {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self { user: tuple.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
#[doc(hidden)]
|
||||
type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::Bool,);
|
||||
#[doc(hidden)]
|
||||
type UnderlyingRustTuple<'a> = (bool,);
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code, unreachable_patterns)]
|
||||
fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq<UnderlyingRustTuple>) {
|
||||
match _t {
|
||||
alloy_sol_types::private::AssertTypeEq::<
|
||||
<UnderlyingSolTuple as alloy_sol_types::SolType>::RustType,
|
||||
>(_) => {}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<userExistsReturn> for UnderlyingRustTuple<'_> {
|
||||
fn from(value: userExistsReturn) -> Self {
|
||||
(value._0,)
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
#[doc(hidden)]
|
||||
impl ::core::convert::From<UnderlyingRustTuple<'_>> for userExistsReturn {
|
||||
fn from(tuple: UnderlyingRustTuple<'_>) -> Self {
|
||||
Self { _0: tuple.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolCall for userExistsCall {
|
||||
type Parameters<'a> = (alloy::sol_types::sol_data::Address,);
|
||||
type Token<'a> = <Self::Parameters<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
type Return = userExistsReturn;
|
||||
type ReturnTuple<'a> = (alloy::sol_types::sol_data::Bool,);
|
||||
type ReturnToken<'a> = <Self::ReturnTuple<'a> as alloy_sol_types::SolType>::Token<'a>;
|
||||
const SIGNATURE: &'static str = "userExists(address)";
|
||||
const SELECTOR: [u8; 4] = [14u8, 102u8, 110u8, 73u8];
|
||||
#[inline]
|
||||
fn new<'a>(
|
||||
tuple: <Self::Parameters<'a> as alloy_sol_types::SolType>::RustType,
|
||||
) -> Self {
|
||||
tuple.into()
|
||||
}
|
||||
#[inline]
|
||||
fn tokenize(&self) -> Self::Token<'_> {
|
||||
(
|
||||
<alloy::sol_types::sol_data::Address as alloy_sol_types::SolType>::tokenize(
|
||||
&self.user,
|
||||
),
|
||||
)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_decode_returns(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self::Return> {
|
||||
<Self::ReturnTuple<'_> as alloy_sol_types::SolType>::abi_decode_sequence(
|
||||
data, validate,
|
||||
)
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
};
|
||||
///Container for all the [`IScKeystore`](self) function calls.
|
||||
pub enum IScKeystoreCalls {
|
||||
addUser(addUserCall),
|
||||
removeUser(removeUserCall),
|
||||
userExists(userExistsCall),
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl IScKeystoreCalls {
|
||||
/// All the selectors of this enum.
|
||||
///
|
||||
/// Note that the selectors might not be in the same order as the variants.
|
||||
/// No guarantees are made about the order of the selectors.
|
||||
///
|
||||
/// Prefer using `SolInterface` methods instead.
|
||||
pub const SELECTORS: &'static [[u8; 4usize]] = &[
|
||||
[14u8, 102u8, 110u8, 73u8],
|
||||
[66u8, 27u8, 45u8, 139u8],
|
||||
[152u8, 87u8, 81u8, 136u8],
|
||||
];
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl alloy_sol_types::SolInterface for IScKeystoreCalls {
|
||||
const NAME: &'static str = "IScKeystoreCalls";
|
||||
const MIN_DATA_LENGTH: usize = 32usize;
|
||||
const COUNT: usize = 3usize;
|
||||
#[inline]
|
||||
fn selector(&self) -> [u8; 4] {
|
||||
match self {
|
||||
Self::addUser(_) => <addUserCall as alloy_sol_types::SolCall>::SELECTOR,
|
||||
Self::removeUser(_) => <removeUserCall as alloy_sol_types::SolCall>::SELECTOR,
|
||||
Self::userExists(_) => <userExistsCall as alloy_sol_types::SolCall>::SELECTOR,
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn selector_at(i: usize) -> ::core::option::Option<[u8; 4]> {
|
||||
Self::SELECTORS.get(i).copied()
|
||||
}
|
||||
#[inline]
|
||||
fn valid_selector(selector: [u8; 4]) -> bool {
|
||||
Self::SELECTORS.binary_search(&selector).is_ok()
|
||||
}
|
||||
#[inline]
|
||||
#[allow(unsafe_code, non_snake_case)]
|
||||
fn abi_decode_raw(
|
||||
selector: [u8; 4],
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<Self> {
|
||||
static DECODE_SHIMS: &[fn(&[u8], bool) -> alloy_sol_types::Result<IScKeystoreCalls>] =
|
||||
&[
|
||||
{
|
||||
fn userExists(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<IScKeystoreCalls> {
|
||||
<userExistsCall as alloy_sol_types::SolCall>::abi_decode_raw(
|
||||
data, validate,
|
||||
)
|
||||
.map(IScKeystoreCalls::userExists)
|
||||
}
|
||||
userExists
|
||||
},
|
||||
{
|
||||
fn addUser(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<IScKeystoreCalls> {
|
||||
<addUserCall as alloy_sol_types::SolCall>::abi_decode_raw(
|
||||
data, validate,
|
||||
)
|
||||
.map(IScKeystoreCalls::addUser)
|
||||
}
|
||||
addUser
|
||||
},
|
||||
{
|
||||
fn removeUser(
|
||||
data: &[u8],
|
||||
validate: bool,
|
||||
) -> alloy_sol_types::Result<IScKeystoreCalls> {
|
||||
<removeUserCall as alloy_sol_types::SolCall>::abi_decode_raw(
|
||||
data, validate,
|
||||
)
|
||||
.map(IScKeystoreCalls::removeUser)
|
||||
}
|
||||
removeUser
|
||||
},
|
||||
];
|
||||
let Ok(idx) = Self::SELECTORS.binary_search(&selector) else {
|
||||
return Err(alloy_sol_types::Error::unknown_selector(
|
||||
<Self as alloy_sol_types::SolInterface>::NAME,
|
||||
selector,
|
||||
));
|
||||
};
|
||||
(unsafe { DECODE_SHIMS.get_unchecked(idx) })(data, validate)
|
||||
}
|
||||
#[inline]
|
||||
fn abi_encoded_size(&self) -> usize {
|
||||
match self {
|
||||
Self::addUser(inner) => {
|
||||
<addUserCall as alloy_sol_types::SolCall>::abi_encoded_size(inner)
|
||||
}
|
||||
Self::removeUser(inner) => {
|
||||
<removeUserCall as alloy_sol_types::SolCall>::abi_encoded_size(inner)
|
||||
}
|
||||
Self::userExists(inner) => {
|
||||
<userExistsCall as alloy_sol_types::SolCall>::abi_encoded_size(inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn abi_encode_raw(&self, out: &mut alloy_sol_types::private::Vec<u8>) {
|
||||
match self {
|
||||
Self::addUser(inner) => {
|
||||
<addUserCall as alloy_sol_types::SolCall>::abi_encode_raw(inner, out)
|
||||
}
|
||||
Self::removeUser(inner) => {
|
||||
<removeUserCall as alloy_sol_types::SolCall>::abi_encode_raw(inner, out)
|
||||
}
|
||||
Self::userExists(inner) => {
|
||||
<userExistsCall as alloy_sol_types::SolCall>::abi_encode_raw(inner, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use alloy::contract as alloy_contract;
|
||||
/**Creates a new wrapper around an on-chain [`IScKeystore`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`IScKeystoreInstance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
) -> IScKeystoreInstance<T, P, N> {
|
||||
IScKeystoreInstance::<T, P, N>::new(address, provider)
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub fn deploy<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> impl ::core::future::Future<Output = alloy_contract::Result<IScKeystoreInstance<T, P, N>>>
|
||||
{
|
||||
IScKeystoreInstance::<T, P, N>::deploy(provider)
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
>(
|
||||
provider: P,
|
||||
) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
IScKeystoreInstance::<T, P, N>::deploy_builder(provider)
|
||||
}
|
||||
/**A [`IScKeystore`](self) instance.
|
||||
|
||||
Contains type-safe methods for interacting with an on-chain instance of the
|
||||
[`IScKeystore`](self) contract located at a given `address`, using a given
|
||||
provider `P`.
|
||||
|
||||
If the contract bytecode is available (see the [`sol!`](alloy_sol_types::sol!)
|
||||
documentation on how to provide it), the `deploy` and `deploy_builder` methods can
|
||||
be used to deploy a new instance of the contract.
|
||||
|
||||
See the [module-level documentation](self) for all the available methods.*/
|
||||
#[derive(Clone)]
|
||||
pub struct IScKeystoreInstance<T, P, N = alloy_contract::private::Ethereum> {
|
||||
address: alloy_sol_types::private::Address,
|
||||
provider: P,
|
||||
_network_transport: ::core::marker::PhantomData<(N, T)>,
|
||||
}
|
||||
#[automatically_derived]
|
||||
impl<T, P, N> ::core::fmt::Debug for IScKeystoreInstance<T, P, N> {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
|
||||
f.debug_tuple("IScKeystoreInstance")
|
||||
.field(&self.address)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
/// Instantiation and getters/setters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IScKeystoreInstance<T, P, N>
|
||||
{
|
||||
/**Creates a new wrapper around an on-chain [`IScKeystore`](self) contract instance.
|
||||
|
||||
See the [wrapper's documentation](`IScKeystoreInstance`) for more details.*/
|
||||
#[inline]
|
||||
pub const fn new(address: alloy_sol_types::private::Address, provider: P) -> Self {
|
||||
Self {
|
||||
address,
|
||||
provider,
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
/**Deploys this contract using the given `provider` and constructor arguments, if any.
|
||||
|
||||
Returns a new instance of the contract, if the deployment was successful.
|
||||
|
||||
For more fine-grained control over the deployment process, use [`deploy_builder`] instead.*/
|
||||
#[inline]
|
||||
pub async fn deploy(provider: P) -> alloy_contract::Result<IScKeystoreInstance<T, P, N>> {
|
||||
let call_builder = Self::deploy_builder(provider);
|
||||
let contract_address = call_builder.deploy().await?;
|
||||
Ok(Self::new(contract_address, call_builder.provider))
|
||||
}
|
||||
/**Creates a `RawCallBuilder` for deploying this contract using the given `provider`
|
||||
and constructor arguments, if any.
|
||||
|
||||
This is a simple wrapper around creating a `RawCallBuilder` with the data set to
|
||||
the bytecode concatenated with the constructor's ABI-encoded arguments.*/
|
||||
#[inline]
|
||||
pub fn deploy_builder(provider: P) -> alloy_contract::RawCallBuilder<T, P, N> {
|
||||
alloy_contract::RawCallBuilder::new_raw_deploy(
|
||||
provider,
|
||||
::core::clone::Clone::clone(&BYTECODE),
|
||||
)
|
||||
}
|
||||
/// Returns a reference to the address.
|
||||
#[inline]
|
||||
pub const fn address(&self) -> &alloy_sol_types::private::Address {
|
||||
&self.address
|
||||
}
|
||||
/// Sets the address.
|
||||
#[inline]
|
||||
pub fn set_address(&mut self, address: alloy_sol_types::private::Address) {
|
||||
self.address = address;
|
||||
}
|
||||
/// Sets the address and returns `self`.
|
||||
pub fn at(mut self, address: alloy_sol_types::private::Address) -> Self {
|
||||
self.set_address(address);
|
||||
self
|
||||
}
|
||||
/// Returns a reference to the provider.
|
||||
#[inline]
|
||||
pub const fn provider(&self) -> &P {
|
||||
&self.provider
|
||||
}
|
||||
}
|
||||
impl<T, P: ::core::clone::Clone, N> IScKeystoreInstance<T, &P, N> {
|
||||
/// Clones the provider and returns a new instance with the cloned provider.
|
||||
#[inline]
|
||||
pub fn with_cloned_provider(self) -> IScKeystoreInstance<T, P, N> {
|
||||
IScKeystoreInstance {
|
||||
address: self.address,
|
||||
provider: ::core::clone::Clone::clone(&self.provider),
|
||||
_network_transport: ::core::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Function calls.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IScKeystoreInstance<T, P, N>
|
||||
{
|
||||
/// Creates a new call builder using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the call can be any function call, not just those defined in this
|
||||
/// contract. Prefer using the other methods for building type-safe contract calls.
|
||||
pub fn call_builder<C: alloy_sol_types::SolCall>(
|
||||
&self,
|
||||
call: &C,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, C, N> {
|
||||
alloy_contract::SolCallBuilder::new_sol(&self.provider, &self.address, call)
|
||||
}
|
||||
///Creates a new call builder for the [`addUser`] function.
|
||||
pub fn addUser(
|
||||
&self,
|
||||
user: alloy::sol_types::private::Address,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, addUserCall, N> {
|
||||
self.call_builder(&addUserCall { user })
|
||||
}
|
||||
///Creates a new call builder for the [`removeUser`] function.
|
||||
pub fn removeUser(
|
||||
&self,
|
||||
user: alloy::sol_types::private::Address,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, removeUserCall, N> {
|
||||
self.call_builder(&removeUserCall { user })
|
||||
}
|
||||
///Creates a new call builder for the [`userExists`] function.
|
||||
pub fn userExists(
|
||||
&self,
|
||||
user: alloy::sol_types::private::Address,
|
||||
) -> alloy_contract::SolCallBuilder<T, &P, userExistsCall, N> {
|
||||
self.call_builder(&userExistsCall { user })
|
||||
}
|
||||
}
|
||||
/// Event filters.
|
||||
#[automatically_derived]
|
||||
impl<
|
||||
T: alloy_contract::private::Transport + ::core::clone::Clone,
|
||||
P: alloy_contract::private::Provider<T, N>,
|
||||
N: alloy_contract::private::Network,
|
||||
> IScKeystoreInstance<T, P, N>
|
||||
{
|
||||
/// Creates a new event filter using this contract instance's provider and address.
|
||||
///
|
||||
/// Note that the type can be any event, not just those defined in this contract.
|
||||
/// Prefer using the other methods for building type-safe event filters.
|
||||
pub fn event_filter<E: alloy_sol_types::SolEvent>(
|
||||
&self,
|
||||
) -> alloy_contract::Event<T, &P, E, N> {
|
||||
alloy_contract::Event::new_sol(&self.provider, &self.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
#![allow(unused_imports, clippy::all, rustdoc::all)]
|
||||
//! This module contains the sol! generated bindings for solidity contracts.
|
||||
//! This is autogenerated code.
|
||||
//! Do not manually edit these files.
|
||||
//! These files may be overwritten by the codegen system at any time.
|
||||
pub mod context;
|
||||
pub mod deploy;
|
||||
pub mod deploymentconfig;
|
||||
pub mod ierc165;
|
||||
pub mod ierc20;
|
||||
pub mod ierc721;
|
||||
pub mod ierc721enumerable;
|
||||
pub mod ierc721metadata;
|
||||
pub mod ierc721tokenreceiver;
|
||||
pub mod isckeystore;
|
||||
pub mod mockerc20;
|
||||
pub mod mockerc721;
|
||||
pub mod ownable;
|
||||
pub mod sckeystore;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
27
crates/de_mls_gateway/Cargo.toml
Normal file
27
crates/de_mls_gateway/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "de_mls_gateway"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.43.0", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"sync",
|
||||
"time",
|
||||
] }
|
||||
anyhow = "1.0.100"
|
||||
kameo = "0.13.0"
|
||||
futures = "0.3.31"
|
||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||
|
||||
de_mls_ui_protocol = { path = "../de_mls_ui_protocol" }
|
||||
ds = { path = "../../ds" }
|
||||
mls_crypto = { path = "../../mls_crypto" }
|
||||
once_cell = "1.21.3"
|
||||
parking_lot = "0.12.5"
|
||||
de_mls = { path = "../../" }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
hex = "0.4"
|
||||
151
crates/de_mls_gateway/src/forwarder.rs
Normal file
151
crates/de_mls_gateway/src/forwarder.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use kameo::actor::ActorRef;
|
||||
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
use tracing::info;
|
||||
|
||||
use de_mls::{
|
||||
message::MessageType,
|
||||
protos::de_mls::messages::v1::{app_message, ConversationMessage},
|
||||
user::{User, UserAction},
|
||||
user_actor::LeaveGroupRequest,
|
||||
user_app_instance::CoreCtx,
|
||||
};
|
||||
use de_mls_ui_protocol::v1::AppEvent;
|
||||
|
||||
use crate::Gateway;
|
||||
|
||||
impl Gateway {
|
||||
pub(crate) fn spawn_consensus_forwarder(&self, core: Arc<CoreCtx>) -> anyhow::Result<()> {
|
||||
let evt_tx = self.evt_tx.clone();
|
||||
let mut rx = core.consensus.subscribe_decisions();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("gateway: consensus forwarder started");
|
||||
while let Ok(res) = rx.recv().await {
|
||||
let _ = evt_tx.unbounded_send(AppEvent::ProposalDecided(res));
|
||||
}
|
||||
tracing::info!("gateway: consensus forwarder ended");
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn the pubsub forwarder once, after first successful login.
|
||||
pub(crate) fn spawn_waku_forwarder(&self, core: Arc<CoreCtx>, user: ActorRef<User>) {
|
||||
if self.started.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let evt_tx = self.evt_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut rx = core.app_state.pubsub.subscribe();
|
||||
tracing::info!("gateway: pubsub forwarder started");
|
||||
|
||||
while let Ok(wmsg) = rx.recv().await {
|
||||
let content_topic = wmsg.content_topic.clone();
|
||||
|
||||
// fast-topic filter
|
||||
if !core.topics.contains(&content_topic).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
// hand over to user actor to decide action
|
||||
let action = match user.ask(wmsg).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("user.ask failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// route the action
|
||||
let res = match action {
|
||||
UserAction::SendToWaku(msg) => core
|
||||
.app_state
|
||||
.waku_node
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("error sending waku message: {e}")),
|
||||
|
||||
UserAction::SendToApp(app_msg) => {
|
||||
// voting
|
||||
let res = match &app_msg.payload {
|
||||
Some(app_message::Payload::VotePayload(vp)) => evt_tx
|
||||
.unbounded_send(AppEvent::VoteRequested(vp.clone()))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("error sending vote requested event: {e}")
|
||||
})
|
||||
.and_then(|_| {
|
||||
// Also clear current epoch proposals when voting starts
|
||||
evt_tx.unbounded_send(AppEvent::CurrentEpochProposalsCleared {
|
||||
group_id: vp.group_id.clone(),
|
||||
})
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("error sending clear current epoch proposals event: {e}")
|
||||
})
|
||||
}),
|
||||
Some(app_message::Payload::ProposalAdded(pa)) => evt_tx
|
||||
.unbounded_send(AppEvent::from(pa.clone()))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("error sending proposal added event: {e}")
|
||||
}),
|
||||
Some(app_message::Payload::BanRequest(br)) => evt_tx
|
||||
.unbounded_send(AppEvent::from(br.clone()))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("error sending proposal added event (ban request): {e}")
|
||||
}),
|
||||
Some(app_message::Payload::ConversationMessage(cm)) => evt_tx
|
||||
.unbounded_send(AppEvent::ChatMessage(ConversationMessage {
|
||||
message: cm.message.clone(),
|
||||
sender: cm.sender.clone(),
|
||||
group_name: cm.group_name.clone(),
|
||||
}))
|
||||
.map_err(|e| anyhow::anyhow!("error sending chat message: {e}")),
|
||||
_ => {
|
||||
AppEvent::Error(format!("Invalid app message: {:?}", app_msg.payload.unwrap().message_type()));
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
};
|
||||
match res {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(anyhow::anyhow!("error sending app message: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
UserAction::LeaveGroup(group_name) => {
|
||||
let _ = user
|
||||
.ask(LeaveGroupRequest {
|
||||
group_name: group_name.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("error leaving group: {e}"));
|
||||
|
||||
core.topics.remove_many(&group_name).await;
|
||||
info!("Leave group: {:?}", &group_name);
|
||||
|
||||
let _ = evt_tx
|
||||
.unbounded_send(AppEvent::GroupRemoved(group_name.clone()))
|
||||
.map_err(|e| anyhow::anyhow!("error sending group removed event: {e}"));
|
||||
|
||||
let _ = evt_tx
|
||||
.unbounded_send(AppEvent::ChatMessage(ConversationMessage {
|
||||
message: format!("You're removed from the group {group_name}")
|
||||
.into_bytes(),
|
||||
sender: "system".to_string(),
|
||||
group_name: group_name.clone(),
|
||||
}))
|
||||
.map_err(|e| anyhow::anyhow!("error sending chat message: {e}"));
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
UserAction::DoNothing => Ok(()),
|
||||
};
|
||||
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("error handling waku action: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("gateway: pubsub forwarder ended");
|
||||
});
|
||||
}
|
||||
}
|
||||
281
crates/de_mls_gateway/src/group.rs
Normal file
281
crates/de_mls_gateway/src/group.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
use de_mls::{
|
||||
protos::de_mls::messages::v1::{app_message, BanRequest},
|
||||
steward,
|
||||
user::UserAction,
|
||||
user_actor::{
|
||||
CreateGroupRequest, GetCurrentEpochProposalsRequest, GetGroupMembersRequest,
|
||||
GetProposalsForStewardVotingRequest, IsStewardStatusRequest, SendGroupMessage,
|
||||
StartStewardEpochRequest, StewardMessageRequest, UserVoteRequest,
|
||||
},
|
||||
user_app_instance::STEWARD_EPOCH,
|
||||
};
|
||||
use de_mls_ui_protocol::v1::AppEvent;
|
||||
|
||||
use crate::Gateway;
|
||||
|
||||
impl Gateway {
|
||||
pub async fn create_group(&self, group_name: String) -> anyhow::Result<()> {
|
||||
let core = self.core();
|
||||
let user = self.user()?;
|
||||
user.ask(CreateGroupRequest {
|
||||
group_name: group_name.clone(),
|
||||
is_creation: true,
|
||||
})
|
||||
.await?;
|
||||
core.topics.add_many(&group_name).await;
|
||||
core.groups.insert(group_name.clone()).await;
|
||||
info!("User start sending steward message for group {group_name:?}");
|
||||
let user_clone = user.clone();
|
||||
let group_name_clone = group_name.clone();
|
||||
let evt_tx_clone = self.evt_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(STEWARD_EPOCH));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// Step 1: Start steward epoch - check for proposals and start epoch if needed
|
||||
let proposals_count = match user_clone
|
||||
.ask(StartStewardEpochRequest {
|
||||
group_name: group_name.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(count) => count,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"start steward epoch request failed for group {group_name:?}: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Send new steward key to the waku node for new epoch
|
||||
let msg = match user_clone
|
||||
.ask(StewardMessageRequest {
|
||||
group_name: group_name.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"steward message request failed for group {group_name:?}: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = core.app_state.waku_node.send(msg).await {
|
||||
tracing::warn!("failed to send steward message for group {group_name:?}: {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if proposals_count == 0 {
|
||||
info!("No proposals to vote on for group: {group_name}, completing epoch without voting");
|
||||
} else {
|
||||
info!("Found {proposals_count} proposals to vote on for group: {group_name}");
|
||||
|
||||
// Step 3: Start voting process - steward gets proposals for voting
|
||||
let action = match user_clone
|
||||
.ask(GetProposalsForStewardVotingRequest {
|
||||
group_name: group_name.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(action) => action,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"get proposals for steward voting failed for group {group_name:?}: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
if let Some(app_message::Payload::VotePayload(vp)) = &app_msg.payload {
|
||||
if let Err(e) =
|
||||
evt_tx_clone.unbounded_send(AppEvent::VoteRequested(vp.clone()))
|
||||
{
|
||||
tracing::warn!("failed to send vote requested event: {e}");
|
||||
}
|
||||
|
||||
// Also clear current epoch proposals when voting starts
|
||||
if let Err(e) = evt_tx_clone.unbounded_send(
|
||||
AppEvent::CurrentEpochProposalsCleared {
|
||||
group_id: group_name.clone(),
|
||||
},
|
||||
) {
|
||||
tracing::warn!("failed to send proposals cleared event: {e}");
|
||||
}
|
||||
}
|
||||
if let Some(app_message::Payload::ProposalAdded(pa)) = &app_msg.payload
|
||||
{
|
||||
if let Err(e) =
|
||||
evt_tx_clone.unbounded_send(AppEvent::from(pa.clone()))
|
||||
{
|
||||
tracing::warn!("failed to send proposal added event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
UserAction::DoNothing => {
|
||||
info!("No action to take for group: {group_name}");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!("Invalid user action: {action}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
tracing::debug!("User started sending steward message for group {group_name_clone:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn join_group(&self, group_name: String) -> anyhow::Result<()> {
|
||||
let core = self.core();
|
||||
let user = self.user()?;
|
||||
user.ask(CreateGroupRequest {
|
||||
group_name: group_name.clone(),
|
||||
is_creation: false,
|
||||
})
|
||||
.await?;
|
||||
core.topics.add_many(&group_name).await;
|
||||
core.groups.insert(group_name.clone()).await;
|
||||
tracing::debug!("User joined group {group_name}");
|
||||
tracing::debug!(
|
||||
"User have topic for group {:?}",
|
||||
core.topics.snapshot().await
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message(&self, group_name: String, message: String) -> anyhow::Result<()> {
|
||||
let core = self.core();
|
||||
let user = self.user()?;
|
||||
let pmt = user
|
||||
.ask(SendGroupMessage {
|
||||
message: message.clone().into_bytes(),
|
||||
group_name: group_name.clone(),
|
||||
})
|
||||
.await?;
|
||||
core.app_state.waku_node.send(pmt).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_ban_request(
|
||||
&self,
|
||||
group_name: String,
|
||||
user_to_ban: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let core = self.core();
|
||||
let user = self.user()?;
|
||||
|
||||
let ban_request = BanRequest {
|
||||
user_to_ban: user_to_ban.clone(),
|
||||
requester: String::new(),
|
||||
group_name: group_name.clone(),
|
||||
};
|
||||
|
||||
let msg = user
|
||||
.ask(de_mls::user_actor::BuildBanMessage {
|
||||
ban_request,
|
||||
group_name: group_name.clone(),
|
||||
})
|
||||
.await?;
|
||||
match msg {
|
||||
UserAction::SendToWaku(msg) => {
|
||||
core.app_state.waku_node.send(msg).await?;
|
||||
}
|
||||
UserAction::SendToApp(app_msg) => {
|
||||
let event = match app_msg.payload {
|
||||
Some(app_message::Payload::ProposalAdded(ref proposal)) => {
|
||||
AppEvent::from(proposal.clone())
|
||||
}
|
||||
Some(app_message::Payload::BanRequest(ref ban_request)) => {
|
||||
AppEvent::from(ban_request.clone())
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Invalid user action")),
|
||||
};
|
||||
self.push_event(event);
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Invalid user action")),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn process_user_vote(
|
||||
&self,
|
||||
group_name: String,
|
||||
proposal_id: u32,
|
||||
vote: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let user = self.user()?;
|
||||
|
||||
let user_vote_result = user
|
||||
.ask(UserVoteRequest {
|
||||
group_name: group_name.clone(),
|
||||
proposal_id,
|
||||
vote,
|
||||
})
|
||||
.await?;
|
||||
if let Some(waku_msg) = user_vote_result {
|
||||
self.core().app_state.waku_node.send(waku_msg).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn group_list(&self) -> Vec<String> {
|
||||
let core = self.core();
|
||||
core.groups.all().await
|
||||
}
|
||||
|
||||
pub async fn get_steward_status(&self, group_name: String) -> anyhow::Result<bool> {
|
||||
let user = self.user()?;
|
||||
let is_steward = user.ask(IsStewardStatusRequest { group_name }).await?;
|
||||
Ok(is_steward)
|
||||
}
|
||||
|
||||
/// Get current epoch proposals for the given group
|
||||
pub async fn get_current_epoch_proposals(
|
||||
&self,
|
||||
group_name: String,
|
||||
) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let user = self.user()?;
|
||||
|
||||
let proposals = user
|
||||
.ask(GetCurrentEpochProposalsRequest { group_name })
|
||||
.await?;
|
||||
let display_proposals: Vec<(String, String)> = proposals
|
||||
.iter()
|
||||
.map(|proposal| match proposal {
|
||||
steward::GroupUpdateRequest::AddMember(kp) => {
|
||||
let address = format!(
|
||||
"0x{}",
|
||||
hex::encode(kp.leaf_node().credential().serialized_content())
|
||||
);
|
||||
("Add Member".to_string(), address)
|
||||
}
|
||||
steward::GroupUpdateRequest::RemoveMember(id) => {
|
||||
("Remove Member".to_string(), id.clone())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(display_proposals)
|
||||
}
|
||||
|
||||
pub async fn get_group_members(&self, group_name: String) -> anyhow::Result<Vec<String>> {
|
||||
let user = self.user()?;
|
||||
let members = user
|
||||
.ask(GetGroupMembersRequest {
|
||||
group_name: group_name.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(members)
|
||||
}
|
||||
}
|
||||
138
crates/de_mls_gateway/src/lib.rs
Normal file
138
crates/de_mls_gateway/src/lib.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! de_mls_gateway: a thin facade between UI (AppCmd/AppEvent) and the core runtime.
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! - Own a single event pipe UI <- gateway (`AppEvent`)
|
||||
//! - Provide a command entrypoint UI -> gateway (`send(AppCmd)`)
|
||||
//! - Hold references to the core context (`CoreCtx`) and current user actor
|
||||
//! - Offer small helper methods (login_with_private_key, etc.)
|
||||
use futures::{
|
||||
channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender},
|
||||
StreamExt,
|
||||
};
|
||||
use kameo::actor::ActorRef;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use de_mls::{
|
||||
user::User,
|
||||
user_app_instance::{create_user_instance, CoreCtx},
|
||||
};
|
||||
use de_mls_ui_protocol::v1::{AppCmd, AppEvent};
|
||||
|
||||
mod forwarder;
|
||||
mod group;
|
||||
// Global, process-wide gateway instance
|
||||
pub static GATEWAY: Lazy<Gateway> = Lazy::new(Gateway::new);
|
||||
|
||||
/// Helper to set the core context once during startup (called by ui_bridge).
|
||||
pub fn init_core(core: Arc<CoreCtx>) {
|
||||
GATEWAY.set_core(core);
|
||||
}
|
||||
|
||||
pub struct Gateway {
|
||||
// UI events (gateway -> UI)
|
||||
// A channel that sends AppEvents to the UI.
|
||||
evt_tx: UnboundedSender<AppEvent>,
|
||||
// A channel that receives AppEvents from the UI.
|
||||
evt_rx: Mutex<UnboundedReceiver<AppEvent>>,
|
||||
|
||||
// UI commands (UI -> gateway)
|
||||
// A channel that sends AppCommands to the gateway (ui_bridge registers the sender here).
|
||||
// It gives the UI an async door to submit AppCmds back to the gateway (`Gateway::send(AppCmd)`).
|
||||
cmd_tx: RwLock<Option<UnboundedSender<AppCmd>>>,
|
||||
|
||||
// It anchors the shared references to consensus, topics, app_state, etc.
|
||||
core: RwLock<Option<Arc<CoreCtx>>>, // set once during startup
|
||||
|
||||
// Current logged-in user actor
|
||||
user: RwLock<Option<ActorRef<User>>>,
|
||||
// Flag that guards against spawning the Waku forwarder more than once.
|
||||
// It's initialized to false and set to true after the first successful login.
|
||||
started: AtomicBool,
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
fn new() -> Self {
|
||||
let (evt_tx, evt_rx) = unbounded();
|
||||
Self {
|
||||
evt_tx,
|
||||
evt_rx: Mutex::new(evt_rx),
|
||||
cmd_tx: RwLock::new(None),
|
||||
core: RwLock::new(None),
|
||||
user: RwLock::new(None),
|
||||
started: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Called once by the bootstrap (ui_bridge) to provide the core context.
|
||||
pub fn set_core(&self, core: Arc<CoreCtx>) {
|
||||
*self.core.write() = Some(core);
|
||||
}
|
||||
|
||||
pub fn core(&self) -> Arc<CoreCtx> {
|
||||
self.core
|
||||
.read()
|
||||
.as_ref()
|
||||
.expect("Gateway core not initialized")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// ui_bridge registers its command sender so `send` can work.
|
||||
pub fn register_cmd_sink(&self, tx: UnboundedSender<AppCmd>) {
|
||||
*self.cmd_tx.write() = Some(tx);
|
||||
}
|
||||
|
||||
/// Push an event to the UI.
|
||||
pub fn push_event(&self, evt: AppEvent) {
|
||||
let _ = self.evt_tx.unbounded_send(evt);
|
||||
}
|
||||
|
||||
/// Await next event on the UI side.
|
||||
pub async fn next_event(&self) -> Option<AppEvent> {
|
||||
let mut rx = self.evt_rx.lock().await;
|
||||
rx.next().await
|
||||
}
|
||||
|
||||
/// UI convenience: enqueue a command (UI -> gateway).
|
||||
pub async fn send(&self, cmd: AppCmd) -> anyhow::Result<()> {
|
||||
if let Some(tx) = self.cmd_tx.read().clone() {
|
||||
tx.unbounded_send(cmd)
|
||||
.map_err(|e| anyhow::anyhow!("send cmd failed: {e}"))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("cmd sink not registered"))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── High-level helpers ───────────────────────────
|
||||
|
||||
/// Create the user actor with a private key (no group yet).
|
||||
/// Returns a derived display name (e.g., address string).
|
||||
pub async fn login_with_private_key(&self, private_key: String) -> anyhow::Result<String> {
|
||||
let core = self.core();
|
||||
let consensus_service = core.consensus.as_ref().clone();
|
||||
|
||||
// Create user actor via core helper (you implement this inside your core)
|
||||
let (user_ref, user_address) = create_user_instance(
|
||||
private_key.clone(),
|
||||
core.app_state.clone(),
|
||||
&consensus_service,
|
||||
)
|
||||
.await?;
|
||||
|
||||
*self.user.write() = Some(user_ref.clone());
|
||||
|
||||
self.spawn_waku_forwarder(core.clone(), user_ref.clone());
|
||||
self.spawn_consensus_forwarder(core.clone())?;
|
||||
Ok(user_address)
|
||||
}
|
||||
|
||||
/// Get a copy of the current user ref (if logged in).
|
||||
pub fn user(&self) -> anyhow::Result<ActorRef<User>> {
|
||||
self.user
|
||||
.read()
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("user not logged in"))
|
||||
}
|
||||
}
|
||||
15
crates/de_mls_ui_protocol/Cargo.toml
Normal file
15
crates/de_mls_ui_protocol/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "de_mls_ui_protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0.163", features = ["derive"] }
|
||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||
thiserror = "2.0.17"
|
||||
|
||||
de_mls = { path = "../../" }
|
||||
mls_crypto = { path = "../../mls_crypto" }
|
||||
127
crates/de_mls_ui_protocol/src/lib.rs
Normal file
127
crates/de_mls_ui_protocol/src/lib.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! UI <-> Gateway protocol (PoC). Keep it dependency-light (serde only).
|
||||
// crates/de_mls_ui_protocol/src/lib.rs
|
||||
pub mod v1 {
|
||||
use de_mls::{
|
||||
message::MessageType,
|
||||
protos::{
|
||||
consensus::v1::{ProposalResult, VotePayload},
|
||||
de_mls::messages::v1::{BanRequest, ConversationMessage, ProposalAdded},
|
||||
},
|
||||
};
|
||||
use mls_crypto::identity::normalize_wallet_address;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum AppCmd {
|
||||
Login {
|
||||
private_key: String,
|
||||
},
|
||||
ListGroups,
|
||||
CreateGroup {
|
||||
name: String,
|
||||
},
|
||||
JoinGroup {
|
||||
name: String,
|
||||
},
|
||||
EnterGroup {
|
||||
group_id: String,
|
||||
},
|
||||
SendMessage {
|
||||
group_id: String,
|
||||
body: String,
|
||||
},
|
||||
LoadHistory {
|
||||
group_id: String,
|
||||
},
|
||||
Vote {
|
||||
group_id: String,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
},
|
||||
LeaveGroup {
|
||||
group_id: String,
|
||||
},
|
||||
GetStewardStatus {
|
||||
group_id: String,
|
||||
},
|
||||
GetCurrentEpochProposals {
|
||||
group_id: String,
|
||||
},
|
||||
SendBanRequest {
|
||||
group_id: String,
|
||||
user_to_ban: String,
|
||||
},
|
||||
GetGroupMembers {
|
||||
group_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum AppEvent {
|
||||
LoggedIn(String),
|
||||
Groups(Vec<String>),
|
||||
GroupCreated(String),
|
||||
GroupRemoved(String),
|
||||
EnteredGroup {
|
||||
group_id: String,
|
||||
},
|
||||
ChatMessage(ConversationMessage),
|
||||
LeaveGroup {
|
||||
group_id: String,
|
||||
},
|
||||
|
||||
StewardStatus {
|
||||
group_id: String,
|
||||
is_steward: bool,
|
||||
},
|
||||
|
||||
VoteRequested(VotePayload),
|
||||
ProposalDecided(ProposalResult),
|
||||
CurrentEpochProposals {
|
||||
group_id: String,
|
||||
proposals: Vec<(String, String)>,
|
||||
},
|
||||
ProposalAdded {
|
||||
group_id: String,
|
||||
action: String,
|
||||
address: String,
|
||||
},
|
||||
CurrentEpochProposalsCleared {
|
||||
group_id: String,
|
||||
},
|
||||
GroupMembers {
|
||||
group_id: String,
|
||||
members: Vec<String>,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl From<ProposalAdded> for AppEvent {
|
||||
fn from(proposal_added: ProposalAdded) -> Self {
|
||||
AppEvent::ProposalAdded {
|
||||
group_id: proposal_added.group_id.clone(),
|
||||
action: proposal_added
|
||||
.request
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.message_type()
|
||||
.to_string(),
|
||||
address: normalize_wallet_address(
|
||||
&proposal_added.request.as_ref().unwrap().wallet_address,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BanRequest> for AppEvent {
|
||||
fn from(ban_request: BanRequest) -> Self {
|
||||
AppEvent::ProposalAdded {
|
||||
group_id: ban_request.group_name.clone(),
|
||||
action: "Remove Member".to_string(),
|
||||
address: ban_request.user_to_ban.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
crates/ui_bridge/Cargo.toml
Normal file
15
crates/ui_bridge/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ui_bridge"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
futures = "0.3.31"
|
||||
anyhow = "1.0.100"
|
||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||
tracing = "0.1.41"
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
de_mls_gateway = { path = "../de_mls_gateway" }
|
||||
de_mls_ui_protocol = { path = "../de_mls_ui_protocol" }
|
||||
de_mls = { path = "../../" }
|
||||
201
crates/ui_bridge/src/lib.rs
Normal file
201
crates/ui_bridge/src/lib.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
//! ui_bridge
|
||||
//!
|
||||
//! Owns the command loop translating `AppCmd` -> core calls
|
||||
//! and pushing `AppEvent` back to the UI via the Gateway.
|
||||
//!
|
||||
//! It ensures there is a Tokio runtime (desktop app may not have one yet).
|
||||
|
||||
// crates/ui_bridge/src/lib.rs
|
||||
use futures::channel::mpsc::{unbounded, UnboundedReceiver};
|
||||
use futures::StreamExt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use de_mls::protos::de_mls::messages::v1::ConversationMessage;
|
||||
use de_mls::user_app_instance::CoreCtx;
|
||||
use de_mls_gateway::{init_core, GATEWAY};
|
||||
use de_mls_ui_protocol::v1::{AppCmd, AppEvent};
|
||||
|
||||
/// Call once during process startup (before launching the Dioxus UI).
|
||||
pub fn start_ui_bridge(core: Arc<CoreCtx>) {
|
||||
// 1) Give the gateway access to the core context.
|
||||
init_core(core);
|
||||
|
||||
// 2) Create a command channel UI -> gateway and register the sender.
|
||||
let (cmd_tx, cmd_rx) = unbounded::<AppCmd>();
|
||||
GATEWAY.register_cmd_sink(cmd_tx);
|
||||
|
||||
// 3) Drive the dispatcher loop on a Tokio runtime
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
if let Err(e) = ui_loop(cmd_rx).await {
|
||||
tracing::error!("ui_loop crashed: {e}");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
std::thread::Builder::new()
|
||||
.name("ui-bridge".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
rt.block_on(async move {
|
||||
if let Err(e) = ui_loop(cmd_rx).await {
|
||||
eprintln!("ui_loop crashed: {e:?}");
|
||||
}
|
||||
});
|
||||
})
|
||||
.expect("spawn ui-bridge");
|
||||
}
|
||||
}
|
||||
|
||||
async fn ui_loop(mut cmd_rx: UnboundedReceiver<AppCmd>) -> anyhow::Result<()> {
|
||||
while let Some(cmd) = cmd_rx.next().await {
|
||||
match cmd {
|
||||
// ───────────── Authentication / session ─────────────
|
||||
AppCmd::Login { private_key } => {
|
||||
match GATEWAY.login_with_private_key(private_key).await {
|
||||
Ok(derived_name) => GATEWAY.push_event(AppEvent::LoggedIn(derived_name)),
|
||||
Err(e) => GATEWAY.push_event(AppEvent::Error(format!("Login failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────── Groups ─────────────
|
||||
AppCmd::ListGroups => {
|
||||
let groups = GATEWAY.group_list().await;
|
||||
GATEWAY.push_event(AppEvent::Groups(groups));
|
||||
}
|
||||
|
||||
AppCmd::CreateGroup { name } => {
|
||||
GATEWAY.create_group(name.clone()).await?;
|
||||
|
||||
let groups = GATEWAY.group_list().await;
|
||||
GATEWAY.push_event(AppEvent::Groups(groups));
|
||||
}
|
||||
|
||||
AppCmd::JoinGroup { name } => {
|
||||
GATEWAY.join_group(name.clone()).await?;
|
||||
|
||||
let groups = GATEWAY.group_list().await;
|
||||
GATEWAY.push_event(AppEvent::Groups(groups));
|
||||
}
|
||||
|
||||
AppCmd::EnterGroup { group_id } => {
|
||||
GATEWAY.push_event(AppEvent::EnteredGroup { group_id });
|
||||
}
|
||||
|
||||
AppCmd::LeaveGroup { group_id } => {
|
||||
GATEWAY.push_event(AppEvent::LeaveGroup { group_id });
|
||||
}
|
||||
|
||||
AppCmd::GetGroupMembers { group_id } => {
|
||||
match GATEWAY.get_group_members(group_id.clone()).await {
|
||||
Ok(members) => {
|
||||
GATEWAY.push_event(AppEvent::GroupMembers { group_id, members });
|
||||
}
|
||||
Err(e) => {
|
||||
GATEWAY
|
||||
.push_event(AppEvent::Error(format!("Get group members failed: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppCmd::SendBanRequest {
|
||||
group_id,
|
||||
user_to_ban,
|
||||
} => {
|
||||
if let Err(e) = GATEWAY
|
||||
.send_ban_request(group_id.clone(), user_to_ban.clone())
|
||||
.await
|
||||
{
|
||||
GATEWAY.push_event(AppEvent::Error(format!("Send ban request failed: {e}")));
|
||||
} else {
|
||||
GATEWAY.push_event(AppEvent::ChatMessage(ConversationMessage {
|
||||
message: "You requested to leave or ban user from the group"
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
sender: "system".to_string(),
|
||||
group_name: group_id.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────── Chat ─────────────
|
||||
AppCmd::SendMessage { group_id, body } => {
|
||||
GATEWAY.push_event(AppEvent::ChatMessage(ConversationMessage {
|
||||
message: body.as_bytes().to_vec(),
|
||||
sender: "me".to_string(),
|
||||
group_name: group_id.clone(),
|
||||
}));
|
||||
|
||||
GATEWAY.send_message(group_id, body).await?;
|
||||
}
|
||||
|
||||
AppCmd::LoadHistory { group_id } => {
|
||||
// TODO: load from storage; stub:
|
||||
GATEWAY.push_event(AppEvent::ChatMessage(ConversationMessage {
|
||||
message: "History loaded (stub)".as_bytes().to_vec(),
|
||||
sender: "system".to_string(),
|
||||
group_name: group_id.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ───────────── Consensus ─────────────
|
||||
AppCmd::Vote {
|
||||
group_id,
|
||||
proposal_id,
|
||||
choice,
|
||||
} => {
|
||||
// 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
|
||||
GATEWAY
|
||||
.process_user_vote(group_id.clone(), proposal_id, choice)
|
||||
.await?;
|
||||
|
||||
GATEWAY.push_event(AppEvent::ChatMessage(ConversationMessage {
|
||||
message: format!(
|
||||
"Your vote ({}) has been submitted for proposal {proposal_id}",
|
||||
if choice { "YES" } else { "NO" }
|
||||
)
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
sender: "system".to_string(),
|
||||
group_name: group_id.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
AppCmd::GetCurrentEpochProposals { group_id } => {
|
||||
match GATEWAY.get_current_epoch_proposals(group_id.clone()).await {
|
||||
Ok(proposals) => {
|
||||
GATEWAY.push_event(AppEvent::CurrentEpochProposals {
|
||||
group_id,
|
||||
proposals,
|
||||
});
|
||||
}
|
||||
Err(e) => GATEWAY.push_event(AppEvent::Error(format!(
|
||||
"Get current epoch proposals failed: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
AppCmd::GetStewardStatus { group_id } => {
|
||||
match GATEWAY.get_steward_status(group_id.clone()).await {
|
||||
Ok(is_steward) => {
|
||||
GATEWAY.push_event(AppEvent::StewardStatus {
|
||||
group_id,
|
||||
is_steward,
|
||||
});
|
||||
}
|
||||
Err(e) => GATEWAY
|
||||
.push_event(AppEvent::Error(format!("Get steward status failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
other => {
|
||||
tracing::warn!("unhandled AppCmd: {:?}", other);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
redis:
|
||||
image: redis@sha256:0c6f34a2d41992ee1e02d52d712c12ac46c4d5a63efdab74915141a52c529586
|
||||
entrypoint: ["redis-server", "--port", "${REDIS_PORT}"]
|
||||
ports:
|
||||
- "${REDIS_PORT}:${REDIS_PORT}"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
anvil:
|
||||
image: ghcr.io/foundry-rs/foundry:latest
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"cast",
|
||||
"chain-id",
|
||||
"--rpc-url",
|
||||
"http://localhost:${ANVIL_PORT}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
ports:
|
||||
- "${ANVIL_PORT}:${ANVIL_PORT}"
|
||||
entrypoint: ["anvil", "--host", "0.0.0.0"]
|
||||
platform: linux/amd64
|
||||
@@ -1,39 +1,30 @@
|
||||
[package]
|
||||
name = "ds"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
# chrono = "=0.4.38"
|
||||
# waku-bindings = "=0.6.0"
|
||||
bus = "=2.4.1"
|
||||
fred = { version = "=9.0.3", features = ["subscriber-client"] }
|
||||
tokio = { version = "=1.38.0", features = ["full"] }
|
||||
tokio-tungstenite = "0.15"
|
||||
tungstenite = "0.14"
|
||||
futures-util = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
alloy = { git = "https://github.com/alloy-rs/alloy", features = [
|
||||
"providers",
|
||||
"node-bindings",
|
||||
"network",
|
||||
"transports",
|
||||
"k256",
|
||||
"rlp",
|
||||
waku-bindings = { git = "https://github.com/waku-org/waku-rust-bindings.git", branch = "rln-fix-deps" }
|
||||
waku-sys = { git = "https://github.com/waku-org/waku-rust-bindings.git", branch = "rln-fix-deps" }
|
||||
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
kameo = "0.13.0"
|
||||
bounded-vec-deque = "0.1.1"
|
||||
|
||||
chrono = "0.4"
|
||||
uuid = { version = "1.11.0", features = [
|
||||
"v4",
|
||||
"fast-rng",
|
||||
"macro-diagnostics",
|
||||
] }
|
||||
tokio-util = "=0.7.11"
|
||||
|
||||
openmls = { version = "=0.5.0", features = ["test-utils"] }
|
||||
rand = { version = "^0.8" }
|
||||
anyhow = "1.0.81"
|
||||
thiserror = "1.0.39"
|
||||
|
||||
anyhow = "=1.0.81"
|
||||
thiserror = "=1.0.61"
|
||||
serde_json = "1.0"
|
||||
serde = "1.0.163"
|
||||
|
||||
tls_codec = "=0.3.0"
|
||||
serde_json = "=1.0"
|
||||
serde = "=1.0.204"
|
||||
|
||||
sc_key_store = { path = "../sc_key_store" }
|
||||
url = "2.5.2"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
use alloy::primitives::Address;
|
||||
use alloy::signers::Signature;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
|
||||
use crate::chat_server::ServerMessage;
|
||||
use crate::DeliveryServiceError;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum ChatMessages {
|
||||
Request(RequestMLSPayload),
|
||||
Response(ResponseMLSPayload),
|
||||
Welcome(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum ReqMessageType {
|
||||
InviteToGroup,
|
||||
RemoveFromGroup,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct RequestMLSPayload {
|
||||
sc_address: String,
|
||||
group_name: String,
|
||||
pub msg_type: ReqMessageType,
|
||||
}
|
||||
|
||||
impl RequestMLSPayload {
|
||||
pub fn new(sc_address: String, group_name: String, msg_type: ReqMessageType) -> Self {
|
||||
RequestMLSPayload {
|
||||
sc_address,
|
||||
group_name,
|
||||
msg_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn msg_to_sign(&self) -> String {
|
||||
self.sc_address.to_owned() + &self.group_name
|
||||
}
|
||||
|
||||
pub fn group_name(&self) -> String {
|
||||
self.group_name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct ResponseMLSPayload {
|
||||
signature: String,
|
||||
user_address: String,
|
||||
pub group_name: String,
|
||||
key_package: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ResponseMLSPayload {
|
||||
pub fn new(
|
||||
signature: String,
|
||||
user_address: String,
|
||||
group_name: String,
|
||||
key_package: Vec<u8>,
|
||||
) -> Self {
|
||||
Self {
|
||||
signature,
|
||||
user_address,
|
||||
group_name,
|
||||
key_package,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(
|
||||
&self,
|
||||
sc_address: String,
|
||||
group_name: String,
|
||||
) -> Result<(String, Vec<u8>), DeliveryServiceError> {
|
||||
let recover_sig: Signature = serde_json::from_str(&self.signature)?;
|
||||
let addr = Address::from_str(&self.user_address)?;
|
||||
// Recover the signer from the message.
|
||||
let recovered =
|
||||
recover_sig.recover_address_from_msg(sc_address.to_owned() + &group_name)?;
|
||||
|
||||
if recovered.ne(&addr) {
|
||||
return Err(DeliveryServiceError::ValidationError(recovered.to_string()));
|
||||
}
|
||||
Ok((self.user_address.clone(), self.key_package.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChatClient {
|
||||
sender: mpsc::UnboundedSender<Message>,
|
||||
}
|
||||
|
||||
impl ChatClient {
|
||||
pub async fn connect(
|
||||
server_addr: &str,
|
||||
username: String,
|
||||
) -> Result<(Self, mpsc::UnboundedReceiver<Message>), DeliveryServiceError> {
|
||||
let (ws_stream, _) = tokio_tungstenite::connect_async(server_addr).await?;
|
||||
let (mut write, read) = ws_stream.split();
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
let (msg_sender, msg_receiver) = mpsc::unbounded_channel();
|
||||
|
||||
let receiver = Arc::new(Mutex::new(receiver));
|
||||
|
||||
// Spawn a task to handle outgoing messages
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = receiver.lock().await.recv().await {
|
||||
if let Err(err) = write.send(message).await {
|
||||
return Err(DeliveryServiceError::SenderError(err.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
// Spawn a task to handle incoming messages
|
||||
tokio::spawn(async move {
|
||||
let mut read = read;
|
||||
while let Some(Ok(message)) = read.next().await {
|
||||
if let Err(err) = msg_sender.send(message) {
|
||||
return Err(DeliveryServiceError::SenderError(err.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
// Send a SystemJoin message when registering
|
||||
let join_msg = ServerMessage::SystemJoin {
|
||||
username: username.to_string(),
|
||||
};
|
||||
let join_json = serde_json::to_string(&join_msg).unwrap();
|
||||
sender
|
||||
.send(Message::Text(join_json))
|
||||
.map_err(|err| DeliveryServiceError::SenderError(err.to_string()))?;
|
||||
|
||||
Ok((ChatClient { sender }, msg_receiver))
|
||||
}
|
||||
|
||||
pub fn send_message(&self, msg: ServerMessage) -> Result<(), DeliveryServiceError> {
|
||||
let msg_json = serde_json::to_string(&msg).unwrap();
|
||||
self.sender
|
||||
.send(Message::Text(msg_json))
|
||||
.map_err(|err| DeliveryServiceError::SenderError(err.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign() {
|
||||
use alloy::signers::SignerSync;
|
||||
let signer = alloy::signers::local::PrivateKeySigner::from_str(
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Sign a message.
|
||||
let message = "You are joining the group with smart contract: ".to_owned()
|
||||
+ "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";
|
||||
let signature = signer.sign_message_sync(message.as_bytes()).unwrap();
|
||||
|
||||
let json = serde_json::to_string(&signature).unwrap();
|
||||
let recover_sig: Signature = serde_json::from_str(&json).unwrap();
|
||||
|
||||
// Recover the signer from the message.
|
||||
let recovered = recover_sig.recover_address_from_msg(message).unwrap();
|
||||
assert_eq!(recovered, signer.address());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_test() {
|
||||
let inner_msg = ChatMessages::Request(RequestMLSPayload::new(
|
||||
"sc_address".to_string(),
|
||||
"group_name".to_string(),
|
||||
ReqMessageType::InviteToGroup,
|
||||
));
|
||||
|
||||
let res = serde_json::to_string(&inner_msg);
|
||||
assert!(res.is_ok());
|
||||
let json_inner_msg = res.unwrap();
|
||||
|
||||
let server_msg = ServerMessage::InMessage {
|
||||
from: "alice".to_string(),
|
||||
to: vec!["bob".to_string()],
|
||||
msg: json_inner_msg,
|
||||
};
|
||||
|
||||
let res = serde_json::to_string(&server_msg);
|
||||
assert!(res.is_ok());
|
||||
let json_server_msg = res.unwrap();
|
||||
|
||||
////
|
||||
|
||||
if let Ok(chat_message) = serde_json::from_str::<ServerMessage>(&json_server_msg) {
|
||||
assert_eq!(chat_message, server_msg);
|
||||
match chat_message {
|
||||
ServerMessage::InMessage { from, to, msg } => {
|
||||
if let Ok(chat_msg) = serde_json::from_str::<ChatMessages>(&msg) {
|
||||
assert_eq!(chat_msg, inner_msg);
|
||||
}
|
||||
}
|
||||
ServerMessage::SystemJoin { username } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{mpsc, Mutex},
|
||||
};
|
||||
use tokio_tungstenite::{accept_async, tungstenite::protocol::Message};
|
||||
|
||||
use crate::DeliveryServiceError;
|
||||
|
||||
type Tx = mpsc::UnboundedSender<Message>;
|
||||
type PeerMap = Arc<Mutex<HashMap<String, Tx>>>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ServerMessage {
|
||||
InMessage {
|
||||
from: String,
|
||||
to: Vec<String>,
|
||||
msg: String,
|
||||
},
|
||||
SystemJoin {
|
||||
username: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn start_server(addr: &str) -> Result<(), DeliveryServiceError> {
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
let peers = PeerMap::new(Mutex::new(HashMap::new()));
|
||||
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
let peers = peers.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(peers, stream).await {
|
||||
eprintln!("Error in connection handling: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
peers: PeerMap,
|
||||
stream: tokio::net::TcpStream,
|
||||
) -> Result<(), DeliveryServiceError> {
|
||||
let ws_stream = accept_async(stream).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
let receiver = Arc::new(Mutex::new(receiver));
|
||||
|
||||
let mut username = String::new();
|
||||
|
||||
// Spawn a task to handle outgoing messages
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = receiver.lock().await.recv().await {
|
||||
if let Err(err) = write.send(message).await {
|
||||
return Err(DeliveryServiceError::SenderError(err.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
while let Some(Ok(Message::Text(text))) = read.next().await {
|
||||
if let Ok(chat_message) = serde_json::from_str::<ServerMessage>(&text) {
|
||||
match chat_message {
|
||||
ServerMessage::SystemJoin {
|
||||
username: join_username,
|
||||
} => {
|
||||
username = join_username.clone();
|
||||
peers
|
||||
.lock()
|
||||
.await
|
||||
.insert(join_username.clone(), sender.clone());
|
||||
println!("{} joined the chat", join_username);
|
||||
}
|
||||
ServerMessage::InMessage { from, to, msg } => {
|
||||
println!("Received message from {} to {:?}: {}", from, to, msg);
|
||||
for recipient in to {
|
||||
if let Some(recipient_sender) = peers.lock().await.get(&recipient) {
|
||||
let message = ServerMessage::InMessage {
|
||||
from: from.clone(),
|
||||
to: vec![recipient.clone()],
|
||||
msg: msg.clone(),
|
||||
};
|
||||
let message_json = serde_json::to_string(&message).unwrap();
|
||||
recipient_sender
|
||||
.send(Message::Text(message_json))
|
||||
.map_err(|err| {
|
||||
DeliveryServiceError::SenderError(err.to_string())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the user from the map when they disconnect
|
||||
if !username.is_empty() {
|
||||
peers.lock().await.remove(&username);
|
||||
println!("{} left the chat", username);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_test() {
|
||||
start_server("127.0.0.1:8080").await.unwrap()
|
||||
}
|
||||
60
ds/src/ds.rs
60
ds/src/ds.rs
@@ -1,60 +0,0 @@
|
||||
use fred::{
|
||||
clients::{RedisClient, SubscriberClient},
|
||||
prelude::*,
|
||||
types::Message,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast::Receiver;
|
||||
|
||||
use crate::DeliveryServiceError;
|
||||
// use waku_bindings::*;
|
||||
|
||||
pub struct RClient {
|
||||
client: RedisClient,
|
||||
sub_client: SubscriberClient,
|
||||
// broadcaster: Receiver<Message>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SenderStruct {
|
||||
pub sender: String,
|
||||
pub msg: Vec<u8>,
|
||||
}
|
||||
|
||||
impl RClient {
|
||||
pub async fn new_with_group(
|
||||
group_id: String,
|
||||
) -> Result<(Self, Receiver<Message>), DeliveryServiceError> {
|
||||
let redis_client = RedisClient::default();
|
||||
let subscriber: SubscriberClient =
|
||||
Builder::default_centralized().build_subscriber_client()?;
|
||||
redis_client.init().await?;
|
||||
subscriber.init().await?;
|
||||
subscriber.subscribe(group_id.clone()).await?;
|
||||
Ok((
|
||||
RClient {
|
||||
client: redis_client,
|
||||
sub_client: subscriber.clone(),
|
||||
},
|
||||
subscriber.message_rx(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remove_group(&mut self, group_id: String) -> Result<(), DeliveryServiceError> {
|
||||
self.sub_client.unsubscribe(group_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn msg_send(
|
||||
&mut self,
|
||||
msg: Vec<u8>,
|
||||
sender: String,
|
||||
group_id: String,
|
||||
) -> Result<(), DeliveryServiceError> {
|
||||
let json_value = SenderStruct { sender, msg };
|
||||
let bytes = serde_json::to_vec(&json_value)?;
|
||||
self.client.publish(group_id, bytes.as_slice()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,67 @@
|
||||
use alloy::{hex::FromHexError, primitives::SignatureError};
|
||||
use fred::error::RedisError;
|
||||
use std::borrow::Cow;
|
||||
use waku_bindings::{node::PubsubTopic, Encoding, WakuContentTopic};
|
||||
|
||||
pub mod chat_client;
|
||||
pub mod chat_server;
|
||||
pub mod ds;
|
||||
pub mod topic_filter;
|
||||
pub mod waku_actor;
|
||||
|
||||
pub const GROUP_VERSION: &str = "1";
|
||||
pub const APP_MSG_SUBTOPIC: &str = "app_msg";
|
||||
pub const WELCOME_SUBTOPIC: &str = "welcome";
|
||||
pub const SUBTOPICS: [&str; 2] = [APP_MSG_SUBTOPIC, WELCOME_SUBTOPIC];
|
||||
|
||||
/// The pubsub topic for the Waku Node
|
||||
/// Fixed for now because nodes on the network would need to be subscribed to existing pubsub topics
|
||||
pub fn pubsub_topic() -> PubsubTopic {
|
||||
PubsubTopic::new("/waku/2/rs/15/1")
|
||||
}
|
||||
|
||||
/// Build the content topics for a group. Subtopics are fixed for de-mls group communication.
|
||||
///
|
||||
/// Input:
|
||||
/// - group_name: The name of the group
|
||||
///
|
||||
/// Returns:
|
||||
/// - content_topics: The content topics of the group
|
||||
pub fn build_content_topics(group_name: &str) -> Vec<WakuContentTopic> {
|
||||
SUBTOPICS
|
||||
.iter()
|
||||
.map(|subtopic| build_content_topic(group_name, GROUP_VERSION, subtopic))
|
||||
.collect::<Vec<WakuContentTopic>>()
|
||||
}
|
||||
|
||||
/// Build the content topic for the given group and subtopic
|
||||
/// Input:
|
||||
/// - group_name: The name of the group
|
||||
/// - group_version: The version of the group
|
||||
/// - subtopic: The subtopic of the group
|
||||
///
|
||||
/// Returns:
|
||||
/// - content_topic: The content topic of the subtopic
|
||||
pub fn build_content_topic(
|
||||
group_name: &str,
|
||||
group_version: &str,
|
||||
subtopic: &str,
|
||||
) -> WakuContentTopic {
|
||||
WakuContentTopic {
|
||||
application_name: Cow::from(group_name.to_string()),
|
||||
version: Cow::from(group_version.to_string()),
|
||||
content_topic_name: Cow::from(subtopic.to_string()),
|
||||
encoding: Encoding::Proto,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeliveryServiceError {
|
||||
#[error("Validation failed: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Redis operation failed: {0}")]
|
||||
RedisError(#[from] RedisError),
|
||||
#[error("Failed to send message to channel: {0}")]
|
||||
SenderError(String),
|
||||
#[error("WebSocket handshake failed.")]
|
||||
HandshakeError(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
TlsError(#[from] tls_codec::Error),
|
||||
#[error("JSON processing error: {0}")]
|
||||
JsonError(#[from] serde_json::Error),
|
||||
#[error("Failed to bind to the address.")]
|
||||
BindError(#[from] std::io::Error),
|
||||
|
||||
#[error("Failed to parse address: {0}")]
|
||||
AlloyFromHexError(#[from] FromHexError),
|
||||
#[error("Failed to recover signature: {0}")]
|
||||
AlloySignatureError(#[from] SignatureError),
|
||||
#[error("Waku publish message error: {0}")]
|
||||
WakuPublishMessageError(String),
|
||||
#[error("Waku subscribe to pubsub topic error: {0}")]
|
||||
WakuSubscribeToPubsubTopicError(String),
|
||||
#[error("Waku node already initialized: {0}")]
|
||||
WakuNodeAlreadyInitialized(String),
|
||||
#[error("Waku connect peer error: {0}")]
|
||||
WakuConnectPeerError(String),
|
||||
#[error("Waku get listen addresses error: {0}")]
|
||||
WakuGetListenAddressesError(String),
|
||||
|
||||
#[error("An unknown error occurred: {0}")]
|
||||
Other(anyhow::Error),
|
||||
|
||||
52
ds/src/topic_filter.rs
Normal file
52
ds/src/topic_filter.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! This module contains the topic filter for the Waku node
|
||||
use tokio::sync::RwLock;
|
||||
use waku_bindings::WakuContentTopic;
|
||||
|
||||
use crate::build_content_topics;
|
||||
|
||||
/// Fast allowlist for content topics without requiring Hash.
|
||||
/// Internally uses a Vec and dedupes on insert.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct TopicFilter {
|
||||
list: RwLock<Vec<WakuContentTopic>>,
|
||||
}
|
||||
|
||||
impl TopicFilter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Build and add topics if not already present.
|
||||
pub async fn add_many(&self, group_name: &str) {
|
||||
let topics = build_content_topics(group_name);
|
||||
self.list.write().await.extend(topics);
|
||||
}
|
||||
|
||||
/// Remove any matching topics.
|
||||
pub async fn remove_many(&self, group_name: &str) {
|
||||
let topics = build_content_topics(group_name);
|
||||
self.list
|
||||
.write()
|
||||
.await
|
||||
.retain(|x| !topics.iter().any(|t| t == x));
|
||||
}
|
||||
|
||||
/// Membership test (first-stage filter).
|
||||
#[inline]
|
||||
pub async fn contains(&self, t: &WakuContentTopic) -> bool {
|
||||
self.list.read().await.iter().any(|x| x == t)
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> Vec<WakuContentTopic> {
|
||||
self.list.read().await.clone()
|
||||
}
|
||||
|
||||
pub async fn get_group_name(&self, t: &WakuContentTopic) -> Option<String> {
|
||||
self.list
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.find(|x| x == &t)
|
||||
.map(|x| x.application_name.clone().to_string())
|
||||
}
|
||||
}
|
||||
206
ds/src/waku_actor.rs
Normal file
206
ds/src/waku_actor.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{thread::sleep, time::Duration};
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::{debug, error, info};
|
||||
use waku_bindings::{
|
||||
node::{WakuNodeConfig, WakuNodeHandle},
|
||||
waku_new, Initialized, LibwakuResponse, Multiaddr, Running, WakuEvent, WakuMessage,
|
||||
};
|
||||
|
||||
use crate::{build_content_topic, pubsub_topic, DeliveryServiceError, GROUP_VERSION};
|
||||
|
||||
pub struct WakuNode<State> {
|
||||
node: WakuNodeHandle<State>,
|
||||
}
|
||||
|
||||
impl WakuNode<Initialized> {
|
||||
/// Create a new WakuNode
|
||||
/// Input:
|
||||
/// - node: The Waku Node to handle. Waku Node is already running
|
||||
pub async fn new(port: usize) -> Result<WakuNode<Initialized>, DeliveryServiceError> {
|
||||
info!("Initializing waku node inside ");
|
||||
// Note: here we are auto-subscribing to the pubsub topic /waku/2/rs/15/1
|
||||
let waku = waku_new(Some(WakuNodeConfig {
|
||||
tcp_port: Some(port),
|
||||
cluster_id: Some(15),
|
||||
shards: vec![1],
|
||||
log_level: Some("FATAL"), // Supported: TRACE, DEBUG, INFO, NOTICE, WARN, ERROR or FATAL
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| DeliveryServiceError::WakuNodeAlreadyInitialized(e.to_string()))?;
|
||||
info!("Waku node initialized");
|
||||
Ok(WakuNode { node: waku })
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
self,
|
||||
waku_sender: Sender<WakuMessage>,
|
||||
) -> Result<WakuNode<Running>, DeliveryServiceError> {
|
||||
let closure = move |response| {
|
||||
if let LibwakuResponse::Success(v) = response {
|
||||
let event: WakuEvent =
|
||||
serde_json::from_str(v.unwrap().as_str()).expect("Parsing event to succeed");
|
||||
match event {
|
||||
WakuEvent::WakuMessage(evt) => {
|
||||
debug!("WakuMessage event received: {:?}", evt.message_hash);
|
||||
waku_sender
|
||||
.blocking_send(evt.waku_message.clone())
|
||||
.expect("Failed to send message to waku");
|
||||
}
|
||||
WakuEvent::RelayTopicHealthChange(evt) => {
|
||||
debug!("Relay topic change evt: {evt:?}");
|
||||
}
|
||||
WakuEvent::ConnectionChange(evt) => {
|
||||
debug!("Conn change evt: {evt:?}");
|
||||
}
|
||||
WakuEvent::Unrecognized(e) => panic!("Unrecognized waku event: {e:?}"),
|
||||
_ => panic!("event case not expected"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
self.node
|
||||
.set_event_callback(closure)
|
||||
.expect("set event call back working");
|
||||
|
||||
let waku = self.node.start().await.map_err(|e| {
|
||||
debug!("Failed to start the Waku Node: {e:?}");
|
||||
DeliveryServiceError::WakuNodeAlreadyInitialized(e.to_string())
|
||||
})?;
|
||||
|
||||
sleep(Duration::from_secs(2));
|
||||
|
||||
// Note: we are not subscribing to the pubsub topic here because we are already subscribed to it
|
||||
// and from waku side we can't check if we are subscribed to it or not
|
||||
// issue - https://github.com/waku-org/nwaku/issues/3246
|
||||
// waku.relay_subscribe(&pubsub_topic()).await.map_err(|e| {
|
||||
// debug!("Failed to subscribe to the Waku Node: {:?}", e);
|
||||
// DeliveryServiceError::WakuSubscribeToPubsubTopicError(e)
|
||||
// })?;
|
||||
|
||||
Ok(WakuNode { node: waku })
|
||||
}
|
||||
}
|
||||
|
||||
impl WakuNode<Running> {
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
msg: WakuMessageToSend,
|
||||
) -> Result<String, DeliveryServiceError> {
|
||||
let waku_message = msg.build_waku_message()?;
|
||||
let msg_id = self
|
||||
.node
|
||||
.relay_publish_message(&waku_message, &pubsub_topic(), None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to relay publish the message: {e:?}");
|
||||
DeliveryServiceError::WakuPublishMessageError(e)
|
||||
})?;
|
||||
|
||||
Ok(msg_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn connect_to_peers(
|
||||
&self,
|
||||
peer_addresses: Vec<Multiaddr>,
|
||||
) -> Result<(), DeliveryServiceError> {
|
||||
for peer_address in peer_addresses {
|
||||
info!("Connecting to peer: {peer_address:?}");
|
||||
self.node
|
||||
.connect(&peer_address, Some(Duration::from_secs(10)))
|
||||
.await
|
||||
.map_err(|e| DeliveryServiceError::WakuConnectPeerError(e.to_string()))?;
|
||||
info!("Connected to peer: {peer_address:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn listen_addresses(&self) -> Result<Vec<Multiaddr>, DeliveryServiceError> {
|
||||
let addresses = self.node.listen_addresses().await.map_err(|e| {
|
||||
debug!("Failed to get the listen addresses: {e:?}");
|
||||
DeliveryServiceError::WakuGetListenAddressesError(e)
|
||||
})?;
|
||||
|
||||
Ok(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
/// Message to send to the Waku Node
|
||||
/// This message is used to send a message to the Waku Node
|
||||
/// Input:
|
||||
/// - msg: The message to send
|
||||
/// - 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
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct WakuMessageToSend {
|
||||
msg: Vec<u8>,
|
||||
subtopic: String,
|
||||
group_id: String,
|
||||
app_id: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WakuMessageToSend {
|
||||
/// Create a new WakuMessageToSend
|
||||
/// Input:
|
||||
/// - msg: The message to send
|
||||
/// - 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<u8>, subtopic: &str, group_id: &str, app_id: &[u8]) -> Self {
|
||||
Self {
|
||||
msg,
|
||||
subtopic: subtopic.to_string(),
|
||||
group_id: group_id.to_string(),
|
||||
app_id: app_id.to_vec(),
|
||||
}
|
||||
}
|
||||
/// Build a WakuMessage from the message to send
|
||||
/// Input:
|
||||
/// - msg: The message to send
|
||||
///
|
||||
/// Returns:
|
||||
/// - WakuMessage: The WakuMessage to send
|
||||
pub fn build_waku_message(&self) -> Result<WakuMessage, DeliveryServiceError> {
|
||||
let content_topic = build_content_topic(&self.group_id, GROUP_VERSION, &self.subtopic);
|
||||
Ok(WakuMessage::new(
|
||||
self.msg.clone(),
|
||||
content_topic,
|
||||
2,
|
||||
self.app_id.clone(),
|
||||
true,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_waku_node(
|
||||
node_port: String,
|
||||
peer_addresses: Option<Vec<Multiaddr>>,
|
||||
waku_sender: Sender<WakuMessage>,
|
||||
receiver: &mut Receiver<WakuMessageToSend>,
|
||||
) -> Result<(), DeliveryServiceError> {
|
||||
info!("Initializing waku node");
|
||||
let waku_node_init = WakuNode::new(
|
||||
node_port
|
||||
.parse::<usize>()
|
||||
.expect("Failed to parse node port"),
|
||||
)
|
||||
.await?;
|
||||
let waku_node = waku_node_init.start(waku_sender).await?;
|
||||
info!("Waku node started");
|
||||
|
||||
if let Some(peer_addresses) = peer_addresses {
|
||||
waku_node.connect_to_peers(peer_addresses).await?;
|
||||
info!("Connected to all peers");
|
||||
}
|
||||
|
||||
info!("Waiting for message to send to waku");
|
||||
while let Some(msg) = receiver.recv().await {
|
||||
debug!("Received message to send to waku");
|
||||
let id = waku_node.send_message(msg).await?;
|
||||
debug!("Successfully publish message with id: {id:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
109
ds/tests/ds_waku_test.rs
Normal file
109
ds/tests/ds_waku_test.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use ds::{
|
||||
waku_actor::{WakuMessageToSend, WakuNode},
|
||||
DeliveryServiceError, APP_MSG_SUBTOPIC,
|
||||
};
|
||||
use kameo::{
|
||||
actor::pubsub::PubSub,
|
||||
message::{Context, Message},
|
||||
Actor,
|
||||
};
|
||||
use tokio::sync::mpsc::channel;
|
||||
use tracing::info;
|
||||
use waku_bindings::WakuMessage;
|
||||
|
||||
#[derive(Debug, Clone, Actor)]
|
||||
pub struct Application {
|
||||
pub app_id: String,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
pub fn new() -> Self {
|
||||
let app_id = uuid::Uuid::new_v4().to_string();
|
||||
Self { app_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Application {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Message<WakuMessage> for Application {
|
||||
type Reply = Result<WakuMessage, DeliveryServiceError>;
|
||||
|
||||
async fn handle(
|
||||
&mut self,
|
||||
msg: WakuMessage,
|
||||
_ctx: Context<'_, Self, Self::Reply>,
|
||||
) -> Self::Reply {
|
||||
info!("Application received message: {:?}", msg.timestamp);
|
||||
Ok(msg)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_waku_client() {
|
||||
tracing_subscriber::fmt::init();
|
||||
let group_name = "new_group";
|
||||
let mut pubsub = PubSub::<WakuMessage>::new();
|
||||
|
||||
let (sender, _) = channel::<WakuMessage>(100);
|
||||
let waku_node_default = WakuNode::new(60002)
|
||||
.await
|
||||
.expect("Failed to create WakuNode");
|
||||
|
||||
let (sender_alice, mut receiver_alice) = channel::<WakuMessage>(100);
|
||||
let waku_node_init = WakuNode::new(60001)
|
||||
.await
|
||||
.expect("Failed to create WakuNode");
|
||||
|
||||
let uuid = uuid::Uuid::new_v4().as_bytes().to_vec();
|
||||
let actor_a = Application::new();
|
||||
let actor_a_ref = kameo::spawn(actor_a);
|
||||
pubsub.subscribe(actor_a_ref);
|
||||
|
||||
// let content_topics = Arc::new(Mutex::new(build_content_topics(&group_name)));
|
||||
|
||||
let waku_node_default = waku_node_default
|
||||
.start(sender)
|
||||
.await
|
||||
.expect("Failed to start WakuNode");
|
||||
|
||||
let node_name = waku_node_default
|
||||
.listen_addresses()
|
||||
.await
|
||||
.expect("Failed to get listen addresses");
|
||||
let waku_node = waku_node_init
|
||||
.start(sender_alice)
|
||||
.await
|
||||
.expect("Failed to start WakuNode");
|
||||
|
||||
waku_node
|
||||
.connect_to_peers(node_name)
|
||||
.await
|
||||
.expect("Failed to connect to peers");
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = receiver_alice.recv().await {
|
||||
info!("msg received from receiver_alice: {:?}", msg.timestamp);
|
||||
pubsub.publish(msg).await;
|
||||
}
|
||||
info!("receiver handle is finished");
|
||||
});
|
||||
|
||||
tokio::task::block_in_place(move || {
|
||||
tokio::runtime::Handle::current().block_on(async move {
|
||||
let res = waku_node
|
||||
.send_message(WakuMessageToSend::new(
|
||||
"test_message_1".as_bytes().to_vec(),
|
||||
APP_MSG_SUBTOPIC,
|
||||
group_name,
|
||||
&uuid,
|
||||
))
|
||||
.await;
|
||||
info!("res: {:?}", res);
|
||||
info!("sender handle is finished");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
[package]
|
||||
name = "mls_crypto"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
openmls = { version = "=0.5.0", features = ["test-utils"] }
|
||||
openmls_basic_credential = "=0.2.0"
|
||||
openmls_rust_crypto = "=0.2.0"
|
||||
openmls_traits = "=0.2.0"
|
||||
openmls = { version = "0.6.0"}
|
||||
openmls_basic_credential = "0.3.0"
|
||||
openmls_rust_crypto = "0.3.0"
|
||||
openmls_traits = "0.3.0"
|
||||
|
||||
anyhow = "1.0.81"
|
||||
thiserror = "1.0.39"
|
||||
|
||||
alloy = { version = "1.0.37", features = [
|
||||
"providers",
|
||||
"node-bindings",
|
||||
"network",
|
||||
"transports",
|
||||
"k256",
|
||||
"signer-local",
|
||||
] }
|
||||
|
||||
22
mls_crypto/src/error.rs
Normal file
22
mls_crypto/src/error.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use openmls::{
|
||||
error::LibraryError,
|
||||
prelude::{CredentialError, KeyPackageNewError},
|
||||
};
|
||||
use openmls_rust_crypto::MemoryStorageError;
|
||||
use openmls_traits::types::CryptoError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IdentityError {
|
||||
#[error(transparent)]
|
||||
UnableToCreateKeyPackage(#[from] KeyPackageNewError),
|
||||
#[error("Invalid hash reference: {0}")]
|
||||
InvalidHashRef(#[from] LibraryError),
|
||||
#[error("Unable to create new signer: {0}")]
|
||||
UnableToCreateSigner(#[from] CryptoError),
|
||||
#[error("Unable to save signature key: {0}")]
|
||||
UnableToSaveSignatureKey(#[from] MemoryStorageError),
|
||||
#[error("Unable to create credential: {0}")]
|
||||
UnableToCreateCredential(#[from] CredentialError),
|
||||
#[error("Invalid wallet address: {0}")]
|
||||
InvalidWalletAddress(String),
|
||||
}
|
||||
206
mls_crypto/src/identity.rs
Normal file
206
mls_crypto/src/identity.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use alloy::{hex, primitives::Address, signers::local::PrivateKeySigner};
|
||||
use openmls::{credentials::CredentialWithKey, key_packages::KeyPackage, prelude::BasicCredential};
|
||||
use openmls_basic_credential::SignatureKeyPair;
|
||||
use openmls_traits::{types::Ciphersuite, OpenMlsProvider};
|
||||
use std::{collections::HashMap, fmt::Display, str::FromStr};
|
||||
|
||||
use crate::error::IdentityError;
|
||||
use crate::openmls_provider::{MlsProvider, CIPHERSUITE};
|
||||
|
||||
pub struct Identity {
|
||||
pub(crate) kp: HashMap<Vec<u8>, KeyPackage>,
|
||||
pub(crate) credential_with_key: CredentialWithKey,
|
||||
pub(crate) signer: SignatureKeyPair,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn new(
|
||||
ciphersuite: Ciphersuite,
|
||||
provider: &MlsProvider,
|
||||
user_wallet_address: &[u8],
|
||||
) -> Result<Identity, IdentityError> {
|
||||
let credential = BasicCredential::new(user_wallet_address.to_vec());
|
||||
let signer = SignatureKeyPair::new(ciphersuite.signature_algorithm())?;
|
||||
let credential_with_key = CredentialWithKey {
|
||||
credential: credential.into(),
|
||||
signature_key: signer.to_public_vec().into(),
|
||||
};
|
||||
signer.store(provider.storage())?;
|
||||
|
||||
let mut kps = HashMap::new();
|
||||
let key_package_bundle = KeyPackage::builder().build(
|
||||
CIPHERSUITE,
|
||||
provider,
|
||||
&signer,
|
||||
credential_with_key.clone(),
|
||||
)?;
|
||||
let key_package = key_package_bundle.key_package();
|
||||
let kp = key_package.hash_ref(provider.crypto())?;
|
||||
kps.insert(kp.as_slice().to_vec(), key_package.clone());
|
||||
|
||||
Ok(Identity {
|
||||
kp: kps,
|
||||
credential_with_key,
|
||||
signer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an additional key package using the credential_with_key/signer bound to this identity
|
||||
pub fn generate_key_package(
|
||||
&mut self,
|
||||
crypto: &MlsProvider,
|
||||
) -> Result<KeyPackage, IdentityError> {
|
||||
let key_package_bundle = KeyPackage::builder().build(
|
||||
CIPHERSUITE,
|
||||
crypto,
|
||||
&self.signer,
|
||||
self.credential_with_key.clone(),
|
||||
)?;
|
||||
let key_package = key_package_bundle.key_package();
|
||||
let kp = key_package.hash_ref(crypto.crypto())?;
|
||||
self.kp.insert(kp.as_slice().to_vec(), key_package.clone());
|
||||
Ok(key_package.clone())
|
||||
}
|
||||
|
||||
/// Get the plain identity as byte vector.
|
||||
pub fn identity(&self) -> &[u8] {
|
||||
self.credential_with_key.credential.serialized_content()
|
||||
}
|
||||
|
||||
pub fn identity_string(&self) -> String {
|
||||
Address::from_slice(self.credential_with_key.credential.serialized_content()).to_string()
|
||||
}
|
||||
|
||||
pub fn signer(&self) -> &SignatureKeyPair {
|
||||
&self.signer
|
||||
}
|
||||
|
||||
pub fn credential_with_key(&self) -> CredentialWithKey {
|
||||
self.credential_with_key.clone()
|
||||
}
|
||||
|
||||
pub fn signature_key(&self) -> Vec<u8> {
|
||||
self.credential_with_key.signature_key.as_slice().to_vec()
|
||||
}
|
||||
|
||||
pub fn is_key_package_exists(&self, kp_hash_ref: &[u8]) -> bool {
|
||||
self.kp.contains_key(kp_hash_ref)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Identity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.identity_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn random_identity() -> Result<Identity, IdentityError> {
|
||||
let signer = PrivateKeySigner::random();
|
||||
let user_address = signer.address();
|
||||
|
||||
let crypto = MlsProvider::default();
|
||||
let id = Identity::new(CIPHERSUITE, &crypto, user_address.as_slice())?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Validates and normalizes Ethereum-style wallet addresses.
|
||||
///
|
||||
/// Accepts either `0x`-prefixed or raw 40-character hex strings, returning a lowercase,
|
||||
/// `0x`-prefixed representation on success.
|
||||
pub fn normalize_wallet_address_str(address: &str) -> Result<String, IdentityError> {
|
||||
parse_wallet_address(address).map(|addr| addr.to_string())
|
||||
}
|
||||
|
||||
/// Parses an Ethereum wallet address into an [`Address`] after validation.
|
||||
///
|
||||
/// This ensures the address is 20 bytes / 40 hex chars and contains only hexadecimal digits.
|
||||
pub fn parse_wallet_address(address: &str) -> Result<Address, IdentityError> {
|
||||
let trimmed = address.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(IdentityError::InvalidWalletAddress(address.to_string()));
|
||||
}
|
||||
|
||||
let hex_part = trimmed
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| trimmed.strip_prefix("0X"))
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
if hex_part.len() != 40 || !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(IdentityError::InvalidWalletAddress(trimmed.to_string()));
|
||||
}
|
||||
|
||||
let normalized = format!("0x{}", hex_part.to_ascii_lowercase());
|
||||
Address::from_str(&normalized)
|
||||
.map_err(|_| IdentityError::InvalidWalletAddress(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn is_prefixed_hex(input: &str) -> bool {
|
||||
let rest = input
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| input.strip_prefix("0X"));
|
||||
match rest {
|
||||
Some(hex_part) if !hex_part.is_empty() => hex_part.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_raw_hex(input: &str) -> bool {
|
||||
!input.is_empty() && input.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub fn normalize_wallet_address(raw: &[u8]) -> String {
|
||||
let as_utf8 = std::str::from_utf8(raw)
|
||||
.map(|s| s.trim())
|
||||
.unwrap_or_default();
|
||||
|
||||
if is_prefixed_hex(as_utf8) {
|
||||
return as_utf8.to_string();
|
||||
}
|
||||
|
||||
if is_raw_hex(as_utf8) {
|
||||
return format!("0x{}", as_utf8);
|
||||
}
|
||||
|
||||
if raw.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("0x{}", hex::encode(raw))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_prefixed_hex, normalize_wallet_address};
|
||||
|
||||
#[test]
|
||||
fn keeps_prefixed_hex() {
|
||||
let addr = normalize_wallet_address(b"0xAbCd1234");
|
||||
assert_eq!(addr, "0xAbCd1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_raw_hex() {
|
||||
let addr = normalize_wallet_address(b"ABCD1234");
|
||||
assert_eq!(addr, "0xABCD1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_binary_bytes() {
|
||||
let addr = normalize_wallet_address(&[0x11, 0x22, 0x33]);
|
||||
assert_eq!(addr, "0x112233");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_ascii_input() {
|
||||
let addr = normalize_wallet_address(b" 0x1F ");
|
||||
assert_eq!(addr, "0x1F");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixed_hex_helper() {
|
||||
assert!(is_prefixed_hex("0xabc"));
|
||||
assert!(is_prefixed_hex("0XABC"));
|
||||
assert!(!is_prefixed_hex("abc"));
|
||||
assert!(!is_prefixed_hex("0x"));
|
||||
}
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod openmls_provider;
|
||||
|
||||
pub use identity::{normalize_wallet_address_str, parse_wallet_address};
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
use openmls::prelude::*;
|
||||
use openmls_rust_crypto::MemoryKeyStore;
|
||||
use openmls_rust_crypto::RustCrypto;
|
||||
use openmls_traits::OpenMlsCryptoProvider;
|
||||
use openmls::prelude::Ciphersuite;
|
||||
use openmls_rust_crypto::{MemoryStorage, RustCrypto};
|
||||
use openmls_traits::OpenMlsProvider;
|
||||
|
||||
pub const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MlsCryptoProvider {
|
||||
pub struct MlsProvider {
|
||||
crypto: RustCrypto,
|
||||
key_storage: MemoryKeyStore,
|
||||
storage: MemoryStorage,
|
||||
}
|
||||
|
||||
impl OpenMlsCryptoProvider for MlsCryptoProvider {
|
||||
impl OpenMlsProvider for MlsProvider {
|
||||
type CryptoProvider = RustCrypto;
|
||||
type RandProvider = RustCrypto;
|
||||
type KeyStoreProvider = MemoryKeyStore;
|
||||
type StorageProvider = MemoryStorage;
|
||||
|
||||
fn crypto(&self) -> &Self::CryptoProvider {
|
||||
&self.crypto
|
||||
@@ -24,7 +23,7 @@ impl OpenMlsCryptoProvider for MlsCryptoProvider {
|
||||
&self.crypto
|
||||
}
|
||||
|
||||
fn key_store(&self) -> &Self::KeyStoreProvider {
|
||||
&self.key_storage
|
||||
fn storage(&self) -> &Self::StorageProvider {
|
||||
&self.storage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "sc_key_store"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
foundry-contracts.workspace = true
|
||||
openmls = { version = "=0.5.0", features = ["test-utils"] }
|
||||
openmls_basic_credential = "=0.2.0"
|
||||
|
||||
thiserror = "=1.0.61"
|
||||
anyhow = "=1.0.81"
|
||||
|
||||
tls_codec = "=0.3.0"
|
||||
hex = "0.4.3"
|
||||
url = "2.5.2"
|
||||
|
||||
eyre = "=0.6"
|
||||
tokio = { version = "=1.38.0", features = ["macros", "rt-multi-thread"] }
|
||||
alloy = { git = "https://github.com/alloy-rs/alloy", features = [
|
||||
"providers",
|
||||
"node-bindings",
|
||||
"network",
|
||||
"signer-local",
|
||||
"transports",
|
||||
"transport-http",
|
||||
"k256",
|
||||
] }
|
||||
|
||||
mls_crypto = { path = "../mls_crypto" }
|
||||
@@ -1,36 +0,0 @@
|
||||
pub mod sc_ks;
|
||||
|
||||
use alloy::hex::FromHexError;
|
||||
|
||||
pub trait SCKeyStoreService {
|
||||
fn does_user_exist(
|
||||
&self,
|
||||
address: &str,
|
||||
) -> impl std::future::Future<Output = Result<bool, KeyStoreError>>;
|
||||
fn add_user(
|
||||
&mut self,
|
||||
address: &str,
|
||||
) -> impl std::future::Future<Output = Result<(), KeyStoreError>>;
|
||||
fn remove_user(
|
||||
&self,
|
||||
address: &str,
|
||||
) -> impl std::future::Future<Output = Result<(), KeyStoreError>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum KeyStoreError {
|
||||
#[error("User already exists.")]
|
||||
UserAlreadyExistsError,
|
||||
|
||||
#[error("User not found.")]
|
||||
UserNotFoundError,
|
||||
|
||||
#[error("Alloy contract operation failed: {0}")]
|
||||
AlloyContractError(#[from] alloy::contract::Error),
|
||||
|
||||
#[error("Failed to parse address: {0}")]
|
||||
AddressParseError(#[from] FromHexError),
|
||||
|
||||
#[error("An unexpected error occurred: {0}")]
|
||||
UnexpectedError(anyhow::Error),
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
use alloy::{network::Network, primitives::Address, providers::Provider, transports::Transport};
|
||||
use foundry_contracts::sckeystore::ScKeystore::{self, ScKeystoreInstance};
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{KeyStoreError, SCKeyStoreService};
|
||||
|
||||
pub struct ScKeyStorage<T, P, N> {
|
||||
instance: ScKeystoreInstance<T, P, N>,
|
||||
address: String,
|
||||
}
|
||||
|
||||
impl<T, P, N> ScKeyStorage<T, P, N>
|
||||
where
|
||||
T: Transport + Clone,
|
||||
P: Provider<T, N>,
|
||||
N: Network,
|
||||
{
|
||||
pub fn new(provider: P, address: Address) -> Self {
|
||||
Self {
|
||||
instance: ScKeystore::new(address, provider),
|
||||
address: address.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sc_adsress(&self) -> String {
|
||||
self.address.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transport + Clone, P: Provider<T, N>, N: Network> SCKeyStoreService
|
||||
for ScKeyStorage<T, P, N>
|
||||
{
|
||||
async fn does_user_exist(&self, address: &str) -> Result<bool, KeyStoreError> {
|
||||
let address = Address::from_str(address)?;
|
||||
let res = self.instance.userExists(address).call().await;
|
||||
match res {
|
||||
Ok(is_exist) => Ok(is_exist._0),
|
||||
Err(err) => Err(KeyStoreError::AlloyContractError(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_user(&mut self, address: &str) -> Result<(), KeyStoreError> {
|
||||
if self.does_user_exist(address).await? {
|
||||
return Err(KeyStoreError::UserAlreadyExistsError);
|
||||
}
|
||||
|
||||
let add_to_acl_binding = self.instance.addUser(Address::from_str(address)?);
|
||||
let res = add_to_acl_binding.send().await;
|
||||
match res {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(KeyStoreError::AlloyContractError(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_user(&self, address: &str) -> Result<(), KeyStoreError> {
|
||||
if !self.does_user_exist(address).await? {
|
||||
return Err(KeyStoreError::UserNotFoundError);
|
||||
}
|
||||
|
||||
let remove_user_binding = self.instance.removeUser(Address::from_str(address)?);
|
||||
let res = remove_user_binding.send().await;
|
||||
match res {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(KeyStoreError::AlloyContractError(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/bootstrap.rs
Normal file
108
src/bootstrap.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
// de_mls/src/bootstrap.rs
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info};
|
||||
use waku_bindings::{Multiaddr, WakuMessage};
|
||||
|
||||
use ds::waku_actor::{run_waku_node, WakuMessageToSend};
|
||||
|
||||
use crate::user_app_instance::{AppState, CoreCtx};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BootstrapConfig {
|
||||
/// TCP/UDP port for the embedded Waku node
|
||||
pub node_port: String,
|
||||
/// Comma-separated peer multiaddrs parsed into a vec
|
||||
pub peers: Vec<Multiaddr>,
|
||||
}
|
||||
|
||||
pub struct Bootstrap {
|
||||
pub core: Arc<CoreCtx>,
|
||||
/// Cancels the Waku→broadcast forwarder task
|
||||
pub cancel: CancellationToken,
|
||||
/// The thread running the Waku node runtime; join on shutdown if you want
|
||||
pub waku_thread: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Same wiring you previously did in `main.rs`, now reusable for server & desktop.
|
||||
pub async fn bootstrap_core(cfg: BootstrapConfig) -> anyhow::Result<Bootstrap> {
|
||||
// Channels used by AppState and Waku runtime
|
||||
let (waku_in_tx, mut waku_in_rx) = mpsc::channel::<WakuMessage>(100);
|
||||
let (to_waku_tx, mut to_waku_rx) = mpsc::channel::<WakuMessageToSend>(100);
|
||||
let (pubsub_tx, _) = broadcast::channel::<WakuMessage>(100);
|
||||
|
||||
let app_state = Arc::new(AppState {
|
||||
waku_node: to_waku_tx.clone(),
|
||||
pubsub: pubsub_tx.clone(),
|
||||
});
|
||||
|
||||
let core = Arc::new(CoreCtx::new(app_state.clone()));
|
||||
|
||||
// Forward Waku messages into broadcast
|
||||
let forward_cancel = CancellationToken::new();
|
||||
{
|
||||
let forward_cancel = forward_cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
info!("Forwarding Waku → broadcast started");
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = forward_cancel.cancelled() => break,
|
||||
maybe = waku_in_rx.recv() => {
|
||||
if let Some(msg) = maybe {
|
||||
let _ = pubsub_tx.send(msg);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Forwarding Waku → broadcast stopped");
|
||||
});
|
||||
}
|
||||
|
||||
// Start Waku node on a dedicated thread with its own Tokio runtime
|
||||
let node_port = cfg.node_port.clone();
|
||||
let peers = cfg.peers.clone();
|
||||
let waku_thread = std::thread::Builder::new()
|
||||
.name("waku-node".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("waku tokio runtime");
|
||||
rt.block_on(async move {
|
||||
if let Err(e) =
|
||||
run_waku_node(node_port, Some(peers), waku_in_tx, &mut to_waku_rx).await
|
||||
{
|
||||
error!("run_waku_node failed: {e}");
|
||||
}
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(Bootstrap {
|
||||
core,
|
||||
cancel: forward_cancel,
|
||||
waku_thread,
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper that exactly mirrors your current env usage:
|
||||
/// - requires NODE_PORT
|
||||
/// - requires PEER_ADDRESSES (comma-separated multiaddrs)
|
||||
pub async fn bootstrap_core_from_env() -> anyhow::Result<Bootstrap> {
|
||||
use anyhow::Context;
|
||||
|
||||
let node_port = std::env::var("NODE_PORT").context("NODE_PORT is not set")?;
|
||||
let peer_addresses = std::env::var("PEER_ADDRESSES").context("PEER_ADDRESSES is not set")?;
|
||||
let peers = peer_addresses
|
||||
.split(',')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| {
|
||||
s.parse::<Multiaddr>()
|
||||
.context(format!("Failed to parse peer address: {s}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
bootstrap_core(BootstrapConfig { node_port, peers }).await
|
||||
}
|
||||
242
src/cli.rs
242
src/cli.rs
@@ -1,242 +0,0 @@
|
||||
use clap::{arg, command, Parser, Subcommand};
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Text,
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
use std::{
|
||||
io::{stdout, Read, Write},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{
|
||||
sync::mpsc::{Receiver, Sender},
|
||||
sync::Mutex,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
|
||||
use crate::CliError;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
/// User private key that correspond to Ethereum wallet
|
||||
#[arg(short = 'K', long)]
|
||||
pub user_priv_key: String,
|
||||
// /// Rpc url
|
||||
// #[arg(short = 'U', long,
|
||||
// default_value_t = Url::from_str("http://localhost:8545").unwrap())]
|
||||
// pub storage_url: Url,
|
||||
|
||||
// /// Storage contract address
|
||||
// #[arg(short = 'S', long)]
|
||||
// pub storage_addr: String,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Input(Message),
|
||||
Refresh(String),
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Message {
|
||||
Incoming(String, String, String),
|
||||
Mine(String, String, String),
|
||||
System(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(multicall = true)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand, Clone)]
|
||||
pub enum Commands {
|
||||
CreateGroup {
|
||||
group_name: String,
|
||||
storage_address: String,
|
||||
storage_url: Url,
|
||||
},
|
||||
Invite {
|
||||
group_name: String,
|
||||
users_wallet_addrs: Vec<String>,
|
||||
},
|
||||
SendMessage {
|
||||
group_name: String,
|
||||
msg: Vec<String>,
|
||||
},
|
||||
// RemoveUser { user_wallet: String },
|
||||
Exit,
|
||||
}
|
||||
|
||||
pub fn readline() -> Result<String, CliError> {
|
||||
write!(std::io::stdout(), "$ ")?;
|
||||
std::io::stdout().flush()?;
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin().read_to_string(&mut buffer)?;
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
pub async fn event_handler(
|
||||
messages_tx: Sender<Msg>,
|
||||
cli_tx: Sender<Commands>,
|
||||
token: CancellationToken,
|
||||
) -> Result<(), CliError> {
|
||||
let mut input = String::new();
|
||||
loop {
|
||||
if let Event::Key(key) = tokio::task::spawn_blocking(event::read).await?? {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let line: String = std::mem::take(&mut input);
|
||||
let args = shlex::split(&line).ok_or(CliError::SplitLineError)?;
|
||||
let cli = Cli::try_parse_from(args);
|
||||
if cli.is_err() {
|
||||
messages_tx
|
||||
.send(Msg::Input(Message::System("Unknown command".to_string())))
|
||||
.await
|
||||
.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
cli_tx
|
||||
.send(cli.unwrap().command)
|
||||
.await
|
||||
.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
messages_tx
|
||||
.send(Msg::Exit)
|
||||
.await
|
||||
.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
token.cancel();
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
messages_tx
|
||||
.send(Msg::Refresh(input.clone()))
|
||||
.await
|
||||
.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
}
|
||||
Ok::<_, CliError>(())
|
||||
}
|
||||
|
||||
pub fn ui(f: &mut Frame, messages: &[Message], input: &str) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(1), Constraint::Length(3)].as_ref())
|
||||
.split(f.size());
|
||||
|
||||
let message_items: Vec<ListItem> = messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
let (content, style) = match message {
|
||||
Message::Incoming(group, from, msg) => (
|
||||
format!("[0x{}]@{}: {}", from, group, msg),
|
||||
Style::default().fg(Color::LightGreen),
|
||||
),
|
||||
Message::Mine(group, from, msg) => (
|
||||
format!("[0x{}]@{}: {}", from, group, msg),
|
||||
Style::default()
|
||||
.fg(Color::LightGreen)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Message::System(msg) => (format!("[System]: {}", msg), Style::default()),
|
||||
Message::Error(msg) => (msg.clone(), Style::default().fg(Color::LightRed)),
|
||||
};
|
||||
ListItem::new(content).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let messages_history = List::new(message_items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("messages history"),
|
||||
);
|
||||
|
||||
let input_line = Paragraph::new(Text::raw(input))
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.block(Block::default().borders(Borders::ALL).title("input line"))
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
f.render_widget(messages_history, chunks[0]);
|
||||
f.render_widget(input_line, chunks[1]);
|
||||
}
|
||||
|
||||
pub async fn terminal_handler(
|
||||
mut messages_rx: Receiver<Msg>,
|
||||
token: CancellationToken,
|
||||
) -> Result<(), CliError> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let terminal = Arc::new(Mutex::new(Terminal::new(backend)?));
|
||||
|
||||
let messages = Arc::new(Mutex::new(vec![]));
|
||||
let input = Arc::new(Mutex::new(String::new()));
|
||||
|
||||
let messages_clone = Arc::clone(&messages);
|
||||
let input_clone = Arc::clone(&input);
|
||||
while let Some(msg) = messages_rx.recv().await {
|
||||
match msg {
|
||||
Msg::Input(m) => {
|
||||
let mut messages = messages_clone.lock().await;
|
||||
messages.push(m);
|
||||
if messages.len() == 100 {
|
||||
messages.remove(0);
|
||||
}
|
||||
}
|
||||
Msg::Refresh(i) => {
|
||||
let mut input = input_clone.lock().await;
|
||||
*input = i;
|
||||
}
|
||||
Msg::Exit => {
|
||||
token.cancel();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let messages = Arc::clone(&messages_clone);
|
||||
let input = Arc::clone(&input_clone);
|
||||
let terminal = Arc::clone(&terminal);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let messages = messages.blocking_lock();
|
||||
let input = input.blocking_lock();
|
||||
terminal
|
||||
.blocking_lock()
|
||||
.draw(|f| ui(f, &messages, &input))
|
||||
.unwrap();
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
let mut terminal_lock = terminal.lock().await;
|
||||
execute!(terminal_lock.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal_lock.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
308
src/consensus/mod.rs
Normal file
308
src/consensus/mod.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
//! 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 prost::Message;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ConsensusError;
|
||||
use crate::protos::consensus::v1::{Outcome, Proposal, ProposalResult, Vote};
|
||||
use crate::LocalSigner;
|
||||
|
||||
pub mod service;
|
||||
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<Vec<u8>, Vote>, // vote_owner -> Vote
|
||||
pub created_at: u64,
|
||||
pub config: ConsensusConfig,
|
||||
pub event_sender: broadcast::Sender<(String, ConsensusEvent)>,
|
||||
pub decisions_tx: broadcast::Sender<ProposalResult>,
|
||||
pub group_name: String,
|
||||
}
|
||||
|
||||
impl ConsensusSession {
|
||||
pub fn new(
|
||||
proposal: Proposal,
|
||||
config: ConsensusConfig,
|
||||
event_sender: broadcast::Sender<(String, ConsensusEvent)>,
|
||||
decisions_tx: broadcast::Sender<ProposalResult>,
|
||||
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,
|
||||
decisions_tx,
|
||||
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!(
|
||||
"[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!(
|
||||
"[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!(
|
||||
"[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!(
|
||||
"[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!(
|
||||
"[mod::check_consensus]: Not enough votes received - consensus not reached"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a consensus event
|
||||
fn emit_consensus_event(&self, event: ConsensusEvent) {
|
||||
info!("[mod::emit_consensus_event]: Emitting consensus event: {event:?}");
|
||||
let _ = self
|
||||
.event_sender
|
||||
.send((self.group_name.clone(), event.clone()));
|
||||
let _ = self.decisions_tx.send(ProposalResult {
|
||||
group_id: self.group_name.clone(),
|
||||
proposal_id: self.proposal.proposal_id,
|
||||
outcome: Outcome::from(event) as i32,
|
||||
decided_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Failed to get current time")
|
||||
.as_secs(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<u8> {
|
||||
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<S: LocalSigner>(
|
||||
proposal: &Proposal,
|
||||
user_vote: bool,
|
||||
signer: S,
|
||||
) -> Result<Vote, ConsensusError> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
703
src/consensus/service.rs
Normal file
703
src/consensus/service.rs
Normal file
@@ -0,0 +1,703 @@
|
||||
//! Consensus service for managing consensus sessions and HashGraph integration
|
||||
use prost::Message;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::consensus::{
|
||||
compute_vote_hash, create_vote_for_proposal, ConsensusConfig, ConsensusEvent, ConsensusSession,
|
||||
ConsensusState, ConsensusStats,
|
||||
};
|
||||
use crate::error::ConsensusError;
|
||||
use crate::protos::consensus::v1::{Proposal, ProposalResult, UpdateRequest, Vote};
|
||||
use crate::{verify_vote_hash, LocalSigner};
|
||||
|
||||
/// Consensus service that manages multiple consensus sessions for multiple groups
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsensusService {
|
||||
/// Active consensus sessions organized by group: group_name -> proposal_id -> session
|
||||
sessions: Arc<RwLock<HashMap<String, HashMap<u32, ConsensusSession>>>>,
|
||||
/// 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)>,
|
||||
/// Event sender for consensus results for UI
|
||||
decisions_tx: broadcast::Sender<ProposalResult>,
|
||||
}
|
||||
|
||||
impl ConsensusService {
|
||||
/// Create a new consensus service
|
||||
pub fn new() -> Self {
|
||||
let (event_sender, _) = broadcast::channel(1000);
|
||||
let (decisions_tx, _) = broadcast::channel(128);
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
max_sessions_per_group: 10,
|
||||
event_sender,
|
||||
decisions_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let (decisions_tx, _) = broadcast::channel(128);
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
max_sessions_per_group,
|
||||
event_sender,
|
||||
decisions_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to consensus events
|
||||
pub fn subscribe_to_events(&self) -> broadcast::Receiver<(String, ConsensusEvent)> {
|
||||
self.event_sender.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribe to consensus decisions
|
||||
pub fn subscribe_decisions(&self) -> broadcast::Receiver<ProposalResult> {
|
||||
self.decisions_tx.subscribe()
|
||||
}
|
||||
|
||||
// /// Send consensus decision to UI
|
||||
// pub fn send_decision(&self, res: ProposalResult) {
|
||||
// let _ = self.decisions_tx.send(res);
|
||||
// }
|
||||
|
||||
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,
|
||||
group_requests: Vec<UpdateRequest>,
|
||||
proposal_owner: Vec<u8>,
|
||||
expected_voters_count: u32,
|
||||
expiration_time: u64,
|
||||
liveness_criteria_yes: bool,
|
||||
) -> Result<Proposal, ConsensusError> {
|
||||
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,
|
||||
group_requests,
|
||||
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(),
|
||||
self.event_sender.clone(),
|
||||
self.decisions_tx.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);
|
||||
self.insert_session(group_sessions, 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!(
|
||||
"[create_proposal]: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!(
|
||||
"[create_proposal]: 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<S: LocalSigner>(
|
||||
&self,
|
||||
group_name: &str,
|
||||
proposal_id: u32,
|
||||
steward_vote: bool,
|
||||
signer: S,
|
||||
) -> Result<Proposal, ConsensusError> {
|
||||
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(())
|
||||
}
|
||||
|
||||
fn insert_session(
|
||||
&self,
|
||||
group_sessions: &mut HashMap<u32, ConsensusSession>,
|
||||
proposal_id: u32,
|
||||
session: ConsensusSession,
|
||||
) {
|
||||
group_sessions.insert(proposal_id, session);
|
||||
self.prune_sessions(group_sessions);
|
||||
}
|
||||
|
||||
fn prune_sessions(&self, group_sessions: &mut HashMap<u32, ConsensusSession>) {
|
||||
if group_sessions.len() <= self.max_sessions_per_group {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut session_entries: Vec<_> = group_sessions.drain().collect();
|
||||
session_entries.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at));
|
||||
|
||||
for (proposal_id, session) in session_entries
|
||||
.into_iter()
|
||||
.take(self.max_sessions_per_group)
|
||||
{
|
||||
group_sessions.insert(proposal_id, session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process incoming proposal message
|
||||
pub async fn process_incoming_proposal(
|
||||
&self,
|
||||
group_name: &str,
|
||||
proposal: Proposal,
|
||||
) -> Result<(), ConsensusError> {
|
||||
info!(
|
||||
"[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(),
|
||||
self.event_sender.clone(),
|
||||
self.decisions_tx.clone(),
|
||||
group_name,
|
||||
);
|
||||
|
||||
session.add_vote(proposal.votes[0].clone())?;
|
||||
self.insert_session(group_sessions, proposal.proposal_id, session);
|
||||
|
||||
info!("[service::process_incoming_proposal]: Proposal stored, waiting for user vote");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process user vote for a proposal
|
||||
pub async fn process_user_vote<S: LocalSigner>(
|
||||
&self,
|
||||
group_name: &str,
|
||||
proposal_id: u32,
|
||||
user_vote: bool,
|
||||
signer: S,
|
||||
) -> Result<Vote, ConsensusError> {
|
||||
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!("[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<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) {
|
||||
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<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) {
|
||||
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<Proposal> {
|
||||
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<String> = 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<String> {
|
||||
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<bool, ConsensusError> {
|
||||
// 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!("[handle_consensus_timeout]: 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!("[handle_consensus_timeout]: 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!(
|
||||
"[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<Vec<u8>, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
213
src/contact.rs
213
src/contact.rs
@@ -1,213 +0,0 @@
|
||||
use alloy::hex::ToHexExt;
|
||||
use ds::{
|
||||
chat_client::{
|
||||
ChatClient, ChatMessages, ReqMessageType, RequestMLSPayload, ResponseMLSPayload,
|
||||
},
|
||||
chat_server::ServerMessage,
|
||||
};
|
||||
// use waku_bindings::*;
|
||||
|
||||
use openmls::prelude::MlsMessageOut;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tls_codec::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::ContactError;
|
||||
|
||||
pub const CHAT_SERVER_ADDR: &str = "ws://127.0.0.1:8080";
|
||||
|
||||
pub struct ContactsList {
|
||||
contacts: Arc<Mutex<HashMap<String, Contact>>>,
|
||||
group_id2sc: HashMap<String, String>,
|
||||
pub future_req: HashMap<String, CancellationToken>,
|
||||
pub chat_client: ChatClient,
|
||||
}
|
||||
|
||||
pub struct Contact {
|
||||
// map group_name to key_package bytes
|
||||
group_id2user_kp: HashMap<String, Vec<u8>>,
|
||||
// user_p2p_addr: WakuPeers,
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
pub fn get_relevant_kp(&mut self, group_name: String) -> Result<Vec<u8>, ContactError> {
|
||||
match self.group_id2user_kp.remove(&group_name) {
|
||||
Some(kp) => Ok(kp.clone()),
|
||||
None => Err(ContactError::MissingKeyPackageForGroup),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_key_package(
|
||||
&mut self,
|
||||
key_package: Vec<u8>,
|
||||
group_name: String,
|
||||
) -> Result<(), ContactError> {
|
||||
match self.group_id2user_kp.insert(group_name, key_package) {
|
||||
Some(_) => Err(ContactError::DuplicateUserError),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContactsList {
|
||||
pub async fn new(chat_client: ChatClient) -> Result<Self, ContactError> {
|
||||
Ok(ContactsList {
|
||||
contacts: Arc::new(Mutex::new(HashMap::new())),
|
||||
group_id2sc: HashMap::new(),
|
||||
future_req: HashMap::new(),
|
||||
chat_client,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_welcome_msg_to_users(
|
||||
&mut self,
|
||||
self_address: String,
|
||||
users_address: Vec<String>,
|
||||
welcome: MlsMessageOut,
|
||||
) -> Result<(), ContactError> {
|
||||
let bytes = welcome.tls_serialize_detached()?;
|
||||
let welcome_str: String = bytes.encode_hex();
|
||||
|
||||
let msg = ChatMessages::Welcome(welcome_str);
|
||||
self.chat_client.send_message(ServerMessage::InMessage {
|
||||
from: self_address,
|
||||
to: users_address,
|
||||
msg: serde_json::to_string(&msg)?,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_msg_req(
|
||||
&mut self,
|
||||
self_address: String,
|
||||
user_address: String,
|
||||
group_name: String,
|
||||
msg_type: ReqMessageType,
|
||||
) -> Result<(), ContactError> {
|
||||
self.future_req
|
||||
.insert(user_address.clone(), CancellationToken::new());
|
||||
|
||||
let sc_address = match self.group_id2sc.get(&group_name).cloned() {
|
||||
Some(sc) => sc,
|
||||
None => return Err(ContactError::MissingSmartContractForGroup),
|
||||
};
|
||||
|
||||
let req = ChatMessages::Request(RequestMLSPayload::new(sc_address, group_name, msg_type));
|
||||
self.chat_client.send_message(ServerMessage::InMessage {
|
||||
from: self_address,
|
||||
to: vec![user_address],
|
||||
msg: serde_json::to_string(&req)?,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_resp_msg_to_user(
|
||||
&mut self,
|
||||
self_address: String,
|
||||
user_address: &str,
|
||||
resp: ResponseMLSPayload,
|
||||
) -> Result<(), ContactError> {
|
||||
let resp_j = ChatMessages::Response(resp);
|
||||
self.chat_client.send_message(ServerMessage::InMessage {
|
||||
from: self_address,
|
||||
to: vec![user_address.to_string()],
|
||||
msg: serde_json::to_string(&resp_j)?,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_new_contact(&mut self, user_address: &str) -> Result<(), ContactError> {
|
||||
let mut contacts = self.contacts.lock().await;
|
||||
if contacts.contains_key(user_address) {
|
||||
return Err(ContactError::DuplicateUserError);
|
||||
}
|
||||
|
||||
match contacts.insert(
|
||||
user_address.to_string(),
|
||||
Contact {
|
||||
group_id2user_kp: HashMap::new(),
|
||||
},
|
||||
) {
|
||||
Some(_) => Err(ContactError::DuplicateUserError),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_key_package_to_contact(
|
||||
&mut self,
|
||||
user_wallet: &str,
|
||||
key_package: Vec<u8>,
|
||||
group_name: String,
|
||||
) -> Result<(), ContactError> {
|
||||
let mut contacts = self.contacts.lock().await;
|
||||
match contacts.get_mut(user_wallet) {
|
||||
Some(user) => user.add_key_package(key_package, group_name)?,
|
||||
None => return Err(ContactError::UserNotFoundError),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn does_user_in_contacts(&self, user_wallet: &str) -> bool {
|
||||
let contacts = self.contacts.lock().await;
|
||||
contacts.get(user_wallet).is_some()
|
||||
}
|
||||
|
||||
pub async fn prepare_joiners(
|
||||
&mut self,
|
||||
user_wallets: Vec<String>,
|
||||
group_name: String,
|
||||
) -> Result<HashMap<String, Vec<u8>>, ContactError> {
|
||||
let mut joiners_kp = HashMap::with_capacity(user_wallets.len());
|
||||
|
||||
for user_wallet in user_wallets {
|
||||
if joiners_kp.contains_key(&user_wallet) {
|
||||
return Err(ContactError::DuplicateUserError);
|
||||
}
|
||||
|
||||
let mut contacts = self.contacts.lock().await;
|
||||
match contacts.get_mut(&user_wallet) {
|
||||
Some(contact) => match contact.get_relevant_kp(group_name.clone()) {
|
||||
Ok(kp) => match joiners_kp.insert(user_wallet, kp) {
|
||||
Some(_) => return Err(ContactError::DuplicateUserError),
|
||||
None => continue,
|
||||
},
|
||||
Err(err) => return Err(err),
|
||||
},
|
||||
None => return Err(ContactError::UserNotFoundError),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(joiners_kp)
|
||||
}
|
||||
|
||||
pub fn handle_response(&mut self, user_address: &str) -> Result<(), ContactError> {
|
||||
match self.future_req.get(user_address) {
|
||||
Some(token) => {
|
||||
token.cancel();
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ContactError::UserNotFoundError),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_group2sc(
|
||||
&mut self,
|
||||
group_name: String,
|
||||
sc_address: String,
|
||||
) -> Result<(), ContactError> {
|
||||
match self.group_id2sc.insert(group_name, sc_address) {
|
||||
Some(_) => Err(ContactError::GroupAlreadyExistsError),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group2sc(&self, group_name: String) -> Result<String, ContactError> {
|
||||
match self.group_id2sc.get(&group_name).cloned() {
|
||||
Some(addr) => Ok(addr),
|
||||
None => Err(ContactError::GroupNotFoundError(group_name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Conversation {
|
||||
messages: Vec<ConversationMessage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConversationMessage {
|
||||
pub group: String,
|
||||
pub author: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Display for ConversationMessage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(
|
||||
f,
|
||||
"Group: {:#?}\nAuthor: {:#?}\nMessage: {:#?}",
|
||||
self.group, self.author, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
/// Add a message string to the conversation list.
|
||||
pub fn add(&mut self, conversation_message: ConversationMessage) {
|
||||
self.messages.push(conversation_message)
|
||||
}
|
||||
|
||||
/// Get a list of messages in the conversation.
|
||||
/// The function returns the `last_n` messages.
|
||||
pub fn get(&self, last_n: usize) -> Option<&[ConversationMessage]> {
|
||||
let num_messages = self.messages.len();
|
||||
let start = if last_n > num_messages {
|
||||
0
|
||||
} else {
|
||||
num_messages - last_n
|
||||
};
|
||||
self.messages.get(start..num_messages)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationMessage {
|
||||
pub fn new(group: String, author: String, message: String) -> Self {
|
||||
Self {
|
||||
group,
|
||||
author,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/error.rs
Normal file
175
src/error.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use alloy::signers::local::LocalSignerError;
|
||||
use openmls::group::WelcomeError;
|
||||
use openmls::{
|
||||
framing::errors::MlsMessageError,
|
||||
group::ProposeRemoveMemberError,
|
||||
prelude::{
|
||||
CommitToPendingProposalsError, CreateMessageError, MergeCommitError,
|
||||
MergePendingCommitError, NewGroupError, ProcessMessageError, ProposeAddMemberError,
|
||||
},
|
||||
};
|
||||
use openmls_rust_crypto::MemoryStorageError;
|
||||
use std::string::FromUtf8Error;
|
||||
|
||||
use ds::DeliveryServiceError;
|
||||
use mls_crypto::error::IdentityError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConsensusError {
|
||||
#[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("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)]
|
||||
pub enum MessageError {
|
||||
#[error("Failed to verify signature: {0}")]
|
||||
InvalidSignature(#[from] libsecp256k1::Error),
|
||||
#[error("JSON processing error: {0}")]
|
||||
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)]
|
||||
pub enum GroupError {
|
||||
#[error(transparent)]
|
||||
MessageError(#[from] MessageError),
|
||||
#[error(transparent)]
|
||||
IdentityError(#[from] IdentityError),
|
||||
|
||||
#[error("Steward not set")]
|
||||
StewardNotSet,
|
||||
#[error("MLS group not initialized")]
|
||||
MlsGroupNotSet,
|
||||
#[error("Group still active")]
|
||||
GroupStillActive,
|
||||
#[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<MemoryStorageError>),
|
||||
#[error("Unable to merge pending commit in MLS group: {0}")]
|
||||
UnableToMergePendingCommit(#[from] MergePendingCommitError<MemoryStorageError>),
|
||||
#[error("Unable to merge staged commit in MLS group: {0}")]
|
||||
UnableToMergeStagedCommit(#[from] MergeCommitError<MemoryStorageError>),
|
||||
#[error("Unable to process message: {0}")]
|
||||
InvalidProcessMessage(#[from] ProcessMessageError),
|
||||
#[error("Unable to encrypt MLS message: {0}")]
|
||||
UnableToEncryptMlsMessage(#[from] CreateMessageError),
|
||||
#[error("Unable to create proposal to add members: {0}")]
|
||||
UnableToCreateProposal(#[from] ProposeAddMemberError<MemoryStorageError>),
|
||||
#[error("Unable to create proposal to remove members: {0}")]
|
||||
UnableToCreateProposalToRemoveMembers(#[from] ProposeRemoveMemberError<MemoryStorageError>),
|
||||
#[error("Unable to revert commit to pending proposals: {0}")]
|
||||
UnableToRevertCommitToPendingProposals(
|
||||
#[from] CommitToPendingProposalsError<MemoryStorageError>,
|
||||
),
|
||||
#[error("Unable to store pending proposal: {0}")]
|
||||
UnableToStorePendingProposal(#[from] MemoryStorageError),
|
||||
#[error("Failed to serialize mls message: {0}")]
|
||||
MlsMessageError(#[from] MlsMessageError),
|
||||
#[error("Failed to decode app message: {0}")]
|
||||
AppMessageDecodeError(#[from] prost::DecodeError),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UserError {
|
||||
#[error(transparent)]
|
||||
DeliveryServiceError(#[from] DeliveryServiceError),
|
||||
#[error(transparent)]
|
||||
IdentityError(#[from] IdentityError),
|
||||
#[error(transparent)]
|
||||
GroupError(#[from] GroupError),
|
||||
#[error(transparent)]
|
||||
MessageError(#[from] MessageError),
|
||||
#[error(transparent)]
|
||||
ConsensusError(#[from] ConsensusError),
|
||||
|
||||
#[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("Invalid user action: {0}")]
|
||||
InvalidUserAction(String),
|
||||
#[error("Unknown content topic type: {0}")]
|
||||
UnknownContentTopicType(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<MemoryStorageError>),
|
||||
#[error("UTF-8 parsing error: {0}")]
|
||||
Utf8ParsingError(#[from] FromUtf8Error),
|
||||
#[error("Failed to parse signer: {0}")]
|
||||
SignerParsingError(#[from] LocalSignerError),
|
||||
#[error("Failed to decode welcome message: {0}")]
|
||||
WelcomeMessageDecodeError(#[from] prost::DecodeError),
|
||||
#[error("Failed to deserialize mls message in: {0}")]
|
||||
MlsMessageInDeserializeError(#[from] openmls::prelude::Error),
|
||||
#[error("Failed to try into protocol message: {0}")]
|
||||
TryIntoProtocolMessageError(#[from] openmls::framing::errors::ProtocolMessageError),
|
||||
#[error("Failed to get current time")]
|
||||
FailedToGetCurrentTime(#[from] std::time::SystemTimeError),
|
||||
}
|
||||
793
src/group.rs
Normal file
793
src/group.rs
Normal file
@@ -0,0 +1,793 @@
|
||||
use alloy::hex;
|
||||
use openmls::{
|
||||
group::{GroupEpoch, GroupId, MlsGroup, MlsGroupCreateConfig},
|
||||
prelude::{
|
||||
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, RwLock};
|
||||
use tracing::{error, info};
|
||||
use uuid;
|
||||
|
||||
use crate::{
|
||||
error::GroupError,
|
||||
message::{message_types, MessageType},
|
||||
protos::{
|
||||
consensus::v1::{Proposal, RequestType, UpdateRequest, Vote},
|
||||
de_mls::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::{identity::normalize_wallet_address_str, 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)]
|
||||
pub struct Group {
|
||||
group_name: String,
|
||||
mls_group: Option<Arc<Mutex<MlsGroup>>>,
|
||||
is_kp_shared: bool,
|
||||
app_id: Vec<u8>,
|
||||
state_machine: Arc<RwLock<GroupStateMachine>>,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
pub fn new(
|
||||
group_name: &str,
|
||||
is_creation: bool,
|
||||
provider: Option<&MlsProvider>,
|
||||
signer: Option<&SignatureKeyPair>,
|
||||
credential_with_key: Option<&CredentialWithKey>,
|
||||
) -> Result<Self, GroupError> {
|
||||
let uuid = uuid::Uuid::new_v4().as_bytes().to_vec();
|
||||
let mut group = Group {
|
||||
group_name: group_name.to_string(),
|
||||
mls_group: None,
|
||||
is_kp_shared: false,
|
||||
app_id: uuid.clone(),
|
||||
state_machine: if is_creation {
|
||||
Arc::new(RwLock::new(GroupStateMachine::new_with_steward()))
|
||||
} else {
|
||||
Arc::new(RwLock::new(GroupStateMachine::new()))
|
||||
},
|
||||
};
|
||||
|
||||
if is_creation {
|
||||
if let (Some(provider), Some(signer), Some(credential_with_key)) =
|
||||
(provider, signer, credential_with_key)
|
||||
{
|
||||
// Create a new MLS group instance
|
||||
let group_config = MlsGroupCreateConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
let mls_group = MlsGroup::new_with_group_id(
|
||||
provider,
|
||||
signer,
|
||||
&group_config,
|
||||
GroupId::from_slice(group_name.as_bytes()),
|
||||
credential_with_key.clone(),
|
||||
)?;
|
||||
group.mls_group = Some(Arc::new(Mutex::new(mls_group)));
|
||||
group.is_kp_shared = true;
|
||||
}
|
||||
}
|
||||
|
||||
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<Vec<Vec<u8>>, GroupError> {
|
||||
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())
|
||||
.collect();
|
||||
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<u8>,
|
||||
) -> Result<Option<LeafNodeIndex>, GroupError> {
|
||||
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)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
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<GroupEpoch, GroupError> {
|
||||
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<Mutex<MlsGroup>>`
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Get the application ID for this group.
|
||||
///
|
||||
/// ## Returns:
|
||||
/// - Reference to the application ID bytes
|
||||
pub fn app_id(&self) -> &[u8] {
|
||||
&self.app_id
|
||||
}
|
||||
|
||||
/// 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<WakuMessageToSend, GroupError> {
|
||||
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<u8>,
|
||||
) -> Result<KeyPackage, GroupError> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// - Returns a serialized `UiUpdateRequest` for UI notification
|
||||
pub async fn store_invite_proposal(
|
||||
&mut self,
|
||||
key_package: Box<KeyPackage>,
|
||||
) -> Result<UpdateRequest, GroupError> {
|
||||
let mut state_machine = self.state_machine.write().await;
|
||||
state_machine
|
||||
.add_proposal(GroupUpdateRequest::AddMember(key_package.clone()))
|
||||
.await;
|
||||
|
||||
let wallet_bytes = key_package
|
||||
.leaf_node()
|
||||
.credential()
|
||||
.serialized_content()
|
||||
.to_vec();
|
||||
|
||||
Ok(UpdateRequest {
|
||||
request_type: RequestType::AddMember as i32,
|
||||
wallet_address: wallet_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a remove proposal in the steward queue for the current epoch.
|
||||
///
|
||||
/// ## Parameters:
|
||||
/// - `identity`: The identity string of the member to remove
|
||||
///
|
||||
/// ## Returns:
|
||||
/// - Returns a serialized `UiUpdateRequest` for UI notification
|
||||
/// - `GroupError::InvalidIdentity` if the identity is invalid
|
||||
pub async fn store_remove_proposal(
|
||||
&mut self,
|
||||
identity: String,
|
||||
) -> Result<UpdateRequest, GroupError> {
|
||||
let normalized_identity = normalize_wallet_address_str(&identity)?;
|
||||
let mut state_machine = self.state_machine.write().await;
|
||||
state_machine
|
||||
.add_proposal(GroupUpdateRequest::RemoveMember(
|
||||
normalized_identity.clone(),
|
||||
))
|
||||
.await;
|
||||
|
||||
let wallet_bytes = hex::decode(
|
||||
normalized_identity
|
||||
.strip_prefix("0x")
|
||||
.unwrap_or(&normalized_identity),
|
||||
)?;
|
||||
|
||||
Ok(UpdateRequest {
|
||||
request_type: RequestType::RemoveMember as i32,
|
||||
wallet_address: wallet_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<GroupAction, GroupError> {
|
||||
let app_msg = AppMessage::decode(message.into_bytes().as_slice())?;
|
||||
match app_msg.payload {
|
||||
Some(app_message::Payload::ConversationMessage(conversation_message)) => {
|
||||
info!("[process_application_message]: Processing conversation message");
|
||||
Ok(GroupAction::GroupAppMsg(conversation_message.into()))
|
||||
}
|
||||
Some(app_message::Payload::Proposal(proposal)) => {
|
||||
info!("[process_application_message]: Processing proposal message");
|
||||
Ok(GroupAction::GroupProposal(proposal))
|
||||
}
|
||||
Some(app_message::Payload::Vote(vote)) => {
|
||||
info!("[process_application_message]: Processing vote message");
|
||||
Ok(GroupAction::GroupVote(vote))
|
||||
}
|
||||
Some(app_message::Payload::BanRequest(ban_request)) => {
|
||||
info!("[process_application_message]: Processing ban request message");
|
||||
|
||||
if self.is_steward().await {
|
||||
info!(
|
||||
"[process_application_message]: Steward adding remove proposal for user {}",
|
||||
ban_request.user_to_ban.clone()
|
||||
);
|
||||
let _ = self
|
||||
.store_remove_proposal(ban_request.user_to_ban.clone())
|
||||
.await?;
|
||||
} else {
|
||||
info!(
|
||||
"[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,
|
||||
) -> Result<GroupAction, GroupError> {
|
||||
let group_id = message.group_id().as_slice();
|
||||
if group_id != self.group_name_bytes() {
|
||||
return Ok(GroupAction::DoNothing);
|
||||
}
|
||||
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)?;
|
||||
|
||||
match processed_message.into_content() {
|
||||
ProcessedMessageContent::ApplicationMessage(application_message) => {
|
||||
drop(mls_group);
|
||||
self.process_application_message(application_message).await
|
||||
}
|
||||
ProcessedMessageContent::ProposalMessage(proposal_ptr) => {
|
||||
mls_group
|
||||
.store_pending_proposal(provider.storage(), proposal_ptr.as_ref().clone())?;
|
||||
Ok(GroupAction::DoNothing)
|
||||
}
|
||||
ProcessedMessageContent::ExternalJoinProposalMessage(_external_proposal_ptr) => {
|
||||
Ok(GroupAction::DoNothing)
|
||||
}
|
||||
ProcessedMessageContent::StagedCommitMessage(commit_ptr) => {
|
||||
let mut remove_proposal: bool = false;
|
||||
if commit_ptr.self_removed() {
|
||||
remove_proposal = true;
|
||||
}
|
||||
mls_group.merge_staged_commit(provider, *commit_ptr)?;
|
||||
if remove_proposal {
|
||||
if mls_group.is_active() {
|
||||
return Err(GroupError::GroupStillActive);
|
||||
}
|
||||
Ok(GroupAction::LeaveGroup)
|
||||
} else {
|
||||
Ok(GroupAction::DoNothing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<WakuMessageToSend, GroupError> {
|
||||
let is_steward = self.is_steward().await;
|
||||
let has_proposals = self.get_pending_proposals_count().await > 0;
|
||||
|
||||
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()
|
||||
.ok_or_else(|| GroupError::MlsGroupNotSet)?
|
||||
.lock()
|
||||
.await
|
||||
.create_message(provider, signer, &msg.encode_to_vec())?
|
||||
.to_bytes()?;
|
||||
Ok(WakuMessageToSend::new(
|
||||
message_out,
|
||||
APP_MSG_SUBTOPIC,
|
||||
&self.group_name,
|
||||
self.app_id(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.state_machine
|
||||
.read()
|
||||
.await
|
||||
.get_current_epoch_proposals_count()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the current epoch proposals for UI display.
|
||||
pub async fn get_current_epoch_proposals(&self) -> Vec<GroupUpdateRequest> {
|
||||
self.state_machine
|
||||
.read()
|
||||
.await
|
||||
.get_current_epoch_proposals()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the number of pending proposals for the voting epoch
|
||||
pub async fn get_voting_proposals_count(&self) -> usize {
|
||||
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<GroupUpdateRequest> {
|
||||
self.state_machine
|
||||
.read()
|
||||
.await
|
||||
.get_voting_epoch_proposals()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_proposals_for_voting_epoch_as_ui_update_requests(&self) -> Vec<UpdateRequest> {
|
||||
self.get_proposals_for_voting_epoch()
|
||||
.await
|
||||
.iter()
|
||||
.map(|p| p.clone().into())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Start voting on proposals for the current epoch
|
||||
pub async fn start_voting(&mut self) -> Result<(), GroupError> {
|
||||
self.state_machine.write().await.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)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// 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<usize, GroupError> {
|
||||
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.
|
||||
///
|
||||
/// ## 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<Vec<WakuMessageToSend>, GroupError> {
|
||||
if !self.is_steward().await {
|
||||
return Err(GroupError::StewardNotSet);
|
||||
}
|
||||
|
||||
let proposals = self.get_proposals_for_voting_epoch().await;
|
||||
|
||||
if proposals.is_empty() {
|
||||
return Err(GroupError::EmptyProposals);
|
||||
}
|
||||
|
||||
let mut member_indices = Vec::new();
|
||||
for proposal in &proposals {
|
||||
if let GroupUpdateRequest::RemoveMember(identity) = proposal {
|
||||
// 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 (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)?;
|
||||
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)?;
|
||||
(out_messages, welcome)
|
||||
};
|
||||
// Create batch proposals message (without welcome)
|
||||
let batch_msg: AppMessage = BatchProposalsMessage {
|
||||
group_name: self.group_name_bytes().to_vec(),
|
||||
mls_proposals,
|
||||
commit_message: out_messages.to_bytes()?,
|
||||
}
|
||||
.into();
|
||||
|
||||
let batch_waku_msg = WakuMessageToSend::new(
|
||||
batch_msg.encode_to_vec(),
|
||||
APP_MSG_SUBTOPIC,
|
||||
&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: WelcomeMessage = welcome.try_into()?;
|
||||
let welcome_waku_msg = WakuMessageToSend::new(
|
||||
welcome_msg.encode_to_vec(),
|
||||
WELCOME_SUBTOPIC,
|
||||
&self.group_name,
|
||||
self.app_id(),
|
||||
);
|
||||
messages.push(welcome_waku_msg);
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Group {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(f, "Group: {:#?}", self.group_name)
|
||||
}
|
||||
}
|
||||
31
src/group_registry.rs
Normal file
31
src/group_registry.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::collections::HashSet;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// src/group_registry.rs
|
||||
#[derive(Default, Debug)]
|
||||
pub struct GroupRegistry {
|
||||
names: RwLock<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl GroupRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn exists(&self, name: &str) -> bool {
|
||||
self.names.read().await.contains(name)
|
||||
}
|
||||
|
||||
pub async fn insert(&self, name: String) -> bool {
|
||||
let mut g = self.names.write().await;
|
||||
if g.contains(&name) {
|
||||
return false;
|
||||
}
|
||||
g.insert(name);
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn all(&self) -> Vec<String> {
|
||||
self.names.read().await.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
use alloy::primitives::Address;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openmls::{credentials::CredentialWithKey, key_packages::*, prelude::*};
|
||||
use openmls_basic_credential::SignatureKeyPair;
|
||||
use openmls_traits::types::Ciphersuite;
|
||||
|
||||
use mls_crypto::openmls_provider::MlsCryptoProvider;
|
||||
|
||||
use crate::IdentityError;
|
||||
|
||||
pub struct Identity {
|
||||
pub(crate) kp: HashMap<Vec<u8>, KeyPackage>,
|
||||
pub(crate) credential_with_key: CredentialWithKey,
|
||||
pub(crate) signer: SignatureKeyPair,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub(crate) fn new(
|
||||
ciphersuite: Ciphersuite,
|
||||
crypto: &MlsCryptoProvider,
|
||||
user_wallet_address: &[u8],
|
||||
) -> Result<Identity, IdentityError> {
|
||||
let credential = Credential::new(user_wallet_address.to_vec(), CredentialType::Basic)?;
|
||||
let signature_keys = SignatureKeyPair::new(ciphersuite.signature_algorithm())?;
|
||||
let credential_with_key = CredentialWithKey {
|
||||
credential,
|
||||
signature_key: signature_keys.to_public_vec().into(),
|
||||
};
|
||||
signature_keys.store(crypto.key_store())?;
|
||||
|
||||
let mut kps = HashMap::new();
|
||||
let key_package = KeyPackage::builder().build(
|
||||
CryptoConfig {
|
||||
ciphersuite,
|
||||
version: ProtocolVersion::default(),
|
||||
},
|
||||
crypto,
|
||||
&signature_keys,
|
||||
credential_with_key.clone(),
|
||||
)?;
|
||||
let kp = key_package.hash_ref(crypto.crypto())?;
|
||||
kps.insert(kp.as_slice().to_vec(), key_package);
|
||||
|
||||
Ok(Identity {
|
||||
kp: kps,
|
||||
credential_with_key,
|
||||
signer: signature_keys,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an additional key package using the credential_with_key/signer bound to this identity
|
||||
pub fn generate_key_package(
|
||||
&mut self,
|
||||
ciphersuite: Ciphersuite,
|
||||
crypto: &MlsCryptoProvider,
|
||||
) -> Result<KeyPackage, IdentityError> {
|
||||
let key_package = KeyPackage::builder().build(
|
||||
CryptoConfig::with_default_version(ciphersuite),
|
||||
crypto,
|
||||
&self.signer,
|
||||
self.credential_with_key.clone(),
|
||||
)?;
|
||||
|
||||
let kp = key_package.hash_ref(crypto.crypto())?;
|
||||
self.kp.insert(kp.as_slice().to_vec(), key_package.clone());
|
||||
Ok(key_package)
|
||||
}
|
||||
|
||||
/// Get the plain identity as byte vector.
|
||||
pub fn identity(&self) -> Vec<u8> {
|
||||
self.credential_with_key.credential.identity().to_vec()
|
||||
}
|
||||
|
||||
pub fn signature_pub_key(&self) -> Vec<u8> {
|
||||
self.signer.public().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for Identity {
|
||||
fn to_string(&self) -> String {
|
||||
Address::from_slice(self.credential_with_key.credential.identity()).to_string()
|
||||
}
|
||||
}
|
||||
371
src/lib.rs
371
src/lib.rs
@@ -1,153 +1,244 @@
|
||||
use alloy::{hex::FromHexError, primitives::SignatureError, signers::local::LocalSignerError};
|
||||
use ds::DeliveryServiceError;
|
||||
use fred::error::RedisError;
|
||||
use openmls::{error::LibraryError, prelude::*};
|
||||
use openmls_rust_crypto::MemoryKeyStoreError;
|
||||
use sc_key_store::KeyStoreError;
|
||||
use std::{str::Utf8Error, string::FromUtf8Error};
|
||||
use tokio::task::JoinError;
|
||||
//! # DE-MLS: Distributed MLS Group Management System
|
||||
//!
|
||||
//! This crate provides a distributed group management system built on top of MLS (Message Layer Security).
|
||||
//! It implements a steward-based epoch management system with HashGraph-like consensus for secure group operations.
|
||||
//!
|
||||
//! ## Architecture Overview
|
||||
//!
|
||||
//! The system consists of several key components:
|
||||
//!
|
||||
//! ### Core Components
|
||||
//!
|
||||
//! - **Group Management** (`group.rs`): Orchestrates MLS group operations and state transitions
|
||||
//! - **State Machine** (`state_machine.rs`): Manages steward epoch states and transitions
|
||||
//! - **Steward** (`steward.rs`): Handles proposal collection and management
|
||||
//! - **Consensus** (`consensus/`): Provides distributed consensus for voting based on HashGraph-like protocol
|
||||
//! - **User Management** (`user.rs`): Manages individual user operations and message handling
|
||||
//!
|
||||
//! ### Actor System
|
||||
//!
|
||||
//! - **User Actor** (`user_actor.rs`): Actor-based user management with message handling
|
||||
//! - **WebSocket Actor** (`ws_actor.rs`): Handles WebSocket connections and message routing
|
||||
//! - **Action Handlers** (`action_handlers.rs`): Processes various system actions
|
||||
//!
|
||||
//! ### Communication
|
||||
//!
|
||||
//! - **Message Handling** (`message.rs`): Protobuf message serialization/deserialization
|
||||
//! - **Protocol Buffers** (`protos/`): Message definitions for network communication
|
||||
//! - **Consensus Messages** (`protos/messages/v1/consensus.proto`): Consensus-specific message types
|
||||
//!
|
||||
//! ## Steward Epoch Flow
|
||||
//!
|
||||
//! The system operates in epochs managed by a steward with robust state management:
|
||||
//!
|
||||
//! 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, VOTE_PAYLOAD/PROPOSAL for steward only)
|
||||
//!
|
||||
//! ### Complete 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 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
|
||||
//! ```text
|
||||
//! User --> Group --> MLS Group --> Other Users
|
||||
//! ```
|
||||
//!
|
||||
//! ### Steward Messages
|
||||
//! ```text
|
||||
//! Steward (with proposals) --> Group --> MLS Group --> Other Users
|
||||
//! ```
|
||||
//!
|
||||
//! ### Batch Proposals
|
||||
//! ```text
|
||||
//! Group --> Create MLS Proposals --> Commit --> Batch Message --> Users
|
||||
//! Users --> Parse Batch --> Apply Proposals --> Update MLS Group
|
||||
//! ```
|
||||
//!
|
||||
//! ## Testing
|
||||
//!
|
||||
//! The system includes comprehensive tests:
|
||||
//!
|
||||
//! - State machine transitions
|
||||
//! - Message handling
|
||||
//!
|
||||
//! Run tests with:
|
||||
//! ```bash
|
||||
//! cargo test
|
||||
//! ```
|
||||
//!
|
||||
//! ## Dependencies
|
||||
//!
|
||||
//! - **MLS**: Message Layer Security for group key management
|
||||
//! - **Tokio**: Async runtime for concurrent operations
|
||||
//! - **Kameo**: Actor system for distributed operations
|
||||
//! - **Prost**: Protocol buffer serialization
|
||||
//! - **OpenMLS**: MLS implementation
|
||||
//! - **Waku**: Decentralized messaging protocol
|
||||
//! - **Alloy**: Ethereum wallet and signing
|
||||
|
||||
pub mod cli;
|
||||
pub mod contact;
|
||||
pub mod conversation;
|
||||
pub mod identity;
|
||||
use alloy::primitives::{Address, Signature};
|
||||
use ecies::{decrypt, encrypt};
|
||||
use libsecp256k1::{sign, verify, Message, PublicKey, SecretKey, Signature as libSignature};
|
||||
use rand::thread_rng;
|
||||
use secp256k1::hashes::{sha256, Hash};
|
||||
|
||||
use error::{GroupError, MessageError};
|
||||
|
||||
pub mod bootstrap;
|
||||
pub use bootstrap::{bootstrap_core, bootstrap_core_from_env, Bootstrap, BootstrapConfig};
|
||||
|
||||
pub mod consensus;
|
||||
pub mod error;
|
||||
pub mod group;
|
||||
pub mod group_registry;
|
||||
pub mod message;
|
||||
pub mod state_machine;
|
||||
pub mod steward;
|
||||
pub mod user;
|
||||
pub mod user_actor;
|
||||
pub mod user_app_instance;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CliError {
|
||||
#[error("Can't split the line")]
|
||||
SplitLineError,
|
||||
#[error("Failed to cancel token")]
|
||||
TokenCancellingError,
|
||||
pub mod protos {
|
||||
pub mod consensus {
|
||||
pub mod v1 {
|
||||
include!(concat!(env!("OUT_DIR"), "/consensus.v1.rs"));
|
||||
}
|
||||
}
|
||||
|
||||
#[error("Problem from std::io library: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("Failed to send message to channel: {0}")]
|
||||
SenderError(String),
|
||||
|
||||
#[error("Redis error: {0}")]
|
||||
RedisError(#[from] RedisError),
|
||||
#[error("Failed from tokio join: {0}")]
|
||||
TokioJoinError(#[from] JoinError),
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
AnyHowError(anyhow::Error),
|
||||
pub mod de_mls {
|
||||
pub mod messages {
|
||||
pub mod v1 {
|
||||
include!(concat!(env!("OUT_DIR"), "/de_mls.messages.v1.rs"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ContactError {
|
||||
#[error("Key package for the specified group does not exist.")]
|
||||
MissingKeyPackageForGroup,
|
||||
#[error("SmartContract address for the specified group does not exist.")]
|
||||
MissingSmartContractForGroup,
|
||||
#[error("User not found.")]
|
||||
UserNotFoundError,
|
||||
#[error("Group not found: {0}")]
|
||||
GroupNotFoundError(String),
|
||||
#[error("Duplicate user found in joiners list.")]
|
||||
DuplicateUserError,
|
||||
#[error("Group already exists")]
|
||||
GroupAlreadyExistsError,
|
||||
#[error("Invalid user address in signature.")]
|
||||
InvalidUserSignatureError,
|
||||
|
||||
#[error(transparent)]
|
||||
DeliveryServiceError(#[from] DeliveryServiceError),
|
||||
|
||||
#[error("Failed to parse signature: {0}")]
|
||||
AlloySignatureParsingError(#[from] SignatureError),
|
||||
#[error("JSON processing error: {0}")]
|
||||
JsonProcessingError(#[from] serde_json::Error),
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] tls_codec::Error),
|
||||
pub fn generate_keypair() -> (PublicKey, SecretKey) {
|
||||
let secret_key = SecretKey::random(&mut thread_rng());
|
||||
let public_key = PublicKey::from_secret_key(&secret_key);
|
||||
(public_key, secret_key)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IdentityError {
|
||||
#[error("Failed to create new key package: {0}")]
|
||||
MlsKeyPackageCreationError(#[from] KeyPackageNewError<MemoryKeyStoreError>),
|
||||
#[error(transparent)]
|
||||
MlsLibraryError(#[from] LibraryError),
|
||||
#[error("Failed to create signature: {0}")]
|
||||
MlsCryptoError(#[from] CryptoError),
|
||||
#[error("Failed to save signature key: {0}")]
|
||||
MlsKeyStoreError(#[from] MemoryKeyStoreError),
|
||||
#[error("Failed to create credential: {0}")]
|
||||
MlsCredentialError(#[from] CredentialError),
|
||||
#[error("An unknown error occurred: {0}")]
|
||||
Other(anyhow::Error),
|
||||
pub fn sign_message(message: &[u8], secret_key: &SecretKey) -> Vec<u8> {
|
||||
let digest = sha256::Hash::hash(message);
|
||||
let msg = Message::parse(&digest.to_byte_array());
|
||||
let signature = sign(&msg, secret_key);
|
||||
signature.0.serialize_der().as_ref().to_vec()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UserError {
|
||||
#[error("User lacks connection to the smart contract.")]
|
||||
MissingSmartContractConnection,
|
||||
#[error("Group not found: {0}")]
|
||||
GroupNotFoundError(String),
|
||||
#[error("Group already exists: {0}")]
|
||||
GroupAlreadyExistsError(String),
|
||||
#[error("Unsupported message type.")]
|
||||
UnsupportedMessageType,
|
||||
#[error("User already exists: {0}")]
|
||||
UserAlreadyExistsError(String),
|
||||
#[error("Welcome message cannot be empty.")]
|
||||
EmptyWelcomeMessageError,
|
||||
#[error("Message from user is invalid")]
|
||||
InvalidChatMessageError,
|
||||
#[error("Message from server is invalid")]
|
||||
InvalidServerMessageError,
|
||||
#[error("User not found.")]
|
||||
UserNotFoundError,
|
||||
pub fn verify_message(
|
||||
message: &[u8],
|
||||
signature: &[u8],
|
||||
public_key: &[u8],
|
||||
) -> Result<bool, MessageError> {
|
||||
const COMPRESSED_PUBLIC_KEY_SIZE: usize = 33;
|
||||
|
||||
#[error(transparent)]
|
||||
DeliveryServiceError(#[from] DeliveryServiceError),
|
||||
#[error(transparent)]
|
||||
KeyStoreError(#[from] KeyStoreError),
|
||||
#[error(transparent)]
|
||||
IdentityError(#[from] IdentityError),
|
||||
#[error(transparent)]
|
||||
ContactError(#[from] ContactError),
|
||||
let digest = sha256::Hash::hash(message);
|
||||
let msg = Message::parse(&digest.to_byte_array());
|
||||
let signature = libSignature::parse_der(signature)?;
|
||||
|
||||
#[error("Error while creating MLS group: {0}")]
|
||||
MlsGroupCreationError(#[from] NewGroupError<MemoryKeyStoreError>),
|
||||
#[error("Error while adding member to MLS group: {0}")]
|
||||
MlsAddMemberError(#[from] AddMembersError<MemoryKeyStoreError>),
|
||||
#[error("Error while merging pending commit in MLS group: {0}")]
|
||||
MlsMergePendingCommitError(#[from] MergePendingCommitError<MemoryKeyStoreError>),
|
||||
#[error("Error while merging commit in MLS group: {0}")]
|
||||
MlsMergeCommitError(#[from] MergeCommitError<MemoryKeyStoreError>),
|
||||
#[error("Error processing unverified message: {0}")]
|
||||
MlsProcessMessageError(#[from] ProcessMessageError),
|
||||
#[error("Error while creating message: {0}")]
|
||||
MlsCreateMessageError(#[from] CreateMessageError),
|
||||
#[error("Failed to create staged join: {0}")]
|
||||
MlsWelcomeError(#[from] WelcomeError<MemoryKeyStoreError>),
|
||||
#[error("Failed to remove member from MLS group: {0}")]
|
||||
MlsRemoveMemberError(#[from] RemoveMembersError<MemoryKeyStoreError>),
|
||||
#[error("Failed to validate user key package: {0}")]
|
||||
MlsKeyPackageVerificationError(#[from] KeyPackageVerifyError),
|
||||
|
||||
#[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 address: {0}")]
|
||||
AddressParsingError(#[from] FromHexError),
|
||||
#[error("Failed to parse signer: {0}")]
|
||||
SignerParsingError(#[from] LocalSignerError),
|
||||
|
||||
#[error("Signing error: {0}")]
|
||||
SigningError(#[from] alloy::signers::Error),
|
||||
#[error("I/O error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("An unknown error occurred: {0}")]
|
||||
UnknownError(anyhow::Error),
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn encrypt_message(message: &[u8], public_key: &[u8]) -> Result<Vec<u8>, MessageError> {
|
||||
let encrypted = encrypt(public_key, message)?;
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
pub fn decrypt_message(message: &[u8], secret_key: SecretKey) -> Result<Vec<u8>, MessageError> {
|
||||
let secret_key_serialized = secret_key.serialize();
|
||||
let decrypted = decrypt(&secret_key_serialized, message)?;
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
pub trait LocalSigner {
|
||||
fn local_sign_message(
|
||||
&self,
|
||||
message: &[u8],
|
||||
) -> impl std::future::Future<Output = Result<Vec<u8>, anyhow::Error>> + Send;
|
||||
|
||||
fn address(&self) -> Address;
|
||||
fn address_string(&self) -> String;
|
||||
fn address_bytes(&self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
pub fn verify_vote_hash(
|
||||
signature: &[u8],
|
||||
public_key: &[u8],
|
||||
message: &[u8],
|
||||
) -> Result<bool, MessageError> {
|
||||
let signature_bytes: [u8; 65] =
|
||||
signature
|
||||
.try_into()
|
||||
.map_err(|_| MessageError::MismatchedLength {
|
||||
expect: 65,
|
||||
actual: signature.len(),
|
||||
})?;
|
||||
let signature = Signature::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]
|
||||
fn test_verify_message() {
|
||||
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())
|
||||
.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())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
264
src/main.rs
264
src/main.rs
@@ -1,264 +0,0 @@
|
||||
use alloy::{providers::ProviderBuilder, signers::local::PrivateKeySigner};
|
||||
use clap::Parser;
|
||||
use std::{error::Error, str::FromStr, sync::Arc};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_tungstenite::tungstenite::protocol::Message as TokioMessage;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use de_mls::{cli::*, user::User, CliError, UserError};
|
||||
use ds::{
|
||||
chat_client::{ChatClient, ChatMessages},
|
||||
chat_server::ServerMessage,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let token = CancellationToken::new();
|
||||
|
||||
let (cli_tx, mut cli_gr_rx) = mpsc::channel::<Commands>(100);
|
||||
|
||||
let args = Args::parse();
|
||||
let signer = PrivateKeySigner::from_str(&args.user_priv_key)?;
|
||||
let user_address = signer.address().to_string();
|
||||
let (client, mut client_recv) =
|
||||
ChatClient::connect("ws://127.0.0.1:8080", user_address.clone()).await?;
|
||||
//// Create user
|
||||
let user_n = User::new(&args.user_priv_key, client).await?;
|
||||
let user_arc = Arc::new(Mutex::new(user_n));
|
||||
|
||||
let (messages_tx, messages_rx) = mpsc::channel::<Msg>(100);
|
||||
messages_tx
|
||||
.send(Msg::Input(Message::System(format!(
|
||||
"Hello, {:}",
|
||||
user_address.clone()
|
||||
))))
|
||||
.await?;
|
||||
|
||||
let messages_tx2 = messages_tx.clone();
|
||||
let event_token = token.clone();
|
||||
let h1 = tokio::spawn(async move { event_handler(messages_tx2, cli_tx, event_token).await });
|
||||
|
||||
let res_msg_tx = messages_tx.clone();
|
||||
let main_token = token.clone();
|
||||
let user = user_arc.clone();
|
||||
let h2 = tokio::spawn(async move {
|
||||
let (redis_tx, mut redis_rx) = mpsc::channel::<Vec<u8>>(100);
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(msg) = client_recv.recv() => {
|
||||
if let TokioMessage::Text(text) = msg {
|
||||
if let Ok(chat_message) = serde_json::from_str::<ServerMessage>(&text) {
|
||||
if let ServerMessage::InMessage { from, to, msg } = chat_message {
|
||||
if let Ok(chat_msg) = serde_json::from_str::<ChatMessages>(&msg) {
|
||||
match chat_msg {
|
||||
ChatMessages::Request(req) => {
|
||||
let res = user.as_ref().lock().await.send_responce_on_request(req, &from);
|
||||
if let Err(err) = res {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
},
|
||||
ChatMessages::Response(resp) => {
|
||||
let res = user.as_ref().lock().await.parce_responce(resp).await;
|
||||
if let Err(err) = res {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
},
|
||||
ChatMessages::Welcome(welcome) => {
|
||||
let res = user.as_ref().lock().await.join_group(welcome).await;
|
||||
match res {
|
||||
Ok(mut buf) => {
|
||||
let msg = format!("Succesfully join to the group: {:#?}", buf.1);
|
||||
res_msg_tx.send(Msg::Input(Message::System(msg))).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
|
||||
let redis_tx = redis_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(msg) = buf.0.recv().await {
|
||||
let bytes: Vec<u8> = msg.value.convert()?;
|
||||
redis_tx.send(bytes).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
Ok::<_, CliError>(())
|
||||
});
|
||||
},
|
||||
Err(err) => {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
} else {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(UserError::InvalidChatMessageError.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(UserError::InvalidServerMessageError.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(val) = redis_rx.recv() =>{
|
||||
let res = user.as_ref().lock().await.receive_msg(val).await;
|
||||
match res {
|
||||
Ok(msg) => {
|
||||
match msg {
|
||||
Some(m) => res_msg_tx.send(Msg::Input(Message::Incoming(m.group, m.author, m.message))).await.map_err(|err| CliError::SenderError(err.to_string()))?,
|
||||
None => continue
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
};
|
||||
}
|
||||
Some(command) = cli_gr_rx.recv() => {
|
||||
// res_msg_tx.send(Msg::Input(Message::System(format!("Get command: {:?}", command)))).await?;
|
||||
match command {
|
||||
Commands::CreateGroup { group_name, storage_address, storage_url } => {
|
||||
let client_provider = ProviderBuilder::new()
|
||||
.with_recommended_fillers()
|
||||
.wallet(user.as_ref().lock().await.wallet())
|
||||
.on_http(storage_url);
|
||||
let res = user.as_ref().lock().await.connect_to_smart_contract(&storage_address, client_provider).await;
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let msg = format!("Successfully connect to Smart Contract on address {:}\n", storage_address);
|
||||
res_msg_tx.send(Msg::Input(Message::System(msg))).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
Err(err) => {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
};
|
||||
|
||||
let res = user.as_ref().lock().await.create_group(group_name.clone()).await;
|
||||
match res {
|
||||
Ok(mut br) => {
|
||||
let msg = format!("Successfully create group: {:?}", group_name.clone());
|
||||
res_msg_tx.send(Msg::Input(Message::System(msg))).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
|
||||
let redis_tx = redis_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(msg) = br.recv().await {
|
||||
let bytes: Vec<u8> = msg.value.convert()?;
|
||||
redis_tx.send(bytes).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
}
|
||||
Ok::<_, CliError>(())
|
||||
});
|
||||
},
|
||||
Err(err) => {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
};
|
||||
},
|
||||
Commands::Invite { group_name, users_wallet_addrs } => {
|
||||
let user_clone = user.clone();
|
||||
let res_msg_tx_c = messages_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
for user_wallet in users_wallet_addrs.iter() {
|
||||
let user_clone_ref = user_clone.as_ref();
|
||||
let opt_token =
|
||||
{
|
||||
let mut user_clone_ref_lock = user_clone_ref.lock().await;
|
||||
let res = user_clone_ref_lock.handle_send_req(user_wallet, group_name.clone()).await;
|
||||
match res {
|
||||
Ok(token) => {
|
||||
token
|
||||
},
|
||||
Err(err) => {
|
||||
res_msg_tx_c
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
None
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
match opt_token {
|
||||
Some(token) => token.cancelled().await,
|
||||
None => return Err(CliError::TokenCancellingError),
|
||||
};
|
||||
|
||||
{
|
||||
let mut user_clone_ref_lock = user_clone.as_ref().lock().await;
|
||||
user_clone_ref_lock.contacts.future_req.remove(user_wallet);
|
||||
let res = user_clone_ref_lock.add_user_to_acl(user_wallet).await;
|
||||
if let Err(err) = res {
|
||||
res_msg_tx_c
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let res = user_clone.as_ref().lock().await.invite(users_wallet_addrs.clone(), group_name.clone()).await;
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let msg = format!("Invite {:?} to the group {:}\n",
|
||||
users_wallet_addrs, group_name
|
||||
);
|
||||
res_msg_tx_c.send(Msg::Input(Message::System(msg))).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
Err(err) => {
|
||||
res_msg_tx_c
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
};
|
||||
Ok::<_, CliError>(())
|
||||
});
|
||||
},
|
||||
Commands::SendMessage { group_name, msg } => {
|
||||
let message = msg.join(" ");
|
||||
let res = user.as_ref().lock().await.send_msg(&message, group_name.clone(), user_address.clone()).await;
|
||||
match res {
|
||||
Ok(_) => {
|
||||
res_msg_tx.send(Msg::Input(Message::Mine(group_name, user_address.clone(), message ))).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
Err(err) => {
|
||||
res_msg_tx
|
||||
.send(Msg::Input(Message::Error(err.to_string())))
|
||||
.await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
},
|
||||
};
|
||||
},
|
||||
Commands::Exit => {
|
||||
res_msg_tx.send(Msg::Input(Message::System("Bye!".to_string()))).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
break
|
||||
},
|
||||
}
|
||||
}
|
||||
_ = main_token.cancelled() => {
|
||||
break;
|
||||
}
|
||||
else => {
|
||||
res_msg_tx.send(Msg::Input(Message::System("Something went wrong".to_string()))).await.map_err(|err| CliError::SenderError(err.to_string()))?;
|
||||
break
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok::<_, CliError>(())
|
||||
});
|
||||
|
||||
let h3 = tokio::spawn(async move { terminal_handler(messages_rx, token).await });
|
||||
|
||||
let handler_res = tokio::join!(h1, h2, h3);
|
||||
handler_res.0??;
|
||||
handler_res.1??;
|
||||
handler_res.2??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
270
src/message.rs
Normal file
270
src/message.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! This module contains the messages that are used to communicate between inside the application
|
||||
//! The high level message is a [`WakuMessage`](waku_bindings::WakuMessage)
|
||||
//! Inside the [`WakuMessage`](waku_bindings::WakuMessage) we have a [`ContentTopic`](waku_bindings::WakuContentTopic) and a payload
|
||||
//! The [`ContentTopic`](waku_bindings::WakuContentTopic) is used to identify the type of message and the payload is the actual message
|
||||
//! Based on the [`ContentTopic`](waku_bindings::WakuContentTopic) we distinguish between:
|
||||
//! - [`WelcomeMessage`] which includes next message types:
|
||||
//! - [`GroupAnnouncement`]
|
||||
//! - `GroupAnnouncement {
|
||||
//! eth_pub_key: Vec<u8>,
|
||||
//! signature: Vec<u8>,
|
||||
//! }`
|
||||
//! - [`UserKeyPackage`]
|
||||
//! - `Encrypted KeyPackage: Vec<u8>`
|
||||
//! - [`InvitationToJoin`]
|
||||
//! - `Serialized MlsMessageOut: Vec<u8>`
|
||||
//! - [`AppMessage`]
|
||||
//! - [`ConversationMessage`]
|
||||
//! - [`BatchProposalsMessage`]
|
||||
//! - [`BanRequest`]
|
||||
//! - [`VotePayload`]
|
||||
//! - [`UserVote`]
|
||||
//!
|
||||
use alloy::hex;
|
||||
use mls_crypto::identity::normalize_wallet_address;
|
||||
use openmls::prelude::{KeyPackage, MlsMessageOut};
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use crate::{
|
||||
consensus::ConsensusEvent,
|
||||
encrypt_message,
|
||||
protos::{
|
||||
consensus::v1::{Outcome, Proposal, RequestType, UpdateRequest, Vote, VotePayload},
|
||||
de_mls::messages::v1::{
|
||||
app_message, welcome_message, AppMessage, BanRequest, BatchProposalsMessage,
|
||||
ConversationMessage, GroupAnnouncement, InvitationToJoin, ProposalAdded,
|
||||
UserKeyPackage, UserVote, WelcomeMessage,
|
||||
},
|
||||
},
|
||||
steward::GroupUpdateRequest,
|
||||
verify_message, MessageError,
|
||||
};
|
||||
|
||||
// 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 VOTE_PAYLOAD: &str = "VotePayload";
|
||||
pub const USER_VOTE: &str = "UserVote";
|
||||
pub const PROPOSAL_ADDED: &str = "ProposalAdded";
|
||||
pub const UNKNOWN: &str = "Unknown";
|
||||
}
|
||||
|
||||
/// 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::VotePayload(_) => VOTE_PAYLOAD,
|
||||
app_message::Payload::UserVote(_) => USER_VOTE,
|
||||
app_message::Payload::ProposalAdded(_) => PROPOSAL_ADDED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageType for UpdateRequest {
|
||||
fn message_type(&self) -> &'static str {
|
||||
match RequestType::try_from(self.request_type) {
|
||||
Ok(RequestType::AddMember) => "Add Member",
|
||||
Ok(RequestType::RemoveMember) => "Remove Member",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WELCOME MESSAGE SUBTOPIC
|
||||
impl GroupAnnouncement {
|
||||
pub fn new(pub_key: Vec<u8>, signature: Vec<u8>) -> Self {
|
||||
GroupAnnouncement {
|
||||
eth_pub_key: pub_key,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> Result<bool, MessageError> {
|
||||
let verified = verify_message(&self.eth_pub_key, &self.signature, &self.eth_pub_key)?;
|
||||
Ok(verified)
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, kp: KeyPackage) -> Result<Vec<u8>, MessageError> {
|
||||
let key_package = serde_json::to_vec(&kp)?;
|
||||
let encrypted = encrypt_message(&key_package, &self.eth_pub_key)?;
|
||||
Ok(encrypted)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GroupAnnouncement> for WelcomeMessage {
|
||||
fn from(group_announcement: GroupAnnouncement) -> Self {
|
||||
WelcomeMessage {
|
||||
payload: Some(welcome_message::Payload::GroupAnnouncement(
|
||||
group_announcement,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<MlsMessageOut> for WelcomeMessage {
|
||||
type Error = MessageError;
|
||||
fn try_from(mls_message: MlsMessageOut) -> Result<Self, MessageError> {
|
||||
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<UserKeyPackage> for WelcomeMessage {
|
||||
fn from(user_key_package: UserKeyPackage) -> Self {
|
||||
WelcomeMessage {
|
||||
payload: Some(welcome_message::Payload::UserKeyPackage(user_key_package)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VotePayload> for AppMessage {
|
||||
fn from(vote_payload: VotePayload) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::VotePayload(vote_payload)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserVote> for AppMessage {
|
||||
fn from(user_vote: UserVote) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::UserVote(user_vote)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConversationMessage> for AppMessage {
|
||||
fn from(conversation_message: ConversationMessage) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::ConversationMessage(
|
||||
conversation_message,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BatchProposalsMessage> for AppMessage {
|
||||
fn from(batch_proposals_message: BatchProposalsMessage) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::BatchProposalsMessage(
|
||||
batch_proposals_message,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BanRequest> for AppMessage {
|
||||
fn from(ban_request: BanRequest) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::BanRequest(ban_request)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Proposal> for AppMessage {
|
||||
fn from(proposal: Proposal) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::Proposal(proposal)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vote> for AppMessage {
|
||||
fn from(vote: Vote) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::Vote(vote)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProposalAdded> for AppMessage {
|
||||
fn from(proposal_added: ProposalAdded) -> Self {
|
||||
AppMessage {
|
||||
payload: Some(app_message::Payload::ProposalAdded(proposal_added)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConsensusEvent> for Outcome {
|
||||
fn from(consensus_event: ConsensusEvent) -> Self {
|
||||
match consensus_event {
|
||||
ConsensusEvent::ConsensusReached {
|
||||
proposal_id: _,
|
||||
result: true,
|
||||
} => Outcome::Accepted,
|
||||
ConsensusEvent::ConsensusReached {
|
||||
proposal_id: _,
|
||||
result: false,
|
||||
} => Outcome::Rejected,
|
||||
ConsensusEvent::ConsensusFailed {
|
||||
proposal_id: _,
|
||||
reason: _,
|
||||
} => Outcome::Unspecified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GroupUpdateRequest> for UpdateRequest {
|
||||
fn from(group_update_request: GroupUpdateRequest) -> Self {
|
||||
match group_update_request {
|
||||
GroupUpdateRequest::AddMember(kp) => UpdateRequest {
|
||||
request_type: RequestType::AddMember as i32,
|
||||
wallet_address: kp.leaf_node().credential().serialized_content().to_vec(),
|
||||
},
|
||||
GroupUpdateRequest::RemoveMember(id) => UpdateRequest {
|
||||
request_type: RequestType::RemoveMember as i32,
|
||||
wallet_address: hex::decode(id.strip_prefix("0x").unwrap_or(&id))
|
||||
.unwrap_or_else(|_| id.into_bytes()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert protobuf UpdateRequest to display format
|
||||
pub fn convert_group_requests_to_display(
|
||||
group_requests: &[UpdateRequest],
|
||||
) -> Vec<(String, String)> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for req in group_requests {
|
||||
match RequestType::try_from(req.request_type) {
|
||||
Ok(RequestType::AddMember) => {
|
||||
results.push((
|
||||
"Add Member".to_string(),
|
||||
normalize_wallet_address(&req.wallet_address),
|
||||
));
|
||||
}
|
||||
Ok(RequestType::RemoveMember) => {
|
||||
results.push((
|
||||
"Remove Member".to_string(),
|
||||
normalize_wallet_address(&req.wallet_address),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
results.push(("Unknown".to_string(), "Invalid request".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
51
src/protos/messages/v1/application.proto
Normal file
51
src/protos/messages/v1/application.proto
Normal file
@@ -0,0 +1,51 @@
|
||||
// src/protos/messages/v1/application.proto
|
||||
|
||||
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;
|
||||
consensus.v1.VotePayload vote_payload = 6;
|
||||
UserVote user_vote = 7;
|
||||
ProposalAdded proposal_added = 8;
|
||||
}
|
||||
}
|
||||
|
||||
message BanRequest {
|
||||
string user_to_ban = 1;
|
||||
string requester = 2;
|
||||
string group_name = 3;
|
||||
}
|
||||
|
||||
message ConversationMessage {
|
||||
bytes message = 1;
|
||||
string sender = 2;
|
||||
string group_name = 3;
|
||||
}
|
||||
|
||||
message BatchProposalsMessage {
|
||||
bytes group_name = 1;
|
||||
repeated bytes mls_proposals = 2; // Individual MLS proposal messages
|
||||
bytes commit_message = 3; // MLS commit message
|
||||
}
|
||||
|
||||
// Yes/No vote for a given proposal. Based on the result, the `consensus.v1.Vote` will be created.
|
||||
message UserVote {
|
||||
uint32 proposal_id = 1;
|
||||
bool vote = 2;
|
||||
string group_name = 3;
|
||||
}
|
||||
|
||||
// Proposal added message is sent to the UI when a new proposal is added to the group.
|
||||
message ProposalAdded {
|
||||
string group_id = 1;
|
||||
consensus.v1.UpdateRequest request = 2;
|
||||
}
|
||||
61
src/protos/messages/v1/consensus.proto
Normal file
61
src/protos/messages/v1/consensus.proto
Normal file
@@ -0,0 +1,61 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package consensus.v1;
|
||||
|
||||
enum RequestType {
|
||||
REQUEST_TYPE_UNSPECIFIED = 0;
|
||||
REQUEST_TYPE_ADD_MEMBER = 1;
|
||||
REQUEST_TYPE_REMOVE_MEMBER = 2;
|
||||
}
|
||||
|
||||
// Proposal represents a consensus proposal that needs voting
|
||||
message Proposal {
|
||||
string name = 10; // Proposal name
|
||||
repeated UpdateRequest group_requests = 11; // Structured group update requests
|
||||
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 {
|
||||
uint32 vote_id = 20; // Unique identifier of the vote
|
||||
bytes vote_owner = 21; // Voter's public key
|
||||
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
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
OUTCOME_UNSPECIFIED = 0;
|
||||
OUTCOME_ACCEPTED = 1;
|
||||
OUTCOME_REJECTED = 2;
|
||||
}
|
||||
|
||||
message ProposalResult {
|
||||
string group_id = 31;
|
||||
uint32 proposal_id = 32;
|
||||
Outcome outcome = 33;
|
||||
uint64 decided_at_ms = 34;
|
||||
}
|
||||
|
||||
message VotePayload {
|
||||
string group_id = 41;
|
||||
uint32 proposal_id = 42;
|
||||
repeated UpdateRequest group_requests = 43; // Structured group update requests
|
||||
uint64 timestamp = 44;
|
||||
}
|
||||
|
||||
message UpdateRequest {
|
||||
RequestType request_type = 51;
|
||||
bytes wallet_address = 52;
|
||||
}
|
||||
28
src/protos/messages/v1/welcome.proto
Normal file
28
src/protos/messages/v1/welcome.proto
Normal file
@@ -0,0 +1,28 @@
|
||||
// src/protos/messages/v1/welcome.proto
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package de_mls.messages.v1;
|
||||
|
||||
// The main message container. It can only contain ONE of the following
|
||||
// message types in its `payload` field.
|
||||
message WelcomeMessage {
|
||||
oneof payload {
|
||||
GroupAnnouncement group_announcement = 1;
|
||||
UserKeyPackage user_key_package = 2;
|
||||
InvitationToJoin invitation_to_join = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message GroupAnnouncement {
|
||||
bytes eth_pub_key = 1;
|
||||
bytes signature = 2;
|
||||
}
|
||||
|
||||
message UserKeyPackage {
|
||||
bytes encrypt_kp = 1;
|
||||
}
|
||||
|
||||
message InvitationToJoin {
|
||||
bytes mls_message_out_bytes = 1;
|
||||
}
|
||||
785
src/state_machine.rs
Normal file
785
src/state_machine.rs
Normal file
@@ -0,0 +1,785 @@
|
||||
//! State machine for steward epoch management and group operations.
|
||||
//!
|
||||
//! This module implements a state machine that manages the lifecycle of steward epochs,
|
||||
//! proposal collection, voting, and application. The state machine ensures proper
|
||||
//! transitions and enforces permissions at each state.
|
||||
//!
|
||||
//! # States
|
||||
//!
|
||||
//! - **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 VOTE_PAYLOAD/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_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 VOTE_PAYLOAD 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 tracing::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 any message freely
|
||||
Working,
|
||||
/// Waiting state during steward epoch - only steward can send BATCH_PROPOSALS_MESSAGE
|
||||
Waiting,
|
||||
/// Voting state - everyone can send VOTE/USER_VOTE, only steward can send VOTE_PAYLOAD/PROPOSAL
|
||||
Voting,
|
||||
/// Consensus reached state - consensus achieved, waiting for steward to send batch proposals
|
||||
ConsensusReached,
|
||||
}
|
||||
|
||||
impl Display for GroupState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = match self {
|
||||
GroupState::Working => "Working",
|
||||
GroupState::Waiting => "Waiting",
|
||||
GroupState::Voting => "Voting",
|
||||
GroupState::ConsensusReached => "ConsensusReached",
|
||||
};
|
||||
write!(f, "{state}")
|
||||
}
|
||||
}
|
||||
|
||||
/// State machine for managing group steward epoch flow
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupStateMachine {
|
||||
/// Current state of the group
|
||||
state: GroupState,
|
||||
/// Optional steward for epoch management
|
||||
steward: Option<Steward>,
|
||||
}
|
||||
|
||||
impl GroupStateMachine {
|
||||
/// Create a new group state machine
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: GroupState::Working,
|
||||
steward: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new group state machine with steward
|
||||
pub fn new_with_steward() -> Self {
|
||||
Self {
|
||||
state: GroupState::Working,
|
||||
steward: Some(Steward::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current state
|
||||
pub fn current_state(&self) -> GroupState {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
/// 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 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::VOTE_PAYLOAD => 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if self.state == GroupState::Voting {
|
||||
return Err(GroupError::InvalidStateTransition {
|
||||
from: self.state.to_string(),
|
||||
to: "Voting".to_string(),
|
||||
});
|
||||
}
|
||||
self.state = GroupState::Voting;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if self.state != GroupState::Voting {
|
||||
return Err(GroupError::InvalidStateTransition {
|
||||
from: self.state.to_string(),
|
||||
to: if vote_result {
|
||||
"ConsensusReached"
|
||||
} else {
|
||||
"Working"
|
||||
}
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if vote_result {
|
||||
// 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 NO - return to working state
|
||||
info!("[complete_voting]: Vote NO, transitioning to Working state");
|
||||
self.start_working();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// 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
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current epoch proposals for UI display.
|
||||
///
|
||||
/// ## Returns:
|
||||
/// - Vector of proposals currently collected for the next steward epoch
|
||||
///
|
||||
/// ## Usage:
|
||||
/// Used to display current proposals in the UI for stewards.
|
||||
pub async fn get_current_epoch_proposals(&self) -> Vec<crate::steward::GroupUpdateRequest> {
|
||||
if let Some(steward) = &self.steward {
|
||||
steward.get_current_epoch_proposals().await
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<GroupUpdateRequest> {
|
||||
if let Some(steward) = &self.steward {
|
||||
steward.get_voting_epoch_proposals().await
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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).
|
||||
///
|
||||
/// ## 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).
|
||||
///
|
||||
/// ## 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<usize, GroupError> {
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_machine_creation() {
|
||||
let state_machine = GroupStateMachine::new();
|
||||
assert_eq!(state_machine.current_state(), GroupState::Working);
|
||||
assert!(!state_machine.has_steward());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_machine_with_steward_creation() {
|
||||
let state_machine = GroupStateMachine::new_with_steward();
|
||||
assert_eq!(state_machine.current_state(), GroupState::Working);
|
||||
assert!(state_machine.has_steward());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_transitions() {
|
||||
let mut state_machine = GroupStateMachine::new_with_steward();
|
||||
|
||||
// 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_with_validation()
|
||||
.await
|
||||
.expect("Failed to start steward epoch");
|
||||
assert_eq!(state_machine.current_state(), GroupState::Waiting);
|
||||
|
||||
// Test start_voting
|
||||
state_machine
|
||||
.start_voting()
|
||||
.expect("Failed to start voting");
|
||||
assert_eq!(state_machine.current_state(), GroupState::Voting);
|
||||
|
||||
// Test complete_voting with success
|
||||
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
|
||||
.handle_yes_vote()
|
||||
.await
|
||||
.expect("Failed to apply proposals");
|
||||
assert_eq!(state_machine.current_state(), GroupState::Working);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_message_type_permissions() {
|
||||
let mut state_machine = GroupStateMachine::new_with_steward();
|
||||
|
||||
// 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_with_validation()
|
||||
.await
|
||||
.expect("Failed to start steward epoch");
|
||||
|
||||
// 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::VOTE_PAYLOAD));
|
||||
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 - 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::VOTE_PAYLOAD));
|
||||
assert!(state_machine.can_send_message_type(true, false, message_types::VOTE_PAYLOAD));
|
||||
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 complete voting from Working state
|
||||
let result = state_machine.complete_voting(true);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GroupError::InvalidStateTransition { .. })
|
||||
));
|
||||
|
||||
// Cannot apply proposals from Working state
|
||||
let result = state_machine.handle_yes_vote().await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GroupError::InvalidStateTransition { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proposal_management() {
|
||||
let mut state_machine = GroupStateMachine::new_with_steward();
|
||||
|
||||
// Add some proposals
|
||||
state_machine
|
||||
.add_proposal(GroupUpdateRequest::RemoveMember(
|
||||
"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc".to_string(),
|
||||
))
|
||||
.await;
|
||||
|
||||
// Start steward epoch - should collect proposals
|
||||
state_machine
|
||||
.start_steward_epoch_with_validation()
|
||||
.await
|
||||
.expect("Failed to start steward epoch");
|
||||
assert_eq!(state_machine.get_voting_epoch_proposals_count().await, 1);
|
||||
|
||||
// Complete the flow
|
||||
state_machine
|
||||
.start_voting()
|
||||
.expect("Failed to start voting");
|
||||
state_machine
|
||||
.complete_voting(true)
|
||||
.expect("Failed to complete voting");
|
||||
state_machine
|
||||
.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);
|
||||
}
|
||||
}
|
||||
168
src/steward.rs
Normal file
168
src/steward.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use alloy::primitives::Address;
|
||||
use libsecp256k1::{PublicKey, SecretKey};
|
||||
use openmls::prelude::KeyPackage;
|
||||
use std::{fmt::Display, str::FromStr, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
decrypt_message, error::MessageError, generate_keypair,
|
||||
protos::de_mls::messages::v1::GroupAnnouncement, sign_message,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Steward {
|
||||
eth_pub: Arc<Mutex<PublicKey>>,
|
||||
eth_secr: Arc<Mutex<SecretKey>>,
|
||||
current_epoch_proposals: Arc<Mutex<Vec<GroupUpdateRequest>>>,
|
||||
voting_epoch_proposals: Arc<Mutex<Vec<GroupUpdateRequest>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GroupUpdateRequest {
|
||||
AddMember(Box<KeyPackage>),
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Steward {
|
||||
pub fn new() -> Self {
|
||||
let (public_key, private_key) = generate_keypair();
|
||||
Steward {
|
||||
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 async fn refresh_key_pair(&mut self) {
|
||||
let (public_key, private_key) = generate_keypair();
|
||||
*self.eth_pub.lock().await = public_key;
|
||||
*self.eth_secr.lock().await = private_key;
|
||||
}
|
||||
|
||||
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 async fn decrypt_message(&self, message: Vec<u8>) -> Result<KeyPackage, MessageError> {
|
||||
let sec_key = self.eth_secr.lock().await;
|
||||
let msg: Vec<u8> = 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.
|
||||
pub async fn start_new_epoch(&mut self) {
|
||||
// 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::<Vec<_>>()
|
||||
};
|
||||
|
||||
// Store proposals for this epoch (for voting and application)
|
||||
if !proposals.is_empty() {
|
||||
let mut voting = self.voting_epoch_proposals.lock().await;
|
||||
voting.extend(proposals);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_current_epoch_proposals_count(&self) -> usize {
|
||||
self.current_epoch_proposals.lock().await.len()
|
||||
}
|
||||
|
||||
/// Get proposals for the current epoch (for voting).
|
||||
pub async fn get_voting_epoch_proposals(&self) -> Vec<GroupUpdateRequest> {
|
||||
self.voting_epoch_proposals.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Get the count of proposals in the current epoch.
|
||||
pub async fn get_voting_epoch_proposals_count(&self) -> usize {
|
||||
self.voting_epoch_proposals.lock().await.len()
|
||||
}
|
||||
|
||||
/// Get the current epoch proposals for UI display.
|
||||
pub async fn get_current_epoch_proposals(&self) -> Vec<GroupUpdateRequest> {
|
||||
self.current_epoch_proposals.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Apply proposals for the current epoch (called after successful voting).
|
||||
pub async fn empty_voting_epoch_proposals(&mut self) {
|
||||
self.voting_epoch_proposals.lock().await.clear();
|
||||
}
|
||||
|
||||
/// Add a proposal to the current epoch
|
||||
pub async fn add_proposal(&mut self, proposal: GroupUpdateRequest) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
512
src/user.rs
512
src/user.rs
@@ -1,512 +0,0 @@
|
||||
use alloy::{
|
||||
hex::{self},
|
||||
network::{EthereumWallet, Network},
|
||||
primitives::Address,
|
||||
providers::Provider,
|
||||
signers::{local::PrivateKeySigner, SignerSync},
|
||||
transports::Transport,
|
||||
};
|
||||
use fred::types::Message;
|
||||
use openmls::{group::*, prelude::*};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
fmt::Display,
|
||||
str::{from_utf8, FromStr},
|
||||
};
|
||||
use tokio::sync::broadcast::Receiver;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use ds::{
|
||||
chat_client::{ChatClient, ReqMessageType, RequestMLSPayload, ResponseMLSPayload},
|
||||
ds::*,
|
||||
};
|
||||
use mls_crypto::openmls_provider::*;
|
||||
use sc_key_store::{sc_ks::ScKeyStorage, *};
|
||||
|
||||
use crate::{contact::ContactsList, conversation::*};
|
||||
use crate::{identity::Identity, UserError};
|
||||
|
||||
pub struct Group {
|
||||
group_name: String,
|
||||
conversation: Conversation,
|
||||
mls_group: RefCell<MlsGroup>,
|
||||
rc_client: RClient,
|
||||
// pubsub_topic: WakuPubSubTopic,
|
||||
// content_topics: Vec<WakuContentTopic>,
|
||||
}
|
||||
impl Display for Group {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(f, "Group: {:#?}", self.group_name)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct User<T, P, N> {
|
||||
pub identity: Identity,
|
||||
pub groups: HashMap<String, Group>,
|
||||
provider: MlsCryptoProvider,
|
||||
eth_signer: PrivateKeySigner,
|
||||
// we don't need on-chain connection if we don't create a group
|
||||
sc_ks: Option<ScKeyStorage<T, P, N>>,
|
||||
pub contacts: ContactsList,
|
||||
}
|
||||
|
||||
impl<T, P, N> User<T, P, N>
|
||||
where
|
||||
T: Transport + Clone,
|
||||
P: Provider<T, N>,
|
||||
N: Network,
|
||||
{
|
||||
/// Create a new user with the given name and a fresh set of credentials.
|
||||
pub async fn new(user_eth_priv_key: &str, chat_client: ChatClient) -> Result<Self, UserError> {
|
||||
let signer = PrivateKeySigner::from_str(user_eth_priv_key)?;
|
||||
let user_address = signer.address();
|
||||
|
||||
let crypto = MlsCryptoProvider::default();
|
||||
let id = Identity::new(CIPHERSUITE, &crypto, user_address.as_slice())?;
|
||||
let user = User {
|
||||
groups: HashMap::new(),
|
||||
identity: id,
|
||||
eth_signer: signer,
|
||||
provider: crypto,
|
||||
sc_ks: None,
|
||||
contacts: ContactsList::new(chat_client).await?,
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn connect_to_smart_contract(
|
||||
&mut self,
|
||||
sc_storage_address: &str,
|
||||
provider: P,
|
||||
) -> Result<(), UserError> {
|
||||
let storage_address = Address::from_str(sc_storage_address)?;
|
||||
self.sc_ks = Some(ScKeyStorage::new(provider, storage_address));
|
||||
self.sc_ks
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.add_user(&self.identity.to_string())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_group(
|
||||
&mut self,
|
||||
group_name: String,
|
||||
) -> Result<Receiver<Message>, UserError> {
|
||||
let group_id = group_name.as_bytes();
|
||||
|
||||
if self.groups.contains_key(&group_name) {
|
||||
return Err(UserError::GroupAlreadyExistsError(group_name));
|
||||
}
|
||||
|
||||
let group_config = MlsGroupConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
|
||||
let mls_group = MlsGroup::new_with_group_id(
|
||||
&self.provider,
|
||||
&self.identity.signer,
|
||||
&group_config,
|
||||
GroupId::from_slice(group_id),
|
||||
self.identity.credential_with_key.clone(),
|
||||
)?;
|
||||
|
||||
let (rc, broadcaster) = RClient::new_with_group(group_name.clone()).await?;
|
||||
let group = Group {
|
||||
group_name: group_name.clone(),
|
||||
conversation: Conversation::default(),
|
||||
mls_group: RefCell::new(mls_group),
|
||||
rc_client: rc,
|
||||
// pubsub_topic: WakuPubSubTopic::new(),
|
||||
// content_topics: Vec::new(),
|
||||
};
|
||||
|
||||
self.groups.insert(group_name.clone(), group);
|
||||
self.contacts
|
||||
.insert_group2sc(group_name, self.sc_address()?)?;
|
||||
Ok(broadcaster)
|
||||
}
|
||||
|
||||
pub async fn add_user_to_acl(&mut self, user_address: &str) -> Result<(), UserError> {
|
||||
if self.sc_ks.is_none() {
|
||||
return Err(UserError::MissingSmartContractConnection);
|
||||
}
|
||||
self.sc_ks.as_mut().unwrap().add_user(user_address).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restore_key_package(
|
||||
&mut self,
|
||||
mut signed_kp: &[u8],
|
||||
) -> Result<KeyPackage, UserError> {
|
||||
if self.sc_ks.is_none() {
|
||||
return Err(UserError::MissingSmartContractConnection);
|
||||
}
|
||||
|
||||
let key_package_in = KeyPackageIn::tls_deserialize(&mut signed_kp)?;
|
||||
let key_package =
|
||||
key_package_in.validate(self.provider.crypto(), ProtocolVersion::Mls10)?;
|
||||
|
||||
Ok(key_package)
|
||||
}
|
||||
|
||||
pub async fn invite(
|
||||
&mut self,
|
||||
users: Vec<String>,
|
||||
group_name: String,
|
||||
) -> Result<(), UserError> {
|
||||
if self.sc_ks.is_none() {
|
||||
return Err(UserError::MissingSmartContractConnection);
|
||||
}
|
||||
|
||||
let users_for_invite = self
|
||||
.contacts
|
||||
.prepare_joiners(users.clone(), group_name.clone())
|
||||
.await?;
|
||||
|
||||
let mut joiners_key_package: Vec<KeyPackage> = Vec::with_capacity(users_for_invite.len());
|
||||
let mut user_addrs = Vec::with_capacity(users_for_invite.len());
|
||||
for (user_addr, user_kp) in users_for_invite {
|
||||
joiners_key_package.push(self.restore_key_package(&user_kp).await?);
|
||||
user_addrs.push(user_addr);
|
||||
}
|
||||
|
||||
// Build a proposal with this key package and do the MLS bits.
|
||||
let group = match self.groups.get_mut(&group_name) {
|
||||
Some(g) => g,
|
||||
None => return Err(UserError::GroupNotFoundError(group_name)),
|
||||
};
|
||||
|
||||
let (out_messages, welcome, _group_info) = group.mls_group.borrow_mut().add_members(
|
||||
&self.provider,
|
||||
&self.identity.signer,
|
||||
&joiners_key_package,
|
||||
)?;
|
||||
|
||||
group
|
||||
.rc_client
|
||||
.msg_send(
|
||||
out_messages.tls_serialize_detached()?,
|
||||
self.identity.to_string(),
|
||||
group_name,
|
||||
)
|
||||
.await?;
|
||||
// Second, process the invitation on our end.
|
||||
group
|
||||
.mls_group
|
||||
.borrow_mut()
|
||||
.merge_pending_commit(&self.provider)?;
|
||||
// Send welcome by p2p
|
||||
self.contacts
|
||||
.send_welcome_msg_to_users(self.identity.to_string(), user_addrs, welcome)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn receive_msg(
|
||||
&mut self,
|
||||
msg_bytes: Vec<u8>,
|
||||
) -> Result<Option<ConversationMessage>, UserError> {
|
||||
let buf: SenderStruct = serde_json::from_slice(&msg_bytes)?;
|
||||
if buf.sender == self.identity.to_string() {
|
||||
return Ok(None);
|
||||
}
|
||||
let res = MlsMessageIn::tls_deserialize_bytes(&buf.msg)?;
|
||||
let msg = match res.extract() {
|
||||
MlsMessageInBody::PrivateMessage(message) => {
|
||||
self.process_protocol_msg(message.into())?
|
||||
}
|
||||
MlsMessageInBody::PublicMessage(message) => {
|
||||
self.process_protocol_msg(message.into())?
|
||||
}
|
||||
_ => return Err(UserError::UnsupportedMessageType),
|
||||
};
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub fn process_protocol_msg(
|
||||
&mut self,
|
||||
message: ProtocolMessage,
|
||||
) -> Result<Option<ConversationMessage>, UserError> {
|
||||
let group_name = from_utf8(message.group_id().as_slice())?.to_string();
|
||||
let group = match self.groups.get_mut(&group_name) {
|
||||
Some(g) => g,
|
||||
None => return Err(UserError::GroupNotFoundError(group_name)),
|
||||
};
|
||||
let mut mls_group = group.mls_group.borrow_mut();
|
||||
|
||||
let processed_message = mls_group.process_message(&self.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.identity() == processed_message_credential.identity()
|
||||
&& (self.identity.credential_with_key.signature_key.as_slice()
|
||||
!= m.signature_key.as_slice())
|
||||
{
|
||||
Some(hex::encode(m.credential.identity()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
user_id.unwrap_or("".to_owned())
|
||||
};
|
||||
|
||||
let conversation_message = ConversationMessage::new(
|
||||
group_name,
|
||||
sender_name,
|
||||
String::from_utf8(application_message.into_bytes())?,
|
||||
);
|
||||
group.conversation.add(conversation_message.clone());
|
||||
return Ok(Some(conversation_message));
|
||||
}
|
||||
ProcessedMessageContent::ProposalMessage(_proposal_ptr) => (),
|
||||
ProcessedMessageContent::ExternalJoinProposalMessage(_external_proposal_ptr) => (),
|
||||
ProcessedMessageContent::StagedCommitMessage(commit_ptr) => {
|
||||
let mut remove_proposal: bool = false;
|
||||
if commit_ptr.self_removed() {
|
||||
remove_proposal = true;
|
||||
}
|
||||
mls_group.merge_staged_commit(&self.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
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn send_msg(
|
||||
&mut self,
|
||||
msg: &str,
|
||||
group_name: String,
|
||||
sender: String,
|
||||
) -> Result<(), UserError> {
|
||||
let group = match self.groups.get_mut(&group_name) {
|
||||
Some(g) => g,
|
||||
None => return Err(UserError::GroupNotFoundError(group_name)),
|
||||
};
|
||||
|
||||
let message_out = group.mls_group.borrow_mut().create_message(
|
||||
&self.provider,
|
||||
&self.identity.signer,
|
||||
msg.as_bytes(),
|
||||
)?;
|
||||
|
||||
group
|
||||
.rc_client
|
||||
.msg_send(message_out.tls_serialize_detached()?, sender, group_name)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn join_group(
|
||||
&mut self,
|
||||
welcome: String,
|
||||
) -> Result<(Receiver<Message>, String), UserError> {
|
||||
let wbytes = hex::decode(welcome).unwrap();
|
||||
let welc = MlsMessageIn::tls_deserialize_bytes(wbytes).unwrap();
|
||||
let welcome = welc.into_welcome();
|
||||
if welcome.is_none() {
|
||||
return Err(UserError::EmptyWelcomeMessageError);
|
||||
}
|
||||
|
||||
let group_config = MlsGroupConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
|
||||
// TODO: After we move from openmls, we will have to delete the used key package here ourselves.
|
||||
let mls_group =
|
||||
MlsGroup::new_from_welcome(&self.provider, &group_config, welcome.unwrap(), None)?;
|
||||
|
||||
let group_id = mls_group.group_id().to_vec();
|
||||
let group_name = String::from_utf8(group_id)?;
|
||||
|
||||
let (rc, br) = RClient::new_with_group(group_name.clone()).await?;
|
||||
let group = Group {
|
||||
group_name: group_name.clone(),
|
||||
conversation: Conversation::default(),
|
||||
mls_group: RefCell::new(mls_group),
|
||||
rc_client: rc,
|
||||
};
|
||||
|
||||
match self.groups.insert(group_name.clone(), group) {
|
||||
Some(old) => Err(UserError::GroupAlreadyExistsError(old.group_name)),
|
||||
None => Ok((br, group_name)),
|
||||
}
|
||||
}
|
||||
|
||||
// pub async fn remove(&mut self, name: String, group_name: String) -> Result<(), UserError> {
|
||||
// // Get the group ID
|
||||
// let group = match self.groups.get_mut(&group_name) {
|
||||
// Some(g) => g,
|
||||
// None => return Err(UserError::UnknownGroupError(group_name)),
|
||||
// };
|
||||
|
||||
// // Get the user leaf index
|
||||
// let leaf_index = group.find_member_index(name)?;
|
||||
|
||||
// // Remove operation on the mls group
|
||||
// let (remove_message, _welcome, _group_info) = group.mls_group.borrow_mut().remove_members(
|
||||
// &self.provider,
|
||||
// &self.identity.signer,
|
||||
// &[leaf_index],
|
||||
// )?;
|
||||
|
||||
// group.rc_client.msg_send(remove_message).await?;
|
||||
|
||||
// // Second, process the removal on our end.
|
||||
// group
|
||||
// .mls_group
|
||||
// .borrow_mut()
|
||||
// .merge_pending_commit(&self.provider)?;
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
/// Return the last 100 messages sent to the group.
|
||||
pub fn read_msgs(
|
||||
&self,
|
||||
group_name: String,
|
||||
) -> Result<Option<Vec<ConversationMessage>>, UserError> {
|
||||
self.groups.get(&group_name).map_or_else(
|
||||
|| Err(UserError::GroupNotFoundError(group_name)),
|
||||
|g| {
|
||||
Ok(g.conversation
|
||||
.get(100)
|
||||
.map(|messages: &[crate::conversation::ConversationMessage]| messages.to_vec()))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn group_members(&self, group_name: String) -> Result<Vec<String>, UserError> {
|
||||
let group = match self.groups.get(&group_name) {
|
||||
Some(g) => g,
|
||||
None => return Err(UserError::GroupNotFoundError(group_name)),
|
||||
};
|
||||
Ok(group.group_members(self.identity.signature_pub_key().as_slice()))
|
||||
}
|
||||
|
||||
pub fn user_groups(&self) -> Result<Vec<String>, UserError> {
|
||||
if self.groups.is_empty() {
|
||||
return Ok(Vec::default());
|
||||
}
|
||||
Ok(self.groups.keys().map(|k| k.to_owned()).collect())
|
||||
}
|
||||
|
||||
pub fn wallet(&self) -> EthereumWallet {
|
||||
EthereumWallet::from(self.eth_signer.clone())
|
||||
}
|
||||
|
||||
fn sign(&self, msg: String) -> Result<String, UserError> {
|
||||
let signature = self.eth_signer.sign_message_sync(msg.as_bytes())?;
|
||||
let res = serde_json::to_string(&signature)?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn send_responce_on_request(
|
||||
&mut self,
|
||||
req: RequestMLSPayload,
|
||||
user_address: &str,
|
||||
) -> Result<(), UserError> {
|
||||
let self_address = self.identity.to_string();
|
||||
match req.msg_type {
|
||||
ReqMessageType::InviteToGroup => {
|
||||
let signature = self.sign(req.msg_to_sign())?;
|
||||
let key_package = self
|
||||
.identity
|
||||
.generate_key_package(CIPHERSUITE, &self.provider)?;
|
||||
let resp = ResponseMLSPayload::new(
|
||||
signature,
|
||||
self_address.clone(),
|
||||
req.group_name(),
|
||||
key_package.tls_serialize_detached()?,
|
||||
);
|
||||
self.contacts
|
||||
.send_resp_msg_to_user(self_address, user_address, resp)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
ReqMessageType::RemoveFromGroup => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parce_responce(&mut self, resp: ResponseMLSPayload) -> Result<(), UserError> {
|
||||
if self.sc_ks.is_none() {
|
||||
return Err(UserError::MissingSmartContractConnection);
|
||||
}
|
||||
let group_name = resp.group_name.clone();
|
||||
let sc_address = self.contacts.group2sc(group_name.clone())?;
|
||||
let (user_wallet, kp) = resp.validate(sc_address, group_name.clone())?;
|
||||
|
||||
self.contacts
|
||||
.add_key_package_to_contact(&user_wallet, kp, group_name.clone())
|
||||
.await?;
|
||||
|
||||
self.contacts.handle_response(&user_wallet)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sc_address(&self) -> Result<String, UserError> {
|
||||
if self.sc_ks.is_none() {
|
||||
return Err(UserError::MissingSmartContractConnection);
|
||||
}
|
||||
Ok(self.sc_ks.as_ref().unwrap().sc_adsress())
|
||||
}
|
||||
|
||||
pub async fn handle_send_req(
|
||||
&mut self,
|
||||
user_wallet: &str,
|
||||
group_name: String,
|
||||
) -> Result<Option<CancellationToken>, UserError> {
|
||||
if !self.contacts.does_user_in_contacts(user_wallet).await {
|
||||
self.contacts.add_new_contact(user_wallet).await?;
|
||||
}
|
||||
self.contacts
|
||||
.send_msg_req(
|
||||
self.identity.to_string(),
|
||||
user_wallet.to_owned(),
|
||||
group_name,
|
||||
ReqMessageType::InviteToGroup,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok(self.contacts.future_req.get(user_wallet).cloned())
|
||||
}
|
||||
}
|
||||
|
||||
impl Group {
|
||||
/// Get a member
|
||||
fn find_member_index(&self, user_id: String) -> Result<LeafNodeIndex, GroupError> {
|
||||
let member = self
|
||||
.mls_group
|
||||
.borrow()
|
||||
.members()
|
||||
.find(|m| m.credential.identity().eq(user_id.as_bytes()));
|
||||
|
||||
match member {
|
||||
Some(m) => Ok(m.index),
|
||||
None => Err(GroupError::UnknownGroupMemberError(user_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_members(&self, user_signature: &[u8]) -> Vec<String> {
|
||||
self.mls_group
|
||||
.borrow()
|
||||
.members()
|
||||
.filter(|m| m.signature_key == user_signature)
|
||||
.map(|m| hex::encode(m.credential.identity()))
|
||||
.collect::<Vec<String>>()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupError {
|
||||
#[error("Unknown group member : {0}")]
|
||||
UnknownGroupMemberError(String),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user