From 72fa9966857153546cc64bf4a6a0a9e3ea74df00 Mon Sep 17 00:00:00 2001 From: Andrei Shuvalov <6286552+dronbas@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:04:12 +0200 Subject: [PATCH 1/3] Add bee connect & bee wallet --- .cargo/config.toml | 19 + .github/workflows/ci.yml | 50 +- .github/workflows/release-rc.yml | 29 +- .github/workflows/release.yml | 29 +- .gitleaks.toml | 71 + Cargo.toml | 37 +- LICENSE.md | 661 +++ NOTICE.md | 31 + README.md | 113 +- bee_connect/Cargo.toml | 37 + bee_connect/PROTOCOL.md | 483 ++ bee_connect/README.md | 398 ++ bee_connect/src/client.rs | 2821 +++++++++++ bee_connect/src/dex_protocol.rs | 308 ++ bee_connect/src/dh.rs | 927 ++++ bee_connect/src/errors.rs | 2 + bee_connect/src/lib.rs | 45 + bee_connect/src/message.rs | 197 + bee_connect/src/wasm.rs | 984 ++++ bee_connect/tests/live_network.rs | 803 +++ bee_crypto/Cargo.toml | 43 + bee_crypto/README.md | 62 + bee_crypto/src/adapters/mod.rs | 3 + bee_crypto/src/adapters/native/dto/crypto.rs | 81 + bee_crypto/src/adapters/native/dto/mod.rs | 1 + bee_crypto/src/adapters/native/mod.rs | 181 + bee_crypto/src/adapters/wasm/dto/crypto.rs | 103 + bee_crypto/src/adapters/wasm/dto/mod.rs | 22 + bee_crypto/src/adapters/wasm/mod.rs | 182 + bee_crypto/src/client.rs | 156 + bee_crypto/src/errors.rs | 2 + bee_crypto/src/lib.rs | 14 + bee_crypto/src/services/boc_hash.rs | 32 + bee_crypto/src/services/chacha.rs | 493 ++ bee_crypto/src/services/encoding.rs | 11 + bee_crypto/src/services/hash.rs | 139 + bee_crypto/src/services/key.rs | 102 + bee_crypto/src/services/mnemonic.rs | 100 + bee_crypto/src/services/mod.rs | 24 + bee_crypto/src/services/signature.rs | 147 + bee_crypto/src/services/storage.rs | 235 + bee_crypto/tests/integration.rs | 246 + bee_errors/Cargo.toml | 24 + bee_errors/src/lib.rs | 322 ++ bee_infra/Cargo.toml | 14 +- bee_infra/src/lib.rs | 76 +- bee_infra/src/rate_limiter.rs | 115 + bee_infra/src/retry.rs | 318 ++ bee_miner/Cargo.toml | 15 +- bee_miner/src/core/context.rs | 50 +- bee_miner/src/core/keys.rs | 91 +- bee_miner/src/core/merkle.rs | 19 +- bee_miner/src/graphql/block.rs | 21 + bee_miner/src/graphql/mod.rs | 21 + bee_miner/src/lib.rs | 1 + bee_miner/src/wasm/miner.rs | 46 +- bee_miner/src/wasm/mod.rs | 25 +- bee_miner/src/wasm/worker.rs | 6 +- bee_sdk/Cargo.toml | 8 +- bee_sdk/src/lib.rs | 3 + bee_shared/Cargo.toml | 5 +- bee_shared/src/miner/leaf.rs | 55 + bee_shared/src/verifier.rs | 32 +- bee_verifier/Cargo.toml | 5 +- bee_verifier/src/bindings.rs | 4 +- bee_verifier/src/lib.rs | 10 +- bee_wallet/ARCHITECTURE.md | 44 + bee_wallet/Cargo.toml | 108 + bee_wallet/README.md | 75 + bee_wallet/src/adapters/mod.rs | 4 + .../src/adapters/native/dto/balances.rs | 37 + bee_wallet/src/adapters/native/dto/deploy.rs | 36 + bee_wallet/src/adapters/native/dto/keys.rs | 15 + bee_wallet/src/adapters/native/dto/miner.rs | 23 + bee_wallet/src/adapters/native/dto/mod.rs | 7 + .../src/adapters/native/dto/multifactor.rs | 4 + bee_wallet/src/adapters/native/dto/tx.rs | 54 + bee_wallet/src/adapters/native/dto/zkp.rs | 30 + bee_wallet/src/adapters/native/mod.rs | 452 ++ bee_wallet/src/adapters/wasm/dto/balances.rs | 80 + bee_wallet/src/adapters/wasm/dto/connect.rs | 219 + bee_wallet/src/adapters/wasm/dto/deploy.rs | 241 + bee_wallet/src/adapters/wasm/dto/keys.rs | 28 + bee_wallet/src/adapters/wasm/dto/miner.rs | 56 + bee_wallet/src/adapters/wasm/dto/mod.rs | 396 ++ .../src/adapters/wasm/dto/multifactor.rs | 475 ++ bee_wallet/src/adapters/wasm/dto/names.rs | 52 + bee_wallet/src/adapters/wasm/dto/tx.rs | 105 + bee_wallet/src/adapters/wasm/dto/write.rs | 112 + bee_wallet/src/adapters/wasm/dto/zkp.rs | 210 + bee_wallet/src/adapters/wasm/mod.rs | 764 +++ bee_wallet/src/bin/faucet.rs | 227 + bee_wallet/src/bin/wallet_info.rs | 1148 +++++ bee_wallet/src/client.rs | 145 + bee_wallet/src/dapp.rs | 68 + bee_wallet/src/errors.rs | 11 + bee_wallet/src/infra/mod.rs | 215 + bee_wallet/src/lib.rs | 98 + bee_wallet/src/modules/balance.rs | 75 + bee_wallet/src/modules/connect.rs | 426 ++ bee_wallet/src/modules/deploy.rs | 370 ++ bee_wallet/src/modules/dex.rs | 69 + bee_wallet/src/modules/history.rs | 27 + bee_wallet/src/modules/miner.rs | 155 + bee_wallet/src/modules/mod.rs | 23 + bee_wallet/src/modules/multifactor.rs | 431 ++ bee_wallet/src/modules/names.rs | 64 + bee_wallet/src/modules/tokens.rs | 419 ++ bee_wallet/src/modules/zkp.rs | 253 + bee_wallet/src/progress.rs | 161 + bee_wallet/src/services/balance/mod.rs | 212 + bee_wallet/src/services/boost.rs | 40 + bee_wallet/src/services/connect.rs | 903 ++++ bee_wallet/src/services/deploy/mod.rs | 44 + .../services/deploy/prepare_deploy_params.rs | 100 + bee_wallet/src/services/dex.rs | 105 + bee_wallet/src/services/miner/cmd.rs | 288 ++ bee_wallet/src/services/miner/mod.rs | 26 + bee_wallet/src/services/miner/query.rs | 35 + bee_wallet/src/services/mod.rs | 25 + bee_wallet/src/services/multifactor/cmd.rs | 507 ++ bee_wallet/src/services/multifactor/jwk.rs | 129 + .../services/multifactor/key_derivation.rs | 488 ++ bee_wallet/src/services/multifactor/mod.rs | 7 + bee_wallet/src/services/multifactor/query.rs | 155 + .../src/services/multifactor/whitelist.rs | 131 + bee_wallet/src/services/resolvers/mod.rs | 61 + bee_wallet/src/services/tokens/mod.rs | 572 +++ bee_wallet/src/services/tokens/sell_orders.rs | 557 +++ .../src/services/transaction/history.rs | 1497 ++++++ bee_wallet/src/services/transaction/mod.rs | 1 + bee_wallet/src/services/validate_name.rs | 50 + bee_wallet/src/services/zkp/auth.rs | 36 + .../src/services/zkp/client_login_complete.rs | 726 +++ .../src/services/zkp/client_login_prepare.rs | 331 ++ bee_wallet/src/services/zkp/mod.rs | 38 + .../zkp/poseidon_lite_constants_1.json | 1 + .../zkp/poseidon_lite_constants_2.json | 1 + .../zkp/poseidon_lite_constants_4.json | 1 + .../zkp/poseidon_lite_constants_5.json | 1 + bee_wallet/src/services/zkp/utils/error.rs | 53 + bee_wallet/src/services/zkp/utils/mod.rs | 3 + bee_wallet/src/services/zkp/utils/provider.rs | 131 + bee_wallet/src/services/zkp/utils/zk_login.rs | 125 + bee_wallet/src/types.rs | 342 ++ bee_wallet/tests/dex_flows/common/context.rs | 34 + bee_wallet/tests/dex_flows/common/keys.rs | 28 + bee_wallet/tests/dex_flows/common/misc.rs | 36 + bee_wallet/tests/dex_flows/common/mod.rs | 20 + bee_wallet/tests/dex_flows/common/pn.rs | 211 + bee_wallet/tests/dex_flows/common/voucher.rs | 231 + bee_wallet/tests/dex_flows/common/wallet.rs | 72 + bee_wallet/tests/dex_flows/flows.rs | 449 ++ bee_wallet/tests/dex_flows/main.rs | 24 + bee_wallet/tests/hello.abi.json | 91 + bee_wallet/tests/hello.sol | 131 + bee_wallet/tests/hello.tvc | Bin 0 -> 994 bytes bee_wallet/tests/integration.rs | 4299 +++++++++++++++++ examples/javascript/miner-react/README.md | 128 +- examples/javascript/miner-react/bun.lock | 10 +- examples/javascript/miner-react/package.json | 9 +- .../javascript/miner-react/public/nackl.svg | 34 + examples/javascript/miner-react/src/App.css | 154 +- examples/javascript/miner-react/src/App.tsx | 1323 ++++- examples/javascript/miner-react/src/index.css | 30 +- .../javascript/miner-react/vite.config.ts | 16 +- scripts/build_wasm.sh | 67 + 167 files changed, 34464 insertions(+), 377 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 LICENSE.md create mode 100644 NOTICE.md create mode 100644 bee_connect/Cargo.toml create mode 100644 bee_connect/PROTOCOL.md create mode 100644 bee_connect/README.md create mode 100644 bee_connect/src/client.rs create mode 100644 bee_connect/src/dex_protocol.rs create mode 100644 bee_connect/src/dh.rs create mode 100644 bee_connect/src/errors.rs create mode 100644 bee_connect/src/lib.rs create mode 100644 bee_connect/src/message.rs create mode 100644 bee_connect/src/wasm.rs create mode 100644 bee_connect/tests/live_network.rs create mode 100644 bee_crypto/Cargo.toml create mode 100644 bee_crypto/README.md create mode 100644 bee_crypto/src/adapters/mod.rs create mode 100644 bee_crypto/src/adapters/native/dto/crypto.rs create mode 100644 bee_crypto/src/adapters/native/dto/mod.rs create mode 100644 bee_crypto/src/adapters/native/mod.rs create mode 100644 bee_crypto/src/adapters/wasm/dto/crypto.rs create mode 100644 bee_crypto/src/adapters/wasm/dto/mod.rs create mode 100644 bee_crypto/src/adapters/wasm/mod.rs create mode 100644 bee_crypto/src/client.rs create mode 100644 bee_crypto/src/errors.rs create mode 100644 bee_crypto/src/lib.rs create mode 100644 bee_crypto/src/services/boc_hash.rs create mode 100644 bee_crypto/src/services/chacha.rs create mode 100644 bee_crypto/src/services/encoding.rs create mode 100644 bee_crypto/src/services/hash.rs create mode 100644 bee_crypto/src/services/key.rs create mode 100644 bee_crypto/src/services/mnemonic.rs create mode 100644 bee_crypto/src/services/mod.rs create mode 100644 bee_crypto/src/services/signature.rs create mode 100644 bee_crypto/src/services/storage.rs create mode 100644 bee_crypto/tests/integration.rs create mode 100644 bee_errors/Cargo.toml create mode 100644 bee_errors/src/lib.rs create mode 100644 bee_infra/src/rate_limiter.rs create mode 100644 bee_infra/src/retry.rs create mode 100644 bee_miner/src/graphql/block.rs create mode 100644 bee_miner/src/graphql/mod.rs create mode 100644 bee_wallet/ARCHITECTURE.md create mode 100644 bee_wallet/Cargo.toml create mode 100644 bee_wallet/README.md create mode 100644 bee_wallet/src/adapters/mod.rs create mode 100644 bee_wallet/src/adapters/native/dto/balances.rs create mode 100644 bee_wallet/src/adapters/native/dto/deploy.rs create mode 100644 bee_wallet/src/adapters/native/dto/keys.rs create mode 100644 bee_wallet/src/adapters/native/dto/miner.rs create mode 100644 bee_wallet/src/adapters/native/dto/mod.rs create mode 100644 bee_wallet/src/adapters/native/dto/multifactor.rs create mode 100644 bee_wallet/src/adapters/native/dto/tx.rs create mode 100644 bee_wallet/src/adapters/native/dto/zkp.rs create mode 100644 bee_wallet/src/adapters/native/mod.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/balances.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/connect.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/deploy.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/keys.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/miner.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/mod.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/multifactor.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/names.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/tx.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/write.rs create mode 100644 bee_wallet/src/adapters/wasm/dto/zkp.rs create mode 100644 bee_wallet/src/adapters/wasm/mod.rs create mode 100644 bee_wallet/src/bin/faucet.rs create mode 100644 bee_wallet/src/bin/wallet_info.rs create mode 100644 bee_wallet/src/client.rs create mode 100644 bee_wallet/src/dapp.rs create mode 100644 bee_wallet/src/errors.rs create mode 100644 bee_wallet/src/infra/mod.rs create mode 100644 bee_wallet/src/lib.rs create mode 100644 bee_wallet/src/modules/balance.rs create mode 100644 bee_wallet/src/modules/connect.rs create mode 100644 bee_wallet/src/modules/deploy.rs create mode 100644 bee_wallet/src/modules/dex.rs create mode 100644 bee_wallet/src/modules/history.rs create mode 100644 bee_wallet/src/modules/miner.rs create mode 100644 bee_wallet/src/modules/mod.rs create mode 100644 bee_wallet/src/modules/multifactor.rs create mode 100644 bee_wallet/src/modules/names.rs create mode 100644 bee_wallet/src/modules/tokens.rs create mode 100644 bee_wallet/src/modules/zkp.rs create mode 100644 bee_wallet/src/progress.rs create mode 100644 bee_wallet/src/services/balance/mod.rs create mode 100644 bee_wallet/src/services/boost.rs create mode 100644 bee_wallet/src/services/connect.rs create mode 100644 bee_wallet/src/services/deploy/mod.rs create mode 100644 bee_wallet/src/services/deploy/prepare_deploy_params.rs create mode 100644 bee_wallet/src/services/dex.rs create mode 100644 bee_wallet/src/services/miner/cmd.rs create mode 100644 bee_wallet/src/services/miner/mod.rs create mode 100644 bee_wallet/src/services/miner/query.rs create mode 100644 bee_wallet/src/services/mod.rs create mode 100644 bee_wallet/src/services/multifactor/cmd.rs create mode 100644 bee_wallet/src/services/multifactor/jwk.rs create mode 100644 bee_wallet/src/services/multifactor/key_derivation.rs create mode 100644 bee_wallet/src/services/multifactor/mod.rs create mode 100644 bee_wallet/src/services/multifactor/query.rs create mode 100644 bee_wallet/src/services/multifactor/whitelist.rs create mode 100644 bee_wallet/src/services/resolvers/mod.rs create mode 100644 bee_wallet/src/services/tokens/mod.rs create mode 100644 bee_wallet/src/services/tokens/sell_orders.rs create mode 100644 bee_wallet/src/services/transaction/history.rs create mode 100644 bee_wallet/src/services/transaction/mod.rs create mode 100644 bee_wallet/src/services/validate_name.rs create mode 100644 bee_wallet/src/services/zkp/auth.rs create mode 100644 bee_wallet/src/services/zkp/client_login_complete.rs create mode 100644 bee_wallet/src/services/zkp/client_login_prepare.rs create mode 100644 bee_wallet/src/services/zkp/mod.rs create mode 100644 bee_wallet/src/services/zkp/poseidon_lite_constants_1.json create mode 100644 bee_wallet/src/services/zkp/poseidon_lite_constants_2.json create mode 100644 bee_wallet/src/services/zkp/poseidon_lite_constants_4.json create mode 100644 bee_wallet/src/services/zkp/poseidon_lite_constants_5.json create mode 100644 bee_wallet/src/services/zkp/utils/error.rs create mode 100644 bee_wallet/src/services/zkp/utils/mod.rs create mode 100644 bee_wallet/src/services/zkp/utils/provider.rs create mode 100644 bee_wallet/src/services/zkp/utils/zk_login.rs create mode 100644 bee_wallet/src/types.rs create mode 100644 bee_wallet/tests/dex_flows/common/context.rs create mode 100644 bee_wallet/tests/dex_flows/common/keys.rs create mode 100644 bee_wallet/tests/dex_flows/common/misc.rs create mode 100644 bee_wallet/tests/dex_flows/common/mod.rs create mode 100644 bee_wallet/tests/dex_flows/common/pn.rs create mode 100644 bee_wallet/tests/dex_flows/common/voucher.rs create mode 100644 bee_wallet/tests/dex_flows/common/wallet.rs create mode 100644 bee_wallet/tests/dex_flows/flows.rs create mode 100644 bee_wallet/tests/dex_flows/main.rs create mode 100644 bee_wallet/tests/hello.abi.json create mode 100644 bee_wallet/tests/hello.sol create mode 100644 bee_wallet/tests/hello.tvc create mode 100644 bee_wallet/tests/integration.rs create mode 100644 examples/javascript/miner-react/public/nackl.svg create mode 100755 scripts/build_wasm.sh diff --git a/.cargo/config.toml b/.cargo/config.toml index c91c3f3..4292deb 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,21 @@ +[build] +# Required by `dark_dex_halo2_private_witness_export_lib` → acki-nacki +# `telemetry_utils` (calls `RuntimeMetrics::spawned_tasks_count`, gated +# behind tokio's unstable cfg). Without this flag, the live witness +# export crate fails to build. +rustflags = ["--cfg", "tokio_unstable"] + +# NOTE: do NOT switch the linker to ld64.lld here. The dep-graph pulls in +# two tvm-sdk versions (via ackinacki-kit and via acki-nacki/node) whose +# `tvm_client` crates both export the same `extern "C"` symbols +# (`tc_create_context`, `tc_request_*`, ...). Apple's system ld64 silently +# picks one; ld64.lld errors out on duplicate symbols. Stick with ld64. + +# NOTE: `rustc-wrapper = "sccache"` is intentionally not configured here. +# sccache is a personal/CI-side speedup; baking it into the project config +# breaks builds for contributors who don't have sccache installed (cargo +# fails if the wrapper binary is missing). Put it in your own +# `~/.cargo/config.toml` instead. + [net] git-fetch-with-cli = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7976705..ed2484b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,16 +21,40 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Rust + # nightly is only for `cargo fmt` (rustfmt.toml uses nightly-only options). + # stable is installed last, so it is the default for build/clippy/test. + - name: Setup Rust (nightly, rustfmt only) + uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - name: Setup Rust (stable) uses: dtolnay/rust-toolchain@stable with: components: clippy + # `gosh_tls_lib` is a private dependency (bee_wallet -> tls_connect); fetch + # it over https with a read-only token. `dodex-sdk` is a dev-dependency used + # only by the `dex_flows` integration tests (not run in CI) and pulls a heavy + # halo2 graph, so strip it to keep the resolve light. Every other git + # dependency is public. + - name: Configure git for private dep + env: + PAT: ${{ secrets.GH_PAT }} + run: | + git config --global url."https://oauth2:${PAT}@github.com/".insteadOf "ssh://git@github.com/" + sed -i '/^dodex-sdk = /d' bee_wallet/Cargo.toml + - name: Cache Rust uses: Swatinem/rust-cache@v2 - - name: Cargo check (workspace except verifier) - run: cargo check --workspace --exclude bee-verifier + - name: Rustfmt + run: cargo +nightly fmt --all -- --check + + # bee-sdk is the wasm aggregator (forces single-wasm on its deps, which leaks + # wasm-only code into native builds via feature unification); bee-verifier + # targets wasm32-wasip2. Both are covered on their own targets below. + - name: Cargo check (workspace, native) + run: cargo check --workspace --exclude bee-sdk --exclude bee-verifier - name: Cargo check (verifier) run: cargo check -p bee-verifier @@ -38,14 +62,24 @@ jobs: - name: Cargo check (miner wasm feature) run: cargo check -p bee-miner --no-default-features --features wasm - - name: Cargo clippy (workspace) - run: cargo clippy --workspace --all-targets -- -D warnings + - name: Cargo check (sdk, wasm target) + run: | + rustup target add wasm32-unknown-unknown + cargo check -p bee-sdk --target wasm32-unknown-unknown + + - name: Cargo clippy (workspace libs) + run: cargo clippy --workspace --exclude bee-sdk --exclude bee-verifier --lib -- -D warnings -A clippy::result_large_err - - name: Cargo test (workspace native) - run: cargo test --workspace --exclude bee-verifier --exclude bee-sdk + # `--lib` only: bee_wallet's integration tests hit shellnet / need local + # halo2 params, so they are run manually, not in CI. + - name: Cargo test (workspace libs) + run: cargo test --workspace --exclude bee-sdk --exclude bee-verifier --lib - name: Cargo test (verifier lib) run: cargo test -p bee-verifier --lib + # Skip the deep-link test: it drives wasm-bindgen futures that panic when + # the wasm feature is exercised on the host target (run it under + # wasm-pack instead). release.yml skips the same test. - name: Cargo test (miner wasm feature on host) - run: cargo test -p bee-miner --no-default-features --features wasm --lib + run: cargo test -p bee-miner --no-default-features --features wasm --lib -- --skip core::keys::tests::gen_mining_keys_generates_keys_and_valid_deep_link diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index d147b33..75706a0 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -46,6 +46,17 @@ jobs: with: components: clippy + # `gosh_tls_lib` is a private dependency (bee_wallet -> tls_connect); fetch + # it over https with a read-only token. `dodex-sdk` is a dev-dependency for + # the `dex_flows` integration tests only (not built here) and pulls a heavy + # halo2 graph, so strip it. Every other git dependency is public. + - name: Configure git for private dep + env: + PAT: ${{ secrets.GH_PAT }} + run: | + git config --global url."https://oauth2:${PAT}@github.com/".insteadOf "ssh://git@github.com/" + sed -i '/^dodex-sdk = /d' bee_wallet/Cargo.toml + - name: Setup Node uses: actions/setup-node@v4 with: @@ -58,8 +69,8 @@ jobs: - name: Install wasm-pack run: cargo install wasm-pack --locked - - name: Cargo check (workspace except verifier) - run: cargo check --workspace --exclude bee-verifier + - name: Cargo check (workspace, native) + run: cargo check --workspace --exclude bee-sdk --exclude bee-verifier - name: Cargo check (verifier) run: cargo check -p bee-verifier @@ -67,11 +78,11 @@ jobs: - name: Cargo check (miner wasm feature) run: cargo check -p bee-miner --no-default-features --features wasm - - name: Cargo clippy (workspace) - run: cargo clippy --workspace --all-targets -- -D warnings + - name: Cargo clippy (workspace libs) + run: cargo clippy --workspace --exclude bee-sdk --exclude bee-verifier --lib -- -D warnings -A clippy::result_large_err - - name: Cargo test (workspace native) - run: cargo test --workspace --exclude bee-verifier --exclude bee-sdk + - name: Cargo test (workspace libs) + run: cargo test --workspace --exclude bee-sdk --exclude bee-verifier --lib - name: Cargo test (verifier lib) run: cargo test -p bee-verifier --lib @@ -84,9 +95,11 @@ jobs: shell: bash run: | set -euo pipefail - VERSION="$(grep -E '^version\s*=' bee_sdk/Cargo.toml | head -n1 | sed -E 's/version\s*=\s*"([^"]+)"/\1/')" + # bee_sdk inherits its version from [workspace.package] in the root + # Cargo.toml, so resolve it there. + VERSION="$(grep -E '^version\s*=' Cargo.toml | head -n1 | sed -E 's/version\s*=\s*"([^"]+)"/\1/')" if [ -z "${VERSION}" ]; then - echo "Failed to resolve bee-sdk version from bee_sdk/Cargo.toml" + echo "Failed to resolve workspace version from Cargo.toml" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 675bf7e..cdf75e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,17 @@ jobs: with: components: clippy + # `gosh_tls_lib` is a private dependency (bee_wallet -> tls_connect); fetch + # it over https with a read-only token. `dodex-sdk` is a dev-dependency for + # the `dex_flows` integration tests only (not built here) and pulls a heavy + # halo2 graph, so strip it. Every other git dependency is public. + - name: Configure git for private dep + env: + PAT: ${{ secrets.GH_PAT }} + run: | + git config --global url."https://oauth2:${PAT}@github.com/".insteadOf "ssh://git@github.com/" + sed -i '/^dodex-sdk = /d' bee_wallet/Cargo.toml + - name: Setup Node uses: actions/setup-node@v4 with: @@ -58,8 +69,8 @@ jobs: - name: Install wasm-pack run: cargo install wasm-pack --locked - - name: Cargo check (workspace except verifier) - run: cargo check --workspace --exclude bee-verifier + - name: Cargo check (workspace, native) + run: cargo check --workspace --exclude bee-sdk --exclude bee-verifier - name: Cargo check (verifier) run: cargo check -p bee-verifier @@ -67,11 +78,11 @@ jobs: - name: Cargo check (miner wasm feature) run: cargo check -p bee-miner --no-default-features --features wasm - - name: Cargo clippy (workspace) - run: cargo clippy --workspace --all-targets -- -D warnings + - name: Cargo clippy (workspace libs) + run: cargo clippy --workspace --exclude bee-sdk --exclude bee-verifier --lib -- -D warnings -A clippy::result_large_err - - name: Cargo test (workspace native) - run: cargo test --workspace --exclude bee-verifier --exclude bee-sdk + - name: Cargo test (workspace libs) + run: cargo test --workspace --exclude bee-sdk --exclude bee-verifier --lib - name: Cargo test (verifier lib) run: cargo test -p bee-verifier --lib @@ -84,9 +95,11 @@ jobs: shell: bash run: | set -euo pipefail - VERSION="$(grep -E '^version\s*=' bee_sdk/Cargo.toml | head -n1 | sed -E 's/version\s*=\s*"([^"]+)"/\1/')" + # bee_sdk inherits its version from [workspace.package] in the root + # Cargo.toml, so resolve it there. + VERSION="$(grep -E '^version\s*=' Cargo.toml | head -n1 | sed -E 's/version\s*=\s*"([^"]+)"/\1/')" if [ -z "${VERSION}" ]; then - echo "Failed to resolve bee-sdk version from bee_sdk/Cargo.toml" + echo "Failed to resolve workspace version from Cargo.toml" exit 1 fi diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..0ecf0f2 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,71 @@ +title = "Gitleaks Config" + +[detect] +redact = true + +# ======== Generic Rules ======== + +[[rules]] +id = "generic-api-key" +description = "Generic API Key" +regex = '''(?i)(api[_-]?key|secret|token|auth)[=:]["']?[0-9a-zA-Z-_]{16,}["']?''' +tags = ["key", "API"] + +[[rules]] +id = "github-token" +description = "GitHub Personal Access Token" +regex = '''ghp_[0-9A-Za-z]{36}''' +tags = ["github", "token"] + +[[rules]] +id = "gitlab-token" +description = "GitLab Personal Access Token" +regex = '''glpat-[0-9a-zA-Z\-\_]{20,}''' +tags = ["gitlab", "token"] + +[[rules]] +id = "slack-token" +description = "Slack Token" +regex = '''xox[baprs]-[0-9A-Za-z-]{10,48}''' +tags = ["slack", "token"] + +[[rules]] +id = "aws-access-key" +description = "AWS Access Key ID" +regex = '''AKIA[0-9A-Z]{16}''' +tags = ["AWS", "key"] + +[[rules]] +id = "aws-secret-key" +description = "AWS Secret Access Key" +regex = '''(?i)aws(.{0,20})?(secret|private)?(.{0,20})?(key|token)['"][0-9a-zA-Z\/+=]{40}['"]''' +tags = ["AWS", "key"] + +[[rules]] +id = "private-key" +description = "Private Key (PEM)" +regex = '''-----BEGIN (RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY-----''' +tags = ["key", "private"] + +[[rules]] +id = "db-connection-string" +description = "Database Connection String" +regex = '''(?i)(?:postgres|mysql|mongodb|redis|sqlserver):\/\/([^ \n]+)''' +tags = ["database", "credentials"] + +# ======== Allowlist ======== + +[allowlist] +description = "Ignore common non-sensitive locations" +paths = [ + '''.*tests?/.*''', + '''.*examples?/.*''', + '''target/.*''', + '''docs?/.*''', + '''\.git/.*''', + '''\.idea/.*''', + '''\.vscode/.*''', +] +regexes = [ + '''(?i)dummy|example|sample|test|placeholder|mock''', +] diff --git a/Cargo.toml b/Cargo.toml index 90a9bc7..b030910 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,37 @@ [workspace] resolver = "3" -members = ["bee_sdk", "bee_miner", "bee_shared", "bee_verifier", "bee_infra"] +members = [ + "bee_sdk", + "bee_miner", + "bee_shared", + "bee_verifier", + "bee_wallet", + "bee_connect", + "bee_infra", + "bee_crypto", + "bee_errors", +] +# `bee_sdk` is the wasm-aggregator (forces `single-wasm` on its deps, which +# leaks wasm-only code into native builds via feature unification). +# `bee_verifier` targets `wasm32-wasip2` and won't build under the host target. +# Excluding both from default-members keeps `cargo run|build|test` from the +# workspace root scoped to crates that compile cleanly natively. +default-members = [ + "bee_miner", + "bee_shared", + "bee_wallet", + "bee_connect", + "bee_infra", + "bee_crypto", + "bee_errors", +] + +[workspace.package] +version = "3.0.0" +edition = "2024" +license = "LicenseRef-Acki-Nacki-Node-License" +license-file = "LICENSE.md" [workspace.dependencies] blake3 = "1.8.2" @@ -15,3 +45,8 @@ serde_json = "1" # Tell `rustc` to optimize for small code size. opt-level = "s" panic = "abort" + +[profile.dev] +# Line-tables-only: backtraces with file:line + symbol names, no locals. +# Cuts debuginfo gen + link time noticeably on a halo2 + acki-nacki node tree. +debug = 1 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..be839cb --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,31 @@ +# Bee-engine + +Copyright (C) 2026 GOSH TECHNOLOGY LTD. + +Bee-engine is free software: you can redistribute it and/or modify it under +the terms of the **GNU Affero General Public License**, version 3, as +published by the Free Software Foundation. The full license text is in +[LICENSE.md](LICENSE.md). + +Bee-engine is distributed in the hope that it will be useful, but **WITHOUT +ANY WARRANTY**; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +for more details. + +## Runtime dependency: Acki Nacki node + +Bee-engine is a client SDK (mining, wallet management, proof generation and +verification, and cryptographic operations) for the **Acki Nacki** chain. It +does not run a chain itself — it signs and dispatches transactions to, and +reads contract and account state from, an Acki Nacki node. + +The Acki Nacki node software is published by **GOSH TECHNOLOGY LTD.** under a +separate license — the **Acki Nacki Node License (ANNL)**, a Business Source +License with a two-year change date to GNU AGPL-3.0. The ANNL covers the node +software itself, not Bee-engine. Refer to the node repository for its current +license text and terms. + +The AGPL terms of Bee-engine apply only to Bee-engine itself — the project's +own source code, builds, deployments, and modifications. They do not extend +to or override the licensing of any separately distributed software (such as +the Acki Nacki node) that Bee-engine interoperates with at runtime. diff --git a/README.md b/README.md index 2c7f466..39d9550 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,105 @@ # Bee-engine -**TODO:** Add a description of the project’s purpose and high-level architecture. +Rust workspace for blockchain mining, proof verification, wallet management, and cryptographic operations. Targets native Rust, browser WASM (`wasm32-unknown-unknown`), and WASI 2.0 (`wasm32-wasip2`). +## Crates +``` +bee_sdk WASM aggregator — re-exports all crates into a single .wasm bundle +bee_wallet Wallet operations (send tokens, accumulator, connect, deploy) +bee_crypto Signing, encryption, mnemonics, hashing +bee_miner Mining: proof generation, merkle trees, WASM workers +bee_connect dApp ↔ wallet connect protocol, encrypted session management +bee_verifier Proof verifier (WASI component, separate target) +bee_shared Shared Borsh-serialized types +bee_infra Async sleep/poll utilities +``` ## Prerequisites -Before building the project, ensure the following tools are installed: +- **Rust** 1.86+ +- **LLVM 21+** (for WASM compilation of `blst` dependency) +- **wasm-pack**: `cargo install wasm-pack` +- **cargo-component**: `cargo install cargo-component` -1. **LLVM** — version **21 or higher** -2. **wasm-pack** - `cargo install wasm-pack` -3. **cargo-component** `cargo install cargo-component` - +## Build + +Both WASM artifacts are built via the script — it remaps machine paths out of +the binaries (`--remap-path-prefix`) and fails the build if a `/Users/...` or +`/home//...` path survives into the artifact: -## Building engine-sdk (WASM) -This step builds the SDK targeting browser-compatible WebAssembly. ```bash -cd engine_sdk -rm -rf pkg && wasm-pack build --target web +scripts/build_wasm.sh sdk # bee_sdk → bee_sdk/pkg (wasm-pack, browser) +scripts/build_wasm.sh verifier # bee_verifier → wasm32-wasip2 (WASI component) +scripts/build_wasm.sh # both ``` -Output artifacts are generated in the `pkg/` directory. - -## Building the Verifier (WASI) +If `bee_verifier/wit` was updated, run `cargo component bindings` in +`bee_verifier/` first (see `bee_verifier/README.md`). -**TODO:** Verify the build commands below. +## Tests ```bash -cd engine - -# Generate WIT bindings -cargo component bindings - -# Build the WASI-compatible WASM binary -cargo +nightly build \ - -Zbuild-std=std,panic_abort \ - -Zbuild-std-features=panic_immediate_abort \ - --target wasm32-wasip2 \ - --release +cargo test --workspace # all native tests +cargo test -p bee-wallet # single crate +cargo test -p bee-wallet -- test_name # single test +cargo test -p bee-wallet -- --test-threads=1 # bee_wallet integration tests (sequential) +wasm-pack test --headless --chrome -p bee_miner # WASM tests (browser) ``` -This produces a WASM component intended to run inside a WASI runtime. +`bee_wallet` integration tests hit shellnet and share on-chain state (accumulator queues). Run with `--test-threads=1` to avoid flaky failures. +## Lint & Format + +```bash +cargo clippy --workspace +cargo fmt --all -- --check +``` +Formatting uses nightly rustfmt features (`imports_granularity = "Item"`, `group_imports = "StdExternalCrate"`). -## Language Bindings +## Examples -**TODO:** Verify the build commands below. +### miner-react -### JavaScript +React dApp demonstrating wallet connect, mining key setup, and wallet ownership verification. ```bash -bun i -bunx jco transpile \ - engine/target/wasm32-wasip2/release/bee_engine_verifier.wasm \ - -o ./bindings/js +cd bee_sdk && rm -rf pkg && wasm-pack build --target web +cd ../examples/javascript/miner-react +npm install +npm run dev ``` -This command generates JavaScript bindings from the WASI component. - ---- - ## Troubleshooting -### Build errors +### `blst` build errors -If you encounter errors similar to the following when running the `wasm-pack build --target web` command: ``` warning: blst@0.3.16: error: unable to create target: 'No available targets are compatible with triple "wasm32-unknown-unknown"' -error: failed to run custom build command for `blst v0.3.16` ``` -This usually indicates that your system `clang` is **too old** and does not support WebAssembly targets. - ---- - -### Fix: Upgrade LLVM (macOS) - -Install a newer LLVM version using Homebrew: +System `clang` is too old. Install LLVM 21+: ```bash brew install llvm ``` -Then add the following lines to the end of your `~/.zshrc`: +Add to `~/.zshrc`: ```bash -# LLVM / clang configuration export PATH="/opt/homebrew/opt/llvm/bin:$PATH" export LDFLAGS="-L/opt/homebrew/opt/llvm/lib" export CPPFLAGS="-I/opt/homebrew/opt/llvm/include" export CMAKE_PREFIX_PATH="/opt/homebrew/opt/llvm" ``` -Reload your shell configuration: +Verify: `clang --version` reports LLVM 21+, `which clang` returns `/opt/homebrew/opt/llvm/bin/clang`. -```bash -source ~/.zshrc -``` +## License -Verify the installation: - - `clang --version` reports LLVM **21+** or higher - - `which clang` returns `/opt/homebrew/opt/llvm/bin/clang` +Bee-engine is licensed under the **GNU Affero General Public License v3.0** — +see [LICENSE.md](LICENSE.md). The AGPL terms apply only to Bee-engine itself; +the Acki Nacki node software it interoperates with at runtime is licensed +separately. See [NOTICE.md](NOTICE.md) for details. diff --git a/bee_connect/Cargo.toml b/bee_connect/Cargo.toml new file mode 100644 index 0000000..b074167 --- /dev/null +++ b/bee_connect/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "bee-connect" +version.workspace = true +license.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["ackinacki-kit/default", "bee-errors/default"] +wasm = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:serde-wasm-bindgen", "dep:js-sys", "bee-infra/wasm", "ackinacki-kit/wasm", "bee-errors/wasm"] +single-wasm = ["wasm"] + +[dependencies] +ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v3.0.0", default-features = false, features = ["contracts"] } +base64 = "0.22.1" +bee-errors = { path = "../bee_errors", default-features = false } +bee-infra = { path = "../bee_infra", default-features = false, features = ["tokio"] } +chacha20poly1305 = "0.10.1" +getrandom = { version = "0.3.4", default-features = false, features = ["wasm_js"] } +hex = "0.4.3" +hkdf = "0.12.4" +js-sys = { optional = true, version = "0.3.85" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde-wasm-bindgen = { optional = true, version = "0.6.5" } +ed25519-dalek = "2" +sha2 = "0.10.9" +x25519-dalek = { version = "2", features = ["static_secrets"] } +zeroize = { version = "1.8", features = ["serde"] } +wasm-bindgen = { optional = true, version = "0.2.106" } +wasm-bindgen-futures = { optional = true, version = "0.4.56" } + +[dev-dependencies] +serial_test = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/bee_connect/PROTOCOL.md b/bee_connect/PROTOCOL.md new file mode 100644 index 0000000..47869bc --- /dev/null +++ b/bee_connect/PROTOCOL.md @@ -0,0 +1,483 @@ +# bee_connect Protocol — DH Key Exchange Flow + +## Overview + +Connect protocol (`bee_connect.msg/1`) provides a secure channel between the client (dApp/miner) and wallet via an on-chain AuthProfile. All messages are encrypted. Secrets are **never transmitted** through URLs, QR codes, or other observable channels. + +Instead of passing a session secret in the URL, ephemeral X25519 Diffie-Hellman key exchange is used. Both sides independently derive identical session keys. + +--- + +## Participants + +- **Client** — dApp or miner, initiates the session +- **Wallet** — mobile or desktop wallet, accepts the session +- **AuthProfile** — on-chain contract for exchanging encrypted messages +- **links.gosh.sh** — deeplink URL resolver (sees only the public DH key) + +--- + +## Phase 1: Session Creation (Client) + +``` +Client: create_shared_key_session(app_id, ttl_secs) +``` + +1. Generates `session_id`, `description`, `expires_at` +2. Generates ephemeral **X25519 keypair** (`client_dh_public`, `client_dh_secret`) +3. Builds deeplink URL: + ``` + https://links.gosh.sh/deeplinks/wallet/v1/connect + ?payload= + &client_dh_public=<64 hex chars> + ``` +4. Caller receives `ResultOfCreateSharedKeySession`: + - `session_id`, `description`, `payload_b64url` + - `client_dh_public` — public key (in URL) + - `client_dh_secret` — secret key (**caller stores**, never transmitted) + - `deep_link` — URL with no secrets + +``` +┌────────────────────────────────────────────────────┐ +│ URL contains ONLY client_dh_public │ +│ links.gosh.sh does NOT see any secrets │ +│ Intercepting the URL is useless without │ +│ client_dh_secret │ +└────────────────────────────────────────────────────┘ +``` + +--- + +## Phase 2: Session Acceptance (Wallet) + +``` +Wallet: accept_shared_key_connect(payload, client_dh_public, wallet_name, wallet_address) +``` + +1. Wallet parses `payload` and `client_dh_public` from the deeplink URL +2. Generates wallet ephemeral **X25519 keypair** (`wallet_dh_public`, `wallet_dh_secret`) +3. **DH Key Agreement**: + ``` + shared_secret = X25519(wallet_dh_secret, client_dh_public) // 32 bytes + ``` +4. **Key Derivation** (HKDF-SHA256, salt = session_id): + ``` + signing_seed = HKDF(shared_secret, info="bee_connect.signing.v1") + encryption_root = HKDF(shared_secret, info="bee_connect.encryption.v1") + + signing_key = Ed25519::SigningKey(signing_seed) // for on-chain transactions + // encryption_root is used as input for per-message HKDF + ``` +5. **Deploy AuthProfile** on-chain with `signing_key.public` +6. **wallet_hello** — first encrypted message: + ```json + { + "v": "bee_connect.msg/1", + "session_id": "...", + "dir": "w2c", + "seq": 1700000000000, + "type": "wallet_hello", + "ts": 1700000000, + "dh_public": "", + "enc": { + "alg": "xchacha20poly1305-hkdf-sha256", + "nonce": "", + "salt": "" + }, + "body": "" + } + ``` + - `dh_public` — **cleartext** top-level field so client can read it before decryption + - `body` is encrypted with `encryption_root` + - On-chain transaction signature uses `signing_key` + +7. Wallet receives `ResultOfAcceptConnect`: + - `session_state: ConnectSessionState` — **wallet stores** for all subsequent operations + (contains signing keys, encryption root, DH keys for forward secrecy) + +--- + +## Phase 3: DH Finalization (Client) + +``` +Client: wait_wallet_hello(session_id, description, client_dh_secret) +``` + +1. Client polls AuthProfile on-chain, waiting for `wallet_hello` +2. Extracts `dh_public` (wallet's public key) from the envelope +3. **DH Key Agreement** (mirrors wallet): + ``` + shared_secret = X25519(client_dh_secret, wallet_dh_public) + // Identical to shared_secret on the wallet side! + ``` +4. **Key Derivation** — identical HKDF: + ``` + signing_seed = HKDF(shared_secret, info="bee_connect.signing.v1") // == wallet's + encryption_root = HKDF(shared_secret, info="bee_connect.encryption.v1") // == wallet's + ``` +5. Decrypts `body` using `encryption_root` — success verifies the DH exchange +6. Client receives `ResultOfWaitWalletHello`: + - `wallet_name`, `wallet_address` — from decrypted body + - `session_state: ConnectSessionState` — **client stores** for all subsequent operations + (contains signing keys, encryption root, DH keys for forward secrecy) + +--- + +## Phase 4: Message Exchange (Forward Secrecy) + +After handshake both sides store a `ConnectSessionState`: + +```rust +ConnectSessionState { + encryption_root: Zeroizing, // rotated every message, zeroed on drop + my_dh_secret: Zeroizing, // rotated on every outbound message, zeroed on drop + peer_dh_public: String, // updated on every inbound message + signing_public: String, // fixed for session lifetime + signing_secret: Zeroizing, // fixed for session lifetime, zeroed on drop + created_at: u64, // UNIX epoch seconds, set at handshake + expires_at: u64, // UNIX epoch seconds, default = created_at + 24h +} +``` + +**Every outbound message performs a DH re-key for forward secrecy:** + +1. Sender generates new ephemeral X25519 keypair +2. Computes `new_shared = X25519(new_secret, peer_dh_public)` +3. Derives `new_root = HKDF(old_root || new_shared, salt=session_id, info="bee_connect.rekey.v1")` +4. Encrypts message body with `new_root` +5. Includes `dh_public` (new public key) in the envelope +6. Updates local state: `encryption_root = new_root`, `my_dh_secret = new_secret` +7. Old root is discarded → forward secrecy + +**Receiver mirrors the re-key:** + +1. Reads `dh_public` from envelope +2. Computes `new_shared = X25519(my_dh_secret, peer_new_dh_public)` +3. Derives same `new_root = HKDF(old_root || new_shared, ...)` +4. Decrypts message body with `new_root` +5. Updates local state: `encryption_root = new_root`, `peer_dh_public = peer_new_dh_public` + +### Client → Wallet: set_mining_keys + +``` +Client: request_set_mining_keys(session_state, app_id, owner_public) → updated_session_state +``` + +Caller MUST persist `updated_session_state` and discard old state. + +Envelope example (after re-key): + +```json +{ + "v": "bee_connect.msg/1", + "session_id": "...", + "dir": "c2w", + "seq": 1700000001000, + "type": "set_mining_keys", + "ts": 1700000001, + "dh_public": "", + "enc": { + "alg": "xchacha20poly1305-hkdf-sha256", + "nonce": "", + "salt": "" + }, + "body": "" +} +``` + +The `dh_public` field contains the sender's new ephemeral public key. The receiver uses it to perform `rekey_inbound` before decryption. + +### Client → Wallet: sign_challenge (Backend Auth) + +``` +Client: request_sign_challenge(session_state, nonce) → updated_session_state +``` + +Proves wallet ownership to a backend. The flow: + +1. **Backend** generates a random nonce (hex string) and gives it to the dApp client +2. **Client** sends `sign_challenge` (c2w) with the nonce through the connect session +3. **Wallet** receives the challenge, signs the nonce with its EPK key, and responds with `challenge_response` (w2c) +4. **Client** receives `challenge_response` containing `{ nonce, signature, wallet_address }` +5. **Client** forwards `signature` + `wallet_address` to the backend +6. **Backend** verifies the Ed25519 signature against the wallet's on-chain public key + +Body (encrypted): +```json +{ "nonce": "" } +``` + +### Wallet → Client: challenge_response + +``` +Wallet: (responds to sign_challenge via query_session_messages) +``` + +The wallet signs the nonce from `sign_challenge` using its EPK key (`sign_detached_hex(nonce, epk_secret)`). + +Body (encrypted): +```json +{ + "nonce": "", + "signature": "", + "wallet_address": "", + "epk_public": "" +} +``` + +`epk_public` is the Ed25519 public key the wallet used to sign the nonce. The field is optional for backward compatibility with older wallets (`#[serde(default)]`). + +**Backend verification (Python example):** +```python +from nacl.signing import VerifyKey # pip install PyNaCl + +# 1. Verify signature (offline, no chain access) +nonce_bytes = bytes.fromhex(nonce) +signature_bytes = bytes.fromhex(signature) +verify_key = VerifyKey(bytes.fromhex(epk_public)) +verify_key.verify(nonce_bytes, signature_bytes) # raises BadSignatureError + +# 2. Confirm EPK is registered in multifactor contract (one RPC call, cacheable) +# Call get_epk_expire_at(wallet_address, epk_public) on chain +# If returns valid non-expired timestamp → EPK belongs to this wallet +``` + +**State persistence:** The dApp MUST NOT persist `updated_session_state` from `request_sign_challenge` until `wait_challenge_response` succeeds. If the wait times out, the wallet has already advanced its DH chain (inbound rekey for `sign_challenge` + outbound rekey for `challenge_response`), while the dApp only did one outbound rekey. Persisting early makes the desync permanent. By keeping the pre-challenge state on timeout, the dApp can re-establish the session cleanly. + +### Client → Wallet: disconnect + +``` +Client: disconnect_session(session_state, reason) → updated_session_state +``` + +### Wallet → query + +``` +Wallet: query_session_messages(session_id, description, session_state) → messages, updated_session_state +``` + +The wallet re-keys for every c2w message with `dh_public` to maintain the chain. + +--- + +## Per-Message Encryption + +Each message is encrypted individually: + +``` +1. random salt (32 bytes) + random nonce (24 bytes) +2. per_message_key = HKDF-SHA256(ikm=encryption_root, salt=random_salt, info="bee_connect.msg.key.v1") +3. ciphertext = XChaCha20-Poly1305(key=per_message_key, nonce=random_nonce, plaintext=body, aad=envelope_metadata) +``` + +AAD (Associated Authenticated Data) includes: `v`, `session_id`, `dir`, `seq`, `type`, `ts` — protects against metadata substitution. + +### Per-Message DH Re-Key (Forward Secrecy) + +Before encrypting, the sender performs a DH re-key: + +``` +new_dh = X25519::generate() +shared = X25519(new_dh.secret, peer_dh_public) +ikm = old_encryption_root || shared (64 bytes) +new_encryption_root = HKDF-SHA256(ikm, salt=session_id, info="bee_connect.rekey.v1") +``` + +The `new_encryption_root` replaces `encryption_root` in the per-message encryption step above. +The `new_dh.public` is included in the envelope's `dh_public` field. + +This ensures: +- **Forward secrecy**: compromising the current state reveals nothing about past messages +- **Post-compromise security**: each message uses fresh DH material + +--- + +## Cryptographic Primitives + +| Purpose | Primitive | Key Size | +|---------|-----------|----------| +| DH key exchange | X25519 | 32 bytes | +| Key derivation | HKDF-SHA256 | — | +| On-chain signing | Ed25519 (verify_strict) | 32 bytes | +| Message encryption | XChaCha20-Poly1305 | 32 bytes | +| Replay protection | seq = now_millis() | u64 | + +--- + +## MITM Analysis + +| Attack | Result | +|--------|--------| +| Passive URL intercept | Sees `client_dh_public` — useless without `client_dh_secret` | +| Substitute `client_dh_public` in URL | Client and Wallet derive different shared_secret → wallet_hello decryption fails → session not established (DoS, not silent MITM) | +| Intercept wallet_hello | `dh_public` is public, body is encrypted → useless | +| Message replay | Different `seq` (millisecond timestamp) → AAD mismatch → AEAD rejects | + +--- + +## Security Considerations + +### Session Timeout + +The bee_connect protocol uses two distinct TTL values: + +**Connect session TTL** (`DEFAULT_CONNECT_TTL_SECS = 300`, 5 minutes) +Controls how long the deeplink / QR code is valid for wallet scanning. +Set via `ttl_secs` in `create_shared_key_session`. Callers SHOULD keep +this short — it is only the window for the wallet to scan and respond, +not the lifetime of the established session. +Maximum recommended value: **86400 seconds (24 hours)**. Values above +this are accepted by the API but strongly discouraged. + +**Active session TTL** (`DEFAULT_SESSION_TTL_SECS = 86400`, 24 hours) +The lifetime of an established `ConnectSessionState` after the DH +handshake completes. Both `rekey_outbound` and `rekey_inbound` reject +operations on expired states. + +Every `ConnectSessionState` carries `created_at` and `expires_at` timestamps (UNIX epoch seconds). + +- `rekey_outbound` and `rekey_inbound` check `expires_at` before performing any crypto — expired sessions are rejected with an error. +- Legacy states (deserialized from JSON without these fields) have `expires_at = 0`, which is treated as **non-expiring** for backward compatibility. +- Callers can set a custom `expires_at` on the state after `create_initial_state` if a shorter or longer TTL is needed. + +**Wallet app requirements:** + +- On session accept: persist `created_at` and `expires_at` from the returned state. +- Before any operation with session state: check `state.is_expired()`. If expired, show "Session expired" to user and destroy the profile. +- On app foreground: check all stored session states, destroy expired ones. +- Display remaining session time in UI so user knows when to reconnect. + +### State Persistence + +`ConnectSessionState` contains secret key material. Callers are responsible for: + +- Storing the JSON-serialized state in a secure location (OS keychain, encrypted storage) +- Overwriting or deleting old state after every re-key operation +- Never logging the state in plaintext + +### Forward Secrecy Guarantees + +After a re-key, old `encryption_root` and `my_dh_secret` are discarded. Even if the current state is compromised, past messages cannot be decrypted because the old DH secrets needed to reverse the HKDF chain are gone. + +Forward secrecy holds only if: +1. The caller actually discards old state (does not keep backups of previous `ConnectSessionState`) +2. The runtime zeroes memory on drop (`ConnectSessionState` secret fields use `Zeroizing` which handles this) + +### Limitations + +- `ConnectSessionState` secret fields (`encryption_root`, `my_dh_secret`, `signing_secret`) are wrapped in `Zeroizing` — their memory is zeroed on drop. The `zeroize` crate's `serde` feature provides transparent JSON serialization. +- The protocol does not authenticate the initial DH exchange beyond decryption success. An active MITM at session creation causes a DoS (decryption failure), not silent interception. +- **Signing keys are fixed for the session lifetime** (see [Signing Key Lifetime](#signing-key-lifetime) below). +- The protocol uses symmetric authentication (AEAD). Both sides share the same keys, so non-repudiation (proving which side sent a specific message) is not provided. +- **Wallet identity is not cryptographically pinned.** The `wallet_address` in `wallet_hello` is self-reported by the wallet and authenticated only by AEAD decryption success — see [Wallet Identity](#wallet-identity) below. + +### Signing Key Lifetime + +The Ed25519 signing keypair (`signing_public`, `signing_secret`) is derived once during the DH handshake (Phase 2/3) and remains constant for the entire session. Unlike `encryption_root` and `my_dh_secret`, which rotate on every message via the DH re-key mechanism, signing keys are **never rotated**. + +**Why this matters:** + +- If `signing_secret` is leaked (memory dump, insecure state persistence, etc.), an attacker can forge on-chain AuthProfile transactions (e.g. `set_mining_keys`, `client_disconnect`) for the remainder of the session. +- Forward secrecy does **not** cover signing keys — compromising the current `ConnectSessionState` reveals the signing key for all past and future on-chain signatures within that session. + +**Why this is acceptable for the current design:** + +1. **Narrow scope.** Signing keys are only used to authorize AuthProfile context additions on-chain. They do not protect message confidentiality (that is handled by the AEAD encryption layer with per-message re-keying). +2. **Session-scoped impact.** A compromised signing key cannot be used outside the session's AuthProfile contract. Destroying the profile (`destroy_connect_profile`) invalidates the key on-chain. +3. **Rotation cost.** Rotating signing keys would require an on-chain transaction to update the AuthProfile's public key, adding latency and gas cost to every message — disproportionate for the threat model. + +**Mitigations for callers:** + +- Keep sessions short-lived. Do not reuse a `ConnectSessionState` across app restarts if avoidable. +- Destroy the AuthProfile (`destroy_connect_profile`) as soon as the session is no longer needed, to invalidate the signing key on-chain. +- Store `ConnectSessionState` in OS keychain or encrypted storage, not in plaintext files. + +### Wallet Identity + +The `wallet_address` field in the `wallet_hello` message body is **self-reported** by the wallet. The protocol does not perform independent on-chain verification that the wallet actually controls this address. + +**Why additional pinning is not needed:** + +1. **DH authentication is sufficient.** An attacker who does not possess the `client_dh_secret` (never transmitted — kept by the dApp) cannot compute the shared secret, and therefore cannot produce a `wallet_hello` that the client can decrypt. A successful decryption proves the wallet participated in the same DH exchange. + +2. **MITM requires URL interception AND replacement.** To impersonate a different wallet, an attacker would need to intercept the deeplink URL, replace `client_dh_public` with their own, and forward it to a target wallet. But then the real client holds `client_dh_secret` for the original keypair — the attacker's `wallet_hello` (encrypted with the attacker's shared secret) will fail decryption on the client. This is a **DoS** (session fails to establish), not a silent impersonation. + +3. **No secret exfiltration path.** Even if an attacker deploys their own AuthProfile and writes a `wallet_hello` with a fake `wallet_address`, the client will reject it because the DH shared secrets won't match. + +**Recommendation for wallet apps:** + +- After `wait_wallet_hello` succeeds, display the received `wallet_name` and `wallet_address` to the user for visual confirmation. +- If the dApp expects a specific wallet, compare `wallet_address` from the decrypted `wallet_hello` against the expected value at the application layer. + +--- + +## Diagram + +``` +Client links.gosh.sh Wallet + │ │ │ + │ generate X25519 │ │ + │ (client_dh_pub, _sec) │ │ + │ │ │ + │── URL: payload + pub ──────>│────> deeplink ────────>│ + │ (NO SECRETS!) │ │ + │ │ │ generate X25519 + │ │ │ (wallet_dh_pub, _sec) + │ │ │ + │ │ │ shared = DH(w_sec, c_pub) + │ │ │ signing, encryption = HKDF(shared) + │ │ │ + │ │ │ deploy AuthProfile(signing.pub) + │ │ │ wallet_hello: + │ │ │ dh_public: wallet_dh_pub + │ │ │ body: encrypted(name, addr) + │ │ │ + │<── poll AuthProfile ─────────────────────────────────│ + │ │ │ + │ extract wallet_dh_pub │ │ + │ shared = DH(c_sec, w_pub) │ │ + │ signing, encryption = HKDF │ │ + │ decrypt body ✓ │ │ + │ │ │ + │ │ │ + │ rekey_outbound: │ │ + │ new DH, new root │ │ + │── set_mining_keys ──────────────────────────────────>│ + │ (encrypted with new_root, │ │ rekey_inbound: + │ dh_public: new_pub) │ │ DH(my_sec, new_pub) → new root + │ │ │ + │ rekey_outbound again │ │ + │── disconnect ───────────────────────────────────────>│ + │ (encrypted with newer_root,│ │ rekey_inbound again + │ dh_public: newer_pub) │ │ + │ │ │ +``` + +--- + +## API Reference + +### Client-side (bee_connect) + +| Method | Input | Output | +|--------|-------|--------| +| `create_shared_key_session` | `app_id`, `ttl_secs` | `session_id`, `client_dh_public`, `client_dh_secret`, `deep_link` | +| `decode_connect_payload_b64url` | `payload_b64url` | `ConnectPayload` | +| `get_profile_address` | `endpoints`, `description` | `profile_address` | +| `is_session_profile_deployed` | `endpoints`, `description` | `bool` | +| `wait_wallet_hello` | `session_id`, `description`, `client_dh_secret` | `wallet_name`, `wallet_address`, `session_state` | +| `request_set_mining_keys` | `session_state`, `app_id`, `owner_public` | `message_id`, `updated_session_state` | +| `wait_set_mining_keys_request` | `session_state` | `app_id`, `owner_public`, `updated_session_state` | +| `request_sign_challenge` | `session_state`, `nonce` | `message_id`, `updated_session_state` | +| `wait_challenge_response` | `session_state` | `nonce`, `signature`, `wallet_address`, `updated_session_state` | +| `disconnect_session` | `session_state`, `reason` | `message_id`, `updated_session_state` | +| `query_active_sessions_by_multifactor` | `multifactor_address`, `app_id?`, `before?` | `sessions[]`, `next_before` | + +### Wallet-side (bee_wallet) + +| Method | Input | Output | +|--------|-------|--------| +| `decode_connect_payload_b64url` | `payload_b64url` | `ConnectPayload` | +| `accept_shared_key_connect` | `payload`, `client_dh_public`, `wallet_name`, `wallet_address` | `profile_address`, `session_state` | +| `query_session_messages` | `session_id`, `description`, `session_state` | `messages[]`, `updated_session_state` | +| `destroy_connect_profile` | `profile_address`, `multifactor_address`, `signer_keys` | — | diff --git a/bee_connect/README.md b/bee_connect/README.md new file mode 100644 index 0000000..fa97bf1 --- /dev/null +++ b/bee_connect/README.md @@ -0,0 +1,398 @@ +# bee_connect + +Client-side helpers for AckiNacki wallet connect flows over `ackinacki-kit` `authservice`. + +`bee_connect` is intended for the dApp/client side. +Wallet-side acceptance/deploy/send-first-message flow lives in `bee_wallet` (`connect` module). + +## What It Does + +`bee_connect` helps the dApp: + +- create a connect session with ephemeral X25519 Diffie-Hellman key exchange +- derive deterministic `AuthProfile` address from `description` +- wait for the first wallet message (`wallet_hello`) and finalize DH key agreement +- send encrypted protocol requests (`set_mining_keys`, `client_disconnect`) +- query active sessions by multifactor wallet + +## Protocol: `shared_key` (bidirectional, DH-authenticated) + +Secure bidirectional channel over one `AuthProfile`. No secrets are transmitted +in URLs or deeplinks — only the client's X25519 public key. + +### Handshake Flow + +``` +Client links.gosh.sh Wallet + │ │ │ + │ generate X25519 keypair │ │ + │ │ │ + │── URL: payload + dh_pub ───>│────> deeplink ────────>│ + │ (NO SECRETS IN URL!) │ │ + │ │ │ generate X25519 keypair + │ │ │ shared_secret = DH(w_sec, c_pub) + │ │ │ signing, encryption = HKDF(shared) + │ │ │ + │ │ │ deploy AuthProfile(signing.pub) + │ │ │ wallet_hello: + │ │ │ dh_public: wallet_dh_pub + │ │ │ body: encrypted(name, addr) + │ │ │ + │<── poll AuthProfile ─────────────────────────────────│ + │ shared_secret = DH(c_sec, w_pub) │ + │ signing, encryption = HKDF(shared) │ + │ decrypt body ✓ │ + │ │ │ + │ rekey_outbound: │ │ + │ new DH, new root │ │ + │── set_mining_keys ──────────────────────────────────>│ + │ (encrypted with new_root, │ │ rekey_inbound: + │ dh_public: new_pub) │ │ DH(my_sec, new_pub) → new root + │ │ │ + │── disconnect ───────────────────────────────────────>│ + │ (re-keyed again) │ │ rekey_inbound again +``` + +### Step by Step + +1. **Client** calls `create_shared_key_session(app_id, ttl_secs)` + - Generates ephemeral X25519 keypair + - Returns `client_dh_public` (in URL) and `client_dh_secret` (caller stores) + - Deeplink URL: `?payload=&client_dh_public=` + +2. **Wallet** receives deeplink, calls `accept_shared_key_connect(payload, client_dh_public, ...)` + - Generates wallet X25519 keypair + - Computes `shared_secret = X25519(wallet_secret, client_dh_public)` + - Derives session keys (`signing_keys` + `encryption_root`) via HKDF-SHA256 + - Returns `session_state: ConnectSessionState` (caller persists) + - Deploys AuthProfile with derived signing public key + - Sends `wallet_hello` with `dh_public` field (wallet's public key, cleartext) + +3. **Client** calls `wait_wallet_hello(session_id, description, client_dh_secret)` + - Polls AuthProfile for `wallet_hello` + - Extracts `dh_public` from envelope + - Computes same `shared_secret = X25519(client_secret, wallet_dh_public)` + - Derives identical session keys + - Decrypts and verifies wallet_hello body + - Returns `session_state: ConnectSessionState` for subsequent operations + +4. **Messaging** — each outbound message performs DH re-key (forward secrecy), encrypts with new root, includes `dh_public` in envelope. Caller persists `updated_session_state` after each send. + +### Key Derivation + +``` +shared_secret = X25519(my_secret, their_public) + +signing_seed = HKDF-SHA256(ikm=shared_secret, salt=session_id, info="bee_connect.signing.v1") +encryption_root = HKDF-SHA256(ikm=shared_secret, salt=session_id, info="bee_connect.encryption.v1") + +signing_key = Ed25519::SigningKey(signing_seed) +``` + +### Re-Key (Forward Secrecy) + +Every outbound message performs a DH re-key before encryption: + +``` +new_dh = X25519::generate() +shared = X25519(new_dh.secret, peer_dh_public) +ikm = old_encryption_root || shared (64 bytes) +new_root = HKDF-SHA256(ikm, salt=session_id, info="bee_connect.rekey.v1") +``` + +The `new_root` replaces `encryption_root` in the session state. The `new_dh.public` is included in the envelope's `dh_public` field. Old roots are discarded — forward secrecy. + +### All HKDF Info Labels + +| Purpose | Info | IKM | +|---------|------|-----| +| Signing key | `bee_connect.signing.v1` | DH shared secret (32 bytes) | +| Encryption root | `bee_connect.encryption.v1` | DH shared secret (32 bytes) | +| Per-message re-key | `bee_connect.rekey.v1` | old_root \|\| new_DH_shared (64 bytes) | +| Per-message encryption key | `bee_connect.msg.key.v1` | current encryption_root | + +All use HKDF-SHA256 with `salt = session_id` (except per-message key which uses `salt = random_32_bytes`). + +### MITM Protection + +- **Passive intercept**: URL contains only `client_dh_public` — useless without secret +- **Active MITM** (public key substitution): client and wallet derive different shared secrets → wallet_hello decryption fails → session not established (DoS, not silent interception) +- **Low-order point attack**: DH shared secret is checked for all-zero bytes — rejected before key derivation + +## Message Envelope (`bee_connect.msg/1`) + +All messages are encrypted. Unencrypted (`alg: "none"`) messages are rejected. + +### Envelope shape + +```json +{ + "v": "bee_connect.msg/1", + "session_id": "base64url-random", + "dir": "w2c", + "seq": 1700000000000, + "type": "wallet_hello", + "ts": 1700000000, + "dh_public": "", + "enc": { + "alg": "xchacha20poly1305-hkdf-sha256", + "nonce": "", + "salt": "" + }, + "body": "" +} +``` + +Fields: +- `v` — protocol version (`bee_connect.msg/1`) +- `session_id` — session identifier +- `dir` — direction: `w2c` (wallet→client) or `c2w` (client→wallet) +- `seq` — monotonic sequence number (millisecond timestamp for replay protection) +- `type` — message type: `wallet_hello`, `set_mining_keys`, `client_disconnect` +- `ts` — timestamp (seconds) +- `dh_public` — ephemeral X25519 public key for DH re-key; present in `wallet_hello` and all c2w messages +- `enc` — encryption metadata +- `body` — encrypted message body (base64url) + +### Per-Message Encryption + +``` +per_message_key = HKDF-SHA256(ikm=encryption_root, salt=random_salt, info="bee_connect.msg.key.v1") +ciphertext = XChaCha20-Poly1305(key=per_message_key, nonce=random, plaintext=body, aad=envelope_metadata) +``` + +AAD includes: `v`, `session_id`, `dir`, `seq`, `type`, `ts`. + +## Deeplink Payload (`bee_connect.dl/1`) + +```json +{ + "v": "bee_connect.dl/1", + "session_id": "base64url-random", + "description": "bee_connect:v1:::", + "expires_at": 1730000600, + "app_id": "0x0000000000000000000000000000000000000000000000000000000000000001" +} +``` + +Deeplink URL query parameters: +- `payload=` — encoded ConnectPayload +- `client_dh_public=` — client X25519 public key (64 hex chars) + +## Cryptographic Primitives + +| Purpose | Primitive | Key Size | +|---------|-----------|----------| +| DH key exchange | X25519 | 32 bytes | +| Key derivation | HKDF-SHA256 | — | +| On-chain signing | Ed25519 (verify_strict) | 32 bytes | +| Message encryption | XChaCha20-Poly1305 | 32 bytes | +| Replay protection | seq = now_millis() | u64 | + +## Rust API + +Core methods on `ConnectClient`: + +- `create_shared_key_session(app_id, ttl_secs)` → session + DH keypair +- `decode_connect_payload_b64url(payload_b64url)` → parsed payload +- `get_profile_address(endpoints, description)` → profile address +- `is_session_profile_deployed(endpoints, description)` → bool +- `wait_wallet_hello(endpoints, session_id, description, client_dh_secret, ...)` → wallet info + session_state +- `request_set_mining_keys(endpoints, session_id, description, session_state, ...)` → message_id, updated_session_state +- `wait_set_mining_keys_request(endpoints, session_id, description, session_state, ...)` → app_id, owner_public, updated_session_state +- `disconnect_session(endpoints, session_id, description, session_state, ...)` → message_id, updated_session_state +- `query_active_sessions_by_multifactor(endpoints, multifactor_address, ...)` → sessions + +DH helpers (`bee_connect::dh`): + +- `generate_dh_keypair()` → DhKeyPair (public_hex, secret_hex) +- `compute_shared_secret(my_secret_hex, their_public_hex)` → shared_secret_hex +- `derive_session_keys(shared_secret_hex, session_id)` → DhSessionKeys (signing + encryption) +- `create_initial_state(session_keys, my_dh_secret, peer_dh_public)` → ConnectSessionState +- `rekey_outbound(state, session_id)` → RekeyResult (new root, new dh_public, updated state) +- `rekey_inbound(state, peer_new_dh_public, session_id)` → RekeyResult (new root, updated state) + +## Data Contracts + +### `ConnectSessionState` + +Persisted by both client and wallet after every operation. JSON-serializable. + +```rust +pub struct ConnectSessionState { + pub encryption_root: Zeroizing, // hex, 32 bytes — rotated every message + pub my_dh_secret: Zeroizing, // hex, 32 bytes — rotated on outbound + pub peer_dh_public: String, // hex, 32 bytes — updated on inbound + pub signing_public: String, // hex, 32 bytes — fixed for session + pub signing_secret: Zeroizing, // hex, 32 bytes — fixed for session + pub created_at: u64, // UNIX epoch seconds + pub expires_at: u64, // UNIX epoch seconds (default: created_at + 24h) +} +``` + +- Secret fields are `Zeroizing` — zeroed on drop. +- `created_at` / `expires_at` — set at handshake, carried through re-keys. +- `expires_at == 0` means legacy state (no expiration). +- `rekey_outbound` / `rekey_inbound` reject expired sessions. +- Default TTL: `DEFAULT_SESSION_TTL_SECS = 86400` (24 hours). + +### `ResultOfWaitWalletHello` + +Returned by `wait_wallet_hello` after successful DH finalization. + +```rust +pub struct ResultOfWaitWalletHello { + pub profile_address: String, // on-chain AuthProfile address + pub event_id: String, // wallet_hello event ID + pub event_created_at: u64, // event timestamp (seconds) + pub wallet_name: String, // from decrypted body + pub wallet_address: String, // from decrypted body + pub raw_message_json: String, // full envelope JSON for audit + pub session_state: ConnectSessionState, // initial state for subsequent operations +} +``` + +### `ResultOfRequestSetMiningKeys` + +```rust +pub struct ResultOfRequestSetMiningKeys { + pub profile_address: String, + pub message_id: Option, + pub updated_session_state: ConnectSessionState, +} +``` + +### `ResultOfWaitSetMiningKeysRequest` + +```rust +pub struct ResultOfWaitSetMiningKeysRequest { + pub profile_address: String, + pub event_id: String, + pub event_created_at: u64, + pub app_id: String, + pub owner_public: String, + pub raw_message_json: String, + pub updated_session_state: Option, +} +``` + +## Wallet-Side API (`bee_wallet`) + +The wallet implements the other side of the protocol: + +| Method | What it does | +|--------|-------------| +| `decode_connect_payload_b64url(payload)` | Parse deeplink payload | +| `accept_connect_shared_key(params)` | DH handshake + deploy AuthProfile + send wallet_hello | +| `query_connect_session_messages(params)` | Poll for c2w messages with automatic re-key | +| `destroy_connect_profile(params)` | Destroy AuthProfile on-chain | + +See `bee_wallet` crate and `PROTOCOL.md` for full wallet-side documentation. + +## TypeScript / WASM + +Session state is passed as JSON string across the WASM boundary. Parse with: + +```typescript +import type { TConnectSessionState } from "@teamgosh/bee-sdk"; + +const state: TConnectSessionState = JSON.parse(sessionStateJson); + +// Check expiration in UI +if (state.expires_at > 0 && Date.now() / 1000 >= state.expires_at) { + // Session expired — destroy profile and reconnect +} + +// Pass back to WASM +const json = JSON.stringify(state); +``` + +The `TConnectSessionState` type is auto-generated by `wasm-bindgen` and exported from both `bee_connect` and `bee_wallet` WASM packages. + +## Input Normalization + +Hex inputs are normalized before use: + +- **`app_id`**: Stripped of `0x`/`0X` prefix, left-padded to 64 chars, lowercased, prepended with `0x`. + Example: `"0x1"` → `"0x0000000000000000000000000000000000000000000000000000000000000001"` +- **`owner_public`**: Stripped of `0x`/`0X` prefix, lowercased. Must be exactly 64 hex chars (32 bytes). + Example: `"0xAABB...CC"` → `"aabb...cc"` + +These normalizations are applied by both `bee_connect` and `bee_wallet` via shared functions in `bee_connect::message`. + +## Polling Limits + +Methods that poll on-chain state (`wait_wallet_hello`, `wait_set_mining_keys_request`, etc.) accept `max_attempts` as `u32`. Internally, `wait_account` (from `ackinacki-kit`) accepts `u8`, so values above **255** are clamped silently. + +## Minimal Usage + +```rust +use bee_connect::{ConnectClient, ParamsOfCreateSharedKeySession, ParamsOfWaitWalletHello, ParamsOfRequestSetMiningKeys}; + +let client = ConnectClient::new(); + +// Phase 1: Create session (generates X25519 DH keypair) +let session = client.create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x1".to_string(), + ttl_secs: Some(300), +})?; + +// session.deep_link — open in wallet app (contains only client_dh_public) +// session.client_dh_secret — store for phase 3 + +// Phase 3: Wait for wallet_hello + DH finalize +let hello = client.wait_wallet_hello(ParamsOfWaitWalletHello { + endpoints: vec!["https://your-endpoint".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + client_dh_secret: session.client_dh_secret.clone(), + created_at_from: Some(session.created_at), + max_attempts: Some(120), + interval_ms: Some(1000), +}).await?; + +// hello.session_state — persist this for all subsequent operations +// Contains signing keys, encryption root, DH keys for forward secrecy + +// Phase 4: Send message (re-keys automatically for forward secrecy) +let result = client.request_set_mining_keys(ParamsOfRequestSetMiningKeys { + endpoints: vec!["https://your-endpoint".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: hello.session_state, // ← pass current state + app_id: "0x1".to_string(), + owner_public: "your-mining-public-key-hex".to_string(), + max_attempts: Some(30), + interval_ms: Some(1000), +}).await?; + +// MUST persist updated state — old state is no longer valid +let session_state = result.updated_session_state; +``` + +## Security Properties + +- **Forward secrecy**: per-message DH re-key discards old roots — past messages are unrecoverable even if current state is compromised. +- **Mutual authentication**: successful `wallet_hello` decryption proves both sides share the same DH secret. +- **Replay protection**: `seq` (millisecond timestamp) is included in AEAD AAD. +- **Metadata integrity**: `session_id`, `dir`, `type`, `ts` are AEAD-authenticated. +- **Session timeout**: `ConnectSessionState` carries `expires_at` — operations reject expired sessions (default 24h TTL). +- **Memory safety**: secret fields use `Zeroizing` — zeroed on drop. +- **Low-order point rejection**: all-zero DH shared secrets are rejected. + +See `PROTOCOL.md` for full security analysis including signing key lifetime, wallet identity, and threat model. + +## Build + +Native: + +```bash +cargo check -p bee-connect +cargo test -p bee-connect +``` + +WASM: + +```bash +cargo check -p bee-connect --target wasm32-unknown-unknown --no-default-features --features single-wasm +``` diff --git a/bee_connect/src/client.rs b/bee_connect/src/client.rs new file mode 100644 index 0000000..c4cdb1f --- /dev/null +++ b/bee_connect/src/client.rs @@ -0,0 +1,2821 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] +use std::time::SystemTime; +#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] +use std::time::UNIX_EPOCH; + +use ackinacki_kit::contracts::account::AccountStatus; +use ackinacki_kit::contracts::account::ParamsOfWaitAccount; +use ackinacki_kit::contracts::authservice::profile::AuthProfile; +use ackinacki_kit::contracts::authservice::profile::ParamsOfQueryProfileEvents; +use ackinacki_kit::contracts::authservice::root::AuthServiceRoot; +use ackinacki_kit::contracts::authservice::root::ParamsOfQueryProfilesByMultifactor; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::ContextAccessor; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::processing::ResultOfSendMessage; +use ackinacki_kit::tvm_client::ClientConfig; +use ackinacki_kit::tvm_client::ClientContext; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::Deserialize; +use serde::Serialize; + +use crate::message::connect_message_aad; +use crate::message::decrypt_connect_body; +use crate::message::encrypt_connect_body; +use crate::message::normalize_owner_public_hex; +use crate::message::normalize_uint256_hex; +use crate::message::CONNECT_DEEPLINK_VERSION; +use crate::message::CONNECT_MESSAGE_ENC_NONE; +use crate::message::CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256; +use crate::message::CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE; +use crate::message::CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT; +use crate::message::CONNECT_MESSAGE_TYPE_SET_MINING_KEYS; +use crate::message::CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE; +use crate::message::CONNECT_MESSAGE_TYPE_WALLET_HELLO; +use crate::message::CONNECT_MESSAGE_VERSION; + +const CONNECT_DEEPLINK_RESOLVER_URL: &str = "https://links.gosh.sh"; +const CONNECT_DEEPLINK_PATH: &str = "/deeplinks/wallet/v1/connect"; +const DEFAULT_CONNECT_TTL_SECS: u64 = 300; +const ACTIVE_SESSIONS_CHUNK_SIZE: u32 = 10; +const CONNECT_DESCRIPTION_PREFIX: &str = "bee_connect:"; +const CONNECT_DESCRIPTION_VERSION: &str = "v1"; + +#[derive(Debug, Clone, Default)] +pub struct ConnectClient { + rate_limiter: Option, + /// Optional BM API token, threaded into every `ClientContext` this client + /// builds (see `root_with_endpoints`). `None` → anonymous requests, which + /// is the intended default for the wasm/browser path (token is optional + /// there). Native callers set it via [`ConnectClient::with_api_token`]. + api_token: Option, +} + +/// Parameters for creating a bidirectional `shared_key` connect session. +/// +/// The client generates an ephemeral X25519 DH keypair. Only the public key +/// is included in the deeplink URL. The wallet will generate its own DH keypair +/// and both sides derive shared session keys via Diffie-Hellman key agreement. +#[derive(Debug, Clone)] +pub struct ParamsOfCreateSharedKeySession { + /// dApp identifier as `uint256` hex string (`0x...` or raw hex). + pub app_id: String, + /// Session TTL in seconds. Defaults to a short connect timeout. + pub ttl_secs: Option, + /// Optional challenge nonce (hex). When present, it is embedded in the + /// deeplink payload so the wallet can sign it and include the signature + /// in `wallet_hello`. This eliminates the separate `sign_challenge` / + /// `challenge_response` roundtrip. + pub nonce: Option, +} + +/// Result of creating a `shared_key` connect session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfCreateSharedKeySession { + /// Connect session identifier used in all protocol messages. + pub session_id: String, + /// Random profile description used to deterministically derive profile + /// address. + pub description: String, + /// Session creation timestamp (unix seconds). + pub created_at: u64, + /// Session expiration timestamp (unix seconds). + pub expires_at: u64, + /// Normalized `app_id` (`0x` + 64 lowercase hex chars). + pub app_id: String, + /// Payload JSON before base64url encoding. + pub payload_json: String, + /// Deeplink URL for opening the wallet app. Contains only + /// `client_dh_public` — no secrets. + pub deep_link: String, + /// Base64url-encoded payload for QR transfer / app bridge. + pub payload_b64url: String, + /// Client X25519 DH public key (hex). Transmitted in the deeplink URL. + pub client_dh_public: String, + /// Client X25519 DH secret key (hex). Caller MUST store this for DH + /// finalization in `wait_wallet_hello`. NEVER transmitted. + pub client_dh_secret: String, +} + +/// Parameters for resolving a deterministic `AuthProfile` address by +/// `description`. +#[derive(Debug, Clone)] +pub struct ParamsOfResolveProfileAddress { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Connect profile description (rendezvous id). + pub description: String, +} + +/// Parameters for waiting for the first wallet message (`wallet_hello`). +#[derive(Debug, Clone)] +pub struct ParamsOfWaitWalletHello { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Session id that must match the received message envelope. + pub session_id: String, + /// Connect profile description used to resolve the profile address. + pub description: String, + /// Client X25519 DH secret key (hex) from `create_shared_key_session`. + /// Used to compute shared secret with wallet's DH public key from + /// wallet_hello. + pub client_dh_secret: String, + /// Optional lower bound for event timestamps (unix seconds). + pub created_at_from: Option, + /// Polling attempts for profile activation and event lookup. + pub max_attempts: Option, + /// Polling interval in milliseconds. + pub interval_ms: Option, +} + +/// Parameters for sending a client-side disconnect request. +#[derive(Debug, Clone)] +pub struct ParamsOfDisconnectSession { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Session id that must be included in the message envelope. + pub session_id: String, + /// Connect profile description used to resolve the profile address. + pub description: String, + /// Current session state (signing keys, encryption root, DH keys). + pub session_state: crate::dh::ConnectSessionState, + /// Optional disconnect reason. + pub reason: Option, + /// Polling attempts for profile activation. + pub max_attempts: Option, + /// Polling interval in milliseconds. + pub interval_ms: Option, +} + +/// Parameters for sending `set_mining_keys` request from client to wallet. +#[derive(Debug, Clone)] +pub struct ParamsOfRequestSetMiningKeys { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Session id that must be included in the message envelope. + pub session_id: String, + /// Connect profile description used to resolve the profile address. + pub description: String, + /// Current session state (signing keys, encryption root, DH keys). + pub session_state: crate::dh::ConnectSessionState, + /// Miner app id (`uint256` hex string). + pub app_id: String, + /// Mining owner public key (hex). + pub owner_public: String, + /// Polling attempts for profile activation. + pub max_attempts: Option, + /// Polling interval in milliseconds. + pub interval_ms: Option, +} + +/// Parameters for querying active connect sessions by multifactor wallet. +#[derive(Debug, Clone)] +pub struct ParamsOfQueryActiveSessionsByMultifactor { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Multifactor wallet address. + pub multifactor_address: String, + /// Optional `app_id` filter (`uint256` hex string). + /// + /// If set, only sessions with successfully parsed matching `app_id` are + /// returned. Malformed descriptions (`app_id = None`) are skipped. + pub app_id: Option, + /// Optional lower bound for deploy event timestamps (unix seconds). + pub created_at_from: Option, + /// Reverse-pagination cursor for next chunk. + pub before: Option, +} + +/// One active connect session resolved from `AuthProfileDeployed`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActiveConnectSession { + /// AuthProfile address. + pub profile_address: String, + /// Connect description (`bee_connect:v1:::...`). + pub description: String, + /// Parsed `app_id` from description (`bee_connect:v1::...`). + /// + /// `None` means description doesn't match current protocol format. + pub app_id: Option, + /// Parsed `session_id` from description (if parseable). + pub session_id: Option, + /// GraphQL event id of `AuthProfileDeployed`. + pub deployed_event_id: String, + /// Deploy event timestamp (unix seconds). + pub deployed_at: u64, +} + +/// Result page for active connect sessions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfQueryActiveSessionsByMultifactor { + /// Active sessions (at most 10 per call). + pub sessions: Vec, + /// Cursor for the next page request. + pub next_before: Option, + /// `true` means there are no more active connect sessions in older pages. + pub exhausted_active: bool, +} + +/// Wallet metadata received in the first `wallet_hello` message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletHelloMetadata { + /// Human-readable wallet name. + pub wallet_name: String, + /// Wallet account address shown to the dApp. + pub wallet_address: String, +} + +/// Result returned when the dApp receives the first `wallet_hello`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfWaitWalletHello { + /// Resolved `AuthProfile` address. + pub profile_address: String, + /// GraphQL event id of the matched `ContextAdded` event. + pub event_id: String, + /// Event timestamp (unix seconds). + pub event_created_at: u64, + /// Wallet name from the `wallet_hello` body. + pub wallet_name: String, + /// Wallet address from the `wallet_hello` body. + pub wallet_address: String, + /// Raw JSON envelope received from the chain. + pub raw_message_json: String, + /// Initial session state after DH key exchange. + /// Contains signing keys, encryption root, and DH keys. + /// Caller MUST persist this for subsequent operations. + pub session_state: crate::dh::ConnectSessionState, + /// Inline challenge nonce (present when the wallet responded to a nonce in + /// the deeplink). + pub nonce: Option, + /// Inline challenge signature (present when the wallet signed the nonce). + pub signature: Option, + /// EPK public key used to sign the nonce (hex). + pub epk_public: Option, +} + +/// Result of sending `client_disconnect`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfDisconnectSession { + /// Resolved `AuthProfile` address. + pub profile_address: String, + /// Outbound message hash returned by TVM processing (if available). + pub message_id: Option, + /// Raw JSON envelope sent to the chain. + pub raw_message_json: String, + /// Updated session state after DH re-key. Caller MUST persist this. + pub updated_session_state: crate::dh::ConnectSessionState, +} + +/// Result of sending `set_mining_keys`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfRequestSetMiningKeys { + /// Resolved `AuthProfile` address. + pub profile_address: String, + /// Outbound message hash returned by TVM processing (if available). + pub message_id: Option, + /// Normalized `app_id`. + pub app_id: String, + /// Normalized mining owner public key (lowercase hex without `0x`). + pub owner_public: String, + /// Raw JSON envelope sent to the chain. + pub raw_message_json: String, + /// Updated session state after DH re-key. Caller MUST persist this. + pub updated_session_state: crate::dh::ConnectSessionState, +} + +/// Parameters for waiting `set_mining_keys` request (wallet-side poll helper). +#[derive(Debug, Clone)] +pub struct ParamsOfWaitSetMiningKeysRequest { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Session id that must match the received message envelope. + pub session_id: String, + /// Connect profile description used to resolve the profile address. + pub description: String, + /// Session state for decrypting and re-keying incoming messages. + pub session_state: Option, + /// Optional lower bound for event timestamps (unix seconds). + pub created_at_from: Option, + /// Polling attempts for profile activation and event lookup. + pub max_attempts: Option, + /// Polling interval in milliseconds. + pub interval_ms: Option, +} + +/// Result of waiting `set_mining_keys` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfWaitSetMiningKeysRequest { + /// Resolved `AuthProfile` address. + pub profile_address: String, + /// GraphQL event id of the matched `ContextAdded` event. + pub event_id: String, + /// Event timestamp (unix seconds). + pub event_created_at: u64, + /// Normalized `app_id`. + pub app_id: String, + /// Normalized mining owner public key. + pub owner_public: String, + /// Raw JSON envelope received from the chain. + pub raw_message_json: String, + /// Updated session state after re-keying. Caller SHOULD persist this. + /// `None` if no `session_state` was provided in params. + pub updated_session_state: Option, +} + +/// Parameters for sending `sign_challenge` request from client to wallet. +/// +/// The client (dApp backend) generates a random nonce and asks the wallet to +/// sign it with the multifactor's EPK key. The resulting signature proves +/// wallet ownership to the backend. +#[derive(Debug, Clone)] +pub struct ParamsOfRequestSignChallenge { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Session id that must be included in the message envelope. + pub session_id: String, + /// Connect profile description used to resolve the profile address. + pub description: String, + /// Current session state (signing keys, encryption root, DH keys). + pub session_state: crate::dh::ConnectSessionState, + /// Random nonce generated by the backend (hex string). + pub nonce: String, + /// Polling attempts for profile activation. + pub max_attempts: Option, + /// Polling interval in milliseconds. + pub interval_ms: Option, +} + +/// Result of sending `sign_challenge`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfRequestSignChallenge { + /// Resolved `AuthProfile` address. + pub profile_address: String, + /// Outbound message hash returned by TVM processing (if available). + pub message_id: Option, + /// Nonce that was sent (echoed back for convenience). + pub nonce: String, + /// Raw JSON envelope sent to the chain. + pub raw_message_json: String, + /// Updated session state after DH re-key. Caller MUST persist this. + pub updated_session_state: crate::dh::ConnectSessionState, + /// Timestamp (unix seconds) when the challenge was sent. + /// Use as `created_at_from` in `wait_challenge_response` for precise event + /// filtering. + pub sent_at: u64, +} + +/// Parameters for waiting `challenge_response` from wallet (client-side poll). +#[derive(Debug, Clone)] +pub struct ParamsOfWaitChallengeResponse { + /// TVM RPC endpoints used to create a client context. + pub endpoints: Vec, + /// Session id that must match the received message envelope. + pub session_id: String, + /// Connect profile description used to resolve the profile address. + pub description: String, + /// Session state for decrypting and re-keying incoming messages. + pub session_state: Option, + /// Optional lower bound for event timestamps (unix seconds). + pub created_at_from: Option, + /// Polling attempts for profile activation and event lookup. + pub max_attempts: Option, + /// Polling interval in milliseconds. + pub interval_ms: Option, +} + +/// Result of waiting `challenge_response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfWaitChallengeResponse { + /// Resolved `AuthProfile` address. + pub profile_address: String, + /// GraphQL event id of the matched `ContextAdded` event. + pub event_id: String, + /// Event timestamp (unix seconds). + pub event_created_at: u64, + /// The nonce that was signed. + pub nonce: String, + /// Ed25519 signature of the nonce (hex). + pub signature: String, + /// Wallet address that signed the challenge. + pub wallet_address: String, + /// EPK public key used to sign the nonce (hex). + /// `None` if the wallet uses an older protocol version without this field. + pub epk_public: Option, + /// Raw JSON envelope received from the chain. + pub raw_message_json: String, + /// Updated session state after re-keying. Caller SHOULD persist this. + /// `None` if no `session_state` was provided in params. + pub updated_session_state: Option, +} + +/// Body of `sign_challenge` message (c2w). +/// Sent by the dApp to ask the wallet to sign a nonce for backend auth. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignChallengeBody { + /// Random hex nonce generated by the backend. + pub nonce: String, +} + +/// Body of `challenge_response` message (w2c). +/// Sent by the wallet in response to `sign_challenge`. +/// Backend verifies the signature to confirm wallet ownership. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChallengeResponseBody { + /// Echoed nonce from the challenge. + pub nonce: String, + /// Ed25519 detached signature of the nonce (hex). + pub signature: String, + /// Multifactor address of the signing wallet. + pub wallet_address: String, + /// EPK public key used to sign the nonce (hex, 64 chars). + /// Backend uses this to verify the signature and then confirms + /// the key is registered in the multifactor contract via + /// `get_epk_expire_at(wallet_address, epk_public)`. + #[serde(default)] + pub epk_public: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectPayload { + pub v: String, + pub session_id: String, + pub description: String, + pub expires_at: u64, + pub app_id: String, + /// Optional challenge nonce for inline wallet ownership verification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConnectMessageEnvelope { + v: String, + session_id: String, + dir: String, + seq: u64, + #[serde(rename = "type")] + msg_type: String, + #[serde(default)] + ts: Option, + #[serde(default)] + dh_public: Option, + #[serde(default)] + enc: Option, + #[serde(default)] + body: serde_json::Value, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConnectMessageEnc { + alg: String, + #[serde(default)] + nonce: Option, + #[serde(default)] + salt: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct WalletHelloBody { + wallet_name: String, + wallet_address: String, + /// Signed nonce (inline challenge response). + #[serde(default)] + nonce: Option, + #[serde(default)] + signature: Option, + #[serde(default)] + epk_public: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SetMiningKeysBody { + app_id: String, + owner_public: String, +} + +impl ConnectClient { + /// Creates a new client-side helper for `bee_connect`. + pub fn new() -> Self { + Self { rate_limiter: None, api_token: None } + } + + /// Creates a client with rate limiting (max requests per second). + pub fn with_rate_limit(max_rps: u32) -> Self { + Self { rate_limiter: Some(bee_infra::RateLimiter::new(max_rps)), api_token: None } + } + + /// Sets the BM API token used for all requests this client makes. + /// Chainable with [`Self::new`] / [`Self::with_rate_limit`], e.g. + /// `ConnectClient::with_rate_limit(rps).with_api_token(token)`. Native + /// apps pass `Some(BM_API_TOKEN)`; the wasm/browser path leaves it unset. + pub fn with_api_token(mut self, api_token: Option) -> Self { + self.api_token = api_token; + self + } + + async fn acquire(&self) { + bee_infra::maybe_acquire(self.rate_limiter.as_ref()).await; + } + + /// Small health-check helper for integration smoke tests. + pub fn ping(&self) -> &'static str { + "bee_connect:stub" + } + + /// Decodes and validates base64url connect payload (`payload` query + /// value). + pub fn decode_connect_payload_b64url( + &self, + payload_b64url: impl AsRef, + ) -> Result { + decode_connect_payload_b64url(payload_b64url) + } + + /// Creates a `shared_key` session and generates an ephemeral X25519 DH + /// keypair. + /// + /// The returned `client_dh_public` is included in the deeplink URL. + /// The returned `client_dh_secret` MUST be stored by the caller and passed + /// to `wait_wallet_hello` for DH key agreement. + /// + /// **No secrets are transmitted in the URL.** + pub fn create_shared_key_session( + &self, + params: ParamsOfCreateSharedKeySession, + ) -> Result { + let common = self.create_session_common(params.app_id, params.ttl_secs, params.nonce)?; + let dh_keypair = crate::dh::generate_dh_keypair()?; + let deep_link = format!( + "{CONNECT_DEEPLINK_RESOLVER_URL}{CONNECT_DEEPLINK_PATH}?payload={}&client_dh_public={}", + common.payload_b64url, dh_keypair.public_hex + ); + + Ok(ResultOfCreateSharedKeySession { + session_id: common.session_id, + description: common.description, + created_at: common.created_at, + expires_at: common.expires_at, + app_id: common.app_id, + payload_json: common.payload_json, + deep_link, + payload_b64url: common.payload_b64url, + client_dh_public: dh_keypair.public_hex, + client_dh_secret: dh_keypair.secret_hex.to_string(), + }) + } + + /// Resolves deterministic `AuthProfile` address from a connect + /// `description`. + pub async fn get_profile_address( + &self, + params: ParamsOfResolveProfileAddress, + ) -> Result { + self.acquire().await; + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let result = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description, + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })?; + Ok(result.profile) + } + + /// Checks whether session profile resolved from `description` is currently + /// deployed on chain. + pub async fn is_session_profile_deployed( + &self, + params: ParamsOfResolveProfileAddress, + ) -> Result { + self.acquire().await; + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let profile_address = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description, + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })? + .profile; + + let profile = AuthProfile::new_default(root.context().clone(), &profile_address); + Ok(profile.is_deployed().await) + } + + /// Waits until the wallet deploys the profile and sends the first + /// `wallet_hello`. + /// + /// Validates message envelope fields: + /// - `v = bee_connect.msg/1` + /// - `dir = w2c` + /// - `seq > 0` + /// - `type = wallet_hello` + /// - `dh_public` present (wallet X25519 public key) + /// - `session_id` matches the requested session + /// + /// Performs DH key agreement using `client_dh_secret` and wallet's + /// `dh_public` from the envelope, derives session signing and encryption + /// keys, and decrypts the `wallet_hello` body. + pub async fn wait_wallet_hello( + &self, + params: ParamsOfWaitWalletHello, + ) -> Result { + self.acquire().await; + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let profile_address = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description.clone(), + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })? + .profile; + + let profile = AuthProfile::new_default(root.context().clone(), &profile_address); + wait_profile_active(&profile, params.max_attempts, params.interval_ms).await?; + + let created_at_from = params + .created_at_from + .unwrap_or_else(|| now_secs().unwrap_or_default().saturating_sub(300)); + + let client_dh_secret = params.client_dh_secret.clone(); + let rl = self.rate_limiter.clone(); + let found = bee_infra::poll_until( + || { + let profile = profile.clone(); + let session_id = params.session_id.clone(); + let client_dh_secret = client_dh_secret.clone(); + let rl = rl.clone(); + async move { + bee_infra::maybe_acquire(rl.as_ref()).await; + find_wallet_hello_event( + &profile, + &session_id, + &client_dh_secret, + created_at_from, + ) + .await + .map_err(|e| e.with_context("Query wallet_hello")) + } + }, + |found| found.is_some(), + params.max_attempts, + params.interval_ms, + ) + .await? + .ok_or_else(|| "wait_wallet_hello: no wallet_hello event found".to_string())?; + + Ok(ResultOfWaitWalletHello { + profile_address, + event_id: found.event_id, + event_created_at: found.event_created_at, + wallet_name: found.wallet_name, + wallet_address: found.wallet_address, + raw_message_json: found.raw_message_json, + session_state: found.session_state, + nonce: found.nonce, + signature: found.signature, + epk_public: found.epk_public, + }) + } + + /// Waits until client->wallet `set_mining_keys` request is written to + /// profile context. + pub async fn wait_set_mining_keys_request( + &self, + params: ParamsOfWaitSetMiningKeysRequest, + ) -> Result { + self.acquire().await; + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let profile_address = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description.clone(), + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })? + .profile; + + let profile = AuthProfile::new_default(root.context().clone(), &profile_address); + wait_profile_active(&profile, params.max_attempts, params.interval_ms).await?; + + let created_at_from = params + .created_at_from + .unwrap_or_else(|| now_secs().unwrap_or_default().saturating_sub(300)); + + let rl = self.rate_limiter.clone(); + let found = bee_infra::poll_until( + || { + let profile = profile.clone(); + let session_id = params.session_id.clone(); + let session_state = params.session_state.clone(); + let rl = rl.clone(); + async move { + bee_infra::maybe_acquire(rl.as_ref()).await; + find_set_mining_keys_event( + &profile, + &session_id, + session_state.as_ref(), + created_at_from, + ) + .await + .map_err(|e| e.with_context("Query set_mining_keys")) + } + }, + |found| found.is_some(), + params.max_attempts, + params.interval_ms, + ) + .await? + .ok_or_else(|| { + "wait_set_mining_keys_request: no set_mining_keys event found".to_string() + })?; + + Ok(ResultOfWaitSetMiningKeysRequest { + profile_address, + event_id: found.event_id, + event_created_at: found.event_created_at, + app_id: found.app_id, + owner_public: found.owner_public, + raw_message_json: found.raw_message_json, + updated_session_state: found.updated_session_state, + }) + } + + /// Sends `client_disconnect` (`dir = c2w`, `type = client_disconnect`) to + /// the connected profile using shared session owner keys. + pub async fn disconnect_session( + &self, + params: ParamsOfDisconnectSession, + ) -> Result { + self.acquire().await; + if params.session_id.trim().is_empty() { + return Err("disconnect_session session_id is empty".to_string().into()); + } + + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let profile_address = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description, + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })? + .profile; + + let profile = AuthProfile::new_default(root.context().clone(), &profile_address); + wait_profile_active(&profile, params.max_attempts, params.interval_ms).await?; + + let body = params + .reason + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(|reason| serde_json::json!({ "reason": reason })) + .unwrap_or_else(|| serde_json::json!({})); + + // DH re-key for forward secrecy + let seq = params.session_state.next_outbound_seq().map_err(|e| { + crate::errors::AppError::from(e).with_context("disconnect_session next_outbound_seq") + })?; + let rekey = crate::dh::rekey_outbound(¶ms.session_state, ¶ms.session_id, seq) + .map_err(|e| { + crate::errors::AppError::from(e).with_context("disconnect_session rekey_outbound") + })?; + + let signing_keys = KeyPair { + public: params.session_state.signing_public.clone(), + secret: params.session_state.signing_secret.to_string(), + }; + + let raw_message_json = encode_connect_message( + ¶ms.session_id, + "c2w", + rekey.outbound_seq, + CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT, + body, + Some(&rekey.message_encryption_root), + rekey.new_dh_public.as_deref(), + )?; + let send_result = profile + .add_context_text(&raw_message_json, Signer::Keys { keys: signing_keys }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthProfile::add_context_text client_disconnect") + })?; + let send_result = ensure_tx_success(send_result, "client_disconnect add_context")?; + + Ok(ResultOfDisconnectSession { + profile_address, + message_id: send_result.message_hash, + raw_message_json, + updated_session_state: rekey.updated_state, + }) + } + + /// Sends `set_mining_keys` (`dir = c2w`) to the connected profile. + /// + /// Wallet app can read this request and execute + /// `bee_wallet.set_mining_keys`. + pub async fn request_set_mining_keys( + &self, + params: ParamsOfRequestSetMiningKeys, + ) -> Result { + self.acquire().await; + if params.session_id.trim().is_empty() { + return Err("request_set_mining_keys session_id is empty".to_string().into()); + } + + let app_id = normalize_uint256_hex(¶ms.app_id)?; + let owner_public = normalize_owner_public_hex(¶ms.owner_public)?; + + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let profile_address = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description, + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })? + .profile; + + let profile = AuthProfile::new_default(root.context().clone(), &profile_address); + wait_profile_active(&profile, params.max_attempts, params.interval_ms).await?; + + let body = serde_json::to_value(SetMiningKeysBody { + app_id: app_id.clone(), + owner_public: owner_public.clone(), + }) + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Serialize set_mining_keys body") + })?; + // DH re-key for forward secrecy + let seq = params.session_state.next_outbound_seq().map_err(|e| { + crate::errors::AppError::from(e) + .with_context("request_set_mining_keys next_outbound_seq") + })?; + let rekey = crate::dh::rekey_outbound(¶ms.session_state, ¶ms.session_id, seq) + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("request_set_mining_keys rekey_outbound") + })?; + + let signing_keys = KeyPair { + public: params.session_state.signing_public.clone(), + secret: params.session_state.signing_secret.to_string(), + }; + + let raw_message_json = encode_connect_message( + ¶ms.session_id, + "c2w", + rekey.outbound_seq, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + body, + Some(&rekey.message_encryption_root), + rekey.new_dh_public.as_deref(), + )?; + let send_result = profile + .add_context_text(&raw_message_json, Signer::Keys { keys: signing_keys }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthProfile::add_context_text set_mining_keys") + })?; + let send_result = ensure_tx_success(send_result, "set_mining_keys add_context")?; + + Ok(ResultOfRequestSetMiningKeys { + profile_address, + message_id: send_result.message_hash, + app_id, + owner_public, + raw_message_json, + updated_session_state: rekey.updated_state, + }) + } + + /// Sends `sign_challenge` (`dir = c2w`) to the connected wallet. + /// + /// The wallet should sign the nonce with its EPK keys and respond + /// with a `challenge_response` message. The backend can then verify + /// the signature to confirm wallet ownership. + pub async fn request_sign_challenge( + &self, + params: ParamsOfRequestSignChallenge, + ) -> Result { + self.acquire().await; + if params.session_id.trim().is_empty() { + return Err("request_sign_challenge session_id is empty".to_string().into()); + } + if params.nonce.trim().is_empty() { + return Err("request_sign_challenge nonce is empty".to_string().into()); + } + + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let profile_address = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description, + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })? + .profile; + + let profile = AuthProfile::new_default(root.context().clone(), &profile_address); + wait_profile_active(&profile, params.max_attempts, params.interval_ms).await?; + + let body = serde_json::to_value(SignChallengeBody { nonce: params.nonce.clone() }) + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Serialize sign_challenge body") + })?; + + let seq = params.session_state.next_outbound_seq().map_err(|e| { + crate::errors::AppError::from(e) + .with_context("request_sign_challenge next_outbound_seq") + })?; + let rekey = crate::dh::rekey_outbound(¶ms.session_state, ¶ms.session_id, seq) + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("request_sign_challenge rekey_outbound") + })?; + + let signing_keys = KeyPair { + public: params.session_state.signing_public.clone(), + secret: params.session_state.signing_secret.to_string(), + }; + + let raw_message_json = encode_connect_message( + ¶ms.session_id, + "c2w", + rekey.outbound_seq, + CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE, + body, + Some(&rekey.message_encryption_root), + rekey.new_dh_public.as_deref(), + )?; + let send_result = profile + .add_context_text(&raw_message_json, Signer::Keys { keys: signing_keys }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthProfile::add_context_text sign_challenge") + })?; + let send_result = ensure_tx_success(send_result, "sign_challenge add_context")?; + + Ok(ResultOfRequestSignChallenge { + profile_address, + message_id: send_result.message_hash, + nonce: params.nonce, + raw_message_json, + updated_session_state: rekey.updated_state, + sent_at: now_secs()?, + }) + } + + /// Waits for `challenge_response` (`dir = w2c`) from the wallet. + /// + /// Polls the AuthProfile until a `challenge_response` message appears, + /// then returns the nonce, signature, and wallet address for backend + /// verification. + pub async fn wait_challenge_response( + &self, + params: ParamsOfWaitChallengeResponse, + ) -> Result { + self.acquire().await; + if params.session_id.trim().is_empty() { + return Err("wait_challenge_response session_id is empty".to_string().into()); + } + + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let profile_address = root + .get_profile_address( + ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress { + description: params.description.clone(), + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::get_profile_address") + })? + .profile; + + let profile = AuthProfile::new_default(root.context().clone(), &profile_address); + wait_profile_active(&profile, params.max_attempts, params.interval_ms).await?; + + let created_at_from = params + .created_at_from + .unwrap_or_else(|| now_secs().unwrap_or_default().saturating_sub(300)); + + let rl = self.rate_limiter.clone(); + let found = bee_infra::poll_until( + || { + let profile = profile.clone(); + let session_id = params.session_id.clone(); + let session_state = params.session_state.clone(); + let rl = rl.clone(); + async move { + bee_infra::maybe_acquire(rl.as_ref()).await; + find_challenge_response_event( + &profile, + &session_id, + session_state.as_ref(), + created_at_from, + ) + .await + .map_err(|e| e.with_context("Query challenge_response")) + } + }, + |found| found.is_some(), + params.max_attempts, + params.interval_ms, + ) + .await? + .ok_or_else(|| "wait_challenge_response: no challenge_response event found".to_string())?; + + Ok(ResultOfWaitChallengeResponse { + profile_address, + event_id: found.event_id, + event_created_at: found.event_created_at, + nonce: found.nonce, + signature: found.signature, + wallet_address: found.wallet_address, + epk_public: found.epk_public, + raw_message_json: found.raw_message_json, + updated_session_state: found.updated_session_state, + }) + } + + /// Queries active `bee_connect` sessions for a multifactor wallet. + /// + /// The method scans one root-event chunk (`10` records) and returns only + /// profiles that are currently deployed (`is_deployed == true`). + /// + /// Stop condition for active-session scan: + /// - when the scanned chunk contains no deployed connect profiles, or + /// - when there are no more root events. + pub async fn query_active_sessions_by_multifactor( + &self, + params: ParamsOfQueryActiveSessionsByMultifactor, + ) -> Result { + self.acquire().await; + let app_id_filter = normalize_optional_app_id(params.app_id)?; + let root = root_with_endpoints(params.endpoints, self.api_token.clone())?; + let mut sessions = Vec::new(); + let mut seen_session_keys = HashSet::new(); + let mut deployed_cache = HashMap::new(); + let mut has_deployed_connect_profile = false; + let mut next_before = None; + let mut before = params.before.clone(); + let mut cursor_stalled = false; + + while sessions.len() < ACTIVE_SESSIONS_CHUNK_SIZE as usize { + let before_for_query = before.clone(); + let query_result = root + .query_profiles_by_multifactor(ParamsOfQueryProfilesByMultifactor { + multifactor: params.multifactor_address.clone(), + created_at_from: params.created_at_from, + limit: Some(ACTIVE_SESSIONS_CHUNK_SIZE), + before: before_for_query.clone(), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("AuthServiceRoot::query_profiles_by_multifactor") + })?; + let records = &query_result.records; + + if records.is_empty() { + break; + } + + for record in records { + if !is_connect_description(&record.data.description) { + continue; + } + + let parsed = parse_connect_description(&record.data.description); + let is_deployed = match deployed_cache.get(&record.data.profile).copied() { + Some(cached) => cached, + None => { + let profile = + AuthProfile::new_default(root.context().clone(), &record.data.profile); + let value = profile.is_deployed().await; + deployed_cache.insert(record.data.profile.clone(), value); + value + } + }; + if !is_deployed { + continue; + } + + has_deployed_connect_profile = true; + if let Some(filter) = app_id_filter.as_deref() { + if parsed.app_id.as_deref() != Some(filter) { + continue; + } + } + + let session = ActiveConnectSession { + profile_address: record.data.profile.clone(), + description: record.data.description.clone(), + app_id: parsed.app_id, + session_id: parsed.session_id, + deployed_event_id: record.event.id.clone(), + deployed_at: record.event.created_at, + }; + push_unique_active_session(&mut sessions, &mut seen_session_keys, session); + + if sessions.len() >= ACTIVE_SESSIONS_CHUNK_SIZE as usize { + break; + } + } + + let Some(cursor) = query_result.oldest_cursor else { + break; + }; + if before_for_query.as_deref() == Some(cursor.as_str()) { + cursor_stalled = true; + next_before = None; + break; + } + + next_before = Some(cursor.clone()); + before = Some(cursor); + + if records.len() < ACTIVE_SESSIONS_CHUNK_SIZE as usize { + break; + } + } + + Ok(ResultOfQueryActiveSessionsByMultifactor { + sessions, + next_before, + exhausted_active: cursor_stalled || !has_deployed_connect_profile, + }) + } +} + +/// Decodes and validates base64url connect payload (`payload` query value). +pub fn decode_connect_payload_b64url( + payload_b64url: impl AsRef, +) -> Result { + let payload_b64url = payload_b64url.as_ref().trim(); + if payload_b64url.is_empty() { + return Err("connect payload is empty".to_string().into()); + } + + let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64url).map_err(|e| { + crate::errors::AppError::from(e.to_string()) + .with_context("Decode connect payload base64url") + })?; + let payload_json = String::from_utf8(payload_bytes).map_err(|e| { + crate::errors::AppError::from(e.to_string()).with_context("Decode connect payload utf8") + })?; + let mut payload: ConnectPayload = serde_json::from_str(&payload_json).map_err(|e| { + crate::errors::AppError::from(e).with_context("Deserialize connect payload") + })?; + + validate_connect_payload_shape(&payload)?; + payload.app_id = normalize_uint256_hex(&payload.app_id)?; + + let parsed_description = parse_connect_description(&payload.description); + if parsed_description.app_id.as_deref() != Some(payload.app_id.as_str()) { + return Err("connect payload description app_id mismatch".to_string().into()); + } + if parsed_description.session_id.as_deref() != Some(payload.session_id.as_str()) { + return Err("connect payload description session_id mismatch".to_string().into()); + } + + Ok(payload) +} + +struct SessionCommon { + session_id: String, + description: String, + created_at: u64, + expires_at: u64, + app_id: String, + payload_json: String, + payload_b64url: String, +} + +impl ConnectClient { + fn create_session_common( + &self, + app_id: String, + ttl_secs: Option, + nonce: Option, + ) -> Result { + let now_secs = now_secs()?; + let ttl_secs = ttl_secs.unwrap_or(DEFAULT_CONNECT_TTL_SECS).max(1); + let expires_at = now_secs.saturating_add(ttl_secs); + let app_id = normalize_uint256_hex(&app_id)?; + let session_id = random_token_b64url(16)?; + let description = format!( + "{CONNECT_DESCRIPTION_PREFIX}{CONNECT_DESCRIPTION_VERSION}:{app_id}:{session_id}:{}", + random_token_b64url(16)?, + ); + + let payload = ConnectPayload { + v: CONNECT_DEEPLINK_VERSION.to_string(), + session_id: session_id.clone(), + description: description.clone(), + expires_at, + app_id: app_id.clone(), + nonce, + }; + let payload_json = serde_json::to_string(&payload).map_err(|e| { + crate::errors::AppError::from(e).with_context("Serialize connect payload") + })?; + let payload_b64url = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + + Ok(SessionCommon { + session_id, + description, + created_at: now_secs, + expires_at, + app_id, + payload_json, + payload_b64url, + }) + } +} + +#[derive(Debug, Clone)] +struct WalletHelloFound { + event_id: String, + event_created_at: u64, + wallet_name: String, + wallet_address: String, + raw_message_json: String, + session_state: crate::dh::ConnectSessionState, + nonce: Option, + signature: Option, + epk_public: Option, +} + +#[derive(Debug, Clone)] +struct SetMiningKeysFound { + event_id: String, + event_created_at: u64, + app_id: String, + owner_public: String, + raw_message_json: String, + updated_session_state: Option, +} + +#[derive(Debug, Clone)] +struct ChallengeResponseFound { + event_id: String, + event_created_at: u64, + nonce: String, + signature: String, + wallet_address: String, + epk_public: Option, + raw_message_json: String, + updated_session_state: Option, +} + +async fn find_wallet_hello_event( + profile: &AuthProfile, + session_id: &str, + client_dh_secret: &str, + created_at_from: u64, +) -> Result, crate::errors::AppError> { + let result = profile + .query_context_added_events(ParamsOfQueryProfileEvents { + created_at_from: Some(created_at_from), + limit: Some(50), + before: None, + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("AuthProfile::query_context_added_events") + })?; + + let total_events = result.events.len(); + let mut no_dh_public: u32 = 0; + let mut dh_failures: u32 = 0; + let mut decrypt_failures: u32 = 0; + let mut body_parse_failures: u32 = 0; + + for record in result.events { + let raw = record.data.text; + let Ok(envelope) = serde_json::from_str::(&raw) else { + continue; + }; + + if envelope.v != CONNECT_MESSAGE_VERSION + || envelope.session_id != session_id + || envelope.dir != "w2c" + || envelope.seq == 0 + || envelope.msg_type != CONNECT_MESSAGE_TYPE_WALLET_HELLO + { + continue; + } + + // From here on — envelope matched our session's wallet_hello. + // Failures below are real diagnostic signals. + + // Extract wallet DH public key from envelope + let Some(wallet_dh_public) = envelope.dh_public.as_deref() else { + no_dh_public += 1; + continue; + }; + + // Compute DH shared secret and derive session keys + let Ok(shared_secret) = + crate::dh::compute_shared_secret(client_dh_secret, wallet_dh_public) + else { + dh_failures += 1; + continue; + }; + let Ok(session_keys) = crate::dh::derive_session_keys(&shared_secret, session_id) else { + dh_failures += 1; + continue; + }; + + // Decrypt body using derived encryption root + let Ok(body_value) = + decode_connect_message_body(&envelope, Some(&session_keys.encryption_root_hex)) + else { + decrypt_failures += 1; + continue; + }; + let Some(body_value) = body_value else { + decrypt_failures += 1; + continue; + }; + let Ok(body) = serde_json::from_value::(body_value) else { + body_parse_failures += 1; + continue; + }; + + let mut session_state = + crate::dh::create_initial_state(&session_keys, client_dh_secret, wallet_dh_public); + session_state.last_seen_seq = envelope.seq; + + return Ok(Some(WalletHelloFound { + event_id: record.event.id, + event_created_at: record.event.created_at, + wallet_name: body.wallet_name, + wallet_address: body.wallet_address, + raw_message_json: raw, + session_state, + nonce: body.nonce, + signature: body.signature, + epk_public: body.epk_public, + })); + } + + let failed_candidates = no_dh_public + dh_failures + decrypt_failures + body_parse_failures; + if failed_candidates > 0 { + return Err(format!( + "wallet_hello: found {failed_candidates} matching event(s) but none succeeded \ + (total_events={total_events}, \ + no_dh={no_dh_public}, dh_fail={dh_failures}, decrypt_fail={decrypt_failures}, \ + body_parse_fail={body_parse_failures})" + ) + .into()); + } + + Ok(None) +} + +async fn find_set_mining_keys_event( + profile: &AuthProfile, + session_id: &str, + session_state: Option<&crate::dh::ConnectSessionState>, + created_at_from: u64, +) -> Result, crate::errors::AppError> { + if let Some(state) = session_state { + state.ensure_not_expired().map_err(|e| { + crate::errors::AppError::from(e).with_context("find_set_mining_keys_event") + })?; + } + + let result = profile + .query_context_added_events(ParamsOfQueryProfileEvents { + created_at_from: Some(created_at_from), + limit: Some(50), + before: None, + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("AuthProfile::query_context_added_events") + })?; + + let total_events = result.events.len(); + let mut rekey_failures: u32 = 0; + let mut decrypt_failures: u32 = 0; + let mut body_parse_failures: u32 = 0; + let current_state = session_state.cloned(); + + for record in result.events { + let raw = record.data.text; + let Ok(envelope) = serde_json::from_str::(&raw) else { + continue; + }; + + if envelope.v != CONNECT_MESSAGE_VERSION + || envelope.session_id != session_id + || envelope.dir != "c2w" + { + continue; + } + + if envelope.msg_type != CONNECT_MESSAGE_TYPE_SET_MINING_KEYS { + continue; + } + + // Re-key + decrypt atomically: only advance DH state if decryption succeeds. + // This prevents stale c2w events (from prior attempts or other message types) + // from corrupting the DH chain for the current set_mining_keys. + if let (Some(state), Some(peer_dh_pub)) = (¤t_state, envelope.dh_public.as_deref()) { + let rekey = match crate::dh::rekey_inbound(state, peer_dh_pub, session_id, envelope.seq) + { + Ok(r) => r, + Err(_) => { + rekey_failures += 1; + continue; + } + }; + let root = rekey.message_encryption_root.as_str(); + let body_value = match decode_connect_message_body(&envelope, Some(root)) { + Ok(Some(v)) => v, + _ => { + decrypt_failures += 1; + continue; + } + }; + match serde_json::from_value::(body_value) { + Ok(body) => { + let app_id = match normalize_uint256_hex(&body.app_id) { + Ok(v) => v, + Err(_) => { + body_parse_failures += 1; + continue; + } + }; + let owner_public = match normalize_owner_public_hex(&body.owner_public) { + Ok(v) => v, + Err(_) => { + body_parse_failures += 1; + continue; + } + }; + return Ok(Some(SetMiningKeysFound { + event_id: record.event.id, + event_created_at: record.event.created_at, + app_id, + owner_public, + raw_message_json: raw, + updated_session_state: Some(rekey.updated_state), + })); + } + Err(_) => { + body_parse_failures += 1; + continue; + } + } + } + } + + let failed_candidates = rekey_failures + decrypt_failures + body_parse_failures; + if failed_candidates > 0 { + return Err(format!( + "set_mining_keys: found {failed_candidates} matching event(s) but none succeeded \ + (total_events={total_events}, \ + rekey_fail={rekey_failures}, decrypt_fail={decrypt_failures}, \ + body_parse_fail={body_parse_failures})" + ) + .into()); + } + + Ok(None) +} + +/// Scans profile events for a `challenge_response` (w2c) message, maintaining +/// the DH re-key chain for all w2c messages encountered along the way. +async fn find_challenge_response_event( + profile: &AuthProfile, + session_id: &str, + session_state: Option<&crate::dh::ConnectSessionState>, + created_at_from: u64, +) -> Result, crate::errors::AppError> { + if let Some(state) = session_state { + state.ensure_not_expired().map_err(|e| { + crate::errors::AppError::from(e).with_context("find_challenge_response_event") + })?; + } + + let result = profile + .query_context_added_events(ParamsOfQueryProfileEvents { + created_at_from: Some(created_at_from), + limit: Some(50), + before: None, + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("AuthProfile::query_context_added_events") + })?; + + let total_events = result.events.len(); + let mut parse_failures: u32 = 0; + let mut filtered_out: u32 = 0; + let mut rekey_failures: u32 = 0; + let mut decrypt_failures: u32 = 0; + let mut body_parse_failures: u32 = 0; + let current_state = session_state.cloned(); + + for record in result.events { + let raw = record.data.text; + let Ok(envelope) = serde_json::from_str::(&raw) else { + parse_failures += 1; + continue; + }; + + if envelope.v != CONNECT_MESSAGE_VERSION + || envelope.session_id != session_id + || envelope.dir != "w2c" + { + filtered_out += 1; + continue; + } + + // Skip wallet_hello — dApp state from wait_wallet_hello already accounts for + // it. + if envelope.msg_type == CONNECT_MESSAGE_TYPE_WALLET_HELLO { + filtered_out += 1; + continue; + } + + if envelope.msg_type != CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE { + filtered_out += 1; + continue; + } + + // Re-key + decrypt: only advance DH state if decryption succeeds. + // This prevents stale challenge_response events (from prior attempts) + // from corrupting the DH chain for the current response. + if let (Some(state), Some(peer_dh_pub)) = (¤t_state, envelope.dh_public.as_deref()) { + let rekey = match crate::dh::rekey_inbound(state, peer_dh_pub, session_id, envelope.seq) + { + Ok(r) => r, + Err(_) => { + rekey_failures += 1; + continue; + } + }; + let root = rekey.message_encryption_root.as_str(); + let body_value = match decode_connect_message_body(&envelope, Some(root)) { + Ok(Some(v)) => v, + _ => { + decrypt_failures += 1; + continue; + } + }; + match serde_json::from_value::(body_value) { + Ok(body) => { + return Ok(Some(ChallengeResponseFound { + event_id: record.event.id, + event_created_at: record.event.created_at, + nonce: body.nonce, + signature: body.signature, + wallet_address: body.wallet_address, + epk_public: body.epk_public, + raw_message_json: raw, + updated_session_state: Some(rekey.updated_state), + })); + } + Err(_) => { + body_parse_failures += 1; + continue; + } + } + } + } + + let had_candidates = + parse_failures + rekey_failures + decrypt_failures + body_parse_failures > 0; + if had_candidates { + let detail = format!( + "(total_events={total_events}, parse_fail={parse_failures}, filtered={filtered_out}, \ + rekey_fail={rekey_failures}, decrypt_fail={decrypt_failures}, \ + body_parse_fail={body_parse_failures})" + ); + // Undecryptable challenge_response events on-chain mean the DH chains + // diverged (e.g. dApp retried from stale state). Flag this as + // session_desync so callers can drop the session immediately instead + // of polling until timeout. + let prefix = if decrypt_failures > 0 { "session_desync: " } else { "" }; + return Err(format!( + "{prefix}challenge_response: found candidates but none succeeded {detail}" + ) + .into()); + } + + Ok(None) +} + +async fn wait_profile_active( + profile: &AuthProfile, + max_attempts: Option, + interval_ms: Option, +) -> Result<(), crate::errors::AppError> { + let attempts = max_attempts.unwrap_or(100).min(u8::MAX as u32) as u8; + let attempts_timeout = interval_ms.unwrap_or(500); + + profile + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::Active, + attempts: Some(attempts), + attempts_timeout: Some(attempts_timeout), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("AuthProfile::wait_account Active") + })?; + + Ok(()) +} + +fn root_with_endpoints( + endpoints: Vec, + api_token: Option, +) -> Result { + let mut cfg = ClientConfig::default(); + cfg.network.endpoints = Some(endpoints); + // Disable tvm_client's internal `query_graphql` re-connect loop — + // it spins without sleep on multi-endpoint setups and turns a + // single 502 into a sustained ~60 rps storm against the same BM. + // We do bounded, classified retries one layer up via + // `bee_infra::retry::with_retry_policy`. + cfg.network.max_reconnect_timeout = 0; + // Authenticate against BM when the caller supplied a token. `None` keeps + // the request anonymous (wasm/browser default). Every ConnectClient method + // funnels through here, so this is the single point that gates auth. + cfg.network.api_token = api_token; + let context = ClientContext::new(cfg) + .map_err(|e| crate::errors::AppError::from(e).with_context("Create tvm client context"))?; + let context = Arc::new(context); + Ok(AuthServiceRoot::new_default(context)) +} + +fn encode_connect_message( + session_id: &str, + dir: &str, + seq: u64, + msg_type: &str, + body: serde_json::Value, + encryption_secret: Option<&str>, + dh_public: Option<&str>, +) -> Result { + if session_id.trim().is_empty() { + return Err("connect message session_id is empty".to_string().into()); + } + if dir.trim().is_empty() { + return Err("connect message dir is empty".to_string().into()); + } + if msg_type.trim().is_empty() { + return Err("connect message type is empty".to_string().into()); + } + + let ts = now_secs()?; + let aad = connect_message_aad(session_id, dir, seq, msg_type, ts)?; + + let encryption_secret = encryption_secret.ok_or_else(|| { + format!("connect message `{msg_type}` requires session owner secret for encryption") + })?; + let encrypted = encrypt_connect_body(&body, encryption_secret, &aad)?; + let mut envelope = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": session_id, + "dir": dir, + "seq": seq, + "type": msg_type, + "ts": ts, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": encrypted.nonce_b64url, + "salt": encrypted.salt_b64url, + }, + "body": encrypted.ciphertext_b64url, + }); + if let Some(dh_pub) = dh_public { + envelope["dh_public"] = serde_json::Value::String(dh_pub.to_string()); + } + + serde_json::to_string(&envelope) + .map_err(|e| crate::errors::AppError::from(e).with_context("Serialize connect message")) +} + +fn decode_connect_message_body( + envelope: &ConnectMessageEnvelope, + encryption_secret: Option<&str>, +) -> Result, crate::errors::AppError> { + let alg = envelope.enc.as_ref().map(|v| v.alg.as_str()).unwrap_or(CONNECT_MESSAGE_ENC_NONE); + if alg == CONNECT_MESSAGE_ENC_NONE { + return Err("Unencrypted connect messages are no longer accepted".to_string().into()); + } + if alg != CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256 { + return Ok(None); + } + + let secret = encryption_secret.map(str::trim).filter(|v| !v.is_empty()); + let Some(secret) = secret else { + return Ok(None); + }; + + let enc = envelope + .enc + .as_ref() + .ok_or_else(|| "Encrypted connect message misses `enc`".to_string())?; + let nonce_b64url = + enc.nonce.as_deref().ok_or_else(|| "Encrypted connect message misses nonce".to_string())?; + let salt_b64url = + enc.salt.as_deref().ok_or_else(|| "Encrypted connect message misses salt".to_string())?; + let ciphertext_b64url = envelope + .body + .as_str() + .ok_or_else(|| "Encrypted connect message body must be base64url string".to_string())?; + let ts = envelope.ts.ok_or_else(|| "Encrypted connect message misses ts".to_string())?; + let aad = connect_message_aad( + &envelope.session_id, + &envelope.dir, + envelope.seq, + &envelope.msg_type, + ts, + )?; + let plaintext = + decrypt_connect_body(ciphertext_b64url, nonce_b64url, salt_b64url, secret, &aad)?; + let body = serde_json::from_slice::(&plaintext).map_err(|e| { + crate::errors::AppError::from(e).with_context("Deserialize decrypted connect body") + })?; + Ok(Some(body)) +} + +fn ensure_tx_success( + result: ResultOfSendMessage, + context: &str, +) -> Result { + let aborted = result.aborted.unwrap_or(false); + let exit_code = result.exit_code.unwrap_or(0); + if aborted || exit_code > 0 { + return Err(format!("{context}: tx aborted={aborted}, exit_code={exit_code}").into()); + } + Ok(result) +} + +fn validate_connect_payload_shape(payload: &ConnectPayload) -> Result<(), crate::errors::AppError> { + if payload.v != CONNECT_DEEPLINK_VERSION { + return Err(format!( + "Unsupported connect deeplink version `{}` (expected `{}`)", + payload.v, CONNECT_DEEPLINK_VERSION + ) + .into()); + } + if payload.session_id.trim().is_empty() { + return Err("connect payload session_id is empty".to_string().into()); + } + if payload.description.trim().is_empty() { + return Err("connect payload description is empty".to_string().into()); + } + if payload.expires_at == 0 { + return Err("connect payload expires_at must be > 0".to_string().into()); + } + normalize_uint256_hex(&payload.app_id)?; + Ok(()) +} + +fn normalize_optional_app_id( + value: Option, +) -> Result, crate::errors::AppError> { + let Some(value) = value else { + return Ok(None); + }; + + if value.trim().is_empty() { + return Ok(None); + } + + Ok(Some(normalize_uint256_hex(&value)?)) +} + +fn is_connect_description(value: &str) -> bool { + value.starts_with(CONNECT_DESCRIPTION_PREFIX) +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ParsedConnectDescription { + app_id: Option, + session_id: Option, +} + +fn parse_connect_description(value: &str) -> ParsedConnectDescription { + let Some(tail) = value.strip_prefix(CONNECT_DESCRIPTION_PREFIX) else { + return ParsedConnectDescription::default(); + }; + + let mut parts = tail.split(':'); + let Some(version) = parts.next() else { + return ParsedConnectDescription::default(); + }; + if version != CONNECT_DESCRIPTION_VERSION { + return ParsedConnectDescription::default(); + } + + let Some(raw_app_id) = parts.next() else { + return ParsedConnectDescription::default(); + }; + let app_id = normalize_uint256_hex(raw_app_id).ok(); + if app_id.is_none() { + return ParsedConnectDescription::default(); + } + + let Some(raw_session_id) = parts.next() else { + return ParsedConnectDescription::default(); + }; + if raw_session_id.is_empty() { + return ParsedConnectDescription::default(); + } + + ParsedConnectDescription { app_id, session_id: Some(raw_session_id.to_string()) } +} + +fn active_session_uniqueness_key(value: &ActiveConnectSession) -> String { + let session_part = value + .session_id + .as_deref() + .filter(|v| !v.trim().is_empty()) + .unwrap_or(value.description.as_str()); + format!("{}::{session_part}", value.profile_address.to_ascii_lowercase()) +} + +fn push_unique_active_session( + sessions: &mut Vec, + seen_keys: &mut HashSet, + session: ActiveConnectSession, +) { + let key = active_session_uniqueness_key(&session); + if seen_keys.insert(key) { + sessions.push(session); + } +} + +fn random_token_b64url(num_bytes: usize) -> Result { + let mut bytes = vec![0u8; num_bytes]; + getrandom::fill(&mut bytes).map_err(|e| { + crate::errors::AppError::from(e.to_string()).with_context("Generate random bytes") + })?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +fn now_secs() -> Result { + let ms = js_sys::Date::now(); + if !ms.is_finite() || ms < 0.0 { + return Err(format!("Invalid JS timestamp: {ms}").into()); + } + Ok((ms / 1000.0).floor() as u64) +} + +#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] +fn now_secs() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| { + crate::errors::AppError::from(e.to_string()) + .with_context("SystemTime before UNIX_EPOCH") + })? + .as_secs()) +} + +#[allow(dead_code)] +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +fn now_millis() -> Result { + let ms = js_sys::Date::now(); + if !ms.is_finite() || ms < 0.0 { + return Err(format!("Invalid JS timestamp: {ms}").into()); + } + Ok(ms.floor() as u64) +} + +#[allow(dead_code)] +#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] +fn now_millis() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| { + crate::errors::AppError::from(e.to_string()) + .with_context("SystemTime before UNIX_EPOCH") + })? + .as_millis() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_uint256_hex_pads_and_lowercases() { + let got = normalize_uint256_hex("0xAbC").expect("normalize"); + assert_eq!(got.len(), 66); + assert!(got.starts_with("0x")); + assert!(got.ends_with("abc")); + } + + #[test] + fn create_shared_key_session_uses_dh() { + let client = ConnectClient::new(); + let result = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x1".to_string(), + ttl_secs: Some(300), + nonce: None, + }) + .expect("create session"); + + // DH public key in result + assert_eq!(hex::decode(&result.client_dh_public).unwrap().len(), 32); + assert_eq!(hex::decode(&result.client_dh_secret).unwrap().len(), 32); + + // URL contains only public key, no secret + assert!(result.deep_link.contains("client_dh_public=")); + assert!(!result.deep_link.contains("session_owner_secret=")); + assert!(!result.deep_link.contains("session_owner_public=")); + assert!(!result.deep_link.contains(&result.client_dh_secret)); + + // Public key in URL matches result + assert!(result.deep_link.contains(&result.client_dh_public)); + } + + #[test] + fn decode_connect_payload_b64url_roundtrip() { + let client = ConnectClient::new(); + let session = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x2".to_string(), + ttl_secs: Some(60), + nonce: None, + }) + .expect("session"); + + let parsed = + decode_connect_payload_b64url(&session.payload_b64url).expect("decode connect payload"); + assert_eq!(parsed.session_id, session.session_id); + assert_eq!(parsed.description, session.description); + assert_eq!(parsed.app_id, session.app_id); + assert_eq!(parsed.expires_at, session.expires_at); + } + + #[test] + fn decode_connect_payload_b64url_rejects_invalid_version() { + let json = serde_json::json!({ + "v": "bee_connect.dl/999", + "session_id": "sess", + "description": "bee_connect:v1:0x0000000000000000000000000000000000000000000000000000000000000001:sess:r", + "expires_at": 1u64, + "app_id": "0x1" + }) + .to_string(); + let b64 = URL_SAFE_NO_PAD.encode(json.as_bytes()); + + let err = decode_connect_payload_b64url(b64).expect_err("must fail"); + assert!(err.message.contains("Unsupported connect deeplink version")); + } + + #[test] + fn push_unique_active_session_dedupes_same_profile_and_session() { + let mut sessions = Vec::new(); + let mut seen = HashSet::new(); + + let first = ActiveConnectSession { + profile_address: "0:abc".to_string(), + description: "bee_connect:v1:0x1:sess_1:r1".to_string(), + app_id: Some("0x1".to_string()), + session_id: Some("sess_1".to_string()), + deployed_event_id: "evt_1".to_string(), + deployed_at: 1, + }; + let duplicate = ActiveConnectSession { + profile_address: "0:AbC".to_string(), + description: "bee_connect:v1:0x1:sess_1:r1".to_string(), + app_id: Some("0x1".to_string()), + session_id: Some("sess_1".to_string()), + deployed_event_id: "evt_2".to_string(), + deployed_at: 2, + }; + + push_unique_active_session(&mut sessions, &mut seen, first); + push_unique_active_session(&mut sessions, &mut seen, duplicate); + assert_eq!(sessions.len(), 1); + } + + #[test] + fn push_unique_active_session_keeps_distinct_sessions_same_profile() { + let mut sessions = Vec::new(); + let mut seen = HashSet::new(); + + let first = ActiveConnectSession { + profile_address: "0:abc".to_string(), + description: "bee_connect:v1:0x1:sess_1:r1".to_string(), + app_id: Some("0x1".to_string()), + session_id: Some("sess_1".to_string()), + deployed_event_id: "evt_1".to_string(), + deployed_at: 1, + }; + let second = ActiveConnectSession { + profile_address: "0:abc".to_string(), + description: "bee_connect:v1:0x1:sess_2:r2".to_string(), + app_id: Some("0x1".to_string()), + session_id: Some("sess_2".to_string()), + deployed_event_id: "evt_2".to_string(), + deployed_at: 2, + }; + + push_unique_active_session(&mut sessions, &mut seen, first); + push_unique_active_session(&mut sessions, &mut seen, second); + assert_eq!(sessions.len(), 2); + } + + #[test] + fn parse_connect_description_v1() { + let parsed = parse_connect_description( + "bee_connect:v1:0x00000000000000000000000000000000000000000000000000000000000000ab:sess_abc:rand_x", + ); + assert_eq!( + parsed.app_id.as_deref(), + Some("0x00000000000000000000000000000000000000000000000000000000000000ab") + ); + assert_eq!(parsed.session_id.as_deref(), Some("sess_abc")); + } + + #[test] + fn parse_connect_description_returns_unknown_for_invalid_input() { + assert_eq!( + parse_connect_description("bee_connect:v1:bad_hex:sess_abc:rand_x"), + ParsedConnectDescription::default() + ); + assert_eq!( + parse_connect_description("bee_connect:sess_abc:rand_x"), + ParsedConnectDescription::default() + ); + } + + #[test] + fn normalize_optional_app_id_empty_is_none() { + assert_eq!(normalize_optional_app_id(None).unwrap(), None); + assert_eq!(normalize_optional_app_id(Some("".to_string())).unwrap(), None); + } + + #[test] + fn normalize_owner_public_hex_strips_prefix_and_lowercases() { + let got = normalize_owner_public_hex( + "0xAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("normalize"); + assert_eq!(got.len(), 64); + assert_eq!(got, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + #[test] + fn encode_connect_message_builds_client_disconnect() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT, + serde_json::json!({ "reason": "user_action" }), + Some(secret), + None, + ) + .expect("encode"); + assert!(raw.contains("\"type\":\"client_disconnect\"")); + assert!(raw.contains("\"dir\":\"c2w\"")); + assert!(raw.contains("\"alg\":\"xchacha20poly1305-hkdf-sha256\"")); + assert!(!raw.contains("\"reason\":\"user_action\"")); // body is encrypted + } + + #[test] + fn encrypted_client_disconnect_roundtrip() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT, + serde_json::json!({ "reason": "user_left" }), + Some(secret), + None, + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + let decoded = + decode_connect_message_body(&envelope, Some(secret)).expect("decode").expect("body"); + assert_eq!(decoded["reason"], "user_left"); + } + + #[test] + fn encode_connect_message_builds_set_mining_keys() { + let body = serde_json::to_value(SetMiningKeysBody { + app_id: "0x1".to_string(), + owner_public: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }) + .expect("body"); + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + body, + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + None, + ) + .expect("encode"); + assert!(raw.contains("\"type\":\"set_mining_keys\"")); + assert!(raw.contains("\"alg\":\"xchacha20poly1305-hkdf-sha256\"")); + assert!(!raw.contains("\"app_id\":\"0x1\"")); + } + + #[test] + fn encrypted_set_mining_keys_roundtrip() { + let body = serde_json::to_value(SetMiningKeysBody { + app_id: "0x1".to_string(), + owner_public: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }) + .expect("body"); + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + body, + Some(secret), + None, + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + let decoded = + decode_connect_message_body(&envelope, Some(secret)).expect("decode").expect("body"); + let parsed: SetMiningKeysBody = serde_json::from_value(decoded).expect("parsed"); + assert_eq!(parsed.app_id, "0x1"); + assert_eq!( + parsed.owner_public, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + + #[test] + fn encrypted_set_mining_keys_without_secret_is_not_decodable() { + let body = serde_json::to_value(SetMiningKeysBody { + app_id: "0x1".to_string(), + owner_public: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }) + .expect("body"); + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + body, + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + None, + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + assert_eq!(decode_connect_message_body(&envelope, None).expect("decode"), None); + } + + #[test] + fn encode_connect_message_includes_dh_public() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let dh_pub = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT, + serde_json::json!({ "reason": "test" }), + Some(secret), + Some(dh_pub), + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + assert_eq!(envelope.dh_public.as_deref(), Some(dh_pub)); + } + + #[test] + fn encode_connect_message_omits_dh_public_when_none() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT, + serde_json::json!({}), + Some(secret), + None, + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + assert!(envelope.dh_public.is_none()); + } + + #[test] + fn encrypted_message_with_dh_public_roundtrip() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let dh_pub = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + serde_json::json!({ "app_id": "0x1", "owner_public": "aa".repeat(32) }), + Some(secret), + Some(dh_pub), + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + assert_eq!(envelope.dh_public.as_deref(), Some(dh_pub)); + let decoded = + decode_connect_message_body(&envelope, Some(secret)).expect("decode").expect("body"); + assert_eq!(decoded["app_id"], "0x1"); + } + + #[test] + fn rekey_inbound_decrypts_rekeyed_message() { + // Setup: Alice (client) and Bob (wallet) establish session + let alice = crate::dh::generate_dh_keypair().unwrap(); + let bob = crate::dh::generate_dh_keypair().unwrap(); + let shared = crate::dh::compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = crate::dh::derive_session_keys(&shared, "sess_1").unwrap(); + + let alice_state = + crate::dh::create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + let bob_state = crate::dh::create_initial_state(&keys, &bob.secret_hex, &alice.public_hex); + + // Alice sends set_mining_keys with rekey_outbound + let rekey = crate::dh::rekey_outbound(&alice_state, "sess_1", 1_700_000_000_000).unwrap(); + let raw = encode_connect_message( + "sess_1", + "c2w", + rekey.outbound_seq, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + serde_json::json!({ "app_id": "0x1", "owner_public": "aa".repeat(32) }), + Some(&rekey.message_encryption_root), + rekey.new_dh_public.as_deref(), + ) + .unwrap(); + + // Bob receives: rekey_inbound with Alice's new dh_public + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).unwrap(); + let peer_dh_pub = envelope.dh_public.as_deref().unwrap(); + let bob_rekey = + crate::dh::rekey_inbound(&bob_state, peer_dh_pub, "sess_1", envelope.seq).unwrap(); + + // Bob decrypts with re-keyed root + let decoded = + decode_connect_message_body(&envelope, Some(&bob_rekey.message_encryption_root)) + .unwrap() + .unwrap(); + assert_eq!(decoded["app_id"], "0x1"); + } + + #[test] + fn rekey_chain_two_messages_decrypt_second() { + let alice = crate::dh::generate_dh_keypair().unwrap(); + let bob = crate::dh::generate_dh_keypair().unwrap(); + let shared = crate::dh::compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = crate::dh::derive_session_keys(&shared, "sess_1").unwrap(); + + let mut alice_state = + crate::dh::create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + let mut bob_state = + crate::dh::create_initial_state(&keys, &bob.secret_hex, &alice.public_hex); + + // Message 1: Alice sends client_disconnect + let rekey1 = crate::dh::rekey_outbound(&alice_state, "sess_1", 1_700_000_000_000).unwrap(); + let raw1 = encode_connect_message( + "sess_1", + "c2w", + rekey1.outbound_seq, + CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT, + serde_json::json!({ "reason": "test" }), + Some(&rekey1.message_encryption_root), + rekey1.new_dh_public.as_deref(), + ) + .unwrap(); + alice_state = rekey1.updated_state; + + // Message 2: Alice sends set_mining_keys + let rekey2 = crate::dh::rekey_outbound(&alice_state, "sess_1", 1_700_000_000_001).unwrap(); + let raw2 = encode_connect_message( + "sess_1", + "c2w", + rekey2.outbound_seq, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + serde_json::json!({ "app_id": "0x2", "owner_public": "bb".repeat(32) }), + Some(&rekey2.message_encryption_root), + rekey2.new_dh_public.as_deref(), + ) + .unwrap(); + + // Bob processes both messages in order (simulates find_set_mining_keys_event) + let env1: ConnectMessageEnvelope = serde_json::from_str(&raw1).unwrap(); + let bob_rekey1 = crate::dh::rekey_inbound( + &bob_state, + env1.dh_public.as_deref().unwrap(), + "sess_1", + env1.seq, + ) + .unwrap(); + bob_state = bob_rekey1.updated_state; + + let env2: ConnectMessageEnvelope = serde_json::from_str(&raw2).unwrap(); + let bob_rekey2 = crate::dh::rekey_inbound( + &bob_state, + env2.dh_public.as_deref().unwrap(), + "sess_1", + env2.seq, + ) + .unwrap(); + + // Bob decrypts message 2 with re-keyed root + let decoded = decode_connect_message_body(&env2, Some(&bob_rekey2.message_encryption_root)) + .unwrap() + .unwrap(); + assert_eq!(decoded["app_id"], "0x2"); + + // Without re-key chain, decryption with initial root fails + let initial_bob = + crate::dh::create_initial_state(&keys, &bob.secret_hex, &alice.public_hex); + let result = decode_connect_message_body(&env2, Some(&initial_bob.encryption_root)); + assert!(result.is_err() || result.unwrap().is_none()); + } + + // ── Tests: sign_challenge / challenge_response ────────────────── + + #[test] + fn encode_sign_challenge_message() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let body = + serde_json::to_value(SignChallengeBody { nonce: "deadbeef".to_string() }).unwrap(); + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE, + body, + Some(secret), + None, + ) + .expect("encode"); + assert!(raw.contains("\"type\":\"sign_challenge\"")); + assert!(raw.contains("\"dir\":\"c2w\"")); + // nonce is encrypted, not visible in envelope + assert!(!raw.contains("\"deadbeef\"")); + } + + #[test] + fn encrypted_sign_challenge_roundtrip() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let body = + serde_json::to_value(SignChallengeBody { nonce: "cafebabe".to_string() }).unwrap(); + let raw = encode_connect_message( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE, + body, + Some(secret), + None, + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + let decoded = + decode_connect_message_body(&envelope, Some(secret)).expect("decode").expect("body"); + let parsed: SignChallengeBody = serde_json::from_value(decoded).expect("parsed"); + assert_eq!(parsed.nonce, "cafebabe"); + } + + #[test] + fn encode_challenge_response_message() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let body = serde_json::to_value(ChallengeResponseBody { + nonce: "cafebabe".to_string(), + signature: "aabb".repeat(32), + wallet_address: "0:1234".to_string(), + epk_public: None, + }) + .unwrap(); + let raw = encode_connect_message( + "sess_1", + "w2c", + 1_700_000_000_001u64, + CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, + body, + Some(secret), + None, + ) + .expect("encode"); + assert!(raw.contains("\"type\":\"challenge_response\"")); + assert!(raw.contains("\"dir\":\"w2c\"")); + } + + #[test] + fn encrypted_challenge_response_roundtrip() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let sig = "cc".repeat(64); + let body = serde_json::to_value(ChallengeResponseBody { + nonce: "deadbeef".to_string(), + signature: sig.clone(), + wallet_address: "0:abcd".to_string(), + epk_public: None, + }) + .unwrap(); + let raw = encode_connect_message( + "sess_1", + "w2c", + 1_700_000_000_001u64, + CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, + body, + Some(secret), + None, + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&raw).expect("envelope"); + let decoded = + decode_connect_message_body(&envelope, Some(secret)).expect("decode").expect("body"); + let parsed: ChallengeResponseBody = serde_json::from_value(decoded).expect("parsed"); + assert_eq!(parsed.nonce, "deadbeef"); + assert_eq!(parsed.signature, sig); + assert_eq!(parsed.wallet_address, "0:abcd"); + } + + // ── Regression: stale challenge_response must not corrupt DH chain ── + + /// Verifies that a `challenge_response` encrypted with a wrong DH key does + /// not prevent decryption of the correct one that follows it. + /// + /// This tests the core invariant of the atomic rekey+decrypt pattern: + /// if rekey_inbound succeeds but decryption fails (wrong key), the DH state + /// must NOT be advanced, so the next event is tried from the original + /// state. + #[test] + fn wrong_key_challenge_response_does_not_corrupt_dh_chain() { + let session_id = "sess_stale_test"; + + // ── Setup: initial DH handshake ── + let alice = crate::dh::generate_dh_keypair().unwrap(); + let bob = crate::dh::generate_dh_keypair().unwrap(); + let shared = crate::dh::compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = crate::dh::derive_session_keys(&shared, session_id).unwrap(); + let dapp_state = crate::dh::create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + let wallet_state = + crate::dh::create_initial_state(&keys, &bob.secret_hex, &alice.public_hex); + + // ── dApp sends sign_challenge, wallet responds correctly ── + let dapp_rekey = + crate::dh::rekey_outbound(&dapp_state, session_id, 1_700_000_000_000).unwrap(); + let dapp_after = dapp_rekey.updated_state; + + let wallet_inbound = crate::dh::rekey_inbound( + &wallet_state, + dapp_rekey.new_dh_public.as_deref().unwrap(), + session_id, + dapp_rekey.outbound_seq, + ) + .unwrap(); + let wallet_outbound = + crate::dh::rekey_outbound(&wallet_inbound.updated_state, session_id, 1_700_000_000_001) + .unwrap(); + + let correct_sig = "aa".repeat(64); + let correct_body = serde_json::to_value(ChallengeResponseBody { + nonce: "correct_nonce".to_string(), + signature: correct_sig.clone(), + wallet_address: "0:good".to_string(), + epk_public: Some("dd".repeat(32)), + }) + .unwrap(); + let correct_response = encode_connect_message( + session_id, + "w2c", + 1_700_000_000_001u64, + CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, + correct_body, + Some(&wallet_outbound.message_encryption_root), + wallet_outbound.new_dh_public.as_deref(), + ) + .unwrap(); + + // ── Craft a bogus challenge_response encrypted with a random key ── + let rogue = crate::dh::generate_dh_keypair().unwrap(); + let bogus_root = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let bogus_body = serde_json::to_value(ChallengeResponseBody { + nonce: "bogus".to_string(), + signature: "bb".repeat(64), + wallet_address: "0:evil".to_string(), + epk_public: None, + }) + .unwrap(); + let bogus_response = encode_connect_message( + session_id, + "w2c", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, + bogus_body, + Some(bogus_root), + Some(&rogue.public_hex), + ) + .unwrap(); + + // ── Simulate event scanning: bogus first, then correct ── + let events = [&bogus_response, &correct_response]; + let state_for_scan = dapp_after; + + let mut found = None; + for raw in &events { + let envelope: ConnectMessageEnvelope = serde_json::from_str(raw).unwrap(); + if envelope.msg_type != CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE { + continue; + } + let Some(peer_dh_pub) = envelope.dh_public.as_deref() else { + continue; + }; + // Atomic rekey+decrypt — do NOT update state_for_scan on failure + let Ok(rekey) = + crate::dh::rekey_inbound(&state_for_scan, peer_dh_pub, session_id, envelope.seq) + else { + continue; + }; + let root = rekey.message_encryption_root.as_str(); + let Ok(Some(body_value)) = decode_connect_message_body(&envelope, Some(root)) else { + continue; + }; + let Ok(body) = serde_json::from_value::(body_value) else { + continue; + }; + found = Some(body); + break; + } + + let body = found + .expect("Must find the correct challenge_response even when a bogus one precedes it"); + assert_eq!(body.nonce, "correct_nonce"); + assert_eq!(body.signature, correct_sig); + assert_eq!(body.wallet_address, "0:good"); + } + + /// Verifies that wallet_hello w2c events preceding challenge_response are + /// correctly skipped and do not interfere with DH re-key. + #[test] + fn wallet_hello_does_not_break_challenge_response_rekey() { + let session_id = "sess_wh_skip"; + + let alice = crate::dh::generate_dh_keypair().unwrap(); + let bob = crate::dh::generate_dh_keypair().unwrap(); + let shared = crate::dh::compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = crate::dh::derive_session_keys(&shared, session_id).unwrap(); + let dapp_state = crate::dh::create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + let wallet_state = + crate::dh::create_initial_state(&keys, &bob.secret_hex, &alice.public_hex); + + // Wallet sends wallet_hello (w2c) — uses initial encryption root, includes + // dh_public + let wallet_hello_json = encode_connect_message( + session_id, + "w2c", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_WALLET_HELLO, + serde_json::json!({ "wallet_name": "test", "wallet_address": "0:abc" }), + Some(&keys.encryption_root_hex), + Some(&bob.public_hex), + ) + .unwrap(); + + // dApp sends sign_challenge (c2w) with rekey_outbound + let dapp_rekey = + crate::dh::rekey_outbound(&dapp_state, session_id, 1_700_000_000_001).unwrap(); + let dapp_after = dapp_rekey.updated_state; + + // Wallet receives sign_challenge, rekeys inbound, then sends challenge_response + let wallet_inbound = crate::dh::rekey_inbound( + &wallet_state, + dapp_rekey.new_dh_public.as_deref().unwrap(), + session_id, + dapp_rekey.outbound_seq, + ) + .unwrap(); + let wallet_outbound = + crate::dh::rekey_outbound(&wallet_inbound.updated_state, session_id, 1_700_000_000_002) + .unwrap(); + + let response_body = serde_json::to_value(ChallengeResponseBody { + nonce: "test_nonce".to_string(), + signature: "bb".repeat(64), + wallet_address: "0:abc".to_string(), + epk_public: Some("ee".repeat(32)), + }) + .unwrap(); + let challenge_response_json = encode_connect_message( + session_id, + "w2c", + 1_700_000_000_001u64, + CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, + response_body, + Some(&wallet_outbound.message_encryption_root), + wallet_outbound.new_dh_public.as_deref(), + ) + .unwrap(); + + // Simulate event scanning: wallet_hello first, then challenge_response + let events_json = [&wallet_hello_json, &challenge_response_json]; + let state_for_scan = dapp_after; + + let mut found = None; + for raw in &events_json { + let envelope: ConnectMessageEnvelope = serde_json::from_str(raw).unwrap(); + if envelope.msg_type == CONNECT_MESSAGE_TYPE_WALLET_HELLO { + continue; // Must skip wallet_hello + } + if envelope.msg_type != CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE { + continue; + } + let peer_dh_pub = envelope.dh_public.as_deref().unwrap(); + let Ok(rekey) = + crate::dh::rekey_inbound(&state_for_scan, peer_dh_pub, session_id, envelope.seq) + else { + continue; + }; + let root = rekey.message_encryption_root.as_str(); + let Ok(Some(body_value)) = decode_connect_message_body(&envelope, Some(root)) else { + continue; + }; + if let Ok(body) = serde_json::from_value::(body_value) { + found = Some(body); + break; + } + } + + let body = + found.expect("challenge_response must decrypt correctly when wallet_hello is skipped"); + assert_eq!(body.nonce, "test_nonce"); + + // Also verify: if wallet_hello is NOT skipped and we naively rekey for it, + // decryption of challenge_response FAILS — proving the skip is necessary. + let mut corrupted_state = state_for_scan.clone(); + let wh_envelope: ConnectMessageEnvelope = serde_json::from_str(&wallet_hello_json).unwrap(); + if let Some(wh_dh_pub) = wh_envelope.dh_public.as_deref() { + if let Ok(bad_rekey) = + crate::dh::rekey_inbound(&corrupted_state, wh_dh_pub, session_id, wh_envelope.seq) + { + corrupted_state = bad_rekey.updated_state; + } + } + // Now try to decrypt challenge_response with corrupted state + let cr_envelope: ConnectMessageEnvelope = + serde_json::from_str(&challenge_response_json).unwrap(); + let cr_dh_pub = cr_envelope.dh_public.as_deref().unwrap(); + let rekey_result = + crate::dh::rekey_inbound(&corrupted_state, cr_dh_pub, session_id, cr_envelope.seq); + if let Ok(rekey) = rekey_result { + let decrypt_result = + decode_connect_message_body(&cr_envelope, Some(&rekey.message_encryption_root)); + assert!( + decrypt_result.is_err() || decrypt_result.unwrap().is_none(), + "Decryption must FAIL when wallet_hello rekey corrupts the DH chain" + ); + } + // If rekey itself failed, that's also acceptable — the chain is broken + } + + // ── Inline challenge (nonce in deeplink) tests ── + + #[test] + fn create_session_with_nonce_includes_nonce_in_payload() { + let client = ConnectClient::new(); + let nonce = "aabbccdd11223344"; + let result = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x1".to_string(), + ttl_secs: Some(300), + nonce: Some(nonce.to_string()), + }) + .expect("create session"); + + // Nonce must appear in payload JSON + assert!(result.payload_json.contains(nonce), "payload_json should contain nonce"); + + // Roundtrip through base64url + let parsed = decode_connect_payload_b64url(&result.payload_b64url).expect("decode payload"); + assert_eq!(parsed.nonce.as_deref(), Some(nonce)); + } + + #[test] + fn create_session_without_nonce_omits_nonce_from_payload() { + let client = ConnectClient::new(); + let result = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x1".to_string(), + ttl_secs: Some(300), + nonce: None, + }) + .expect("create session"); + + // Nonce field should be absent (skip_serializing_if) + assert!( + !result.payload_json.contains("nonce"), + "payload_json should not contain nonce key" + ); + + let parsed = decode_connect_payload_b64url(&result.payload_b64url).expect("decode payload"); + assert_eq!(parsed.nonce, None); + } + + #[test] + fn decode_old_payload_without_nonce_returns_none() { + // Simulates a payload from old SDK (no nonce field) + let json = serde_json::json!({ + "v": CONNECT_DEEPLINK_VERSION, + "session_id": "sess_old", + "description": "bee_connect:v1:0x0000000000000000000000000000000000000000000000000000000000000001:sess_old:r", + "expires_at": 9999999999u64, + "app_id": "0x0000000000000000000000000000000000000000000000000000000000000001" + }) + .to_string(); + let b64 = URL_SAFE_NO_PAD.encode(json.as_bytes()); + let parsed = decode_connect_payload_b64url(b64).expect("decode"); + assert_eq!(parsed.nonce, None, "old payload without nonce should parse as None"); + } + + #[test] + fn wallet_hello_body_with_challenge_deserializes() { + let body = serde_json::json!({ + "wallet_name": "TestWallet", + "wallet_address": "0:abc", + "nonce": "deadbeef", + "signature": "cafebabe", + "epk_public": "1234abcd" + }); + let parsed: WalletHelloBody = serde_json::from_value(body).expect("deserialize"); + assert_eq!(parsed.wallet_name, "TestWallet"); + assert_eq!(parsed.nonce.as_deref(), Some("deadbeef")); + assert_eq!(parsed.signature.as_deref(), Some("cafebabe")); + assert_eq!(parsed.epk_public.as_deref(), Some("1234abcd")); + } + + #[test] + fn wallet_hello_body_without_challenge_deserializes() { + let body = serde_json::json!({ + "wallet_name": "OldWallet", + "wallet_address": "0:def" + }); + let parsed: WalletHelloBody = serde_json::from_value(body).expect("deserialize"); + assert_eq!(parsed.wallet_name, "OldWallet"); + assert_eq!(parsed.nonce, None); + assert_eq!(parsed.signature, None); + assert_eq!(parsed.epk_public, None); + } +} diff --git a/bee_connect/src/dex_protocol.rs b/bee_connect/src/dex_protocol.rs new file mode 100644 index 0000000..aabc630 --- /dev/null +++ b/bee_connect/src/dex_protocol.rs @@ -0,0 +1,308 @@ +//! DEX operations over bee_connect — typed request/response structs. +//! +//! Implements the message protocol from `dex_connect_protocol.md v1`. +//! All u128/u64 values are serialized as decimal strings per §6.3. + +use serde::Deserialize; +use serde::Serialize; + +// ── Common envelope ────────────────────────────────────────────── + +pub const PROTOCOL_VERSION: &str = "1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DexRequestEnvelope { + pub protocol_version: String, + pub chain_id: String, + pub kind: String, + pub request_id: String, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub valid_until: Option, + pub params: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DexResponseEnvelope { + pub protocol_version: String, + pub kind: String, + pub request_id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DexErrorPayload { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DexRequestAck { + pub protocol_version: String, + pub kind: String, // "dex_request_received" + pub request_id: String, + pub ack: bool, +} + +// ── Labels (informational, from site) ──────────────────────────── + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DexLabels { + #[serde(default)] + pub event: Option, + #[serde(default)] + pub outcomes: Option>, + #[serde(default)] + pub outcome: Option, + #[serde(default)] + pub site_description: Option, +} + +// ── deploy_pmp ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeployPmpParams { + pub event_id: String, + pub oracle_list_hash: String, + pub oracle_fee: Vec, // u128 as decimal strings + pub token_type: u32, + pub names: Vec, + pub index: Vec, + pub initial_stakes: Vec, // u128 as decimal strings + #[serde(default)] + pub outcome_count: Option, + #[serde(default)] + pub gas_to_fund: Option, // u128 as decimal string + #[serde(default)] + pub labels: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeployPmpResult { + pub pmp_address: String, + pub source_note_dih: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub gas_funded: Option, +} + +// ── set_stake ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetStakeParams { + pub pmp_address: String, + pub event_id: String, + pub oracle_list_hash: String, + pub token_type: u32, + pub outcome: u32, + pub amount: String, // u128 as decimal string + #[serde(default)] + pub use_coupon: bool, + #[serde(default)] + pub gas_to_fund: Option, + #[serde(default)] + pub labels: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetStakeResult { + pub source_note_dih: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tx_hash: Option, +} + +// ── claim ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaimParams { + pub pmp_address: String, + pub event_id: String, + pub oracle_list_hash: String, + pub token_type: u32, + #[serde(default)] + pub gas_to_fund: Option, + #[serde(default)] + pub labels: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaimResult { + pub source_note_dih: String, +} + +// ── cancel_stake ───────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CancelStakeParams { + pub pmp_address: String, + pub event_id: String, + pub oracle_list_hash: String, + pub token_type: u32, + #[serde(default)] + pub gas_to_fund: Option, + #[serde(default)] + pub labels: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CancelStakeResult { + pub source_note_dih: String, +} + +// ── Error codes ────────────────────────────────────────────────── + +pub mod error_code { + pub const USER_REJECTED: &str = "user_rejected"; + pub const EXPIRED: &str = "expired"; + pub const CHAIN_MISMATCH: &str = "chain_mismatch"; + pub const UNSUPPORTED_VERSION: &str = "unsupported_version"; + pub const INVALID_PARAMS: &str = "invalid_params"; + pub const INVALID_U128_FORMAT: &str = "invalid_u128_format"; + pub const TOKEN_TYPE_UNSUPPORTED: &str = "token_type_unsupported"; + pub const OUTCOME_COUNT_MISMATCH: &str = "outcome_count_mismatch"; + pub const NO_MATCHING_NOTE: &str = "no_matching_note"; + pub const INSUFFICIENT_BALANCE: &str = "insufficient_balance"; + pub const PMP_ADDRESS_MISMATCH: &str = "pmp_address_mismatch"; + pub const GAS_FUND_FAILED: &str = "gas_fund_failed"; + pub const DEPLOY_FAILED: &str = "deploy_failed"; + pub const SET_STAKE_FAILED: &str = "set_stake_failed"; + pub const CLAIM_FAILED: &str = "claim_failed"; + pub const CANCEL_STAKE_FAILED: &str = "cancel_stake_failed"; + pub const RATE_LIMITED: &str = "rate_limited"; + pub const IN_PROGRESS: &str = "in_progress"; + pub const INTERNAL_ERROR: &str = "internal_error"; +} + +// ── Helpers ────────────────────────────────────────────────────── + +impl DexRequestEnvelope { + /// Parse typed params from the envelope based on `kind`. + pub fn parse_params(&self) -> Result { + match self.kind.as_str() { + super::message::CONNECT_MESSAGE_TYPE_DEX_DEPLOY_PMP_REQUEST => { + let p: DeployPmpParams = serde_json::from_value(self.params.clone()) + .map_err(|e| format!("parse deploy_pmp params: {e}"))?; + Ok(DexTypedParams::DeployPmp(p)) + } + super::message::CONNECT_MESSAGE_TYPE_DEX_SET_STAKE_REQUEST => { + let p: SetStakeParams = serde_json::from_value(self.params.clone()) + .map_err(|e| format!("parse set_stake params: {e}"))?; + Ok(DexTypedParams::SetStake(p)) + } + super::message::CONNECT_MESSAGE_TYPE_DEX_CLAIM_REQUEST => { + let p: ClaimParams = serde_json::from_value(self.params.clone()) + .map_err(|e| format!("parse claim params: {e}"))?; + Ok(DexTypedParams::Claim(p)) + } + super::message::CONNECT_MESSAGE_TYPE_DEX_CANCEL_STAKE_REQUEST => { + let p: CancelStakeParams = serde_json::from_value(self.params.clone()) + .map_err(|e| format!("parse cancel_stake params: {e}"))?; + Ok(DexTypedParams::CancelStake(p)) + } + _ => Err(format!("unknown dex request kind: {}", self.kind)), + } + } + + /// Check if this request has expired. + pub fn is_expired(&self, now_unix: u64) -> bool { + self.valid_until.is_some_and(|t| now_unix > t) + } + + /// Validate protocol_version. + pub fn validate_version(&self) -> Result<(), String> { + if self.protocol_version != PROTOCOL_VERSION { + return Err(format!("unsupported protocol_version: {}", self.protocol_version)); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub enum DexTypedParams { + DeployPmp(DeployPmpParams), + SetStake(SetStakeParams), + Claim(ClaimParams), + CancelStake(CancelStakeParams), +} + +impl DexResponseEnvelope { + pub fn success(request_kind: &str, request_id: &str, result: serde_json::Value) -> Self { + Self { + protocol_version: PROTOCOL_VERSION.to_string(), + kind: format!("{}_response", request_kind.trim_end_matches("_request")), + request_id: request_id.to_string(), + ok: true, + result: Some(result), + error: None, + } + } + + pub fn error(request_kind: &str, request_id: &str, code: &str, message: &str) -> Self { + Self { + protocol_version: PROTOCOL_VERSION.to_string(), + kind: format!("{}_response", request_kind.trim_end_matches("_request")), + request_id: request_id.to_string(), + ok: false, + result: None, + error: Some(DexErrorPayload { + code: code.to_string(), + message: message.to_string(), + details: None, + }), + } + } +} + +impl DexRequestAck { + pub fn new(request_id: &str) -> Self { + Self { + protocol_version: PROTOCOL_VERSION.to_string(), + kind: super::message::CONNECT_MESSAGE_TYPE_DEX_REQUEST_RECEIVED.to_string(), + request_id: request_id.to_string(), + ack: true, + } + } +} + +/// Parse a decimal string as u128, rejecting hex/scientific/number-typed +/// values. +pub fn parse_u128_decimal(s: &str) -> Result { + s.parse::().map_err(|e| format!("invalid u128 decimal '{s}': {e}")) +} + +/// Validate all u128 string fields in DeployPmpParams. +pub fn validate_deploy_pmp_params(p: &DeployPmpParams) -> Result<(), (String, String)> { + for (i, fee) in p.oracle_fee.iter().enumerate() { + parse_u128_decimal(fee).map_err(|e| (format!("oracle_fee[{i}]"), e))?; + } + for (i, stake) in p.initial_stakes.iter().enumerate() { + parse_u128_decimal(stake).map_err(|e| (format!("initial_stakes[{i}]"), e))?; + } + if let Some(ref gas) = p.gas_to_fund { + parse_u128_decimal(gas).map_err(|e| ("gas_to_fund".to_string(), e))?; + } + if let Some(oc) = p.outcome_count { + if p.initial_stakes.len() != oc as usize { + return Err(( + "outcome_count".to_string(), + format!("initial_stakes.len()={} != outcome_count={oc}", p.initial_stakes.len()), + )); + } + } + Ok(()) +} + +/// Validate u128 fields in SetStakeParams. +pub fn validate_set_stake_params(p: &SetStakeParams) -> Result<(), (String, String)> { + parse_u128_decimal(&p.amount).map_err(|e| ("amount".to_string(), e))?; + if let Some(ref gas) = p.gas_to_fund { + parse_u128_decimal(gas).map_err(|e| ("gas_to_fund".to_string(), e))?; + } + Ok(()) +} diff --git a/bee_connect/src/dh.rs b/bee_connect/src/dh.rs new file mode 100644 index 0000000..477b133 --- /dev/null +++ b/bee_connect/src/dh.rs @@ -0,0 +1,927 @@ +//! Ephemeral X25519 Diffie-Hellman key exchange, session key derivation, +//! and per-message re-keying for bee_connect protocol. +//! +//! ## Handshake +//! 1. Client calls `generate_dh_keypair()` → puts `dh_public` (hex) in URL +//! 2. Wallet calls `generate_dh_keypair()` → computes shared secret with +//! client's public +//! 3. Both sides call `derive_session_keys(shared_secret, session_id)` → +//! identical signing + encryption keys +//! 4. Both sides call `create_initial_state(session_keys, my_dh_secret, +//! peer_dh_public)` → `ConnectSessionState` +//! +//! ## Per-message re-keying (forward secrecy) +//! - Sender: `rekey_outbound(state, session_id)` → new DH, new root, updated +//! state +//! - Receiver: `rekey_inbound(state, peer_new_dh_public, session_id)` → new +//! root, updated state +//! - Each message rotates the encryption root. Old roots are discarded (forward +//! secrecy). + +use ed25519_dalek::SigningKey; +use hkdf::Hkdf; +use serde::Deserialize; +use serde::Serialize; +use sha2::Sha256; +use x25519_dalek::PublicKey; +use x25519_dalek::StaticSecret; +use zeroize::Zeroize; +use zeroize::Zeroizing; + +const SIGNING_KEY_INFO: &[u8] = b"bee_connect.signing.v1"; +const ENCRYPTION_KEY_INFO: &[u8] = b"bee_connect.encryption.v1"; +const REKEY_INFO: &[u8] = b"bee_connect.rekey.v1"; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +fn now_secs() -> u64 { + let ms = js_sys::Date::now(); + if !ms.is_finite() || ms < 0.0 { + return 0; + } + (ms / 1000.0).floor() as u64 +} + +#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +fn now_millis() -> Result { + let ms = js_sys::Date::now(); + if !ms.is_finite() || ms < 0.0 { + return Err("js_sys::Date::now() returned invalid value".to_string()); + } + Ok(ms.floor() as u64) +} + +#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] +fn now_millis() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .map_err(|e| format!("SystemTime error: {e}")) +} + +/// X25519 ephemeral keypair for DH exchange. +pub struct DhKeyPair { + /// Hex-encoded X25519 public key (32 bytes = 64 hex chars). + /// Safe to transmit in URLs and on-chain. + pub public_hex: String, + /// Hex-encoded X25519 secret key (32 bytes = 64 hex chars). + /// MUST be kept private. Caller stores this for phase 2 (shared secret + /// computation). + pub secret_hex: Zeroizing, +} + +/// Session keys derived from DH shared secret. +/// Both sides derive identical keys from the same shared secret + session_id. +pub struct DhSessionKeys { + /// Ed25519 signing keypair derived from shared secret. + /// Used for signing on-chain AuthProfile transactions. + pub signing_public_hex: String, + pub signing_secret_hex: Zeroizing, + /// Hex-encoded 32-byte encryption root. + /// Used as `encryption_secret` in `derive_connect_message_key` for + /// per-message HKDF. + pub encryption_root_hex: Zeroizing, +} + +/// Generates a random X25519 ephemeral keypair. +pub fn generate_dh_keypair() -> Result { + let mut secret_bytes = [0u8; 32]; + getrandom::fill(&mut secret_bytes) + .map_err(|e| format!("Failed to generate random bytes: {e}"))?; + let secret = StaticSecret::from(secret_bytes); + secret_bytes.zeroize(); + let public = PublicKey::from(&secret); + + Ok(DhKeyPair { + public_hex: hex::encode(public.as_bytes()), + secret_hex: Zeroizing::new(hex::encode(secret.to_bytes())), + }) +} + +/// Computes X25519 shared secret from own secret key and peer's public key. +/// Returns 32-byte shared secret as hex string (zeroized on drop). +pub fn compute_shared_secret( + my_secret_hex: &str, + their_public_hex: &str, +) -> Result, String> { + let my_secret_bytes: [u8; 32] = hex::decode(my_secret_hex) + .map_err(|e| format!("Decode DH secret hex ({e})"))? + .try_into() + .map_err(|_| "DH secret must be 32 bytes".to_string())?; + + let their_public_bytes: [u8; 32] = hex::decode(their_public_hex) + .map_err(|e| format!("Decode DH public hex ({e})"))? + .try_into() + .map_err(|_| "DH public key must be 32 bytes".to_string())?; + + let my_secret = StaticSecret::from(my_secret_bytes); + let their_public = PublicKey::from(their_public_bytes); + + let shared = my_secret.diffie_hellman(&their_public); + let shared_bytes = shared.as_bytes(); + + // Reject all-zero shared secret (low-order point attack) + if shared_bytes.iter().all(|&b| b == 0) { + return Err("DH shared secret is zero (invalid peer public key)".to_string()); + } + + Ok(Zeroizing::new(hex::encode(shared_bytes))) +} + +/// Derives session signing keys and encryption root from shared secret. +/// +/// `shared_secret_hex` — 32-byte shared secret from `compute_shared_secret`. +/// `session_id` — unique session identifier, used as HKDF salt for domain +/// separation. +/// +/// Both sides call this with the same inputs and get identical output. +pub fn derive_session_keys( + shared_secret_hex: &str, + session_id: &str, +) -> Result { + let shared_bytes = + hex::decode(shared_secret_hex).map_err(|e| format!("Decode shared secret hex ({e})"))?; + if shared_bytes.len() != 32 { + return Err(format!("Shared secret must be 32 bytes, got {}", shared_bytes.len())); + } + + let salt = session_id.as_bytes(); + + // Single extraction — one PRK, two independent expand calls + let hk = Hkdf::::new(Some(salt), &shared_bytes); + + // 1. Signing seed → Ed25519 keypair + let mut signing_seed = Zeroizing::new([0u8; 32]); + hk.expand(SIGNING_KEY_INFO, &mut *signing_seed) + .map_err(|e| format!("HKDF expand signing key ({e})"))?; + + let signing_key = SigningKey::from_bytes(&signing_seed); + let verifying_key = signing_key.verifying_key(); + + // 2. Encryption root + let mut encryption_root = Zeroizing::new([0u8; 32]); + hk.expand(ENCRYPTION_KEY_INFO, &mut *encryption_root) + .map_err(|e| format!("HKDF expand encryption root ({e})"))?; + + Ok(DhSessionKeys { + signing_public_hex: hex::encode(verifying_key.to_bytes()), + signing_secret_hex: Zeroizing::new(hex::encode(signing_key.to_bytes())), + encryption_root_hex: Zeroizing::new(hex::encode(*encryption_root)), + }) +} + +/// Complete session state for the bee_connect DH protocol. +/// +/// Both client and wallet maintain one instance. Every send/receive operation +/// takes the current state, performs a DH re-key, and returns the updated +/// state. The caller MUST persist the updated state and discard the old one. +/// +/// Signing keys are fixed for the session lifetime (tied to the on-chain +/// AuthProfile). Encryption root and DH keys rotate on every message +/// to provide forward secrecy. +/// +/// Default session TTL: 24 hours in seconds. +pub const DEFAULT_SESSION_TTL_SECS: u64 = 24 * 60 * 60; + +/// # Security note +/// +/// Secret fields (`encryption_root`, `my_dh_secret`, `signing_secret`) are +/// wrapped in `Zeroizing` — their memory is zeroed on drop. +/// Callers SHOULD drop the old state promptly after re-key. +/// The `Debug` impl redacts all secret fields. +#[derive(Clone, Serialize, Deserialize)] +pub struct ConnectSessionState { + /// Current encryption root key (hex, 32 bytes). + /// Rotated on every sent or received message. + pub encryption_root: Zeroizing, + /// My current X25519 secret key (hex, 32 bytes). + /// Rotated on every outbound message. + pub my_dh_secret: Zeroizing, + /// Peer's last known X25519 public key (hex, 32 bytes). + /// Updated on every inbound message. + pub peer_dh_public: String, + /// Ed25519 signing public key (hex, 32 bytes). Fixed for session lifetime. + pub signing_public: String, + /// Ed25519 signing secret key (hex, 32 bytes). Fixed for session lifetime. + pub signing_secret: Zeroizing, + /// Session creation timestamp (seconds since UNIX epoch). + /// Set once during handshake, carried through all re-keys. + #[serde(default)] + pub created_at: u64, + /// Session expiration timestamp (seconds since UNIX epoch). + /// Operations MUST reject expired sessions. + /// Default: `created_at + DEFAULT_SESSION_TTL_SECS` (24h). + #[serde(default)] + pub expires_at: u64, + /// Highest `seq` successfully decrypted from an inbound message. + /// Zero means no inbound message has been processed yet. + /// `rekey_inbound` rejects any envelope with seq <= this value. + #[serde(default)] + pub last_seen_seq: u64, + /// Highest `seq` used in an outbound message. + /// Zero means no outbound message has been sent yet. + /// `rekey_outbound` ensures the next seq is strictly greater than this. + #[serde(default)] + pub last_sent_seq: u64, +} + +impl ConnectSessionState { + /// Returns `true` if the session has expired. + /// A session with `expires_at == 0` (legacy/unset) is considered + /// non-expiring. + pub fn is_expired(&self) -> bool { + if self.expires_at == 0 { + return false; + } + now_secs() >= self.expires_at + } + + /// Checks that the session is still valid. Returns `Err` if expired. + pub fn ensure_not_expired(&self) -> Result<(), String> { + if self.is_expired() { + Err(format!( + "Connect session expired at {} (created_at={})", + self.expires_at, self.created_at + )) + } else { + Ok(()) + } + } + + /// Returns the next seq value for an outbound message. + /// Guarantees strict monotonicity: result > last_sent_seq. + pub fn next_outbound_seq(&self) -> Result { + let now = now_millis()?; + Ok(now.max(self.last_sent_seq + 1)) + } +} + +impl std::fmt::Debug for ConnectSessionState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ConnectSessionState") + .field("encryption_root", &"[REDACTED]") + .field("my_dh_secret", &"[REDACTED]") + .field("peer_dh_public", &self.peer_dh_public) + .field("signing_public", &self.signing_public) + .field("signing_secret", &"[REDACTED]") + .field("created_at", &self.created_at) + .field("expires_at", &self.expires_at) + .field("last_seen_seq", &self.last_seen_seq) + .field("last_sent_seq", &self.last_sent_seq) + .finish() + } +} + +/// Result of a re-key operation. +pub struct RekeyResult { + /// Encryption root to use for this specific message (hex). + /// This is the new root AFTER re-keying — use it for encrypt/decrypt. + pub message_encryption_root: Zeroizing, + /// New ephemeral DH public key (hex). Include in the message envelope. + /// `None` for inbound re-key (receiver does not generate new DH). + pub new_dh_public: Option, + /// Updated session state. Caller MUST persist this and discard the old + /// state. + pub updated_state: ConnectSessionState, + /// The seq value to use in the outbound message envelope. + /// Only set for outbound re-key; `0` for inbound. + pub outbound_seq: u64, +} + +impl std::fmt::Debug for RekeyResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RekeyResult") + .field("message_encryption_root", &"[REDACTED]") + .field("new_dh_public", &self.new_dh_public) + .field("updated_state", &self.updated_state) + .finish() + } +} + +/// Creates the initial session state from handshake results. +/// +/// Called once after the DH handshake completes (after `derive_session_keys`). +/// - `session_keys` — derived signing + encryption keys +/// - `my_dh_secret_hex` — my X25519 secret used in the handshake +/// - `peer_dh_public_hex` — peer's X25519 public from the handshake +/// +/// Sets `created_at` to now and `expires_at` to now + +/// `DEFAULT_SESSION_TTL_SECS` (24h). +pub fn create_initial_state( + session_keys: &DhSessionKeys, + my_dh_secret_hex: &str, + peer_dh_public_hex: &str, +) -> ConnectSessionState { + let now = now_secs(); + ConnectSessionState { + encryption_root: Zeroizing::new(session_keys.encryption_root_hex.to_string()), + my_dh_secret: Zeroizing::new(my_dh_secret_hex.to_string()), + peer_dh_public: peer_dh_public_hex.to_string(), + signing_public: session_keys.signing_public_hex.clone(), + signing_secret: Zeroizing::new(session_keys.signing_secret_hex.to_string()), + created_at: now, + expires_at: now + DEFAULT_SESSION_TTL_SECS, + last_seen_seq: 0, + last_sent_seq: 0, + } +} + +/// Performs DH re-key for an outbound (sending) message. +/// +/// 1. Generates a new ephemeral X25519 keypair +/// 2. Computes DH shared secret with peer's current public key +/// 3. Derives new encryption root from `old_root || shared_secret` +/// 4. Returns the new root (for encrypting this message), new DH public (for +/// the envelope), and the updated state +/// +/// The caller MUST: +/// - Use `message_encryption_root` to encrypt the message body +/// - Include `new_dh_public` in the message envelope +/// - Persist `updated_state` and discard the old state +pub fn rekey_outbound( + state: &ConnectSessionState, + session_id: &str, + outbound_seq: u64, +) -> Result { + state.ensure_not_expired()?; + + if outbound_seq == 0 { + return Err("rekey_outbound: outbound_seq must be > 0".to_string()); + } + if outbound_seq <= state.last_sent_seq { + return Err(format!( + "rekey_outbound: seq {} is not monotonically increasing (last_sent={})", + outbound_seq, state.last_sent_seq + )); + } + + // 1. Generate new ephemeral DH keypair + let new_dh = generate_dh_keypair()?; + + // 2. Compute shared secret: DH(new_secret, peer_public) + let shared = compute_shared_secret(&new_dh.secret_hex, &state.peer_dh_public)?; + + // 3. Derive new root from old_root || shared + let new_root = derive_rekey_root(&state.encryption_root, &shared, session_id)?; + + // 4. Build updated state + let updated_state = ConnectSessionState { + encryption_root: Zeroizing::new(new_root.to_string()), + my_dh_secret: Zeroizing::new(new_dh.secret_hex.to_string()), + peer_dh_public: state.peer_dh_public.clone(), + signing_public: state.signing_public.clone(), + signing_secret: state.signing_secret.clone(), + created_at: state.created_at, + expires_at: state.expires_at, + last_seen_seq: state.last_seen_seq, + last_sent_seq: outbound_seq, + }; + + Ok(RekeyResult { + message_encryption_root: new_root, + new_dh_public: Some(new_dh.public_hex), + updated_state, + outbound_seq, + }) +} + +/// Performs DH re-key for an inbound (receiving) message. +/// +/// 1. Computes DH shared secret with peer's new public key from the envelope +/// 2. Derives new encryption root from `old_root || shared_secret` +/// 3. Returns the new root (for decrypting this message) and updated state +/// +/// The caller MUST: +/// - Use `message_encryption_root` to decrypt the message body +/// - Persist `updated_state` and discard the old state +pub fn rekey_inbound( + state: &ConnectSessionState, + peer_new_dh_public: &str, + session_id: &str, + incoming_seq: u64, +) -> Result { + state.ensure_not_expired()?; + + if incoming_seq == 0 { + return Err("rekey_inbound: incoming seq must be > 0".to_string()); + } + if incoming_seq <= state.last_seen_seq { + return Err(format!( + "rekey_inbound: replay detected — incoming seq {} <= last_seen_seq {}", + incoming_seq, state.last_seen_seq + )); + } + + // 1. Compute shared secret: DH(my_secret, peer_new_public) + let shared = compute_shared_secret(&state.my_dh_secret, peer_new_dh_public)?; + + // 2. Derive new root from old_root || shared + let new_root = derive_rekey_root(&state.encryption_root, &shared, session_id)?; + + // 3. Build updated state + let updated_state = ConnectSessionState { + encryption_root: Zeroizing::new(new_root.to_string()), + my_dh_secret: state.my_dh_secret.clone(), + peer_dh_public: peer_new_dh_public.to_string(), + signing_public: state.signing_public.clone(), + signing_secret: state.signing_secret.clone(), + created_at: state.created_at, + expires_at: state.expires_at, + last_seen_seq: incoming_seq, + last_sent_seq: state.last_sent_seq, + }; + + Ok(RekeyResult { + message_encryption_root: new_root, + new_dh_public: None, + updated_state, + outbound_seq: 0, + }) +} + +/// Derives a new encryption root by mixing the current root with a fresh DH +/// shared secret. +/// +/// `ikm = old_root_bytes || shared_secret_bytes` ensures both must be known +/// to derive the new root (defense in depth). +fn derive_rekey_root( + current_root_hex: &str, + shared_secret_hex: &str, + session_id: &str, +) -> Result, String> { + let root_bytes = + hex::decode(current_root_hex).map_err(|e| format!("Decode current root hex ({e})"))?; + if root_bytes.len() != 32 { + return Err(format!("Encryption root must be 32 bytes, got {}", root_bytes.len())); + } + + let shared_bytes = + hex::decode(shared_secret_hex).map_err(|e| format!("Decode shared secret hex ({e})"))?; + if shared_bytes.len() != 32 { + return Err(format!("Shared secret must be 32 bytes, got {}", shared_bytes.len())); + } + + // Concatenate: old_root || shared_secret (64 bytes total) + let mut ikm = Vec::with_capacity(64); + ikm.extend_from_slice(&root_bytes); + ikm.extend_from_slice(&shared_bytes); + + let salt = session_id.as_bytes(); + let hk = Hkdf::::new(Some(salt), &ikm); + let mut new_root = Zeroizing::new([0u8; 32]); + hk.expand(REKEY_INFO, &mut *new_root).map_err(|e| format!("HKDF expand rekey root ({e})"))?; + + // Zeroize intermediate material + ikm.zeroize(); + + Ok(Zeroizing::new(hex::encode(*new_root))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_dh_keypair_produces_valid_keys() { + let kp = generate_dh_keypair().unwrap(); + assert_eq!(hex::decode(&kp.public_hex).unwrap().len(), 32); + assert_eq!(hex::decode(&*kp.secret_hex).unwrap().len(), 32); + // Public and secret must differ + assert_ne!(kp.public_hex, *kp.secret_hex); + } + + #[test] + fn dh_shared_secret_symmetric() { + // Both sides compute the same shared secret + let client = generate_dh_keypair().unwrap(); + let wallet = generate_dh_keypair().unwrap(); + + let secret_a = compute_shared_secret(&client.secret_hex, &wallet.public_hex).unwrap(); + let secret_b = compute_shared_secret(&wallet.secret_hex, &client.public_hex).unwrap(); + + assert_eq!(*secret_a, *secret_b, "DH must be symmetric"); + } + + #[test] + fn derive_session_keys_deterministic() { + let client = generate_dh_keypair().unwrap(); + let wallet = generate_dh_keypair().unwrap(); + + let shared = compute_shared_secret(&client.secret_hex, &wallet.public_hex).unwrap(); + + let keys_1 = derive_session_keys(&shared, "test_session_1").unwrap(); + let keys_2 = derive_session_keys(&shared, "test_session_1").unwrap(); + + assert_eq!(keys_1.signing_public_hex, keys_2.signing_public_hex); + assert_eq!(*keys_1.signing_secret_hex, *keys_2.signing_secret_hex); + assert_eq!(*keys_1.encryption_root_hex, *keys_2.encryption_root_hex); + } + + #[test] + fn derive_session_keys_different_sessions_differ() { + let client = generate_dh_keypair().unwrap(); + let wallet = generate_dh_keypair().unwrap(); + + let shared = compute_shared_secret(&client.secret_hex, &wallet.public_hex).unwrap(); + + let keys_1 = derive_session_keys(&shared, "session_A").unwrap(); + let keys_2 = derive_session_keys(&shared, "session_B").unwrap(); + + assert_ne!(keys_1.signing_public_hex, keys_2.signing_public_hex); + assert_ne!(*keys_1.encryption_root_hex, *keys_2.encryption_root_hex); + } + + #[test] + fn both_sides_derive_identical_session_keys() { + // Full flow: client and wallet independently derive the same keys + let client_dh = generate_dh_keypair().unwrap(); + let wallet_dh = generate_dh_keypair().unwrap(); + let session_id = "test_session_123"; + + // Client side + let client_shared = + compute_shared_secret(&client_dh.secret_hex, &wallet_dh.public_hex).unwrap(); + let client_keys = derive_session_keys(&client_shared, session_id).unwrap(); + + // Wallet side + let wallet_shared = + compute_shared_secret(&wallet_dh.secret_hex, &client_dh.public_hex).unwrap(); + let wallet_keys = derive_session_keys(&wallet_shared, session_id).unwrap(); + + assert_eq!(client_keys.signing_public_hex, wallet_keys.signing_public_hex); + assert_eq!(*client_keys.signing_secret_hex, *wallet_keys.signing_secret_hex); + assert_eq!(*client_keys.encryption_root_hex, *wallet_keys.encryption_root_hex); + } + + #[test] + fn different_keypairs_different_shared_secrets() { + let client = generate_dh_keypair().unwrap(); + let wallet_1 = generate_dh_keypair().unwrap(); + let wallet_2 = generate_dh_keypair().unwrap(); + + let shared_1 = compute_shared_secret(&client.secret_hex, &wallet_1.public_hex).unwrap(); + let shared_2 = compute_shared_secret(&client.secret_hex, &wallet_2.public_hex).unwrap(); + + assert_ne!(*shared_1, *shared_2); + } + + #[test] + fn derived_signing_key_is_valid_ed25519() { + let client = generate_dh_keypair().unwrap(); + let wallet = generate_dh_keypair().unwrap(); + + let shared = compute_shared_secret(&client.secret_hex, &wallet.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + + // Verify signing key produces valid signatures + use ed25519_dalek::Signer; + let secret_bytes: [u8; 32] = + hex::decode(&*keys.signing_secret_hex).unwrap().try_into().unwrap(); + let signing_key = SigningKey::from_bytes(&secret_bytes); + let signature = signing_key.sign(b"test message"); + + use ed25519_dalek::Verifier; + let pub_bytes: [u8; 32] = + hex::decode(&keys.signing_public_hex).unwrap().try_into().unwrap(); + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes).unwrap(); + verifying_key.verify(b"test message", &signature).unwrap(); + } + + #[test] + fn reject_zero_shared_secret() { + // All-zero public key leads to all-zero shared secret (low-order point) + let kp = generate_dh_keypair().unwrap(); + let zero_pub = "0000000000000000000000000000000000000000000000000000000000000000"; + let result = compute_shared_secret(&kp.secret_hex, zero_pub); + assert!(result.is_err(), "Zero shared secret must be rejected"); + } + + #[test] + fn invalid_hex_rejected() { + assert!(compute_shared_secret( + "not_hex", + "0000000000000000000000000000000000000000000000000000000000000001" + ) + .is_err()); + assert!(compute_shared_secret( + "aa", + "0000000000000000000000000000000000000000000000000000000000000001" + ) + .is_err()); + } + + #[test] + fn create_initial_state_preserves_keys() { + let client = generate_dh_keypair().unwrap(); + let wallet = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&client.secret_hex, &wallet.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + + let state = create_initial_state(&keys, &client.secret_hex, &wallet.public_hex); + + assert_eq!(*state.encryption_root, *keys.encryption_root_hex); + assert_eq!(state.signing_public, keys.signing_public_hex); + assert_eq!(*state.signing_secret, *keys.signing_secret_hex); + assert_eq!(*state.my_dh_secret, *client.secret_hex); + assert_eq!(state.peer_dh_public, wallet.public_hex); + } + + #[test] + fn rekey_outbound_rotates_root_and_dh() { + let client = generate_dh_keypair().unwrap(); + let wallet = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&client.secret_hex, &wallet.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &client.secret_hex, &wallet.public_hex); + + let result = rekey_outbound(&state, "session", 1000).unwrap(); + + // Root rotated + assert_ne!(result.updated_state.encryption_root, state.encryption_root); + // DH secret rotated + assert_ne!(result.updated_state.my_dh_secret, state.my_dh_secret); + // Peer public unchanged + assert_eq!(result.updated_state.peer_dh_public, state.peer_dh_public); + // Signing keys unchanged + assert_eq!(result.updated_state.signing_public, state.signing_public); + assert_eq!(result.updated_state.signing_secret, state.signing_secret); + // New DH public returned for envelope + assert!(result.new_dh_public.is_some()); + let new_pub = result.new_dh_public.unwrap(); + assert_eq!(hex::decode(&new_pub).unwrap().len(), 32); + // message_encryption_root == updated root + assert_eq!(*result.message_encryption_root, *result.updated_state.encryption_root); + } + + #[test] + fn rekey_inbound_rotates_root_and_peer_public() { + let client = generate_dh_keypair().unwrap(); + let wallet = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&client.secret_hex, &wallet.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &client.secret_hex, &wallet.public_hex); + + // Simulate peer sending with new DH + let peer_new = generate_dh_keypair().unwrap(); + let result = rekey_inbound(&state, &peer_new.public_hex, "session", 1000).unwrap(); + + // Root rotated + assert_ne!(result.updated_state.encryption_root, state.encryption_root); + // My DH secret unchanged + assert_eq!(result.updated_state.my_dh_secret, state.my_dh_secret); + // Peer public updated + assert_eq!(result.updated_state.peer_dh_public, peer_new.public_hex); + // No new DH public (inbound) + assert!(result.new_dh_public.is_none()); + } + + #[test] + fn rekey_symmetric_outbound_inbound() { + // Full flow: Alice sends to Bob, both derive same root + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + + let alice_state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + let bob_state = create_initial_state(&keys, &bob.secret_hex, &alice.public_hex); + + // Alice sends message (rekey outbound) + let alice_result = rekey_outbound(&alice_state, "session", 1000).unwrap(); + let alice_new_pub = alice_result.new_dh_public.as_ref().unwrap(); + + // Bob receives message (rekey inbound with Alice's new DH public) + let bob_result = rekey_inbound(&bob_state, alice_new_pub, "session", 1000).unwrap(); + + // Both derive the same message encryption root + assert_eq!( + *alice_result.message_encryption_root, *bob_result.message_encryption_root, + "Sender and receiver must derive the same encryption root" + ); + + // Both have the same updated encryption root in state + assert_eq!( + alice_result.updated_state.encryption_root, + bob_result.updated_state.encryption_root, + ); + } + + #[test] + fn rekey_chain_forward_secrecy() { + // After multiple re-keys, old roots cannot be derived from current state + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + + let mut alice_state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + let mut bob_state = create_initial_state(&keys, &bob.secret_hex, &alice.public_hex); + + let mut roots = vec![alice_state.encryption_root.clone()]; + let mut seq: u64 = 1000; + + // Simulate 5 message exchanges + for _ in 0..5 { + // Alice → Bob + seq += 1; + let a_result = rekey_outbound(&alice_state, "session", seq).unwrap(); + let a_pub = a_result.new_dh_public.clone().unwrap(); + alice_state = a_result.updated_state; + + let b_result = rekey_inbound(&bob_state, &a_pub, "session", seq).unwrap(); + bob_state = b_result.updated_state; + + roots.push(alice_state.encryption_root.clone()); + + // Bob → Alice + seq += 1; + let b_result = rekey_outbound(&bob_state, "session", seq).unwrap(); + let b_pub = b_result.new_dh_public.clone().unwrap(); + bob_state = b_result.updated_state; + + let a_result = rekey_inbound(&alice_state, &b_pub, "session", seq).unwrap(); + alice_state = a_result.updated_state; + + roots.push(alice_state.encryption_root.clone()); + } + + // All roots must be unique + let unique: std::collections::HashSet<&String> = roots.iter().map(|r| &**r).collect(); + assert_eq!(unique.len(), roots.len(), "Every re-key must produce a unique root"); + + // Final states must match + assert_eq!(alice_state.encryption_root, bob_state.encryption_root); + } + + #[test] + fn rekey_different_sessions_different_roots() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session_A").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + let result_a = rekey_outbound(&state, "session_A", 1000).unwrap(); + + // Same state but different session_id + let keys_b = derive_session_keys(&shared, "session_B").unwrap(); + let state_b = create_initial_state(&keys_b, &alice.secret_hex, &bob.public_hex); + let result_b = rekey_outbound(&state_b, "session_B", 1000).unwrap(); + + assert_ne!(*result_a.message_encryption_root, *result_b.message_encryption_root,); + } + + #[test] + fn connect_session_state_serializable() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + let json = serde_json::to_string(&state).expect("serialize"); + let restored: ConnectSessionState = serde_json::from_str(&json).expect("deserialize"); + + assert_eq!(state.encryption_root, restored.encryption_root); + assert_eq!(state.my_dh_secret, restored.my_dh_secret); + assert_eq!(state.peer_dh_public, restored.peer_dh_public); + assert_eq!(state.signing_public, restored.signing_public); + assert_eq!(state.signing_secret, restored.signing_secret); + } + + #[test] + fn rekey_inbound_rejects_replay() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + let peer_new = generate_dh_keypair().unwrap(); + let result = rekey_inbound(&state, &peer_new.public_hex, "session", 1000).unwrap(); + let advanced = result.updated_state; + assert_eq!(advanced.last_seen_seq, 1000); + + // Same seq → replay + let peer2 = generate_dh_keypair().unwrap(); + let err = rekey_inbound(&advanced, &peer2.public_hex, "session", 1000).unwrap_err(); + assert!(err.contains("replay"), "expected replay error, got: {err}"); + + // Lower seq → replay + let err = rekey_inbound(&advanced, &peer2.public_hex, "session", 999).unwrap_err(); + assert!(err.contains("replay"), "expected replay error, got: {err}"); + + // Higher seq → ok + assert!(rekey_inbound(&advanced, &peer2.public_hex, "session", 1001).is_ok()); + } + + #[test] + fn rekey_inbound_rejects_seq_zero() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + let peer = generate_dh_keypair().unwrap(); + let err = rekey_inbound(&state, &peer.public_hex, "session", 0).unwrap_err(); + assert!(err.contains("must be > 0"), "expected seq>0 error, got: {err}"); + } + + #[test] + fn last_seen_seq_carried_through_outbound_rekey() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + // Simulate inbound at seq=5000 + let peer = generate_dh_keypair().unwrap(); + let inbound = rekey_inbound(&state, &peer.public_hex, "session", 5000).unwrap(); + assert_eq!(inbound.updated_state.last_seen_seq, 5000); + + // Outbound must preserve last_seen_seq + let outbound = rekey_outbound(&inbound.updated_state, "session", 6000).unwrap(); + assert_eq!(outbound.updated_state.last_seen_seq, 5000); + } + + #[test] + fn last_seen_seq_zero_in_legacy_deserialized_state() { + // JSON without last_seen_seq → deserializes to 0 + let json = r#"{ + "encryption_root": "aa", + "my_dh_secret": "bb", + "peer_dh_public": "cc", + "signing_public": "dd", + "signing_secret": "ee", + "created_at": 1000, + "expires_at": 2000 + }"#; + let state: ConnectSessionState = serde_json::from_str(json).unwrap(); + assert_eq!(state.last_seen_seq, 0); + assert_eq!(state.last_sent_seq, 0); + } + + #[test] + fn rekey_outbound_rejects_non_monotonic_seq() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + let result = rekey_outbound(&state, "session", 1000).unwrap(); + let advanced = result.updated_state; + assert_eq!(advanced.last_sent_seq, 1000); + + // Same seq → error + let err = rekey_outbound(&advanced, "session", 1000).unwrap_err(); + assert!(err.contains("not monotonically"), "expected monotonic error, got: {err}"); + + // Lower seq → error + let err = rekey_outbound(&advanced, "session", 999).unwrap_err(); + assert!(err.contains("not monotonically"), "expected monotonic error, got: {err}"); + + // Higher seq → ok + assert!(rekey_outbound(&advanced, "session", 1001).is_ok()); + } + + #[test] + fn rekey_outbound_rejects_seq_zero() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + let err = rekey_outbound(&state, "session", 0).unwrap_err(); + assert!(err.contains("must be > 0"), "expected seq>0 error, got: {err}"); + } + + #[test] + fn last_sent_seq_carried_through_inbound_rekey() { + let alice = generate_dh_keypair().unwrap(); + let bob = generate_dh_keypair().unwrap(); + let shared = compute_shared_secret(&alice.secret_hex, &bob.public_hex).unwrap(); + let keys = derive_session_keys(&shared, "session").unwrap(); + let state = create_initial_state(&keys, &alice.secret_hex, &bob.public_hex); + + // Outbound at seq=3000 + let outbound = rekey_outbound(&state, "session", 3000).unwrap(); + assert_eq!(outbound.updated_state.last_sent_seq, 3000); + + // Inbound must preserve last_sent_seq + let peer = generate_dh_keypair().unwrap(); + let inbound = + rekey_inbound(&outbound.updated_state, &peer.public_hex, "session", 4000).unwrap(); + assert_eq!(inbound.updated_state.last_sent_seq, 3000); + } +} diff --git a/bee_connect/src/errors.rs b/bee_connect/src/errors.rs new file mode 100644 index 0000000..a23c3e3 --- /dev/null +++ b/bee_connect/src/errors.rs @@ -0,0 +1,2 @@ +pub use bee_errors::AppError; +pub use bee_errors::AppResult; diff --git a/bee_connect/src/lib.rs b/bee_connect/src/lib.rs new file mode 100644 index 0000000..02665c3 --- /dev/null +++ b/bee_connect/src/lib.rs @@ -0,0 +1,45 @@ +//! Client-side helpers for AckiNacki wallet connect flows over `authservice`. +//! +//! The crate currently provides: +//! - `shared_key` (both sides write to a shared-session profile) +//! - protocol helpers for `wallet_hello`, `set_mining_keys`, +//! `client_disconnect`, `sign_challenge`, `challenge_response` + +mod client; +pub mod dex_protocol; +pub mod dh; +pub mod errors; +pub mod message; + +pub use client::decode_connect_payload_b64url; +pub use client::ActiveConnectSession; +pub use client::ChallengeResponseBody; +pub use client::ConnectClient; +pub use client::ConnectPayload; +pub use client::ParamsOfCreateSharedKeySession; +pub use client::ParamsOfDisconnectSession; +pub use client::ParamsOfQueryActiveSessionsByMultifactor; +pub use client::ParamsOfRequestSetMiningKeys; +pub use client::ParamsOfRequestSignChallenge; +pub use client::ParamsOfResolveProfileAddress; +pub use client::ParamsOfWaitChallengeResponse; +pub use client::ParamsOfWaitSetMiningKeysRequest; +pub use client::ParamsOfWaitWalletHello; +pub use client::ResultOfCreateSharedKeySession; +pub use client::ResultOfDisconnectSession; +pub use client::ResultOfQueryActiveSessionsByMultifactor; +pub use client::ResultOfRequestSetMiningKeys; +pub use client::ResultOfRequestSignChallenge; +pub use client::ResultOfWaitChallengeResponse; +pub use client::ResultOfWaitSetMiningKeysRequest; +pub use client::ResultOfWaitWalletHello; +pub use client::SignChallengeBody; +pub use client::WalletHelloMetadata; +pub use dh::ConnectSessionState; +pub use dh::DhKeyPair; +pub use dh::DhSessionKeys; +pub use dh::RekeyResult; +pub use dh::DEFAULT_SESSION_TTL_SECS; + +#[cfg(feature = "wasm")] +pub mod wasm; diff --git a/bee_connect/src/message.rs b/bee_connect/src/message.rs new file mode 100644 index 0000000..5360a4f --- /dev/null +++ b/bee_connect/src/message.rs @@ -0,0 +1,197 @@ +//! Shared connect protocol message utilities. +//! +//! These functions are used by both `bee_connect` (client-side) and +//! `bee_wallet` (wallet-side) for message encryption, decryption, AAD +//! construction, and input normalization. Having a single implementation +//! prevents divergence. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use chacha20poly1305::aead::Aead; +use chacha20poly1305::aead::KeyInit; +use chacha20poly1305::aead::Payload; +use chacha20poly1305::XChaCha20Poly1305; +use chacha20poly1305::XNonce; +use hkdf::Hkdf; +use sha2::Sha256; + +// --- Protocol constants --- + +pub const CONNECT_DEEPLINK_VERSION: &str = "bee_connect.dl/1"; +pub const CONNECT_MESSAGE_VERSION: &str = "bee_connect.msg/1"; +pub const CONNECT_MESSAGE_TYPE_WALLET_HELLO: &str = "wallet_hello"; +pub const CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT: &str = "client_disconnect"; +pub const CONNECT_MESSAGE_TYPE_SET_MINING_KEYS: &str = "set_mining_keys"; +pub const CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE: &str = "sign_challenge"; +pub const CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE: &str = "challenge_response"; + +// DEX operations over bee_connect (dex_connect_protocol v1) +pub const CONNECT_MESSAGE_TYPE_DEX_DEPLOY_PMP_REQUEST: &str = "dex_deploy_pmp_request"; +pub const CONNECT_MESSAGE_TYPE_DEX_DEPLOY_PMP_RESPONSE: &str = "dex_deploy_pmp_response"; +pub const CONNECT_MESSAGE_TYPE_DEX_SET_STAKE_REQUEST: &str = "dex_set_stake_request"; +pub const CONNECT_MESSAGE_TYPE_DEX_SET_STAKE_RESPONSE: &str = "dex_set_stake_response"; +pub const CONNECT_MESSAGE_TYPE_DEX_CLAIM_REQUEST: &str = "dex_claim_request"; +pub const CONNECT_MESSAGE_TYPE_DEX_CLAIM_RESPONSE: &str = "dex_claim_response"; +pub const CONNECT_MESSAGE_TYPE_DEX_CANCEL_STAKE_REQUEST: &str = "dex_cancel_stake_request"; +pub const CONNECT_MESSAGE_TYPE_DEX_CANCEL_STAKE_RESPONSE: &str = "dex_cancel_stake_response"; +pub const CONNECT_MESSAGE_TYPE_DEX_REQUEST_RECEIVED: &str = "dex_request_received"; + +pub const CONNECT_MESSAGE_ENC_NONE: &str = "none"; +pub const CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256: &str = "xchacha20poly1305-hkdf-sha256"; +pub const CONNECT_MESSAGE_KEY_INFO: &[u8] = b"bee_connect.msg.key.v1"; + +// --- Encrypted body --- + +/// Result of encrypting a connect message body. +#[derive(Debug, Clone)] +pub struct EncryptedConnectBody { + pub ciphertext_b64url: String, + pub nonce_b64url: String, + pub salt_b64url: String, +} + +/// Encrypts a JSON body with XChaCha20-Poly1305 using a per-message key derived +/// from `encryption_secret` via HKDF. +pub fn encrypt_connect_body( + body: &serde_json::Value, + encryption_secret: &str, + aad: &[u8], +) -> Result { + let plaintext = serde_json::to_vec(body) + .map_err(|e| format!("Serialize connect body for encryption ({e})"))?; + let mut nonce = [0u8; 24]; + getrandom::fill(&mut nonce).map_err(|e| format!("Generate connect nonce ({e})"))?; + let mut salt = [0u8; 32]; + getrandom::fill(&mut salt).map_err(|e| format!("Generate connect salt ({e})"))?; + let key = derive_connect_message_key(encryption_secret, &salt)?; + let cipher = XChaCha20Poly1305::new((&key).into()); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: &plaintext, aad }) + .map_err(|e| format!("Encrypt connect body ({e})"))?; + + Ok(EncryptedConnectBody { + ciphertext_b64url: URL_SAFE_NO_PAD.encode(ciphertext), + nonce_b64url: URL_SAFE_NO_PAD.encode(nonce), + salt_b64url: URL_SAFE_NO_PAD.encode(salt), + }) +} + +/// Decrypts a connect message body from base64url-encoded components. +pub fn decrypt_connect_body( + ciphertext_b64url: &str, + nonce_b64url: &str, + salt_b64url: &str, + encryption_secret: &str, + aad: &[u8], +) -> Result, String> { + let ciphertext = URL_SAFE_NO_PAD + .decode(ciphertext_b64url) + .map_err(|e| format!("Decode encrypted connect ciphertext ({e})"))?; + let nonce = URL_SAFE_NO_PAD + .decode(nonce_b64url) + .map_err(|e| format!("Decode encrypted connect nonce ({e})"))?; + if nonce.len() != 24 { + return Err(format!("Invalid encrypted connect nonce length: {}", nonce.len())); + } + let salt = URL_SAFE_NO_PAD + .decode(salt_b64url) + .map_err(|e| format!("Decode encrypted connect salt ({e})"))?; + if salt.len() != 32 { + return Err(format!("Invalid encrypted connect salt length: {}", salt.len())); + } + let key = derive_connect_message_key(encryption_secret, &salt)?; + let cipher = XChaCha20Poly1305::new((&key).into()); + cipher + .decrypt(XNonce::from_slice(&nonce), Payload { msg: &ciphertext, aad }) + .map_err(|e| format!("Decrypt connect body ({e})")) +} + +/// Derives a per-message 32-byte encryption key via HKDF-SHA256. +/// +/// `encryption_secret` is hex-encoded (with optional `0x` prefix). +/// `salt` is random 32 bytes (unique per message). +pub fn derive_connect_message_key( + encryption_secret: &str, + salt: &[u8], +) -> Result<[u8; 32], String> { + let secret_hex = + encryption_secret.strip_prefix("0x").or_else(|| encryption_secret.strip_prefix("0X")); + let secret_hex = secret_hex.unwrap_or(encryption_secret); + if secret_hex.is_empty() || !secret_hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("encryption secret must be valid hex".to_string()); + } + let secret = + hex::decode(secret_hex).map_err(|e| format!("Decode encryption secret hex ({e})"))?; + if secret.is_empty() { + return Err("encryption secret must not be empty".to_string()); + } + let hk = Hkdf::::new(Some(salt), &secret); + let mut key = [0u8; 32]; + hk.expand(CONNECT_MESSAGE_KEY_INFO, &mut key) + .map_err(|e| format!("HKDF expand connect key ({e})"))?; + Ok(key) +} + +// --- AAD --- + +/// Builds the AAD (Associated Authenticated Data) bytes for a connect message. +/// +/// AAD includes: `v`, `session_id`, `dir`, `seq`, `type`, `ts`. +pub fn connect_message_aad( + session_id: &str, + dir: &str, + seq: u64, + msg_type: &str, + ts: u64, +) -> Result, String> { + serde_json::to_vec(&serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": session_id, + "dir": dir, + "seq": seq, + "type": msg_type, + "ts": ts, + })) + .map_err(|e| format!("Serialize connect message AAD ({e})")) +} + +// --- Input normalization --- + +/// Normalizes a uint256 hex value: strips `0x` prefix, left-pads to 64 chars, +/// lowercases, and prepends `0x`. +/// +/// # Examples +/// - `"0x1"` → `"0x0000...0001"` +/// - `"FF"` → `"0x0000...00ff"` +pub fn normalize_uint256_hex(value: &str) -> Result { + let stripped = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value); + if stripped.is_empty() { + return Err("app_id must be a non-empty uint256 hex string".to_string()); + } + if stripped.len() > 64 { + return Err(format!("app_id hex is too long for uint256 ({} > 64)", stripped.len())); + } + if !stripped.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("app_id is not valid hex: {value}")); + } + Ok(format!("0x{:0>64}", stripped.to_ascii_lowercase())) +} + +/// Normalizes an owner public key hex: strips `0x` prefix, lowercases, +/// validates exactly 64 hex chars (32 bytes). +/// +/// # Examples +/// - `"0xAABB...CC"` → `"aabb...cc"` (64 chars) +pub fn normalize_owner_public_hex(value: &str) -> Result { + let stripped = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value); + if stripped.is_empty() { + return Err("owner_public must be non-empty hex string".to_string()); + } + if stripped.len() != 64 { + return Err(format!("owner_public must be 64 hex chars (got {})", stripped.len())); + } + if !stripped.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("owner_public is not valid hex: {value}")); + } + Ok(stripped.to_ascii_lowercase()) +} diff --git a/bee_connect/src/wasm.rs b/bee_connect/src/wasm.rs new file mode 100644 index 0000000..59fc064 --- /dev/null +++ b/bee_connect/src/wasm.rs @@ -0,0 +1,984 @@ +use js_sys::Array; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsError; +use wasm_bindgen::JsValue; + +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &str = r#" +/** + * Connect session state — opaque JSON blob passed between WASM calls. + * + * Secret fields (encryption_root, my_dh_secret, signing_secret) are sensitive. + * Store in secure storage (OS keychain), never log in plaintext. + * Parse with: `JSON.parse(sessionStateJson) as TConnectSessionState` + * Pass back as: `JSON.stringify(state)` + */ +export type TConnectSessionState = { + encryption_root: string; // hex, 32 bytes — DO NOT log + my_dh_secret: string; // hex, 32 bytes — DO NOT log + peer_dh_public: string; // hex, 32 bytes + signing_public: string; // hex, 32 bytes + signing_secret: string; // hex, 32 bytes — DO NOT log + created_at: number; // UNIX epoch seconds, set at handshake + expires_at: number; // UNIX epoch seconds (0 = no expiration, default = created_at + 24h) +}; +"#; + +use crate::ActiveConnectSession as CoreActiveConnectSession; +use crate::ConnectClient; +use crate::ConnectPayload as CoreConnectPayload; +use crate::ParamsOfCreateSharedKeySession; +use crate::ParamsOfDisconnectSession; +use crate::ParamsOfQueryActiveSessionsByMultifactor; +use crate::ParamsOfRequestSetMiningKeys; +use crate::ParamsOfRequestSignChallenge; +use crate::ParamsOfResolveProfileAddress; +use crate::ParamsOfWaitChallengeResponse; +use crate::ParamsOfWaitSetMiningKeysRequest; +use crate::ParamsOfWaitWalletHello; +use crate::ResultOfCreateSharedKeySession as CoreResultOfCreateSharedKeySession; +use crate::ResultOfDisconnectSession as CoreResultOfDisconnectSession; +use crate::ResultOfQueryActiveSessionsByMultifactor as CoreResultOfQueryActiveSessionsByMultifactor; +use crate::ResultOfRequestSetMiningKeys as CoreResultOfRequestSetMiningKeys; +use crate::ResultOfRequestSignChallenge as CoreResultOfRequestSignChallenge; +use crate::ResultOfWaitChallengeResponse as CoreResultOfWaitChallengeResponse; +use crate::ResultOfWaitSetMiningKeysRequest as CoreResultOfWaitSetMiningKeysRequest; +use crate::ResultOfWaitWalletHello as CoreResultOfWaitWalletHello; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfCreateSharedKeySession { + session_id: String, + description: String, + created_at: u64, + expires_at: u64, + app_id: String, + payload_json: String, + deep_link: String, + payload_b64url: String, + client_dh_public: String, + client_dh_secret: String, +} + +impl From for ResultOfCreateSharedKeySession { + fn from(value: CoreResultOfCreateSharedKeySession) -> Self { + Self { + session_id: value.session_id, + description: value.description, + created_at: value.created_at, + expires_at: value.expires_at, + app_id: value.app_id, + payload_json: value.payload_json, + deep_link: value.deep_link, + payload_b64url: value.payload_b64url, + client_dh_public: value.client_dh_public, + client_dh_secret: value.client_dh_secret, + } + } +} + +#[wasm_bindgen] +impl ResultOfCreateSharedKeySession { + #[wasm_bindgen(getter)] + pub fn session_id(&self) -> String { + self.session_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn description(&self) -> String { + self.description.clone() + } + + #[wasm_bindgen(getter)] + pub fn created_at(&self) -> u64 { + self.created_at + } + + #[wasm_bindgen(getter)] + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + #[wasm_bindgen(getter)] + pub fn app_id(&self) -> String { + self.app_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn payload_json(&self) -> String { + self.payload_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn deep_link(&self) -> String { + self.deep_link.clone() + } + + #[wasm_bindgen(getter)] + pub fn payload_b64url(&self) -> String { + self.payload_b64url.clone() + } + + #[wasm_bindgen(getter)] + pub fn client_dh_public(&self) -> String { + self.client_dh_public.clone() + } + + #[wasm_bindgen(getter)] + pub fn client_dh_secret(&self) -> String { + self.client_dh_secret.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ParsedConnectPayload { + v: String, + session_id: String, + description: String, + expires_at: u64, + app_id: String, + nonce: Option, +} + +impl From for ParsedConnectPayload { + fn from(value: CoreConnectPayload) -> Self { + Self { + v: value.v, + session_id: value.session_id, + description: value.description, + expires_at: value.expires_at, + app_id: value.app_id, + nonce: value.nonce, + } + } +} + +#[wasm_bindgen] +impl ParsedConnectPayload { + #[wasm_bindgen(getter)] + pub fn v(&self) -> String { + self.v.clone() + } + + #[wasm_bindgen(getter)] + pub fn session_id(&self) -> String { + self.session_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn description(&self) -> String { + self.description.clone() + } + + #[wasm_bindgen(getter)] + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + #[wasm_bindgen(getter)] + pub fn app_id(&self) -> String { + self.app_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn nonce(&self) -> Option { + self.nonce.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfWaitWalletHello { + profile_address: String, + event_id: String, + event_created_at: u64, + wallet_name: String, + wallet_address: String, + raw_message_json: String, + session_state_json: String, + nonce: Option, + signature: Option, + epk_public: Option, +} + +impl From for ResultOfWaitWalletHello { + fn from(value: CoreResultOfWaitWalletHello) -> Self { + Self { + profile_address: value.profile_address, + event_id: value.event_id, + event_created_at: value.event_created_at, + wallet_name: value.wallet_name, + wallet_address: value.wallet_address, + raw_message_json: value.raw_message_json, + session_state_json: serde_json::to_string(&value.session_state).unwrap_or_default(), + nonce: value.nonce, + signature: value.signature, + epk_public: value.epk_public, + } + } +} + +#[wasm_bindgen] +impl ResultOfWaitWalletHello { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn event_id(&self) -> String { + self.event_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn event_created_at(&self) -> u64 { + self.event_created_at + } + + #[wasm_bindgen(getter)] + pub fn wallet_name(&self) -> String { + self.wallet_name.clone() + } + + #[wasm_bindgen(getter)] + pub fn wallet_address(&self) -> String { + self.wallet_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn raw_message_json(&self) -> String { + self.raw_message_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn session_state_json(&self) -> String { + self.session_state_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn nonce(&self) -> Option { + self.nonce.clone() + } + + #[wasm_bindgen(getter)] + pub fn signature(&self) -> Option { + self.signature.clone() + } + + #[wasm_bindgen(getter)] + pub fn epk_public(&self) -> Option { + self.epk_public.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfDisconnectSession { + profile_address: String, + message_id: Option, + raw_message_json: String, + updated_session_state_json: String, +} + +impl From for ResultOfDisconnectSession { + fn from(value: CoreResultOfDisconnectSession) -> Self { + Self { + profile_address: value.profile_address, + message_id: value.message_id, + raw_message_json: value.raw_message_json, + updated_session_state_json: serde_json::to_string(&value.updated_session_state) + .unwrap_or_default(), + } + } +} + +#[wasm_bindgen] +impl ResultOfDisconnectSession { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn message_id(&self) -> Option { + self.message_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn raw_message_json(&self) -> String { + self.raw_message_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn updated_session_state_json(&self) -> String { + self.updated_session_state_json.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfRequestSetMiningKeys { + profile_address: String, + message_id: Option, + app_id: String, + owner_public: String, + raw_message_json: String, + updated_session_state_json: String, +} + +impl From for ResultOfRequestSetMiningKeys { + fn from(value: CoreResultOfRequestSetMiningKeys) -> Self { + Self { + profile_address: value.profile_address, + message_id: value.message_id, + app_id: value.app_id, + owner_public: value.owner_public, + raw_message_json: value.raw_message_json, + updated_session_state_json: serde_json::to_string(&value.updated_session_state) + .unwrap_or_default(), + } + } +} + +#[wasm_bindgen] +impl ResultOfRequestSetMiningKeys { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn message_id(&self) -> Option { + self.message_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn app_id(&self) -> String { + self.app_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn owner_public(&self) -> String { + self.owner_public.clone() + } + + #[wasm_bindgen(getter)] + pub fn raw_message_json(&self) -> String { + self.raw_message_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn updated_session_state_json(&self) -> String { + self.updated_session_state_json.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfWaitSetMiningKeysRequest { + profile_address: String, + event_id: String, + event_created_at: u64, + app_id: String, + owner_public: String, + raw_message_json: String, + updated_session_state_json: Option, +} + +impl From for ResultOfWaitSetMiningKeysRequest { + fn from(value: CoreResultOfWaitSetMiningKeysRequest) -> Self { + Self { + profile_address: value.profile_address, + event_id: value.event_id, + event_created_at: value.event_created_at, + app_id: value.app_id, + owner_public: value.owner_public, + raw_message_json: value.raw_message_json, + updated_session_state_json: value + .updated_session_state + .map(|s| serde_json::to_string(&s).unwrap_or_default()), + } + } +} + +#[wasm_bindgen] +impl ResultOfWaitSetMiningKeysRequest { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn event_id(&self) -> String { + self.event_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn event_created_at(&self) -> u64 { + self.event_created_at + } + + #[wasm_bindgen(getter)] + pub fn app_id(&self) -> String { + self.app_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn owner_public(&self) -> String { + self.owner_public.clone() + } + + #[wasm_bindgen(getter)] + pub fn raw_message_json(&self) -> String { + self.raw_message_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn updated_session_state_json(&self) -> Option { + self.updated_session_state_json.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ActiveConnectSession { + profile_address: String, + description: String, + app_id: Option, + session_id: Option, + deployed_event_id: String, + deployed_at: u64, +} + +impl From for ActiveConnectSession { + fn from(value: CoreActiveConnectSession) -> Self { + Self { + profile_address: value.profile_address, + description: value.description, + app_id: value.app_id, + session_id: value.session_id, + deployed_event_id: value.deployed_event_id, + deployed_at: value.deployed_at, + } + } +} + +#[wasm_bindgen] +impl ActiveConnectSession { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn description(&self) -> String { + self.description.clone() + } + + #[wasm_bindgen(getter)] + pub fn app_id(&self) -> Option { + self.app_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn session_id(&self) -> Option { + self.session_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn deployed_event_id(&self) -> String { + self.deployed_event_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn deployed_at(&self) -> u64 { + self.deployed_at + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfQueryActiveSessionsByMultifactor { + sessions: Vec, + next_before: Option, + exhausted_active: bool, +} + +impl From + for ResultOfQueryActiveSessionsByMultifactor +{ + fn from(value: CoreResultOfQueryActiveSessionsByMultifactor) -> Self { + Self { + sessions: value.sessions.into_iter().map(ActiveConnectSession::from).collect(), + next_before: value.next_before, + exhausted_active: value.exhausted_active, + } + } +} + +#[wasm_bindgen] +impl ResultOfQueryActiveSessionsByMultifactor { + #[wasm_bindgen(getter)] + pub fn sessions(&self) -> Array { + self.sessions.iter().cloned().map(JsValue::from).collect() + } + + #[wasm_bindgen(getter)] + pub fn next_before(&self) -> Option { + self.next_before.clone() + } + + #[wasm_bindgen(getter)] + pub fn exhausted_active(&self) -> bool { + self.exhausted_active + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfRequestSignChallenge { + profile_address: String, + message_id: Option, + nonce: String, + raw_message_json: String, + updated_session_state_json: String, + sent_at: u64, +} + +impl From for ResultOfRequestSignChallenge { + fn from(value: CoreResultOfRequestSignChallenge) -> Self { + Self { + profile_address: value.profile_address, + message_id: value.message_id, + nonce: value.nonce, + raw_message_json: value.raw_message_json, + updated_session_state_json: serde_json::to_string(&value.updated_session_state) + .unwrap_or_default(), + sent_at: value.sent_at, + } + } +} + +#[wasm_bindgen] +impl ResultOfRequestSignChallenge { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn message_id(&self) -> Option { + self.message_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn nonce(&self) -> String { + self.nonce.clone() + } + + #[wasm_bindgen(getter)] + pub fn raw_message_json(&self) -> String { + self.raw_message_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn updated_session_state_json(&self) -> String { + self.updated_session_state_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn sent_at(&self) -> u64 { + self.sent_at + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfWaitChallengeResponse { + profile_address: String, + event_id: String, + event_created_at: u64, + nonce: String, + signature: String, + wallet_address: String, + epk_public: Option, + raw_message_json: String, + updated_session_state_json: Option, +} + +impl From for ResultOfWaitChallengeResponse { + fn from(value: CoreResultOfWaitChallengeResponse) -> Self { + Self { + profile_address: value.profile_address, + event_id: value.event_id, + event_created_at: value.event_created_at, + nonce: value.nonce, + signature: value.signature, + wallet_address: value.wallet_address, + epk_public: value.epk_public, + raw_message_json: value.raw_message_json, + updated_session_state_json: value + .updated_session_state + .map(|s| serde_json::to_string(&s).unwrap_or_default()), + } + } +} + +#[wasm_bindgen] +impl ResultOfWaitChallengeResponse { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn event_id(&self) -> String { + self.event_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn event_created_at(&self) -> u64 { + self.event_created_at + } + + #[wasm_bindgen(getter)] + pub fn nonce(&self) -> String { + self.nonce.clone() + } + + #[wasm_bindgen(getter)] + pub fn signature(&self) -> String { + self.signature.clone() + } + + #[wasm_bindgen(getter)] + pub fn wallet_address(&self) -> String { + self.wallet_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn epk_public(&self) -> Option { + self.epk_public.clone() + } + + #[wasm_bindgen(getter)] + pub fn raw_message_json(&self) -> String { + self.raw_message_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn updated_session_state_json(&self) -> Option { + self.updated_session_state_json.clone() + } +} + +#[wasm_bindgen] +pub struct BeeConnect { + inner: ConnectClient, +} + +#[wasm_bindgen] +impl BeeConnect { + /// Creates a new wasm-facing `bee_connect` client wrapper. + #[wasm_bindgen(constructor)] + pub fn new(max_rps: Option) -> Self { + let inner = match max_rps { + Some(rps) => ConnectClient::with_rate_limit(rps), + None => ConnectClient::new(), + }; + Self { inner } + } + + /// Small health-check helper for smoke tests. + #[wasm_bindgen(js_name = ping)] + pub fn ping(&self) -> String { + self.inner.ping().to_string() + } + + /// Creates a `shared_key` session and returns payload + temporary owner + /// keys. + #[wasm_bindgen(js_name = create_shared_key_session)] + pub fn create_shared_key_session( + &self, + app_id: String, + ttl_secs: Option, + nonce: Option, + ) -> Result { + let result = self + .inner + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id, + ttl_secs: ttl_secs.map(u64::from), + nonce, + }) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfCreateSharedKeySession::from(result)) + } + + /// Decodes and validates base64url connect payload (`payload` query + /// value). + #[wasm_bindgen(js_name = decode_connect_payload_b64url)] + pub fn decode_connect_payload_b64url( + &self, + payload_b64url: String, + ) -> Result { + let payload = self + .inner + .decode_connect_payload_b64url(payload_b64url) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ParsedConnectPayload::from(payload)) + } + + /// Resolves deterministic `AuthProfile` address by `description`. + #[wasm_bindgen(js_name = resolve_profile_address)] + pub async fn resolve_profile_address( + &self, + endpoints: Vec, + description: String, + ) -> Result { + self.inner + .get_profile_address(ParamsOfResolveProfileAddress { endpoints, description }) + .await + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Returns `true` if session profile is currently deployed. + #[wasm_bindgen(js_name = is_session_profile_deployed)] + pub async fn is_session_profile_deployed( + &self, + endpoints: Vec, + description: String, + ) -> Result { + self.inner + .is_session_profile_deployed(ParamsOfResolveProfileAddress { endpoints, description }) + .await + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Waits for the wallet's first `wallet_hello` message on the profile. + #[wasm_bindgen(js_name = wait_wallet_hello)] + pub async fn wait_wallet_hello( + &self, + endpoints: Vec, + session_id: String, + description: String, + client_dh_secret: String, + created_at_from: Option, + max_attempts: Option, + interval_ms: Option, + ) -> Result { + let result = self + .inner + .wait_wallet_hello(ParamsOfWaitWalletHello { + endpoints, + session_id, + description, + client_dh_secret, + created_at_from, + max_attempts, + interval_ms: interval_ms.map(u64::from), + }) + .await + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfWaitWalletHello::from(result)) + } + + /// Waits for `set_mining_keys` request (`dir = c2w`) in session profile. + #[wasm_bindgen(js_name = wait_set_mining_keys_request)] + pub async fn wait_set_mining_keys_request( + &self, + endpoints: Vec, + session_id: String, + description: String, + created_at_from: Option, + max_attempts: Option, + interval_ms: Option, + session_state_json: Option, + ) -> Result { + let session_state = session_state_json + .map(|json| { + serde_json::from_str::(&json) + .map_err(|e| JsError::new(&format!("Bad ConnectSessionState JSON: {e}"))) + }) + .transpose()?; + + let result = self + .inner + .wait_set_mining_keys_request(ParamsOfWaitSetMiningKeysRequest { + endpoints, + session_id, + description, + session_state, + created_at_from, + max_attempts, + interval_ms: interval_ms.map(u64::from), + }) + .await + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfWaitSetMiningKeysRequest::from(result)) + } + + /// Sends a `client_disconnect` message (`dir = c2w`) to the connected + /// profile. Performs DH re-key for forward secrecy. + #[wasm_bindgen(js_name = disconnect_session)] + pub async fn disconnect_session( + &self, + endpoints: Vec, + session_id: String, + description: String, + session_state_json: String, + reason: Option, + max_attempts: Option, + interval_ms: Option, + ) -> Result { + let session_state: crate::dh::ConnectSessionState = + serde_json::from_str(&session_state_json) + .map_err(|e| JsError::new(&format!("Bad ConnectSessionState JSON: {e}")))?; + + let result = self + .inner + .disconnect_session(ParamsOfDisconnectSession { + endpoints, + session_id, + description, + session_state, + reason, + max_attempts, + interval_ms: interval_ms.map(u64::from), + }) + .await + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfDisconnectSession::from(result)) + } + + /// Sends `set_mining_keys` request (`dir = c2w`) to wallet over connect + /// profile. Performs DH re-key for forward secrecy. + #[wasm_bindgen(js_name = request_set_mining_keys)] + pub async fn request_set_mining_keys( + &self, + endpoints: Vec, + session_id: String, + description: String, + session_state_json: String, + app_id: String, + owner_public: String, + max_attempts: Option, + interval_ms: Option, + ) -> Result { + let session_state: crate::dh::ConnectSessionState = + serde_json::from_str(&session_state_json) + .map_err(|e| JsError::new(&format!("Bad ConnectSessionState JSON: {e}")))?; + + let result = self + .inner + .request_set_mining_keys(ParamsOfRequestSetMiningKeys { + endpoints, + session_id, + description, + session_state, + app_id, + owner_public, + max_attempts, + interval_ms: interval_ms.map(u64::from), + }) + .await + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfRequestSetMiningKeys::from(result)) + } + + /// Sends `sign_challenge` (`dir = c2w`) to the wallet. The wallet should + /// sign the nonce and respond with `challenge_response`. + #[wasm_bindgen(js_name = request_sign_challenge)] + pub async fn request_sign_challenge( + &self, + endpoints: Vec, + session_id: String, + description: String, + session_state_json: String, + nonce: String, + max_attempts: Option, + interval_ms: Option, + ) -> Result { + let session_state: crate::dh::ConnectSessionState = + serde_json::from_str(&session_state_json) + .map_err(|e| JsError::new(&format!("Bad ConnectSessionState JSON: {e}")))?; + + let result = self + .inner + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints, + session_id, + description, + session_state, + nonce, + max_attempts, + interval_ms: interval_ms.map(u64::from), + }) + .await + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfRequestSignChallenge::from(result)) + } + + /// Waits for `challenge_response` (`dir = w2c`) from the wallet. + #[wasm_bindgen(js_name = wait_challenge_response)] + pub async fn wait_challenge_response( + &self, + endpoints: Vec, + session_id: String, + description: String, + session_state_json: Option, + created_at_from: Option, + max_attempts: Option, + interval_ms: Option, + ) -> Result { + let session_state = session_state_json + .map(|json| { + serde_json::from_str::(&json) + .map_err(|e| JsError::new(&format!("Bad ConnectSessionState JSON: {e}"))) + }) + .transpose()?; + + let result = self + .inner + .wait_challenge_response(ParamsOfWaitChallengeResponse { + endpoints, + session_id, + description, + session_state, + created_at_from, + max_attempts, + interval_ms: interval_ms.map(u64::from), + }) + .await + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfWaitChallengeResponse::from(result)) + } + + /// Queries one chunk of active connect sessions by multifactor. + /// + /// Returns at most 10 deployed `bee_connect` sessions and a cursor for the + /// next chunk. Optional `app_id` filters to one application. + #[wasm_bindgen(js_name = query_active_sessions_by_multifactor)] + pub async fn query_active_sessions_by_multifactor( + &self, + endpoints: Vec, + multifactor_address: String, + app_id: Option, + created_at_from: Option, + before: Option, + ) -> Result { + let result = self + .inner + .query_active_sessions_by_multifactor(ParamsOfQueryActiveSessionsByMultifactor { + endpoints, + multifactor_address, + app_id, + created_at_from, + before, + }) + .await + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(ResultOfQueryActiveSessionsByMultifactor::from(result)) + } +} diff --git a/bee_connect/tests/live_network.rs b/bee_connect/tests/live_network.rs new file mode 100644 index 0000000..fd9374a --- /dev/null +++ b/bee_connect/tests/live_network.rs @@ -0,0 +1,803 @@ +// Live network tests run by default — shellnet is always available. +// Tests share on-chain state, so they must run sequentially. +use std::collections::HashSet; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use ackinacki_kit::contracts::account::AccountStatus; +use ackinacki_kit::contracts::account::ParamsOfWaitAccount; +use ackinacki_kit::contracts::authservice::profile::AuthProfile; +use ackinacki_kit::contracts::authservice::root::AuthServiceRoot; +use ackinacki_kit::contracts::authservice::root::ParamsOfDeployProfileByIdentity; +use ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress; +use ackinacki_kit::contracts::authservice::root::ParamsOfHashMultifactor; +use ackinacki_kit::contracts::authservice::root::ParamsOfHashPubkey; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSubmitTransaction; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::ClientConfig; +use ackinacki_kit::tvm_client::ClientContext; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use bee_connect::ConnectClient; +use bee_connect::ParamsOfCreateSharedKeySession; +use bee_connect::ParamsOfDisconnectSession; +use bee_connect::ParamsOfQueryActiveSessionsByMultifactor; +use bee_connect::ParamsOfRequestSetMiningKeys; +use bee_connect::ParamsOfResolveProfileAddress; +use bee_connect::ParamsOfWaitSetMiningKeysRequest; +use bee_connect::ParamsOfWaitWalletHello; +use chacha20poly1305::aead::Aead; +use chacha20poly1305::aead::KeyInit; +use chacha20poly1305::aead::Payload; +use chacha20poly1305::XChaCha20Poly1305; +use chacha20poly1305::XNonce; +use hkdf::Hkdf; +use serial_test::serial; +use sha2::Sha256; + +const ENDPOINT: &str = "https://shellnet.ackinacki.org"; +const AUTH_SERVICE_MULTIFACTOR_ADDRESS: &str = + "0:3d51528b8ad806dea2018d24fa9a428386f1c6883fb0944684fc08c4bbbe223a"; +const AUTH_SERVICE_MULTIFACTOR_EPK: &str = + "2b9d728a42e05dfe43a10fa0d8e16b0b06ad482822309fe1aabac01aff8b34ee"; +const AUTH_SERVICE_MULTIFACTOR_ESK: &str = + "8328afbf10019fa8d0002a3764f8bb433f8f5cf84ec51af0cdc172c6ef72dd29"; +const AUTH_SERVICE_MULTIFACTOR_EPK_EXPIRE_AT: u64 = 1_788_095_099; + +#[tokio::test] +#[serial] +async fn test_shared_key_handshake() { + let context = create_context(); + let root = AuthServiceRoot::new_default(context.clone()); + + let client = ConnectClient::new(); + let session = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x1".to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session"); + + // Simulate wallet-side DH key exchange + let wallet_dh = bee_connect::dh::generate_dh_keypair().unwrap(); + let shared = + bee_connect::dh::compute_shared_secret(&wallet_dh.secret_hex, &session.client_dh_public) + .unwrap(); + let keys = bee_connect::dh::derive_session_keys(&shared, &session.session_id).unwrap(); + let signing_keys = KeyPair { + public: keys.signing_public_hex.clone(), + secret: keys.signing_secret_hex.to_string(), + }; + + let started_at = now_secs().saturating_sub(5); + let endpoints = vec![ENDPOINT.to_string()]; + + let profile_address = client + .get_profile_address(ParamsOfResolveProfileAddress { + endpoints: endpoints.clone(), + description: session.description.clone(), + }) + .await + .expect("resolve profile address"); + + let pubkey_hash = root + .hash_pubkey(ParamsOfHashPubkey { pubkey: format!("0x{}", signing_keys.public) }) + .await + .expect("hash_pubkey") + .hash; + let multifactor_hash = root + .hash_multifactor(ParamsOfHashMultifactor { + multifactor: AUTH_SERVICE_MULTIFACTOR_ADDRESS.to_string(), + }) + .await + .expect("hash_multifactor") + .hash; + + let expected_profile = root + .get_profile_address(ParamsOfGetProfileAddress { description: session.description.clone() }) + .await + .expect("root get_profile_address") + .profile; + assert_eq!(profile_address.to_lowercase(), expected_profile.to_lowercase()); + + let deploy_result = root + .deploy_profile_by_identity( + ParamsOfDeployProfileByIdentity { + pubkey: format!("0x{}", signing_keys.public), + multifactor_address: AUTH_SERVICE_MULTIFACTOR_ADDRESS.to_string(), + description: session.description.clone(), + }, + Signer::None, + ) + .await + .expect("deploy_profile_by_identity"); + ensure_tx_success(deploy_result, "deploy_profile_by_identity"); + + let profile = AuthProfile::new_default(context.clone(), &profile_address); + profile + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::Active, + attempts: Some(60), + attempts_timeout: Some(2_000), + }) + .await + .expect("wait profile active"); + + let details = profile.get_details().await.expect("get profile details"); + assert_eq!(details.description, session.description); + assert_eq!(details.pubkey_hash.to_lowercase(), pubkey_hash.to_lowercase()); + assert_eq!(details.multifactor_hash.to_lowercase(), multifactor_hash.to_lowercase()); + + // Wallet-side: send encrypted wallet_hello with DH public key + let wallet_hello_json = encode_wallet_hello( + &session.session_id, + "shellnet_test_wallet", + "0:feedbeef", + &keys.encryption_root_hex, + &wallet_dh.public_hex, + ); + let hello_result = profile + .add_context_text(&wallet_hello_json, Signer::Keys { keys: signing_keys.clone() }) + .await + .expect("add_context_text wallet_hello"); + ensure_tx_success(hello_result, "wallet_hello add_context"); + + // Client-side: wait for wallet_hello, finalize DH handshake + let hello = retry_on_transient("wait_wallet_hello", || { + let client = client.clone(); + let endpoints = endpoints.clone(); + let session_id = session.session_id.clone(); + let description = session.description.clone(); + let client_dh_secret = session.client_dh_secret.clone(); + async move { + client + .wait_wallet_hello(ParamsOfWaitWalletHello { + endpoints, + session_id, + description, + client_dh_secret, + created_at_from: Some(started_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + } + }) + .await + .expect("wait_wallet_hello"); + assert_eq!(hello.wallet_name, "shellnet_test_wallet"); + assert_eq!(hello.wallet_address, "0:feedbeef"); + assert_eq!(hello.profile_address.to_lowercase(), profile_address.to_lowercase()); + + let mut session_state = hello.session_state; + + // Client → Wallet: set_mining_keys + let request_set_keys = client + .request_set_mining_keys(ParamsOfRequestSetMiningKeys { + endpoints: endpoints.clone(), + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: session_state.clone(), + app_id: "0x1".to_string(), + owner_public: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_set_mining_keys"); + session_state = request_set_keys.updated_session_state.clone(); + assert_eq!(request_set_keys.profile_address.to_lowercase(), profile_address.to_lowercase()); + assert!(request_set_keys.message_id.is_some()); + + // Wallet reads set_mining_keys (using wallet-side session state) + let wallet_session_state = bee_connect::dh::create_initial_state( + &keys, + &wallet_dh.secret_hex, + &session.client_dh_public, + ); + let set_keys = client + .wait_set_mining_keys_request(ParamsOfWaitSetMiningKeysRequest { + endpoints: endpoints.clone(), + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: Some(wallet_session_state), + created_at_from: Some(started_at), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("wait_set_mining_keys_request"); + assert_eq!(set_keys.profile_address.to_lowercase(), profile_address.to_lowercase()); + assert_eq!( + set_keys.app_id, + "0x0000000000000000000000000000000000000000000000000000000000000001" + ); + assert_eq!( + set_keys.owner_public, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + + // Client → Wallet: disconnect + let disconnect = client + .disconnect_session(ParamsOfDisconnectSession { + endpoints: endpoints.clone(), + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: session_state.clone(), + reason: Some("user_requested".to_string()), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("disconnect_session"); + assert_eq!(disconnect.profile_address.to_lowercase(), profile_address.to_lowercase()); + assert!(disconnect.message_id.is_some()); + + // Cleanup (best-effort — multifactor may not be active on testnet) + destroy_profile_via_multifactor(context.clone(), &profile).await; + let _ = profile + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::NonExist, + attempts: Some(10), + attempts_timeout: Some(2_000), + }) + .await; +} + +#[tokio::test] +#[serial] +async fn test_query_active_sessions_by_multifactor_with_optional_app_id_filter() { + let context = create_context(); + let root = AuthServiceRoot::new_default(context.clone()); + let client = ConnectClient::new(); + let endpoints = vec![ENDPOINT.to_string()]; + let started_at = now_secs().saturating_sub(2); + + let app_id_a = format!("0x{:064x}", now_secs()); + let app_id_b = format!("0x{:064x}", now_secs().saturating_add(1)); + + let session_a = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: app_id_a.clone(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session a"); + let session_b = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: app_id_b.clone(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session b"); + + // Simulate wallet-side DH to derive signing keys for profile deployment + let wallet_dh_a = bee_connect::dh::generate_dh_keypair().unwrap(); + let shared_a = bee_connect::dh::compute_shared_secret( + &wallet_dh_a.secret_hex, + &session_a.client_dh_public, + ) + .unwrap(); + let keys_a = bee_connect::dh::derive_session_keys(&shared_a, &session_a.session_id).unwrap(); + + let wallet_dh_b = bee_connect::dh::generate_dh_keypair().unwrap(); + let shared_b = bee_connect::dh::compute_shared_secret( + &wallet_dh_b.secret_hex, + &session_b.client_dh_public, + ) + .unwrap(); + let keys_b = bee_connect::dh::derive_session_keys(&shared_b, &session_b.session_id).unwrap(); + + let malformed_description = format!("bee_connect:malformed:{}", now_secs()); + + let profile_a = deploy_profile_for_description( + &root, + &context, + &format!("0x{}", keys_a.signing_public_hex), + &session_a.description, + ) + .await; + let profile_b = deploy_profile_for_description( + &root, + &context, + &format!("0x{}", keys_b.signing_public_hex), + &session_b.description, + ) + .await; + let malformed_profile = deploy_profile_for_description( + &root, + &context, + &format!("0x{}", keys_a.signing_public_hex), + &malformed_description, + ) + .await; + + let test_result: Result<(), bee_connect::errors::AppError> = async { + let mut unfiltered = None; + for _ in 0..20 { + let page = retry_on_transient("query_active_sessions_unfiltered", || { + let client = client.clone(); + let endpoints = endpoints.clone(); + async move { + client + .query_active_sessions_by_multifactor( + ParamsOfQueryActiveSessionsByMultifactor { + endpoints, + multifactor_address: AUTH_SERVICE_MULTIFACTOR_ADDRESS.to_string(), + app_id: None, + created_at_from: Some(started_at), + before: None, + }, + ) + .await + } + }) + .await?; + + let has_a = page.sessions.iter().any(|s| { + s.description == session_a.description + && s.app_id.as_deref() == Some(session_a.app_id.as_str()) + }); + let has_b = page.sessions.iter().any(|s| { + s.description == session_b.description + && s.app_id.as_deref() == Some(session_b.app_id.as_str()) + }); + let has_malformed = page.sessions.iter().any(|s| { + s.description == malformed_description + && s.app_id.is_none() + && s.session_id.is_none() + }); + + if has_a && has_b && has_malformed { + unfiltered = Some(page); + break; + } + + tokio::time::sleep(Duration::from_secs(2)).await; + } + + let unfiltered = unfiltered.ok_or_else(|| { + "query_active_sessions_by_multifactor: expected sessions not found".to_string() + })?; + assert!(!unfiltered.sessions.is_empty()); + + let mut filtered = None; + for _ in 0..20 { + let page = retry_on_transient("query_active_sessions_filtered", || { + let client = client.clone(); + let endpoints = endpoints.clone(); + let app_id = session_a.app_id.clone(); + async move { + client + .query_active_sessions_by_multifactor( + ParamsOfQueryActiveSessionsByMultifactor { + endpoints, + multifactor_address: AUTH_SERVICE_MULTIFACTOR_ADDRESS.to_string(), + app_id: Some(app_id), + created_at_from: Some(started_at), + before: None, + }, + ) + .await + } + }) + .await?; + + let has_a = page.sessions.iter().any(|s| s.description == session_a.description); + if has_a { + filtered = Some(page); + break; + } + + tokio::time::sleep(Duration::from_secs(2)).await; + } + + let filtered = filtered.ok_or_else(|| { + "query_active_sessions_by_multifactor: filtered result empty".to_string() + })?; + assert!(filtered + .sessions + .iter() + .all(|s| s.app_id.as_deref() == Some(session_a.app_id.as_str()))); + assert!(filtered.sessions.iter().any(|s| s.description == session_a.description)); + assert!(!filtered.sessions.iter().any(|s| s.description == session_b.description)); + assert!(!filtered.sessions.iter().any(|s| s.description == malformed_description)); + + Ok(()) + } + .await; + + // Cleanup (best-effort — multifactor may not be active on testnet) + destroy_profile_via_multifactor(context.clone(), &profile_a).await; + destroy_profile_via_multifactor(context.clone(), &profile_b).await; + destroy_profile_via_multifactor(context.clone(), &malformed_profile).await; + + for (label, p) in [("a", &profile_a), ("b", &profile_b), ("malformed", &malformed_profile)] { + let _ = p + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::NonExist, + attempts: Some(10), + attempts_timeout: Some(2_000), + }) + .await + .inspect_err(|e| eprintln!("Warning: profile_{label} cleanup wait failed: {e}")); + } + + if let Err(err) = test_result { + panic!("{err}"); + } +} + +#[tokio::test] +#[serial] +async fn test_query_active_sessions_regression_known_multifactor_no_duplicates() { + let client = ConnectClient::new(); + let endpoints = vec![ENDPOINT.to_string()]; + + let first_page = retry_on_transient("query_active_sessions_regression_page1", || { + let client = client.clone(); + let endpoints = endpoints.clone(); + async move { + client + .query_active_sessions_by_multifactor(ParamsOfQueryActiveSessionsByMultifactor { + endpoints, + multifactor_address: AUTH_SERVICE_MULTIFACTOR_ADDRESS.to_string(), + app_id: None, + created_at_from: None, + before: None, + }) + .await + } + }) + .await + .expect("query first page"); + + assert_no_duplicate_events(&first_page.sessions); + + if let Some(next_before) = first_page.next_before.clone() { + let second_page = retry_on_transient("query_active_sessions_regression_page2", || { + let client = client.clone(); + let endpoints = endpoints.clone(); + let next_before = next_before.clone(); + async move { + client + .query_active_sessions_by_multifactor( + ParamsOfQueryActiveSessionsByMultifactor { + endpoints, + multifactor_address: AUTH_SERVICE_MULTIFACTOR_ADDRESS.to_string(), + app_id: None, + created_at_from: None, + before: Some(next_before), + }, + ) + .await + } + }) + .await + .expect("query second page"); + + assert_no_duplicate_events(&second_page.sessions); + + let first_events: HashSet = + first_page.sessions.iter().map(active_session_event_key).collect(); + let second_events: HashSet = + second_page.sessions.iter().map(active_session_event_key).collect(); + assert!( + first_events.is_disjoint(&second_events), + "pagination overlap by deploy event_id: first={first_events:?}, second={second_events:?}" + ); + } +} + +#[tokio::test] +#[serial] +async fn test_ping() { + let client = ConnectClient::new(); + let result = client.ping(); + assert!(!result.is_empty(), "ping must return a non-empty string"); +} + +#[tokio::test] +#[serial] +async fn test_is_session_profile_deployed() { + let context = create_context(); + let root = AuthServiceRoot::new_default(context.clone()); + let client = ConnectClient::new(); + let endpoints = vec![ENDPOINT.to_string()]; + + // Create a session so we get a valid description + let session = client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x1".to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session"); + + // Simulate wallet-side DH to derive signing keys for profile deployment + let wallet_dh = bee_connect::dh::generate_dh_keypair().unwrap(); + let shared = + bee_connect::dh::compute_shared_secret(&wallet_dh.secret_hex, &session.client_dh_public) + .unwrap(); + let keys = bee_connect::dh::derive_session_keys(&shared, &session.session_id).unwrap(); + + // Deploy profile + let profile = deploy_profile_for_description( + &root, + &context, + &format!("0x{}", keys.signing_public_hex), + &session.description, + ) + .await; + + // Assert deployed profile is detected + let deployed = retry_on_transient("is_session_profile_deployed_true", || { + let client = client.clone(); + let endpoints = endpoints.clone(); + let description = session.description.clone(); + async move { + client + .is_session_profile_deployed(ParamsOfResolveProfileAddress { + endpoints, + description, + }) + .await + } + }) + .await + .expect("is_session_profile_deployed for deployed profile"); + assert!(deployed, "expected deployed profile to return true"); + + // Assert bogus description returns false + let bogus_description = format!("bee_connect:{}:bogus_session_id", now_secs()); + let not_deployed = retry_on_transient("is_session_profile_deployed_false", || { + let client = client.clone(); + let endpoints = endpoints.clone(); + let description = bogus_description.clone(); + async move { + client + .is_session_profile_deployed(ParamsOfResolveProfileAddress { + endpoints, + description, + }) + .await + } + }) + .await + .expect("is_session_profile_deployed for bogus description"); + assert!(!not_deployed, "expected bogus description to return false"); + + // Cleanup + destroy_profile_via_multifactor(context.clone(), &profile).await; + let _ = profile + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::NonExist, + attempts: Some(10), + attempts_timeout: Some(2_000), + }) + .await; +} + +// --------------------------------------------------------------------------- +// Wallet-hello encoder (replicates bee_wallet logic for test isolation) +// --------------------------------------------------------------------------- + +/// Builds an encrypted `wallet_hello` JSON envelope identical to what +/// `bee_wallet::services::connect::encode_wallet_hello_message` produces. +fn encode_wallet_hello( + session_id: &str, + wallet_name: &str, + wallet_address: &str, + encryption_root_hex: &str, + wallet_dh_public_hex: &str, +) -> String { + let seq = now_millis(); + let ts = now_secs(); + + let body = serde_json::json!({ + "wallet_name": wallet_name, + "wallet_address": wallet_address, + }); + let plaintext = serde_json::to_vec(&body).expect("serialize wallet_hello body"); + + let aad = serde_json::to_vec(&serde_json::json!({ + "v": "bee_connect.msg/1", + "session_id": session_id, + "dir": "w2c", + "seq": seq, + "type": "wallet_hello", + "ts": ts, + })) + .expect("serialize AAD"); + + let mut nonce = [0u8; 24]; + getrandom::fill(&mut nonce).expect("generate nonce"); + let mut salt = [0u8; 32]; + getrandom::fill(&mut salt).expect("generate salt"); + + let root_bytes = hex::decode(encryption_root_hex).expect("decode encryption_root_hex"); + let hk = Hkdf::::new(Some(&salt), &root_bytes); + let mut key = [0u8; 32]; + hk.expand(b"bee_connect.msg.key.v1", &mut key).expect("HKDF expand"); + + let cipher = XChaCha20Poly1305::new((&key).into()); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: &plaintext, aad: &aad }) + .expect("encrypt wallet_hello"); + + let envelope = serde_json::json!({ + "v": "bee_connect.msg/1", + "session_id": session_id, + "dir": "w2c", + "seq": seq, + "type": "wallet_hello", + "ts": ts, + "dh_public": wallet_dh_public_hex, + "enc": { + "alg": "xchacha20poly1305-hkdf-sha256", + "nonce": URL_SAFE_NO_PAD.encode(nonce), + "salt": URL_SAFE_NO_PAD.encode(salt), + }, + "body": URL_SAFE_NO_PAD.encode(ciphertext), + }); + + serde_json::to_string(&envelope).expect("serialize wallet_hello envelope") +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn create_context() -> Arc { + let mut config = ClientConfig::default(); + config.network.endpoints = Some(vec![ENDPOINT.to_string()]); + Arc::new(ClientContext::new(config).expect("Create live network client context")) +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_secs() +} + +fn now_millis() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_millis() as u64 +} + +fn multifactor_signer() -> Signer { + Signer::Keys { + keys: KeyPair { + public: AUTH_SERVICE_MULTIFACTOR_EPK.to_string(), + secret: AUTH_SERVICE_MULTIFACTOR_ESK.to_string(), + }, + } +} + +async fn deploy_profile_for_description( + root: &AuthServiceRoot, + context: &Arc, + owner_pubkey: &str, + description: &str, +) -> AuthProfile { + let deploy_result = root + .deploy_profile_by_identity( + ParamsOfDeployProfileByIdentity { + pubkey: owner_pubkey.to_string(), + multifactor_address: AUTH_SERVICE_MULTIFACTOR_ADDRESS.to_string(), + description: description.to_string(), + }, + Signer::None, + ) + .await + .expect("deploy_profile_by_identity"); + ensure_tx_success(deploy_result, "deploy_profile_by_identity"); + + let profile_address = root + .get_profile_address(ParamsOfGetProfileAddress { description: description.to_string() }) + .await + .expect("get_profile_address") + .profile; + let profile = AuthProfile::new_default(context.clone(), &profile_address); + profile + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::Active, + attempts: Some(60), + attempts_timeout: Some(2_000), + }) + .await + .expect("wait profile active"); + profile +} + +async fn destroy_profile_via_multifactor(context: Arc, profile: &AuthProfile) { + let multifactor = Multifactor::new_default(context, AUTH_SERVICE_MULTIFACTOR_ADDRESS); + let send_result = retry_on_transient("destroy_profile_via_multifactor", || { + let multifactor = multifactor.clone(); + let profile_address = profile.address().to_string(); + async move { + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: profile_address, + value: 1_000_000_000, + cc: Default::default(), + bounce: false, + all_balance: false, + epk_expire_at: AUTH_SERVICE_MULTIFACTOR_EPK_EXPIRE_AT, + payload: String::new(), + }, + multifactor_signer(), + ) + .await + .map_err(|e| format!("submit_transaction destroy profile ({e})"))?; + ensure_tx_success(result, "destroy_profile_via_multifactor"); + Ok(()) + } + }) + .await; + + if let Err(e) = send_result { + eprintln!( + "Warning: failed to destroy profile {} (non-fatal cleanup): {e}", + profile.address() + ); + } +} + +fn ensure_tx_success( + result: ackinacki_kit::tvm_client::processing::ResultOfSendMessage, + context: &str, +) { + let aborted = result.aborted.unwrap_or(false); + let exit_code = result.exit_code.unwrap_or(0); + assert!(!(aborted || exit_code > 0), "{context}: tx aborted={aborted}, exit_code={exit_code}"); +} + +fn is_transient_client_error(err: &bee_connect::errors::AppError) -> bool { + let lower = err.message.to_ascii_lowercase(); + lower.contains("connection reset by peer") + || lower.contains("client error (sendrequest)") + || lower.contains("all attempts failed") +} + +async fn retry_on_transient( + label: &str, + mut action: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let max_attempts = 4; + for attempt in 1..=max_attempts { + match action().await { + Ok(v) => return Ok(v), + Err(err) if is_transient_client_error(&err) && attempt < max_attempts => { + eprintln!("{label}: transient error on attempt {attempt}/{max_attempts}: {err}"); + tokio::time::sleep(Duration::from_millis(700)).await; + } + Err(err) => return Err(err), + } + } + + unreachable!() +} + +fn active_session_event_key(session: &bee_connect::ActiveConnectSession) -> String { + session.deployed_event_id.clone() +} + +fn assert_no_duplicate_events(sessions: &[bee_connect::ActiveConnectSession]) { + let mut seen = HashSet::new(); + for session in sessions { + let key = active_session_event_key(session); + assert!(seen.insert(key.clone()), "duplicate deploy event in page: {key}"); + } +} diff --git a/bee_crypto/Cargo.toml b/bee_crypto/Cargo.toml new file mode 100644 index 0000000..71f1b83 --- /dev/null +++ b/bee_crypto/Cargo.toml @@ -0,0 +1,43 @@ +[package] +edition.workspace = true +name = "bee-crypto" +version.workspace = true +license.workspace = true + +[features] +default = ["ackinacki-kit/default", "bee-errors/default"] +wasm = [ + "dep:wasm-bindgen", + "dep:serde-wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:console_error_panic_hook", + "dep:js-sys", + "dep:web-sys", + "ackinacki-kit/wasm", + "bee-errors/wasm", +] +single-wasm = ["wasm"] + +[dependencies] +ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v3.0.0", default-features = false } +base64 = "0.22.1" +bee-errors = { path = "../bee_errors", default-features = false } +chacha20poly1305 = "0.10.1" +console_error_panic_hook = { optional = true, version = "0.1.7" } +ed25519-dalek = "2" +getrandom = { version = "0.3", features = ["std"] } +hex = { workspace = true } +pbkdf2 = { version = "0.12.2", features = ["simple"] } +serde = { workspace = true } +serde_json = { workspace = true } +serde-wasm-bindgen = { optional = true, version = "0.6.5" } +sha2 = "0.10.9" +subtle = "2.6.1" +zeroize = { version = "1.8", features = ["derive"] } +js-sys = { optional = true, version = "0.3.83" } +web-sys = { optional = true, version = "0.3.83", features = ["Crypto", "SubtleCrypto", "CryptoKey"] } +wasm-bindgen = { optional = true, version = "0.2.106", features = ["serde-serialize"] } +wasm-bindgen-futures = { optional = true, version = "0.4.56" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.3", features = ["wasm_js"] } diff --git a/bee_crypto/README.md b/bee_crypto/README.md new file mode 100644 index 0000000..90cf4e9 --- /dev/null +++ b/bee_crypto/README.md @@ -0,0 +1,62 @@ +# bee_crypto + +Reusable crypto library for Bee ecosystem crates. + +## Public API + +`bee_crypto` intentionally exposes a small surface: + +- native client: `bee_crypto::Crypto` +- wasm adapter module: `bee_crypto::wasm` +- error types: `bee_crypto::errors` + +Domain-specific wallet logic (for example multifactor key-derivation workflows) +is intentionally out of scope and should live in wallet domain modules. + +## Features + +- `default`: empty +- `wasm`: enables wasm bindings (`wasm-bindgen`, `serde-wasm-bindgen`) +- `single-wasm`: convenience alias for `wasm` + +## Native Usage (Rust) + +```rust,no_run +use bee_crypto::Crypto; + +let crypto = Crypto::new(vec!["mainnet.ackinacki.org".to_string()])?; +let password_hash = crypto.hash_password("my-password".to_string())?; +let ok = crypto.verify_password_hash("my-password".to_string(), password_hash)?; +assert!(ok); +# Ok::<(), bee_crypto::errors::AppError>(()) +``` + +If you already have `Arc`, prefer: + +```rust,no_run +use std::sync::Arc; +use ackinacki_kit::tvm_client::ClientContext; +use bee_crypto::Crypto; + +let tvm = Arc::new(ClientContext::new(Default::default())?); +let crypto = Crypto::from_client_context(tvm); +# Ok::<(), bee_crypto::errors::AppError>(()) +``` + +## Wasm Usage (JS/TS) + +```ts +import { Crypto } from "bee_crypto"; + +const crypto = new Crypto(["mainnet.ackinacki.org"]); +const hash = await crypto.hash_password("my-password"); +const ok = await crypto.verify_password_hash("my-password", hash); +``` + +## Build & Test + +```bash +cargo check -p bee-crypto +cargo test -p bee-crypto +cargo check -p bee-crypto --features wasm +``` diff --git a/bee_crypto/src/adapters/mod.rs b/bee_crypto/src/adapters/mod.rs new file mode 100644 index 0000000..877db93 --- /dev/null +++ b/bee_crypto/src/adapters/mod.rs @@ -0,0 +1,3 @@ +pub mod native; +#[cfg(feature = "wasm")] +pub mod wasm; diff --git a/bee_crypto/src/adapters/native/dto/crypto.rs b/bee_crypto/src/adapters/native/dto/crypto.rs new file mode 100644 index 0000000..9b4d4f6 --- /dev/null +++ b/bee_crypto/src/adapters/native/dto/crypto.rs @@ -0,0 +1,81 @@ +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::crypto::ResultOfSign as TvmResultOfSign; +use ackinacki_kit::tvm_client::crypto::ResultOfVerifySignature as TvmResultOfVerifySignature; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfSign { + pub signed: String, + pub signature: String, +} + +impl From for ResultOfSign { + fn from(value: TvmResultOfSign) -> Self { + Self { signed: value.signed, signature: value.signature } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfVerifySignature { + pub unsigned: String, +} + +impl From for ResultOfVerifySignature { + fn from(value: TvmResultOfVerifySignature) -> Self { + Self { unsigned: value.unsigned } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfEncrypt { + pub encrypted: String, +} + +impl From for ResultOfEncrypt { + fn from(value: String) -> Self { + Self { encrypted: value } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ResultOfGetKeys { + pub public: String, + pub secret: String, +} + +impl std::fmt::Debug for ResultOfGetKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResultOfGetKeys") + .field("public", &self.public) + .field("secret", &"[REDACTED]") + .finish() + } +} + +impl From for ResultOfGetKeys { + fn from(value: KeyPair) -> Self { + Self { public: value.public.clone(), secret: value.secret.clone() } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ResultOfGenSeedAndKeys { + pub phrase: String, + pub keys: ResultOfGetKeys, +} + +impl std::fmt::Debug for ResultOfGenSeedAndKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResultOfGenSeedAndKeys") + .field("phrase", &"[REDACTED]") + .field("keys", &self.keys) + .finish() + } +} + +impl From<(String, KeyPair)> for ResultOfGenSeedAndKeys { + fn from(params: (String, KeyPair)) -> Self { + Self { phrase: params.0, keys: ResultOfGetKeys::from(params.1) } + } +} diff --git a/bee_crypto/src/adapters/native/dto/mod.rs b/bee_crypto/src/adapters/native/dto/mod.rs new file mode 100644 index 0000000..274f0ed --- /dev/null +++ b/bee_crypto/src/adapters/native/dto/mod.rs @@ -0,0 +1 @@ +pub mod crypto; diff --git a/bee_crypto/src/adapters/native/mod.rs b/bee_crypto/src/adapters/native/mod.rs new file mode 100644 index 0000000..f2eaace --- /dev/null +++ b/bee_crypto/src/adapters/native/mod.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::ParamsOfHash; +use ackinacki_kit::tvm_client::crypto::ParamsOfSign; +use ackinacki_kit::tvm_client::crypto::ParamsOfVerifySignature; +use ackinacki_kit::tvm_client::crypto::ResultOfHash; +use ackinacki_kit::tvm_client::ClientContext; + +use crate::client::CryptoClient; +use crate::errors::AppResult; + +pub mod dto; + +/// High-level native API for local crypto operations. +pub struct Crypto { + inner: CryptoClient, +} + +impl Crypto { + /// Creates a crypto client bound to the provided network endpoints. + pub fn new(endpoints: Vec) -> AppResult { + Ok(Self { inner: CryptoClient::new(endpoints)? }) + } + + /// Creates a crypto client from an existing TVM client context. + pub fn from_client_context(tvm_client: Arc) -> Self { + Self { inner: CryptoClient::from_tvm_client(tvm_client) } + } + + /// Computes a password hash with random salt and version prefix. + #[cfg(not(target_arch = "wasm32"))] + pub fn hash_password(&self, data: String) -> AppResult { + self.inner.hash_password(data) + } + + #[cfg(target_arch = "wasm32")] + pub async fn hash_password(&self, data: String) -> AppResult { + self.inner.hash_password(data).await + } + + /// Verifies a plain password against a `v2` or `v3` hash string. + #[cfg(not(target_arch = "wasm32"))] + pub fn verify_password_hash(&self, password: String, expected: String) -> AppResult { + self.inner.verify_password_hash(&password, &expected) + } + + #[cfg(target_arch = "wasm32")] + pub async fn verify_password_hash( + &self, + password: String, + expected: String, + ) -> AppResult { + self.inner.verify_password_hash(&password, &expected).await + } + + /// Encrypts plaintext with a password and returns envelope metadata. + #[cfg(not(target_arch = "wasm32"))] + pub fn encrypt( + &self, + plaintext: String, + password: String, + ) -> AppResult { + let res = self.inner.encrypt(plaintext, password)?; + Ok(dto::crypto::ResultOfEncrypt::from(res)) + } + + #[cfg(target_arch = "wasm32")] + pub async fn encrypt( + &self, + plaintext: String, + password: String, + ) -> AppResult { + let res = self.inner.encrypt(plaintext, password).await?; + Ok(dto::crypto::ResultOfEncrypt::from(res)) + } + + /// Decrypts previously encrypted data with a password. + #[cfg(not(target_arch = "wasm32"))] + pub fn decrypt(&self, encrypted: String, password: String) -> AppResult { + self.inner.decrypt(encrypted, password) + } + + #[cfg(target_arch = "wasm32")] + pub async fn decrypt(&self, encrypted: String, password: String) -> AppResult { + self.inner.decrypt(encrypted, password).await + } + + /// Signs base64-encoded input with an Ed25519 key pair. + pub fn sign(&self, params: ParamsOfSign) -> AppResult { + let res = self.inner.sign(params)?; + Ok(dto::crypto::ResultOfSign::from(res)) + } + + /// Verifies signed data and returns base64-encoded unsigned payload. + pub fn verify_signature( + &self, + params: ParamsOfVerifySignature, + ) -> AppResult { + let res = self.inner.verify_signature(params)?; + Ok(dto::crypto::ResultOfVerifySignature::from(res)) + } + + /// Produces detached Ed25519 signature (`hex`) for hex-encoded input bytes. + pub fn sign_detached_hex(&self, data: String, secret_key: String) -> AppResult { + self.inner.sign_detached_hex(data, secret_key) + } + + /// Generates a short-lived mining key pair. + pub fn gen_mining_keys(&self) -> AppResult { + let key_pair = self.inner.gen_mining_keys()?; + Ok(dto::crypto::ResultOfGetKeys::from(key_pair)) + } + + /// Generates a 24-word mnemonic phrase and derives owner keys from it. + pub fn gen_mnemonic_and_derive_keys(&self) -> AppResult { + let res = self.inner.gen_mnemonic_and_derive_keys()?; + Ok(dto::crypto::ResultOfGenSeedAndKeys::from(res)) + } + + /// Derives owner keys from a mnemonic phrase. + pub fn get_keys_from_mnemonic( + &self, + phrase: String, + ) -> AppResult { + let res = self.inner.get_keys_from_mnemonic(phrase)?; + Ok(dto::crypto::ResultOfGetKeys::from(res)) + } + + /// Derives keys from a mnemonic phrase using a specific HD derivation path. + /// + /// Use incrementing paths to generate multiple key pairs from one seed, + /// e.g. `"m/44'/396'/0'/0/0"`, `"m/44'/396'/0'/0/1"`, etc. + pub fn get_keys_from_mnemonic_with_path( + &self, + phrase: String, + path: String, + ) -> AppResult { + let res = self.inner.get_keys_from_mnemonic_with_path(phrase, path)?; + Ok(dto::crypto::ResultOfGetKeys::from(res)) + } + + /// Verifies mnemonic phrase checksum and format. + pub fn verify_mnemonic(&self, phrase: String) -> AppResult { + self.inner.verify_mnemonic(phrase) + } + + /// Returns the full BIP39 dictionary used by mnemonic utilities. + pub fn mnemonic_words(&self) -> AppResult { + self.inner.mnemonic_words() + } + + /// Computes SHA-256 hash for input payload. + pub fn sha_256(&self, params: ParamsOfHash) -> AppResult { + self.inner.sha_256(params) + } + + /// Computes BOC hash for hex-encoded data. + pub fn get_boc_hash(&self, data: String) -> AppResult { + self.inner.get_boc_hash(data) + } + + /// Derives a 32-byte storage encryption key from password and salt. + /// PBKDF2-HMAC-SHA256, `PBKDF2_ITERATIONS` rounds. + /// + /// The returned key is wrapped in `Zeroizing` — it will be zeroed on drop. + /// Caller should cache the key for the session and drop it on lock/logout. + pub fn derive_storage_key(password: &[u8], salt: &[u8]) -> zeroize::Zeroizing<[u8; 32]> { + crate::services::storage::derive_storage_key(password, salt) + } + + /// Encrypts plaintext with a pre-derived 32-byte key. + /// Returns envelope: `enc1:{nonce_hex}:{ciphertext_hex}` + pub fn storage_encrypt(plaintext: &[u8], key: &[u8; 32]) -> AppResult { + crate::services::storage::storage_encrypt(plaintext, key) + } + + /// Decrypts an `enc1:` envelope with a pre-derived 32-byte key. + pub fn storage_decrypt(envelope: &str, key: &[u8; 32]) -> AppResult { + crate::services::storage::storage_decrypt(envelope, key) + } +} diff --git a/bee_crypto/src/adapters/wasm/dto/crypto.rs b/bee_crypto/src/adapters/wasm/dto/crypto.rs new file mode 100644 index 0000000..cf5d07d --- /dev/null +++ b/bee_crypto/src/adapters/wasm/dto/crypto.rs @@ -0,0 +1,103 @@ +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::crypto::ResultOfSign as TvmResultOfSign; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct CryptoResultOfSign { + /// Signed data combined with signature encoded in `base64`. + signed: String, + /// Signature encoded in `hex`. + signature: String, +} + +#[wasm_bindgen] +impl CryptoResultOfSign { + #[wasm_bindgen(getter)] + pub fn signed(&self) -> String { + self.signed.clone() + } + + #[wasm_bindgen(getter)] + pub fn signature(&self) -> String { + self.signature.clone() + } +} + +impl From for CryptoResultOfSign { + fn from(value: TvmResultOfSign) -> Self { + Self { signed: value.signed, signature: value.signature } + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct CryptoResultOfEncrypt { + encrypted: String, +} + +impl From for CryptoResultOfEncrypt { + fn from(value: String) -> Self { + Self { encrypted: value } + } +} + +#[wasm_bindgen] +impl CryptoResultOfEncrypt { + #[wasm_bindgen(getter)] + pub fn encrypted(&self) -> String { + self.encrypted.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct CryptoResultOfGetKeys { + public: String, + secret: String, +} + +impl From for CryptoResultOfGetKeys { + fn from(value: KeyPair) -> Self { + Self { public: value.public.clone(), secret: value.secret.clone() } + } +} + +#[wasm_bindgen] +impl CryptoResultOfGetKeys { + #[wasm_bindgen(getter)] + pub fn public(&self) -> String { + self.public.clone() + } + + #[wasm_bindgen(getter)] + pub fn secret(&self) -> String { + self.secret.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct CryptoResultOfGenSeedAndKeys { + phrase: String, + keys: CryptoResultOfGetKeys, +} + +impl From<(String, KeyPair)> for CryptoResultOfGenSeedAndKeys { + fn from(params: (String, KeyPair)) -> Self { + Self { phrase: params.0, keys: CryptoResultOfGetKeys::from(params.1) } + } +} + +#[wasm_bindgen] +impl CryptoResultOfGenSeedAndKeys { + #[wasm_bindgen(getter)] + pub fn phrase(&self) -> String { + self.phrase.clone() + } + + #[wasm_bindgen(getter)] + pub fn keys(&self) -> CryptoResultOfGetKeys { + self.keys.clone() + } +} diff --git a/bee_crypto/src/adapters/wasm/dto/mod.rs b/bee_crypto/src/adapters/wasm/dto/mod.rs new file mode 100644 index 0000000..66044a0 --- /dev/null +++ b/bee_crypto/src/adapters/wasm/dto/mod.rs @@ -0,0 +1,22 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +pub(crate) mod crypto; + +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &str = r#" +export type TKeyPair = { + public: string; + secret: string; +}; + +export type TParamsOfSign = { + unsigned: string; + keys: TKeyPair; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TParamsOfSign")] + pub type TParamsOfSign; +} diff --git a/bee_crypto/src/adapters/wasm/mod.rs b/bee_crypto/src/adapters/wasm/mod.rs new file mode 100644 index 0000000..1eb1b49 --- /dev/null +++ b/bee_crypto/src/adapters/wasm/mod.rs @@ -0,0 +1,182 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::ParamsOfSign; +use ackinacki_kit::tvm_client::ClientContext; +use serde_wasm_bindgen; +use wasm_bindgen::prelude::*; + +use crate::client::CryptoClient; + +mod dto; + +/// High-level wasm API for crypto operations. +#[wasm_bindgen] +pub struct Crypto { + inner: CryptoClient, +} + +impl Crypto { + /// Creates a crypto client from an existing TVM client context. + /// + /// This helper is primarily for internal Rust-side composition. + pub fn from_client_context(tvm_client: Arc) -> Self { + Self { inner: CryptoClient::from_tvm_client(tvm_client) } + } + + /// Produces detached Ed25519 signature (`hex`) for hex-encoded input bytes. + /// + /// Rust-only compatibility helper used by `bee_wallet` internals. + pub fn sign_detached_hex(&self, data: String, secret_key: String) -> Result { + self.inner + .sign_detached_hex(data, secret_key) + .map_err(|e| JsError::new(&format!("failed to sign detached hex: {e:?}"))) + } + + /// Computes BOC hash for hex-encoded data. + /// + /// Rust-only compatibility helper used by `bee_wallet` internals. + pub fn get_boc_hash(&self, data: String) -> Result { + self.inner + .get_boc_hash(data) + .map_err(|e| JsError::new(&format!("failed to get boc hash: {e:?}"))) + } +} + +#[wasm_bindgen] +impl Crypto { + /// Creates a crypto client bound to network endpoints. + #[wasm_bindgen(constructor)] + pub fn new(endpoints: Vec) -> Result { + let inner = CryptoClient::new(endpoints).map_err(|e| JsError::new(&e.to_string()))?; + Ok(Self { inner }) + } + + /// Computes a salted password hash in `v3::` format. + #[wasm_bindgen(js_name = hash_password)] + pub async fn hash_password(&self, data: String) -> Result { + Ok(self + .inner + .hash_password(data) + .await + .map_err(|e| JsError::new(&format!("Failed to hash password: {e:?}")))?) + } + + /// Verifies a plain password against a `v2` or `v3` hash. + #[wasm_bindgen(js_name = verify_password_hash)] + pub async fn verify_password_hash( + &self, + password: String, + expected: String, + ) -> Result { + Ok(self + .inner + .verify_password_hash(&password, &expected) + .await + .map_err(|e| JsError::new(&format!("Failed to verify pwd hash: {e:?}")))?) + } + + /// Encrypts plaintext with a password. + #[wasm_bindgen(js_name = encrypt)] + pub async fn encrypt( + &self, + plaintext: String, + password: String, + ) -> Result { + let res = self + .inner + .encrypt(plaintext, password) + .await + .map_err(|e| JsError::new(&format!("Failed to encrypt data: {e:?}")))?; + + Ok(dto::crypto::CryptoResultOfEncrypt::from(res)) + } + + /// Decrypts data previously encrypted with `encrypt`. + #[wasm_bindgen(js_name = decrypt)] + pub async fn decrypt(&self, encrypted: String, password: String) -> Result { + Ok(self + .inner + .decrypt(encrypted, password) + .await + .map_err(|e| JsError::new(&format!("Failed to decrypt data: {e:?}")))?) + } + + /// Signs base64-encoded payload with an Ed25519 keypair. + #[wasm_bindgen(js_name = sign)] + pub async fn sign( + &self, + params_js: dto::TParamsOfSign, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfSign = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsOfSign: {e:?}")))?; + let res = self + .inner + .sign(params) + .map_err(|e| JsError::new(&format!("failed to sign data: {e:?}")))?; + + Ok(dto::crypto::CryptoResultOfSign::from(res)) + } + + /// Generates a short-lived mining keypair. + #[wasm_bindgen(js_name = gen_mining_keys)] + pub async fn gen_mining_keys(&self) -> Result { + let key_pair = self + .inner + .gen_mining_keys() + .map_err(|e| JsError::new(&format!("failed to gen mining keys: {e:?}")))?; + + Ok(dto::crypto::CryptoResultOfGetKeys::from(key_pair)) + } + + /// Generates 24-word mnemonic and derives keys from it. + #[wasm_bindgen(js_name = gen_mnemonic_and_derive_keys)] + pub async fn gen_mnemonic_and_derive_keys( + &self, + ) -> Result { + let res = self + .inner + .gen_mnemonic_and_derive_keys() + .map_err(|e| JsError::new(&format!("failed to gen mnemonic and derive keys: {e:?}")))?; + + Ok(dto::crypto::CryptoResultOfGenSeedAndKeys::from(res)) + } + + /// Derives keys from a mnemonic phrase. + #[wasm_bindgen(js_name = get_keys_from_mnemonic)] + pub async fn get_keys_from_mnemonic( + &self, + phrase: String, + ) -> Result { + let res = self + .inner + .get_keys_from_mnemonic(phrase) + .map_err(|e| JsError::new(&format!("failed to derive keys from mnemonic: {e:?}")))?; + + Ok(dto::crypto::CryptoResultOfGetKeys::from(res)) + } + + /// Derives keys from a mnemonic phrase using a specific HD derivation path. + #[wasm_bindgen(js_name = get_keys_from_mnemonic_with_path)] + pub async fn get_keys_from_mnemonic_with_path( + &self, + phrase: String, + path: String, + ) -> Result { + let res = self + .inner + .get_keys_from_mnemonic_with_path(phrase, path) + .map_err(|e| JsError::new(&format!("failed to derive keys with path: {e:?}")))?; + + Ok(dto::crypto::CryptoResultOfGetKeys::from(res)) + } + + /// Verifies mnemonic checksum and format. + #[wasm_bindgen(js_name = verify_mnemonic)] + pub async fn verify_mnemonic(&self, phrase: String) -> Result { + Ok(self + .inner + .verify_mnemonic(phrase) + .map_err(|e| JsError::new(&format!("failed to verify mnemonic: {e:?}")))?) + } +} diff --git a/bee_crypto/src/client.rs b/bee_crypto/src/client.rs new file mode 100644 index 0000000..7fa0362 --- /dev/null +++ b/bee_crypto/src/client.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::crypto::ParamsOfHash; +use ackinacki_kit::tvm_client::crypto::ParamsOfSign; +use ackinacki_kit::tvm_client::crypto::ParamsOfVerifySignature; +use ackinacki_kit::tvm_client::crypto::ResultOfHash; +use ackinacki_kit::tvm_client::crypto::ResultOfSign; +use ackinacki_kit::tvm_client::crypto::ResultOfVerifySignature; +use ackinacki_kit::tvm_client::ClientConfig; +use ackinacki_kit::tvm_client::ClientContext; + +use crate::errors::AppError; +use crate::errors::AppResult; +use crate::services; +use crate::services::ParamsOfDecrypt; + +pub(crate) struct CryptoContext { + pub(crate) tvm_client: Arc, +} + +impl CryptoContext { + pub(crate) fn new(endpoints: Vec) -> AppResult { + #[cfg(feature = "wasm")] + console_error_panic_hook::set_once(); + + let mut config = ClientConfig::default(); + config.network.endpoints = Some(endpoints); + let client = ClientContext::new(config) + .map_err(|e| AppError::from(e).with_context("failed to create tvm client"))?; + + Ok(Self { tvm_client: Arc::new(client) }) + } + + pub(crate) fn from_tvm_client(tvm_client: Arc) -> Self { + #[cfg(feature = "wasm")] + console_error_panic_hook::set_once(); + + Self { tvm_client } + } +} + +pub(crate) struct CryptoClient { + ctx: CryptoContext, +} + +impl CryptoClient { + pub(crate) fn new(endpoints: Vec) -> AppResult { + Ok(Self { ctx: CryptoContext::new(endpoints)? }) + } + + pub(crate) fn from_tvm_client(tvm_client: Arc) -> Self { + Self { ctx: CryptoContext::from_tvm_client(tvm_client) } + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn hash_password(&self, data: String) -> AppResult { + services::random_salted_hash(self.ctx.tvm_client.clone(), data) + .map(|result_of_hash| result_of_hash.hash) + } + + #[cfg(target_arch = "wasm32")] + pub(crate) async fn hash_password(&self, data: String) -> AppResult { + services::random_salted_hash(self.ctx.tvm_client.clone(), data) + .await + .map(|result_of_hash| result_of_hash.hash) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn verify_password_hash(&self, password: &str, expected: &str) -> AppResult { + services::verify_salted_hash(password, expected) + } + + #[cfg(target_arch = "wasm32")] + pub(crate) async fn verify_password_hash( + &self, + password: &str, + expected: &str, + ) -> AppResult { + services::verify_salted_hash(password, expected).await + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn encrypt(&self, plaintext: String, password: String) -> AppResult { + services::encrypt(self.ctx.tvm_client.clone(), plaintext, password) + } + + #[cfg(target_arch = "wasm32")] + pub(crate) async fn encrypt(&self, plaintext: String, password: String) -> AppResult { + services::encrypt(self.ctx.tvm_client.clone(), plaintext, password).await + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn decrypt(&self, encrypted: String, password: String) -> AppResult { + services::decrypt(self.ctx.tvm_client.clone(), ParamsOfDecrypt { encrypted, password }) + } + + #[cfg(target_arch = "wasm32")] + pub(crate) async fn decrypt(&self, encrypted: String, password: String) -> AppResult { + services::decrypt(self.ctx.tvm_client.clone(), ParamsOfDecrypt { encrypted, password }) + .await + } + + pub(crate) fn sign(&self, params: ParamsOfSign) -> AppResult { + services::sign(self.ctx.tvm_client.clone(), params) + } + + pub(crate) fn verify_signature( + &self, + params: ParamsOfVerifySignature, + ) -> AppResult { + services::verify_signature(self.ctx.tvm_client.clone(), params) + } + + pub(crate) fn sign_detached_hex(&self, data: String, secret_key: String) -> AppResult { + services::sign_detached_hex(data, secret_key) + } + + pub(crate) fn gen_mining_keys(&self) -> AppResult { + let res = services::gen_mnemonic_and_derive_keys(self.ctx.tvm_client.clone(), 12)?; + Ok(res.1) + } + + pub(crate) fn gen_mnemonic_and_derive_keys(&self) -> AppResult<(String, KeyPair)> { + services::gen_mnemonic_and_derive_keys(self.ctx.tvm_client.clone(), 24) + } + + pub(crate) fn get_keys_from_mnemonic(&self, phrase: String) -> AppResult { + services::derive_keys_from_mnemonic(self.ctx.tvm_client.clone(), phrase) + } + + pub(crate) fn get_keys_from_mnemonic_with_path( + &self, + phrase: String, + path: String, + ) -> AppResult { + services::derive_keys_from_mnemonic_with_path(self.ctx.tvm_client.clone(), phrase, path) + } + + pub(crate) fn verify_mnemonic(&self, phrase: String) -> AppResult { + services::verify_mnemonic(self.ctx.tvm_client.clone(), phrase) + } + + pub(crate) fn mnemonic_words(&self) -> AppResult { + services::mnemonic_words(self.ctx.tvm_client.clone()) + } + + pub(crate) fn sha_256(&self, params: ParamsOfHash) -> AppResult { + services::sha256(self.ctx.tvm_client.clone(), params.data) + .map_err(|e| e.with_context("failed to hash data")) + } + + pub(crate) fn get_boc_hash(&self, data: String) -> AppResult { + services::get_boc_hash(self.ctx.tvm_client.clone(), data) + } +} diff --git a/bee_crypto/src/errors.rs b/bee_crypto/src/errors.rs new file mode 100644 index 0000000..a23c3e3 --- /dev/null +++ b/bee_crypto/src/errors.rs @@ -0,0 +1,2 @@ +pub use bee_errors::AppError; +pub use bee_errors::AppResult; diff --git a/bee_crypto/src/lib.rs b/bee_crypto/src/lib.rs new file mode 100644 index 0000000..5804fda --- /dev/null +++ b/bee_crypto/src/lib.rs @@ -0,0 +1,14 @@ +#![doc = include_str!("../README.md")] + +mod adapters; +mod client; +mod services; + +/// Error and result types used by `bee_crypto`. +pub mod errors; + +/// Native crypto client API. +pub use adapters::native::Crypto; +/// Wasm adapter namespace (available with `wasm` feature). +#[cfg(feature = "wasm")] +pub use adapters::wasm; diff --git a/bee_crypto/src/services/boc_hash.rs b/bee_crypto/src/services/boc_hash.rs new file mode 100644 index 0000000..a3bb441 --- /dev/null +++ b/bee_crypto/src/services/boc_hash.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::boc; +use ackinacki_kit::tvm_client::boc::BuilderOp; +use ackinacki_kit::tvm_client::boc::ParamsOfEncodeBoc; +use ackinacki_kit::tvm_client::boc::ParamsOfGetBocHash; +use ackinacki_kit::tvm_client::ClientContext; + +pub fn get_boc_hash( + tvm_client: Arc, + data: String, +) -> crate::errors::AppResult { + let bytes = hex::decode(&data) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to hex decode"))?; + + let boc = boc::encode_boc( + tvm_client.clone(), + ParamsOfEncodeBoc { + builder: vec![BuilderOp::Integer { + size: bytes.len() as u32 * 8, + value: format!("0x{}", data).into(), + }], + boc_cache: None, + }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to encode boc"))?; + + let result = boc::get_boc_hash(tvm_client.clone(), ParamsOfGetBocHash { boc: boc.boc }) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to get_boc_hash"))?; + + Ok(result.hash) +} diff --git a/bee_crypto/src/services/chacha.rs b/bee_crypto/src/services/chacha.rs new file mode 100644 index 0000000..7114d85 --- /dev/null +++ b/bee_crypto/src/services/chacha.rs @@ -0,0 +1,493 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::chacha20; +use ackinacki_kit::tvm_client::crypto::ParamsOfChaCha20; +use ackinacki_kit::tvm_client::ClientContext; +use chacha20poly1305::aead::Aead; +use chacha20poly1305::aead::KeyInit; +use chacha20poly1305::XChaCha20Poly1305; +use chacha20poly1305::XNonce; +use serde::Deserialize; +use zeroize::Zeroize; + +use crate::errors::AppError; +use crate::errors::AppResult; + +const LEGACY_PBKDF2_ITERATIONS: u32 = 100_000; + +// ── AEAD helpers (sync, platform-independent) ────────────────────── + +fn aead_encrypt( + plaintext: &[u8], + key_hex: &str, + salt: &[u8], + nonce: &[u8], + version_tag: &str, +) -> AppResult { + let mut key_vec = hex::decode(key_hex) + .map_err(|e| AppError::from(e).with_context("Failed to decode derived key"))?; + let mut key_bytes: [u8; 32] = + key_vec.as_slice().try_into().map_err(|_| AppError::new("Derived key must be 32 bytes"))?; + key_vec.zeroize(); + + let cipher = XChaCha20Poly1305::new((&key_bytes).into()); + key_bytes.zeroize(); + let ciphertext = cipher + .encrypt(XNonce::from_slice(nonce), plaintext) + .map_err(|e| AppError::new(format!("AEAD encryption failed: {e}")))?; + + Ok(format!( + "{}:{}:{}:{}", + version_tag, + hex::encode(salt), + hex::encode(nonce), + hex::encode(ciphertext) + )) +} + +fn aead_decrypt(key_hex: &str, nonce: &[u8], ciphertext: &[u8]) -> AppResult { + let mut key_vec = hex::decode(key_hex) + .map_err(|e| AppError::from(e).with_context("Failed to decode derived key"))?; + let mut key_bytes: [u8; 32] = + key_vec.as_slice().try_into().map_err(|_| AppError::new("Derived key must be 32 bytes"))?; + key_vec.zeroize(); + + let cipher = XChaCha20Poly1305::new((&key_bytes).into()); + key_bytes.zeroize(); + let plaintext = cipher.decrypt(XNonce::from_slice(nonce), ciphertext).map_err(|_| { + AppError::new("Decryption failed: authentication tag mismatch (data may be tampered)") + })?; + + String::from_utf8(plaintext) + .map_err(|e| AppError::from(e).with_context("result decryption failure")) +} + +// ── Envelope parsing helpers ─────────────────────────────────────── + +fn parse_aead_envelope(rest: &str) -> AppResult<(Vec, Vec, Vec)> { + let mut parts = rest.splitn(3, ':'); + + let salt_hex = parts.next().ok_or_else(|| AppError::new("Invalid envelope format"))?; + let nonce_hex = parts.next().ok_or_else(|| AppError::new("Invalid envelope format"))?; + let ciphertext_hex = parts.next().ok_or_else(|| AppError::new("Invalid envelope format"))?; + + let salt = hex::decode(salt_hex) + .map_err(|e| AppError::from(e).with_context("Failed to decode salt"))?; + if salt.len() != 32 { + return Err(AppError::new(format!("Invalid salt length: expected 32, got {}", salt.len()))); + } + + let nonce = hex::decode(nonce_hex) + .map_err(|e| AppError::from(e).with_context("Failed to decode nonce"))?; + if nonce.len() != 24 { + return Err(AppError::new(format!( + "Invalid nonce length: expected 24, got {}", + nonce.len() + ))); + } + + let ciphertext = hex::decode(ciphertext_hex) + .map_err(|e| AppError::from(e).with_context("Failed to decode ciphertext"))?; + + Ok((salt, nonce, ciphertext)) +} + +fn parse_v1_envelope(rest: &str) -> AppResult<(Vec, &str, &str)> { + let mut parts = rest.splitn(3, ':'); + + let salt_hex = parts.next().ok_or_else(|| AppError::new("Invalid envelope format"))?; + let nonce_hex = parts.next().ok_or_else(|| AppError::new("Invalid envelope format"))?; + let ciphertext = parts.next().ok_or_else(|| AppError::new("Invalid envelope format"))?; + + if nonce_hex.len() != 24 { + // v1 nonce is 12 bytes = 24 hex characters + return Err(AppError::new(format!( + "Invalid v1 nonce length: expected 24 hex chars, got {}", + nonce_hex.len() + ))); + } + + let salt_bytes = hex::decode(salt_hex) + .map_err(|e| AppError::from(e).with_context("Failed to decode salt hex"))?; + + if salt_bytes.len() != 16 { + return Err(AppError::new(format!( + "Invalid v1 salt length: expected 16, got {}", + salt_bytes.len() + ))); + } + + Ok((salt_bytes, nonce_hex, ciphertext)) +} + +// ── Public API: encrypt ──────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +pub fn encrypt( + _tvm_client: Arc, + plaintext: String, + password: String, +) -> AppResult { + let mut salt = [0u8; 32]; + getrandom::fill(&mut salt) + .map_err(|e| AppError::new(format!("Failed to generate salt: {e}")))?; + let mut nonce = [0u8; 24]; + getrandom::fill(&mut nonce) + .map_err(|e| AppError::new(format!("Failed to generate nonce: {e}")))?; + let key_hex = super::key::derive_key_pbkdf2(password.as_bytes(), &salt)?; + aead_encrypt(plaintext.as_bytes(), &key_hex, &salt, &nonce, "v3") +} + +#[cfg(target_arch = "wasm32")] +pub async fn encrypt( + _tvm_client: Arc, + plaintext: String, + password: String, +) -> AppResult { + let mut salt = [0u8; 32]; + getrandom::fill(&mut salt) + .map_err(|e| AppError::new(format!("Failed to generate salt: {e}")))?; + let mut nonce = [0u8; 24]; + getrandom::fill(&mut nonce) + .map_err(|e| AppError::new(format!("Failed to generate nonce: {e}")))?; + let key_hex = super::key::derive_key_pbkdf2(password.as_bytes(), &salt).await?; + aead_encrypt(plaintext.as_bytes(), &key_hex, &salt, &nonce, "v3") +} + +// ── Public API: decrypt ──────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ParamsOfDecrypt { + /// Encrypted envelope (e.g. "v3:...", "v2:..." or legacy "v1:...") + pub encrypted: String, + pub password: String, +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn decrypt(tvm_client: Arc, params: ParamsOfDecrypt) -> AppResult { + if let Some(rest) = params.encrypted.strip_prefix("v3:") { + let (salt, nonce, ciphertext) = parse_aead_envelope(rest)?; + let key_hex = super::key::derive_key_pbkdf2(params.password.as_bytes(), &salt)?; + return aead_decrypt(&key_hex, &nonce, &ciphertext); + } + + if let Some(rest) = params.encrypted.strip_prefix("v2:") { + let (salt, nonce, ciphertext) = parse_aead_envelope(rest)?; + let key_hex = super::key::derive_key_pbkdf2_with_rounds( + params.password.as_bytes(), + &salt, + LEGACY_PBKDF2_ITERATIONS, + )?; + return aead_decrypt(&key_hex, &nonce, &ciphertext); + } + + if let Some(rest) = params.encrypted.strip_prefix("v1:") { + // TODO: Replace with log::warn! when logging dependency is added to bee_crypto + eprintln!( + "[DEPRECATION] Decrypting v1 envelope (unauthenticated ChaCha20). Re-encrypt with v3 format." + ); + return decrypt_v1_legacy(tvm_client, rest, ¶ms.password); + } + + Err(AppError::new("Unknown encryption envelope version")) +} + +#[cfg(target_arch = "wasm32")] +pub async fn decrypt(tvm_client: Arc, params: ParamsOfDecrypt) -> AppResult { + if let Some(rest) = params.encrypted.strip_prefix("v3:") { + let (salt, nonce, ciphertext) = parse_aead_envelope(rest)?; + let key_hex = super::key::derive_key_pbkdf2(params.password.as_bytes(), &salt).await?; + return aead_decrypt(&key_hex, &nonce, &ciphertext); + } + + if let Some(rest) = params.encrypted.strip_prefix("v2:") { + let (salt, nonce, ciphertext) = parse_aead_envelope(rest)?; + let key_hex = super::key::derive_key_pbkdf2_with_rounds( + params.password.as_bytes(), + &salt, + LEGACY_PBKDF2_ITERATIONS, + ) + .await?; + return aead_decrypt(&key_hex, &nonce, &ciphertext); + } + + if let Some(rest) = params.encrypted.strip_prefix("v1:") { + // TODO: Replace with log::warn! when logging dependency is added to bee_crypto + eprintln!( + "[DEPRECATION] Decrypting v1 envelope (unauthenticated ChaCha20). Re-encrypt with v3 format." + ); + return decrypt_v1_legacy(tvm_client, rest, ¶ms.password).await; + } + + Err(AppError::new("Unknown encryption envelope version")) +} + +// ── v1 legacy decrypt ────────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +fn decrypt_v1_legacy( + tvm_client: Arc, + rest: &str, + password: &str, +) -> AppResult { + let (salt_bytes, nonce_hex, ciphertext) = parse_v1_envelope(rest)?; + let key_hex = super::key::derive_key_pbkdf2_with_rounds( + password.as_bytes(), + &salt_bytes, + LEGACY_PBKDF2_ITERATIONS, + )?; + + let res = chacha20( + tvm_client.clone(), + ParamsOfChaCha20 { + data: ciphertext.to_string(), + key: key_hex.to_string(), + nonce: nonce_hex.to_string(), + }, + ) + .map_err(|e| AppError::from(e).with_context("ChaCha20 decryption failed"))?; + + let decrypted_bytes = super::encoding::b64_std_decode(&res.data) + .map_err(|e| AppError::from(e).with_context("Base64 decode error"))?; + + String::from_utf8(decrypted_bytes) + .map_err(|e| AppError::from(e).with_context("result decryption failure")) +} + +#[cfg(target_arch = "wasm32")] +async fn decrypt_v1_legacy( + tvm_client: Arc, + rest: &str, + password: &str, +) -> AppResult { + let (salt_bytes, nonce_hex, ciphertext) = parse_v1_envelope(rest)?; + let key_hex = super::key::derive_key_pbkdf2_with_rounds( + password.as_bytes(), + &salt_bytes, + LEGACY_PBKDF2_ITERATIONS, + ) + .await?; + + let res = chacha20( + tvm_client.clone(), + ParamsOfChaCha20 { + data: ciphertext.to_string(), + key: key_hex.to_string(), + nonce: nonce_hex.to_string(), + }, + ) + .map_err(|e| AppError::from(e).with_context("ChaCha20 decryption failed"))?; + + let decrypted_bytes = super::encoding::b64_std_decode(&res.data) + .map_err(|e| AppError::from(e).with_context("Base64 decode error"))?; + + String::from_utf8(decrypted_bytes) + .map_err(|e| AppError::from(e).with_context("result decryption failure")) +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use ackinacki_kit::tvm_client::ClientContext; + + use super::decrypt; + use super::encrypt; + use super::ParamsOfDecrypt; + + fn tvm() -> Arc { + Arc::new(ClientContext::new(Default::default()).unwrap()) + } + + #[test] + fn test_v3_encrypt_decrypt_round_trip() { + let tc = tvm(); + let encrypted = encrypt(tc.clone(), "secret data".into(), "password".into()).unwrap(); + assert!(encrypted.starts_with("v3:"), "Must produce v3 envelope"); + let decrypted = + decrypt(tc, ParamsOfDecrypt { encrypted, password: "password".into() }).unwrap(); + assert_eq!(decrypted, "secret data"); + } + + #[test] + fn test_v3_wrong_password_fails() { + let tc = tvm(); + let encrypted = encrypt(tc.clone(), "secret".into(), "correct".into()).unwrap(); + let result = decrypt(tc, ParamsOfDecrypt { encrypted, password: "wrong".into() }); + assert!(result.is_err(), "Wrong password must fail AEAD auth"); + } + + #[test] + fn test_v3_tampered_ciphertext_detected() { + let tc = tvm(); + let encrypted = encrypt(tc.clone(), "secret".into(), "pass".into()).unwrap(); + let parts: Vec<&str> = encrypted.splitn(4, ':').collect(); + let mut ct_bytes = hex::decode(parts[3]).unwrap(); + ct_bytes[0] ^= 0x01; + let tampered = format!("v3:{}:{}:{}", parts[1], parts[2], hex::encode(&ct_bytes)); + let result = decrypt(tc, ParamsOfDecrypt { encrypted: tampered, password: "pass".into() }); + assert!(result.is_err(), "Tampered ciphertext MUST be rejected by AEAD"); + } + + #[test] + fn test_v3_envelope_format() { + let tc = tvm(); + let encrypted = encrypt(tc, "data".into(), "pw".into()).unwrap(); + let parts: Vec<&str> = encrypted.splitn(4, ':').collect(); + assert_eq!(parts[0], "v3"); + assert_eq!(hex::decode(parts[1]).unwrap().len(), 32, "salt must be 32 bytes"); + assert_eq!(hex::decode(parts[2]).unwrap().len(), 24, "nonce must be 24 bytes"); + let ct_bytes = hex::decode(parts[3]).unwrap(); + // "data" = 4 bytes plaintext + 16 bytes Poly1305 authentication tag + assert_eq!(ct_bytes.len(), 4 + 16, "ciphertext must include 16-byte Poly1305 auth tag"); + } + + #[test] + fn test_v3_tampered_salt_detected() { + let tc = tvm(); + let encrypted = encrypt(tc.clone(), "secret".into(), "pass".into()).unwrap(); + let parts: Vec<&str> = encrypted.splitn(4, ':').collect(); + let mut salt_bytes = hex::decode(parts[1]).unwrap(); + salt_bytes[0] ^= 0x01; + let tampered = format!("v3:{}:{}:{}", hex::encode(&salt_bytes), parts[2], parts[3]); + let result = decrypt(tc, ParamsOfDecrypt { encrypted: tampered, password: "pass".into() }); + assert!(result.is_err(), "Tampered salt must cause AEAD auth failure"); + } + + #[test] + fn test_v3_tampered_nonce_detected() { + let tc = tvm(); + let encrypted = encrypt(tc.clone(), "secret".into(), "pass".into()).unwrap(); + let parts: Vec<&str> = encrypted.splitn(4, ':').collect(); + let mut nonce_bytes = hex::decode(parts[2]).unwrap(); + nonce_bytes[0] ^= 0x01; + let tampered = format!("v3:{}:{}:{}", parts[1], hex::encode(&nonce_bytes), parts[3]); + let result = decrypt(tc, ParamsOfDecrypt { encrypted: tampered, password: "pass".into() }); + assert!(result.is_err(), "Tampered nonce must cause AEAD auth failure"); + } + + #[test] + fn test_v2_backward_compat() { + let tc = tvm(); + // Create a v2 envelope the way SEC1-F01 did (AEAD, 100k iterations) + let password = "testpass"; + let plaintext = "v2 aead data"; + + let mut salt = [0u8; 32]; + getrandom::fill(&mut salt).unwrap(); + let mut nonce = [0u8; 24]; + getrandom::fill(&mut nonce).unwrap(); + + // Derive key with legacy 100k iterations + let key_hex = + super::super::key::derive_key_pbkdf2_with_rounds(password.as_bytes(), &salt, 100_000) + .unwrap(); + + // Encrypt with AEAD + let key_vec: Vec = hex::decode(&key_hex).unwrap(); + let key_bytes: [u8; 32] = key_vec.try_into().unwrap(); + use chacha20poly1305::aead::Aead; + use chacha20poly1305::aead::KeyInit; + use chacha20poly1305::XChaCha20Poly1305; + use chacha20poly1305::XNonce; + let cipher = XChaCha20Poly1305::new((&key_bytes).into()); + let ciphertext = cipher.encrypt(XNonce::from_slice(&nonce), plaintext.as_bytes()).unwrap(); + + let v2_envelope = + format!("v2:{}:{}:{}", hex::encode(salt), hex::encode(nonce), hex::encode(ciphertext)); + + let decrypted = + decrypt(tc, ParamsOfDecrypt { encrypted: v2_envelope, password: password.into() }) + .unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_v1_backward_compat() { + let tc = tvm(); + use ackinacki_kit::tvm_client::crypto::chacha20; + use ackinacki_kit::tvm_client::crypto::generate_random_bytes; + use ackinacki_kit::tvm_client::crypto::ParamsOfChaCha20; + use ackinacki_kit::tvm_client::crypto::ParamsOfGenerateRandomBytes; + + let password = "testpass"; + let plaintext = "v1 legacy data"; + + let nonce_random = + generate_random_bytes(tc.clone(), ParamsOfGenerateRandomBytes { length: 12 }).unwrap(); + let nonce_bytes = super::super::encoding::b64_std_decode(&nonce_random.bytes).unwrap(); + let nonce_hex = hex::encode(&nonce_bytes); + + let salt_random = + generate_random_bytes(tc.clone(), ParamsOfGenerateRandomBytes { length: 16 }).unwrap(); + let salt_bytes = super::super::encoding::b64_std_decode(&salt_random.bytes).unwrap(); + let salt_hex = hex::encode(&salt_bytes); + + let data_base64 = super::super::encoding::b64_std_encode(plaintext.as_bytes()); + // Use legacy 100k iterations to match real v1 envelopes + let key_hex = super::super::key::derive_key_pbkdf2_with_rounds( + password.as_bytes(), + &salt_bytes, + 100_000, + ) + .unwrap(); + + let res = chacha20( + tc.clone(), + ParamsOfChaCha20 { + data: data_base64, + key: key_hex.to_string(), + nonce: nonce_hex.clone(), + }, + ) + .unwrap(); + + let v1_envelope = format!("v1:{salt_hex}:{nonce_hex}:{}", res.data); + + let decrypted = + decrypt(tc, ParamsOfDecrypt { encrypted: v1_envelope, password: password.into() }) + .unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_v3_empty_plaintext() { + let tc = tvm(); + let encrypted = encrypt(tc.clone(), "".into(), "pass".into()).unwrap(); + assert!(encrypted.starts_with("v3:")); + let decrypted = + decrypt(tc, ParamsOfDecrypt { encrypted, password: "pass".into() }).unwrap(); + assert_eq!(decrypted, ""); + } + + #[test] + fn test_v3_unicode_plaintext() { + let tc = tvm(); + let text = "Привет мир 🌍 日本語"; + let encrypted = encrypt(tc.clone(), text.into(), "pass".into()).unwrap(); + let decrypted = + decrypt(tc, ParamsOfDecrypt { encrypted, password: "pass".into() }).unwrap(); + assert_eq!(decrypted, text); + } + + #[test] + fn test_unknown_version_rejected() { + let tc = tvm(); + let result = decrypt( + tc, + ParamsOfDecrypt { encrypted: "v99:aabb:ccdd:eeff".into(), password: "pass".into() }, + ); + assert!(result.is_err(), "Unknown envelope version must be rejected"); + } + + #[test] + fn test_pbkdf2_iterations() { + assert_eq!( + super::super::key::PBKDF2_ITERATIONS, + 100_000, + "PBKDF2 iterations = 100k (mobile UX tradeoff, AEAD integrity preserved)" + ); + } +} diff --git a/bee_crypto/src/services/encoding.rs b/bee_crypto/src/services/encoding.rs new file mode 100644 index 0000000..4548df5 --- /dev/null +++ b/bee_crypto/src/services/encoding.rs @@ -0,0 +1,11 @@ +use base64::Engine; + +#[inline] +pub(crate) fn b64_std_encode>(data: T) -> String { + base64::engine::general_purpose::STANDARD.encode(data) +} + +#[inline] +pub(crate) fn b64_std_decode(s: &str) -> Result, base64::DecodeError> { + base64::engine::general_purpose::STANDARD.decode(s) +} diff --git a/bee_crypto/src/services/hash.rs b/bee_crypto/src/services/hash.rs new file mode 100644 index 0000000..41488e5 --- /dev/null +++ b/bee_crypto/src/services/hash.rs @@ -0,0 +1,139 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::ParamsOfHash; +use ackinacki_kit::tvm_client::crypto::ResultOfHash; +use ackinacki_kit::tvm_client::ClientContext; +use pbkdf2::pbkdf2_hmac; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use zeroize::Zeroize; +#[cfg(target_arch = "wasm32")] +use zeroize::Zeroizing; + +pub fn sha256( + tvm_client: Arc, + data: String, +) -> crate::errors::AppResult { + let data_base64 = super::encoding::b64_std_encode(data); + + let res = ackinacki_kit::tvm_client::crypto::sha256( + tvm_client.clone(), + ParamsOfHash { data: data_base64 }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to hash data"))?; + + Ok(res) +} + +const PBKDF2_ROUNDS_V3: u32 = super::key::PBKDF2_ITERATIONS; +const PBKDF2_ROUNDS_V2_LEGACY: u32 = 100_000; +const DK_LEN: usize = 32; +const SALT_LEN: usize = 32; + +#[cfg(not(target_arch = "wasm32"))] +pub fn random_salted_hash( + _tvm_client: Arc, + data: String, +) -> crate::errors::AppResult { + let mut salt_bytes = [0u8; SALT_LEN]; + getrandom::fill(&mut salt_bytes) + .map_err(|e| crate::errors::AppError::new(format!("Failed to generate salt: {e}")))?; + + let mut dk = [0u8; DK_LEN]; + pbkdf2_hmac::(data.as_bytes(), &salt_bytes, PBKDF2_ROUNDS_V3, &mut dk); + + let envelope = format!("v3:{}:{}", hex::encode(salt_bytes), hex::encode(dk)); + dk.zeroize(); + Ok(ResultOfHash { hash: envelope }) +} + +#[cfg(target_arch = "wasm32")] +pub async fn random_salted_hash( + _tvm_client: Arc, + data: String, +) -> crate::errors::AppResult { + let mut salt_bytes = [0u8; SALT_LEN]; + getrandom::fill(&mut salt_bytes) + .map_err(|e| crate::errors::AppError::new(format!("Failed to generate salt: {e}")))?; + + let dk_hex: Zeroizing = + super::key::derive_key_pbkdf2(data.as_bytes(), &salt_bytes).await?; + + let envelope = format!("v3:{}:{}", hex::encode(salt_bytes), &*dk_hex); + Ok(ResultOfHash { hash: envelope }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn verify_salted_hash(password: &str, envelope: &str) -> crate::errors::AppResult { + let parts: Vec<&str> = envelope.split(':').collect(); + if parts.len() != 3 { + return Err("Bad format".into()); + } + + let iterations = match parts[0] { + "v3" => PBKDF2_ROUNDS_V3, + "v2" => PBKDF2_ROUNDS_V2_LEGACY, + _ => return Err(format!("Unknown hash version: {}", parts[0]).into()), + }; + + let salt_bytes = hex::decode(parts[1]).map_err(|_| "Bad salt hex")?; + if salt_bytes.len() != SALT_LEN { + return Err("Bad salt length".into()); + } + + let expected = hex::decode(parts[2]).map_err(|_| "Bad dk hex")?; + if expected.len() != DK_LEN { + return Err("Bad dk length".into()); + } + + let mut dk = [0u8; DK_LEN]; + pbkdf2_hmac::(password.as_bytes(), &salt_bytes, iterations, &mut dk); + + let result = dk.as_slice().ct_eq(expected.as_slice()).into(); + dk.zeroize(); + Ok(result) +} + +#[cfg(target_arch = "wasm32")] +pub async fn verify_salted_hash(password: &str, envelope: &str) -> crate::errors::AppResult { + let parts: Vec<&str> = envelope.split(':').collect(); + if parts.len() != 3 { + return Err("Bad format".into()); + } + + let salt_bytes = hex::decode(parts[1]).map_err(|_| "Bad salt hex")?; + if salt_bytes.len() != SALT_LEN { + return Err("Bad salt length".into()); + } + + let expected = hex::decode(parts[2]).map_err(|_| "Bad dk hex")?; + if expected.len() != DK_LEN { + return Err("Bad dk length".into()); + } + + let dk_hex: Zeroizing = match parts[0] { + "v3" => { + // 100k iterations — use SubtleCrypto + super::key::derive_key_pbkdf2(password.as_bytes(), &salt_bytes).await? + } + "v2" => { + // 100k iterations — Rust crate is fast enough + let mut dk = [0u8; DK_LEN]; + pbkdf2_hmac::( + password.as_bytes(), + &salt_bytes, + PBKDF2_ROUNDS_V2_LEGACY, + &mut dk, + ); + let hex_str = Zeroizing::new(hex::encode(dk)); + dk.zeroize(); + hex_str + } + _ => return Err(format!("Unknown hash version: {}", parts[0]).into()), + }; + + let mut dk_bytes = hex::decode(&*dk_hex).map_err(|_| "Bad computed dk hex")?; + let result = dk_bytes.as_slice().ct_eq(expected.as_slice()).into(); + dk_bytes.zeroize(); + Ok(result) +} diff --git a/bee_crypto/src/services/key.rs b/bee_crypto/src/services/key.rs new file mode 100644 index 0000000..f8d3488 --- /dev/null +++ b/bee_crypto/src/services/key.rs @@ -0,0 +1,102 @@ +use zeroize::Zeroize; +use zeroize::Zeroizing; + +pub(crate) const PBKDF2_ITERATIONS: u32 = 100_000; +pub(crate) const DERIVED_KEY_LEN: usize = 32; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn derive_key_pbkdf2( + password: &[u8], + salt: &[u8], +) -> crate::errors::AppResult> { + derive_key_pbkdf2_with_rounds(password, salt, PBKDF2_ITERATIONS) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn derive_key_pbkdf2( + password: &[u8], + salt: &[u8], +) -> crate::errors::AppResult> { + derive_key_pbkdf2_with_rounds(password, salt, PBKDF2_ITERATIONS).await +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn derive_key_pbkdf2_with_rounds( + password: &[u8], + salt: &[u8], + rounds: u32, +) -> crate::errors::AppResult> { + use pbkdf2::pbkdf2_hmac; + use sha2::Sha256; + + let mut key = [0u8; DERIVED_KEY_LEN]; + pbkdf2_hmac::(password, salt, rounds, &mut key); + let result = Zeroizing::new(hex::encode(key)); + key.zeroize(); + Ok(result) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn derive_key_pbkdf2_with_rounds( + password: &[u8], + salt: &[u8], + rounds: u32, +) -> crate::errors::AppResult> { + use js_sys::Array; + use js_sys::ArrayBuffer; + use js_sys::Object; + use js_sys::Reflect; + use js_sys::Uint8Array; + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let global = js_sys::global(); + let crypto: web_sys::Crypto = Reflect::get(&global, &"crypto".into()) + .map_err(|_| crate::errors::AppError::new("Web Crypto API not available"))? + .unchecked_into(); + let subtle = crypto.subtle(); + + let password_buf = Uint8Array::from(password); + let usages = Array::of1(&"deriveBits".into()); + + let key_material: web_sys::CryptoKey = JsFuture::from( + subtle + .import_key_with_str("raw", &password_buf.buffer().into(), "PBKDF2", false, &usages) + .map_err(|e| { + crate::errors::AppError::new(format!("SubtleCrypto.importKey failed: {e:?}")) + })?, + ) + .await + .map_err(|e| crate::errors::AppError::new(format!("SubtleCrypto.importKey rejected: {e:?}")))? + .unchecked_into(); + + let salt_buf = Uint8Array::from(salt); + let algorithm = Object::new(); + Reflect::set(&algorithm, &"name".into(), &"PBKDF2".into()) + .map_err(|_| crate::errors::AppError::new("Failed to set algorithm.name"))?; + Reflect::set(&algorithm, &"hash".into(), &"SHA-256".into()) + .map_err(|_| crate::errors::AppError::new("Failed to set algorithm.hash"))?; + Reflect::set(&algorithm, &"salt".into(), &salt_buf.into()) + .map_err(|_| crate::errors::AppError::new("Failed to set algorithm.salt"))?; + Reflect::set(&algorithm, &"iterations".into(), &wasm_bindgen::JsValue::from_f64(rounds as f64)) + .map_err(|_| crate::errors::AppError::new("Failed to set algorithm.iterations"))?; + + let derived: ArrayBuffer = JsFuture::from( + subtle + .derive_bits_with_object(&algorithm, &key_material, (DERIVED_KEY_LEN * 8) as u32) + .map_err(|e| { + crate::errors::AppError::new(format!("SubtleCrypto.deriveBits failed: {e:?}")) + })?, + ) + .await + .map_err(|e| crate::errors::AppError::new(format!("SubtleCrypto.deriveBits rejected: {e:?}")))? + .unchecked_into(); + + let result = Uint8Array::new(&derived); + let mut key = [0u8; DERIVED_KEY_LEN]; + result.copy_to(&mut key); + let hex_key = Zeroizing::new(hex::encode(key)); + key.zeroize(); + + Ok(hex_key) +} diff --git a/bee_crypto/src/services/mnemonic.rs b/bee_crypto/src/services/mnemonic.rs new file mode 100644 index 0000000..ec3498b --- /dev/null +++ b/bee_crypto/src/services/mnemonic.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicDeriveSignKeys; +use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicFromRandom; +use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicVerify; +use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicWords; +use ackinacki_kit::tvm_client::ClientContext; + +pub fn gen_mnemonic_and_derive_keys( + tvm_client: Arc, + word_count: u8, +) -> crate::errors::AppResult<(String, KeyPair)> { + let result_of_mnemonic_from_random = ackinacki_kit::tvm_client::crypto::mnemonic_from_random( + tvm_client.clone(), + ParamsOfMnemonicFromRandom { dictionary: None, word_count: Some(word_count) }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to gen seed"))?; + + let phrase = result_of_mnemonic_from_random.phrase; + let result_of_mnemonic_derive_sign_keys = + ackinacki_kit::tvm_client::crypto::mnemonic_derive_sign_keys( + tvm_client.clone(), + ParamsOfMnemonicDeriveSignKeys { + phrase: phrase.clone(), + path: None, + dictionary: None, + word_count: Some(word_count), + }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to gen seed"))?; + + Ok((phrase, result_of_mnemonic_derive_sign_keys)) +} + +pub fn derive_keys_from_mnemonic( + tvm_client: Arc, + phrase: String, +) -> crate::errors::AppResult { + let result_of_mnemonic_derive_sign_keys = + ackinacki_kit::tvm_client::crypto::mnemonic_derive_sign_keys( + tvm_client.clone(), + ParamsOfMnemonicDeriveSignKeys { + phrase: phrase.clone(), + path: None, + dictionary: None, + word_count: Some(24), + }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to derive keys"))?; + + Ok(result_of_mnemonic_derive_sign_keys) +} + +pub fn derive_keys_from_mnemonic_with_path( + tvm_client: Arc, + phrase: String, + path: String, +) -> crate::errors::AppResult { + let result = ackinacki_kit::tvm_client::crypto::mnemonic_derive_sign_keys( + tvm_client, + ParamsOfMnemonicDeriveSignKeys { + phrase, + path: Some(path), + dictionary: None, + word_count: Some(24), + }, + ) + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Failed to derive keys with path") + })?; + + Ok(result) +} + +pub fn verify_mnemonic( + tvm_client: Arc, + phrase: String, +) -> crate::errors::AppResult { + let result_of_mnemonic_verify = ackinacki_kit::tvm_client::crypto::mnemonic_verify( + tvm_client.clone(), + ParamsOfMnemonicVerify { phrase: phrase.clone(), dictionary: None, word_count: Some(24) }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to derive keys"))?; + + Ok(result_of_mnemonic_verify.valid) +} + +pub fn mnemonic_words(tvm_client: Arc) -> crate::errors::AppResult { + let result = ackinacki_kit::tvm_client::crypto::mnemonic_words( + tvm_client.clone(), + ParamsOfMnemonicWords { dictionary: None }, + ) + .map_err(|e| { + crate::errors::AppError::new(format!("Get mnemonic words failed: ({})", e)) + .with_details(e.message().to_string()) + })?; + + Ok(result.words) +} diff --git a/bee_crypto/src/services/mod.rs b/bee_crypto/src/services/mod.rs new file mode 100644 index 0000000..9155934 --- /dev/null +++ b/bee_crypto/src/services/mod.rs @@ -0,0 +1,24 @@ +mod boc_hash; +mod chacha; +mod encoding; +mod hash; +mod key; +mod mnemonic; +mod signature; +pub mod storage; + +pub use boc_hash::get_boc_hash; +pub use chacha::decrypt; +pub use chacha::encrypt; +pub use chacha::ParamsOfDecrypt; +pub use hash::random_salted_hash; +pub use hash::sha256; +pub use hash::verify_salted_hash; +pub use mnemonic::derive_keys_from_mnemonic; +pub use mnemonic::derive_keys_from_mnemonic_with_path; +pub use mnemonic::gen_mnemonic_and_derive_keys; +pub use mnemonic::mnemonic_words; +pub use mnemonic::verify_mnemonic; +pub use signature::sign; +pub use signature::sign_detached_hex; +pub use signature::verify_signature; diff --git a/bee_crypto/src/services/signature.rs b/bee_crypto/src/services/signature.rs new file mode 100644 index 0000000..46c9588 --- /dev/null +++ b/bee_crypto/src/services/signature.rs @@ -0,0 +1,147 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::ParamsOfSign; +use ackinacki_kit::tvm_client::crypto::ParamsOfVerifySignature; +use ackinacki_kit::tvm_client::crypto::ResultOfSign; +use ackinacki_kit::tvm_client::crypto::ResultOfVerifySignature; +use ackinacki_kit::tvm_client::ClientContext; +use ed25519_dalek::Signer; +use ed25519_dalek::SigningKey; +use ed25519_dalek::PUBLIC_KEY_LENGTH as ED25519_PUBLIC_KEY_LENGTH; +use ed25519_dalek::SIGNATURE_LENGTH as ED25519_SIGNATURE_LENGTH; +use zeroize::Zeroize; + +pub fn sign( + blockchain_context: Arc, + params: ParamsOfSign, +) -> crate::errors::AppResult { + ackinacki_kit::tvm_client::crypto::sign(blockchain_context, params) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to sign data")) +} + +pub fn verify_signature( + _blockchain_context: Arc, + params: ParamsOfVerifySignature, +) -> crate::errors::AppResult { + use ed25519_dalek::Signature; + use ed25519_dalek::VerifyingKey; + + // 1. Decode signed payload: base64(signature_64 || message) + let signed_bytes = super::encoding::b64_std_decode(¶ms.signed).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to decode signed base64") + })?; + + if signed_bytes.len() < ED25519_SIGNATURE_LENGTH { + return Err(crate::errors::AppError::new( + "Signed data too short: must contain at least 64-byte signature", + )); + } + + let (sig_bytes, message) = signed_bytes.split_at(ED25519_SIGNATURE_LENGTH); + + // 2. Parse signature + let signature = Signature::from_slice(sig_bytes) + .map_err(|e| crate::errors::AppError::new(format!("Invalid signature encoding: {e}")))?; + + // 3. Decode public key from hex + let pub_bytes = hex::decode(¶ms.public).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to decode public key hex") + })?; + let pub_arr: [u8; ED25519_PUBLIC_KEY_LENGTH] = pub_bytes + .try_into() + .map_err(|_| crate::errors::AppError::new("Public key must be 32 bytes"))?; + let verifying_key = VerifyingKey::from_bytes(&pub_arr) + .map_err(|e| crate::errors::AppError::new(format!("Invalid public key: {e}")))?; + + // 4. Strict verification — rejects non-canonical signatures + verifying_key + .verify_strict(message, &signature) + .map_err(|e| crate::errors::AppError::new(format!("Signature verification failed: {e}")))?; + + // 5. Return unsigned message as base64 + Ok(ResultOfVerifySignature { unsigned: super::encoding::b64_std_encode(message) }) +} + +pub fn sign_detached_hex(data: String, secret_key: String) -> crate::errors::AppResult { + let data_bytes = hex::decode(data.as_str()) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to decode hex data"))?; + let mut secret_key_bytes = hex::decode(secret_key.as_str()).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to decode hex secret") + })?; + let mut secret_key_arr: [u8; ED25519_PUBLIC_KEY_LENGTH] = + secret_key_bytes.as_slice().try_into().map_err(|e| { + crate::errors::AppError::new(format!("{e:?}")) + .with_context("failed to convert secret bytes") + })?; + secret_key_bytes.zeroize(); + let signing_key = SigningKey::from_bytes(&secret_key_arr); + secret_key_arr.zeroize(); + let signature: [u8; ED25519_SIGNATURE_LENGTH] = signing_key.sign(&data_bytes).to_bytes(); + + Ok(hex::encode(signature)) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicDeriveSignKeys; + use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicFromRandom; + use ackinacki_kit::tvm_client::crypto::ParamsOfSign; + use ackinacki_kit::tvm_client::crypto::ParamsOfVerifySignature; + use ackinacki_kit::tvm_client::ClientContext; + use base64::Engine; + + #[test] + fn sign_and_verify_signature_roundtrip() { + let blockchain_context = Arc::new(ClientContext::new(Default::default()).unwrap()); + let unsigned = + base64::engine::general_purpose::STANDARD.encode(b"bee_crypto_signature_test"); + + let phrase = ackinacki_kit::tvm_client::crypto::mnemonic_from_random( + blockchain_context.clone(), + ParamsOfMnemonicFromRandom { dictionary: None, word_count: Some(24) }, + ) + .unwrap(); + + let keys = ackinacki_kit::tvm_client::crypto::mnemonic_derive_sign_keys( + blockchain_context.clone(), + ParamsOfMnemonicDeriveSignKeys { + phrase: phrase.phrase, + path: None, + dictionary: None, + word_count: Some(24), + }, + ) + .unwrap(); + let public = keys.public.clone(); + + let sign_result = super::sign( + blockchain_context.clone(), + ParamsOfSign { unsigned: unsigned.clone(), keys }, + ) + .unwrap(); + + let verify_result = super::verify_signature( + blockchain_context, + ParamsOfVerifySignature { signed: sign_result.signed, public }, + ) + .unwrap(); + assert_eq!(verify_result.unsigned, unsigned); + } + + #[test] + fn verify_strict_rejects_non_canonical_signature() { + let blockchain_context = Arc::new(ClientContext::new(Default::default()).unwrap()); + + let result = super::verify_signature( + blockchain_context, + ParamsOfVerifySignature { + signed: base64::engine::general_purpose::STANDARD.encode(vec![0u8; 65]), + public: "0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + }, + ); + assert!(result.is_err(), "Invalid signature must be rejected"); + } +} diff --git a/bee_crypto/src/services/storage.rs b/bee_crypto/src/services/storage.rs new file mode 100644 index 0000000..6549e70 --- /dev/null +++ b/bee_crypto/src/services/storage.rs @@ -0,0 +1,235 @@ +//! App-level storage encryption with a pre-derived key. +//! +//! Unlike `chacha.rs` (password-based, PBKDF2 per call), these functions +//! accept a raw 32-byte key. The caller derives the key once via +//! `derive_storage_key` and caches it for the session. + +use chacha20poly1305::aead::Aead; +use chacha20poly1305::aead::KeyInit; +use chacha20poly1305::XChaCha20Poly1305; +use chacha20poly1305::XNonce; +use zeroize::Zeroizing; + +use crate::errors::AppError; +use crate::errors::AppResult; + +const STORAGE_ENVELOPE_VERSION: &str = "enc1"; + +/// Derives a 32-byte storage encryption key from a password and salt +/// using PBKDF2-HMAC-SHA256 with `PBKDF2_ITERATIONS` rounds. +/// +/// Returns a `Zeroizing` wrapper that zeroes the key bytes on drop. +/// +/// # Arguments +/// * `password` — user password bytes +/// * `salt` — random 32-byte salt (stored in plaintext WalletMeta) +pub fn derive_storage_key(password: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> { + use pbkdf2::pbkdf2_hmac; + use sha2::Sha256; + + let mut key = [0u8; 32]; + pbkdf2_hmac::(password, salt, super::key::PBKDF2_ITERATIONS, &mut key); + Zeroizing::new(key) +} + +/// Encrypts plaintext with a pre-derived 32-byte key. +/// +/// Returns an envelope string: `enc1:{nonce_hex}:{ciphertext_hex}` +/// +/// Uses XChaCha20-Poly1305 AEAD with a random 24-byte nonce. +/// Ciphertext includes a 16-byte Poly1305 authentication tag. +/// +/// # Arguments +/// * `plaintext` — data to encrypt (UTF-8 bytes) +/// * `key` — 32-byte encryption key from `derive_storage_key` +pub fn storage_encrypt(plaintext: &[u8], key: &[u8; 32]) -> AppResult { + let mut nonce_bytes = [0u8; 24]; + getrandom::fill(&mut nonce_bytes) + .map_err(|e| AppError::new(format!("Failed to generate nonce: {e}")))?; + + let cipher = XChaCha20Poly1305::new(key.into()); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce_bytes), plaintext) + .map_err(|e| AppError::new(format!("AEAD encryption failed: {e}")))?; + + Ok(format!( + "{}:{}:{}", + STORAGE_ENVELOPE_VERSION, + hex::encode(nonce_bytes), + hex::encode(ciphertext), + )) +} + +/// Decrypts an `enc1:` envelope with a pre-derived 32-byte key. +/// +/// # Arguments +/// * `envelope` — encrypted string in format +/// `enc1:{nonce_hex}:{ciphertext_hex}` +/// * `key` — 32-byte encryption key from `derive_storage_key` +/// +/// # Errors +/// Returns error if envelope format is invalid, version is unknown, +/// or AEAD authentication fails (wrong key or tampered data). +pub fn storage_decrypt(envelope: &str, key: &[u8; 32]) -> AppResult { + let rest = envelope + .strip_prefix("enc1:") + .ok_or_else(|| AppError::new("Unknown storage envelope version"))?; + + let mut parts = rest.splitn(2, ':'); + let nonce_hex = + parts.next().ok_or_else(|| AppError::new("Missing nonce in storage envelope"))?; + let ct_hex = + parts.next().ok_or_else(|| AppError::new("Missing ciphertext in storage envelope"))?; + + let nonce_bytes = + hex::decode(nonce_hex).map_err(|e| AppError::from(e).with_context("Invalid nonce hex"))?; + if nonce_bytes.len() != 24 { + return Err(AppError::new(format!( + "Invalid nonce length: expected 24, got {}", + nonce_bytes.len() + ))); + } + + let ciphertext = hex::decode(ct_hex) + .map_err(|e| AppError::from(e).with_context("Invalid ciphertext hex"))?; + + let cipher = XChaCha20Poly1305::new(key.into()); + let plaintext = cipher + .decrypt(XNonce::from_slice(&nonce_bytes), ciphertext.as_ref()) + .map_err(|_| AppError::new("Storage decryption failed: wrong key or tampered data"))?; + + String::from_utf8(plaintext) + .map_err(|e| AppError::from(e).with_context("Decrypted data is not valid UTF-8")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> Zeroizing<[u8; 32]> { + derive_storage_key(b"test-password", b"test-salt-32-bytes-long-padding!") + } + + #[test] + fn round_trip() { + let key = test_key(); + let encrypted = storage_encrypt(b"hello world", &key).unwrap(); + assert!(encrypted.starts_with("enc1:")); + let decrypted = storage_decrypt(&encrypted, &key).unwrap(); + assert_eq!(decrypted, "hello world"); + } + + #[test] + fn wrong_key_fails() { + let key1 = derive_storage_key(b"password1", b"test-salt-32-bytes-long-padding!"); + let key2 = derive_storage_key(b"password2", b"test-salt-32-bytes-long-padding!"); + let encrypted = storage_encrypt(b"secret", &key1).unwrap(); + let result = storage_decrypt(&encrypted, &key2); + assert!(result.is_err()); + } + + #[test] + fn tampered_ciphertext_detected() { + let key = test_key(); + let encrypted = storage_encrypt(b"secret", &key).unwrap(); + let parts: Vec<&str> = encrypted.splitn(3, ':').collect(); + let mut ct_bytes = hex::decode(parts[2]).unwrap(); + ct_bytes[0] ^= 0x01; + let tampered = format!("enc1:{}:{}", parts[1], hex::encode(&ct_bytes)); + assert!(storage_decrypt(&tampered, &key).is_err()); + } + + #[test] + fn tampered_nonce_detected() { + let key = test_key(); + let encrypted = storage_encrypt(b"secret", &key).unwrap(); + let parts: Vec<&str> = encrypted.splitn(3, ':').collect(); + let mut nonce_bytes = hex::decode(parts[1]).unwrap(); + nonce_bytes[0] ^= 0x01; + let tampered = format!("enc1:{}:{}", hex::encode(&nonce_bytes), parts[2]); + assert!(storage_decrypt(&tampered, &key).is_err()); + } + + #[test] + fn envelope_format() { + let key = test_key(); + let encrypted = storage_encrypt(b"data", &key).unwrap(); + let parts: Vec<&str> = encrypted.splitn(3, ':').collect(); + assert_eq!(parts.len(), 3); + assert_eq!(parts[0], "enc1"); + assert_eq!(hex::decode(parts[1]).unwrap().len(), 24); // nonce + let ct_bytes = hex::decode(parts[2]).unwrap(); + assert_eq!(ct_bytes.len(), 4 + 16); // plaintext + poly1305 tag + } + + #[test] + fn empty_plaintext() { + let key = test_key(); + let encrypted = storage_encrypt(b"", &key).unwrap(); + let decrypted = storage_decrypt(&encrypted, &key).unwrap(); + assert_eq!(decrypted, ""); + } + + #[test] + fn unicode_plaintext() { + let key = test_key(); + let text = "Привет мир 🌍 日本語"; + let encrypted = storage_encrypt(text.as_bytes(), &key).unwrap(); + let decrypted = storage_decrypt(&encrypted, &key).unwrap(); + assert_eq!(decrypted, text); + } + + #[test] + fn large_plaintext() { + let key = test_key(); + let text = "x".repeat(100_000); + let encrypted = storage_encrypt(text.as_bytes(), &key).unwrap(); + let decrypted = storage_decrypt(&encrypted, &key).unwrap(); + assert_eq!(decrypted, text); + } + + #[test] + fn unknown_version_rejected() { + let key = test_key(); + assert!(storage_decrypt("enc2:aabb:ccdd", &key).is_err()); + } + + #[test] + fn malformed_envelope_rejected() { + let key = test_key(); + assert!(storage_decrypt("enc1:", &key).is_err()); + assert!(storage_decrypt("enc1:aabb", &key).is_err()); + assert!(storage_decrypt("not-an-envelope", &key).is_err()); + } + + #[test] + fn derive_storage_key_deterministic() { + let k1 = derive_storage_key(b"pwd", b"salt"); + let k2 = derive_storage_key(b"pwd", b"salt"); + assert_eq!(k1.as_ref(), k2.as_ref()); + } + + #[test] + fn derive_storage_key_different_salt() { + let k1 = derive_storage_key(b"pwd", b"salt1"); + let k2 = derive_storage_key(b"pwd", b"salt2"); + assert_ne!(k1.as_ref(), k2.as_ref()); + } + + #[test] + fn derive_storage_key_different_password() { + let k1 = derive_storage_key(b"pwd1", b"salt"); + let k2 = derive_storage_key(b"pwd2", b"salt"); + assert_ne!(k1.as_ref(), k2.as_ref()); + } + + #[test] + fn two_encryptions_produce_different_ciphertext() { + let key = test_key(); + let e1 = storage_encrypt(b"same", &key).unwrap(); + let e2 = storage_encrypt(b"same", &key).unwrap(); + assert_ne!(e1, e2); // different random nonces + assert_eq!(storage_decrypt(&e1, &key).unwrap(), "same"); + assert_eq!(storage_decrypt(&e2, &key).unwrap(), "same"); + } +} diff --git a/bee_crypto/tests/integration.rs b/bee_crypto/tests/integration.rs new file mode 100644 index 0000000..8df6333 --- /dev/null +++ b/bee_crypto/tests/integration.rs @@ -0,0 +1,246 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::crypto::ParamsOfHash; +use ackinacki_kit::tvm_client::crypto::ParamsOfSign; +use ackinacki_kit::tvm_client::crypto::ParamsOfVerifySignature; +use ackinacki_kit::tvm_client::ClientContext; +use base64::Engine; +use bee_crypto::Crypto; + +fn create_crypto() -> Crypto { + let endpoints = vec!["mainnet.ackinacki.org".to_string()]; + Crypto::new(endpoints).expect("Failed to create Crypto") +} + +#[test] +fn test_hash_password_format() { + let crypto = create_crypto(); + let hash = crypto.hash_password("password123".to_string()).unwrap(); + assert!(hash.starts_with("v3:")); + let parts: Vec<&str> = hash.split(':').collect(); + assert_eq!(parts.len(), 3); + assert_eq!(parts[1].len(), 64); + assert_eq!(parts[2].len(), 64); +} + +#[test] +fn test_verify_password_hash_round_trip() { + let crypto = create_crypto(); + let password = "password123".to_string(); + let hash = crypto.hash_password(password.clone()).unwrap(); + + let ok = crypto.verify_password_hash(password, hash.clone()).unwrap(); + assert!(ok, "correct password should verify"); + + let bad = crypto.verify_password_hash("wrongpass".to_string(), hash).unwrap(); + assert!(!bad, "wrong password should not verify"); +} + +#[test] +fn test_encrypt_decrypt_round_trip() { + let crypto = create_crypto(); + let plaintext = "hello world secret data"; + let password = "strongpassword"; + + let encrypted = crypto.encrypt(plaintext.to_string(), password.to_string()).unwrap(); + assert!(encrypted.encrypted.starts_with("v3:"), "Must produce v3 envelope"); + assert_ne!(encrypted.encrypted, plaintext); + + let decrypted = crypto.decrypt(encrypted.encrypted, password.to_string()).unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn test_decrypt_wrong_password_fails() { + let crypto = create_crypto(); + let encrypted = crypto.encrypt("secret".to_string(), "correct_password".to_string()).unwrap(); + + let result = crypto.decrypt(encrypted.encrypted, "wrong_password".to_string()); + assert!(result.is_err()); +} + +#[test] +fn test_gen_mnemonic_and_derive_keys() { + let crypto = create_crypto(); + let result = crypto.gen_mnemonic_and_derive_keys().unwrap(); + let words: Vec<&str> = result.phrase.split_whitespace().collect(); + assert_eq!(words.len(), 24); + assert!(!result.keys.public.is_empty()); + assert!(!result.keys.secret.is_empty()); +} + +#[test] +fn test_get_keys_from_mnemonic_matches_gen() { + let crypto = create_crypto(); + let generated = crypto.gen_mnemonic_and_derive_keys().unwrap(); + + let derived = crypto.get_keys_from_mnemonic(generated.phrase.clone()).unwrap(); + assert_eq!(derived.public, generated.keys.public); + assert_eq!(derived.secret, generated.keys.secret); +} + +#[test] +fn test_verify_mnemonic_valid() { + let crypto = create_crypto(); + let generated = crypto.gen_mnemonic_and_derive_keys().unwrap(); + let is_valid = crypto.verify_mnemonic(generated.phrase).unwrap(); + assert!(is_valid); +} + +#[test] +fn test_verify_mnemonic_invalid() { + let crypto = create_crypto(); + let is_valid = + crypto.verify_mnemonic("this is not a valid mnemonic phrase at all".to_string()).unwrap(); + assert!(!is_valid); +} + +#[test] +fn test_sign_deterministic() { + let crypto = create_crypto(); + let keys = crypto.gen_mnemonic_and_derive_keys().unwrap(); + + let params = ParamsOfSign { + unsigned: base64::engine::general_purpose::STANDARD.encode(b"test message"), + keys: KeyPair { public: keys.keys.public.clone(), secret: keys.keys.secret.clone() }, + }; + + let result1 = crypto.sign(params).unwrap(); + assert!(!result1.signature.is_empty()); + assert!(!result1.signed.is_empty()); + + let params2 = ParamsOfSign { + unsigned: base64::engine::general_purpose::STANDARD.encode(b"test message"), + keys: KeyPair { public: keys.keys.public.clone(), secret: keys.keys.secret.clone() }, + }; + let result2 = crypto.sign(params2).unwrap(); + assert_eq!(result1.signature, result2.signature); +} + +#[test] +fn test_verify_signature_roundtrip() { + let crypto = create_crypto(); + let keys = crypto.gen_mnemonic_and_derive_keys().unwrap(); + + let sign_result = crypto + .sign(ParamsOfSign { + unsigned: base64::engine::general_purpose::STANDARD.encode(b"verify me"), + keys: KeyPair { public: keys.keys.public.clone(), secret: keys.keys.secret.clone() }, + }) + .unwrap(); + + let verify_result = crypto + .verify_signature(ParamsOfVerifySignature { + signed: sign_result.signed, + public: keys.keys.public, + }) + .unwrap(); + + assert_eq!( + verify_result.unsigned, + base64::engine::general_purpose::STANDARD.encode(b"verify me") + ); +} + +#[test] +fn test_gen_mining_keys() { + let crypto = create_crypto(); + let keys = crypto.gen_mining_keys().unwrap(); + assert!(!keys.public.is_empty()); + assert!(!keys.secret.is_empty()); + assert_eq!(keys.public.len(), 64); + assert_eq!(keys.secret.len(), 64); +} + +#[test] +fn test_mnemonic_words() { + let crypto = create_crypto(); + let words = crypto.mnemonic_words().unwrap(); + let word_list: Vec<&str> = words.split(' ').collect(); + assert!(word_list.len() > 2000); // BIP39 english = 2048 +} + +#[test] +fn test_sha_256() { + let crypto = create_crypto(); + let result = crypto.sha_256(ParamsOfHash { data: "hello world".to_string() }).unwrap(); + assert_eq!(result.hash, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"); +} + +#[test] +fn test_get_boc_hash() { + let crypto = create_crypto(); + let hash = crypto.get_boc_hash("deadbeef".to_string()).unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit())); +} + +#[test] +fn test_from_client_context() { + let tvm_client = Arc::new(ClientContext::new(Default::default()).unwrap()); + let crypto = Crypto::from_client_context(tvm_client); + let words = crypto.mnemonic_words().unwrap(); + let word_list: Vec<&str> = words.split(' ').collect(); + assert!(word_list.len() > 2000); +} + +#[test] +fn test_sign_detached_hex() { + let crypto = create_crypto(); + let generated = crypto.gen_mnemonic_and_derive_keys().unwrap(); + let secret = generated.keys.secret; + + let hex_data = hex::encode(b"test payload"); + let sig = crypto.sign_detached_hex(hex_data.clone(), secret.clone()).unwrap(); + + assert_eq!(sig.len(), 128, "detached signature must be 128 hex chars"); + assert!(sig.chars().all(|ch| ch.is_ascii_hexdigit())); + + // Deterministic: same input produces same signature + let sig2 = crypto.sign_detached_hex(hex_data.clone(), secret.clone()).unwrap(); + assert_eq!(sig, sig2); + + // Different data produces different signature + let other_data = hex::encode(b"other payload"); + let sig3 = crypto.sign_detached_hex(other_data, secret).unwrap(); + assert_ne!(sig, sig3); +} + +#[test] +fn test_random_salted_hash() { + let crypto = create_crypto(); + let input = "same_password".to_string(); + + let hash1 = crypto.hash_password(input.clone()).unwrap(); + let hash2 = crypto.hash_password(input).unwrap(); + + // Verify v3 format: "v3:{salt_hex}:{hash_hex}" + assert!(hash1.starts_with("v3:")); + let parts: Vec<&str> = hash1.split(':').collect(); + assert_eq!(parts.len(), 3); + + // Two calls with same input must produce different hashes (random salt) + assert_ne!(hash1, hash2); +} + +#[test] +fn test_verify_salted_hash() { + let crypto = create_crypto(); + let password = "my_secret_value".to_string(); + + let hash = crypto.hash_password(password.clone()).unwrap(); + + // Correct password verifies + let ok = crypto.verify_password_hash(password, hash.clone()).unwrap(); + assert!(ok, "correct password should verify"); + + // Wrong password does not verify + let bad = crypto.verify_password_hash("wrong_value".to_string(), hash.clone()).unwrap(); + assert!(!bad, "wrong password should not verify"); + + // Corrupted hash fails + let corrupted = format!("v3:0000:ffff"); + let result = crypto.verify_password_hash("my_secret_value".to_string(), corrupted); + assert!(result.is_err() || !result.unwrap()); +} diff --git a/bee_errors/Cargo.toml b/bee_errors/Cargo.toml new file mode 100644 index 0000000..8a89ff9 --- /dev/null +++ b/bee_errors/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bee-errors" +version.workspace = true +license.workspace = true +edition.workspace = true + +[features] +default = ["ackinacki-kit/default", "ackinacki-kit/contracts"] +wasm = ["ackinacki-kit/wasm", "ackinacki-kit/contracts"] +single-wasm = ["wasm"] + +[dependencies] +ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v3.0.0", default-features = false } +base64 = "0.22.1" +hex = { workspace = true } +pbkdf2 = { version = "0.12.2", features = ["simple"] } +reqwest = { version = "0.12", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +# `rt` makes `tokio::task::JoinError` (used by the `From` impl below) exist in +# every build — incl. wasm — instead of relying on a downstream crate to unify +# the feature in. `rt` compiles on wasm32 (current-thread runtime, no I/O). +tokio = { version = "1", default-features = false, features = ["rt"] } +url = "2" diff --git a/bee_errors/src/lib.rs b/bee_errors/src/lib.rs new file mode 100644 index 0000000..a2e2e19 --- /dev/null +++ b/bee_errors/src/lib.rs @@ -0,0 +1,322 @@ +use std::fmt; + +use ackinacki_kit::contracts::error::KitError; +use ackinacki_kit::contracts::error::KitErrorCode; +use ackinacki_kit::contracts::error::KitModule; +use ackinacki_kit::tvm_client::error::ClientError; +use serde::Deserialize; +use serde::Serialize; + +pub type AppResult = Result; + +#[derive(Debug, Serialize, Clone)] +pub struct AppError { + pub message: String, + pub details: Option, + pub module: Option, + pub error_code: Option, + pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tvm_error: Option, +} + +impl AppError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + details: None, + error_code: None, + module: None, + kind: None, + tvm_error: None, + } + } + + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } + + pub fn with_code(mut self, code: impl Into) -> Self { + self.error_code = Some(code.into()); + self + } + + pub fn with_module(mut self, module: impl Into) -> Self { + self.module = Some(module.into()); + self + } + + pub fn with_kind(mut self, kind: impl Into) -> Self { + self.kind = Some(kind.into()); + self + } + + pub fn with_context(mut self, context: impl Into) -> Self { + let context = context.into(); + self.message = format!("{context}: {}", self.message); + self.details = Some(match self.details.take() { + Some(details) => format!("{context}; {details}"), + None => context, + }); + // tvm_error preserved through context wrapping + self + } +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for AppError {} + +impl From for AppError { + fn from(message: String) -> Self { + AppError::new(message) + } +} + +impl From<&str> for AppError { + fn from(message: &str) -> Self { + AppError::new(message) + } +} + +impl From for AppError { + fn from(e: hex::FromHexError) -> Self { + AppError::new(e.to_string()).with_kind("hex") + } +} + +impl From for AppError { + fn from(e: base64::DecodeError) -> Self { + AppError::new(e.to_string()).with_kind("base64") + } +} + +impl From for AppError { + fn from(e: std::string::FromUtf8Error) -> Self { + AppError::new(e.to_string()).with_kind("utf8") + } +} + +impl From for AppError { + fn from(e: std::num::ParseIntError) -> Self { + AppError::new(e.to_string()).with_kind("parse") + } +} + +impl From for AppError { + fn from(e: url::ParseError) -> Self { + AppError::new(e.to_string()).with_kind("url") + } +} + +impl From for AppError { + fn from(e: tokio::task::JoinError) -> Self { + AppError::new(e.to_string()).with_kind("tokio") + } +} + +impl From for AppError { + fn from(e: reqwest::Error) -> Self { + AppError::new(e.to_string()).with_kind("http") + } +} + +impl From for AppError { + fn from(e: pbkdf2::password_hash::Error) -> Self { + AppError::new(e.to_string()).with_kind("crypto") + } +} + +impl From for AppError { + fn from(e: serde_json::Error) -> Self { + AppError::new(e.to_string()).with_kind("json") + } +} + +impl From for AppError { + fn from(e: ClientError) -> Self { + let code = e.code().to_string(); + let details = format!( + "tvm_code={}, tvm_details={}, exit_code={:?}", + e.code(), + e, + extract_exit_code(&e) + ); + let tvm_error = serde_json::to_value(&e).ok(); + + let mut err = AppError::new(e.message().to_string()) + .with_kind("tvm") + .with_module("tvm") + .with_code(code) + .with_details(details); + err.tvm_error = tvm_error; + err + } +} + +impl From for AppError { + fn from(e: KitError) -> Self { + let kit_module = module_prefix(e.module).to_string(); + + let (kind, module, code) = if e.code != KitErrorCode::None { + ("kit".to_string(), kit_module, e.code.as_i32().to_string()) + } else if let Some(te) = e.tvm_error.as_ref() { + if let Some(exit) = extract_exit_code(te) { + ("tvm_exit".to_string(), kit_module, exit.to_string()) + } else { + ("tvm".to_string(), "tvm".to_string(), te.code().to_string()) + } + } else { + ("kit".to_string(), kit_module, e.code.as_i32().to_string()) + }; + + let details = + match &e.tvm_error { + Some(te) => { + format!( + "kit_module={:?}, kit_code={:?}, tvm_code={}, exit_code={:?}, tvm_details={}", + e.module, e.code, te.code(), extract_exit_code(te), te + ) + } + None => format!("kit_module={:?}, kit_code={:?}", e.module, e.code), + }; + + let tvm_error = e.tvm_error.as_ref().and_then(|te| serde_json::to_value(te).ok()); + + let mut err = AppError::new(e.message) + .with_kind(kind) + .with_module(module) + .with_code(code) + .with_details(details); + err.tvm_error = tvm_error; + err + } +} + +fn module_prefix(module: KitModule) -> &'static str { + match module { + KitModule::MvSystem(_) => "mvsystem", + KitModule::BkSystem(_) => "bksystem", + KitModule::AuthService(_) => "authservice", + KitModule::Token(_) => "token", + KitModule::Account => "account", + KitModule::Event => "event", + KitModule::MvConfig => "mvconfig", + KitModule::Multisig => "multisig", + KitModule::Giver(_) => "giver", + KitModule::Accumulator(_) => "accumulator", + KitModule::Exchange(_) => "exchange", + // Downstream-owned modules (e.g. relocated DEX wrappers) carry their + // own stable id as the prefix. + KitModule::External(name) => name, + // `KitModule` is `#[non_exhaustive]`; tolerate future variants. + _ => "unknown", + } +} + +#[derive(Debug, Deserialize)] +struct TvmData { + #[serde(default)] + node_error: Option, +} + +#[derive(Debug, Deserialize)] +struct TvmNodeError { + #[serde(default)] + extensions: Option, +} + +#[derive(Debug, Deserialize)] +struct TvmExtensions { + #[serde(default)] + details: Option, +} + +#[derive(Debug, Deserialize)] +struct TvmDetails { + #[serde(default)] + exit_code: Option, +} + +fn extract_exit_code(err: &ClientError) -> Option { + let data: TvmData = serde_json::from_value(err.data().clone()).ok()?; + data.node_error?.extensions?.details?.exit_code +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kit_error_with_tvm_preserves_raw_value() { + let te = ClientError::new( + 414, + "test", + serde_json::json!({ + "node_error": { "extensions": { "details": { "exit_code": 42 } } } + }), + ); + let kit = KitError { + module: KitModule::Account, + code: KitErrorCode::None, + message: "Send message".into(), + tvm_error: Some(te), + }; + let app: AppError = kit.into(); + assert!(app.tvm_error.is_some(), "tvm_error must survive conversion"); + let v = app.tvm_error.unwrap(); + assert_eq!(v["code"], 414); + } + + #[test] + fn kit_error_without_tvm_leaves_field_none() { + let kit = KitError { + module: KitModule::Account, + code: KitErrorCode::AccountIsNotActive, + message: "nope".into(), + tvm_error: None, + }; + let app: AppError = kit.into(); + assert!(app.tvm_error.is_none()); + } + + #[test] + fn with_context_preserves_tvm_error() { + let mut app = AppError::new("boom").with_kind("tvm"); + app.tvm_error = Some(serde_json::json!({ "code": 1 })); + let wrapped = app.with_context("caller"); + assert!(wrapped.tvm_error.is_some(), "with_context must preserve tvm_error"); + assert!(wrapped.message.starts_with("caller: ")); + } + + #[test] + fn client_error_preserves_raw_value() { + let ce = + ClientError::new(601, "query failed", serde_json::json!({"hint": "use other field"})); + let app: AppError = ce.into(); + assert!(app.tvm_error.is_some()); + assert_eq!(app.tvm_error.as_ref().unwrap()["code"], 601); + assert_eq!(app.tvm_error.as_ref().unwrap()["data"]["hint"], "use other field"); + } + + #[test] + fn serde_skips_none_tvm_error() { + let app = AppError::new("test"); + let json = serde_json::to_value(&app).unwrap(); + assert!(json.get("tvm_error").is_none(), "None tvm_error should be skipped in JSON"); + } + + #[test] + fn serde_includes_some_tvm_error() { + let mut app = AppError::new("test"); + app.tvm_error = Some(serde_json::json!({"code": 42})); + let json = serde_json::to_value(&app).unwrap(); + assert!(json.get("tvm_error").is_some()); + assert_eq!(json["tvm_error"]["code"], 42); + } +} diff --git a/bee_infra/Cargo.toml b/bee_infra/Cargo.toml index d519c27..fabd618 100644 --- a/bee_infra/Cargo.toml +++ b/bee_infra/Cargo.toml @@ -1,14 +1,20 @@ [package] name = "bee-infra" -version = "0.1.0" -edition = "2021" +version.workspace = true +license.workspace = true +edition.workspace = true [features] default = [] -wasm = ["dep:gloo-timers"] +wasm = ["dep:gloo-timers", "dep:js-sys"] +tokio = ["dep:tokio"] [dependencies] gloo-timers = { version = "0.3.0", features = ["futures"], optional = true } +js-sys = { version = "0.3.83", optional = true } +parking_lot = "0.12" +tokio = { version = "1.49.0", features = ["time"], optional = true } [dev-dependencies] -futures = "0.3.31" +tokio = { version = "1.49.0", features = ["rt", "macros", "time"] } +wasm-bindgen-test = "0.3" diff --git a/bee_infra/src/lib.rs b/bee_infra/src/lib.rs index af53182..63f8823 100644 --- a/bee_infra/src/lib.rs +++ b/bee_infra/src/lib.rs @@ -1,33 +1,48 @@ +mod rate_limiter; +pub mod retry; +pub use rate_limiter::maybe_acquire; +pub use rate_limiter::RateLimiter; +pub use retry::with_retry_policy; +pub use retry::RetryPolicy; + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] -async fn sleep_ms(ms: u64) { +pub async fn sleep_ms(ms: u64) { use gloo_timers::future::TimeoutFuture; let ms_u32 = ms.min(u32::MAX as u64) as u32; TimeoutFuture::new(ms_u32).await; } -#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] -async fn sleep_ms(ms: u64) { +#[cfg(all(not(all(feature = "wasm", target_arch = "wasm32")), feature = "tokio"))] +pub async fn sleep_ms(ms: u64) { + tokio::time::sleep(std::time::Duration::from_millis(ms)).await; +} + +#[cfg(all(not(all(feature = "wasm", target_arch = "wasm32")), not(feature = "tokio")))] +pub async fn sleep_ms(ms: u64) { std::thread::sleep(std::time::Duration::from_millis(ms)); } -pub async fn poll_until( +pub async fn poll_until( fetch_data: FetchFn, predicate: VerifyFn, max_attempts: Option, interval_ms: Option, -) -> Result +) -> Result where FetchFn: Fn() -> FetchFut, - FetchFut: std::future::Future>, + FetchFut: std::future::Future>, VerifyFn: Fn(&T) -> bool, + E: From, { let mut attempts = 0; let max_attempts = max_attempts.unwrap_or(100); let interval_ms = interval_ms.unwrap_or(100); + let max_attempts_err = + || E::from(format!("Wait for property. Max {max_attempts} attempts reached.")); if max_attempts == 0 { - return Err("Wait for property. Max 0 attempts reached.".to_string()); + return Err(max_attempts_err()); } loop { @@ -39,7 +54,7 @@ where attempts += 1; if attempts >= max_attempts { - return Err(format!("Wait for property. Max {max_attempts} attempts reached.")); + return Err(max_attempts_err()); } sleep_ms(interval_ms).await; @@ -51,13 +66,15 @@ mod tests { use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - use futures::executor::block_on; + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::wasm_bindgen_test; + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); - #[test] - fn poll_until_returns_when_predicate_passes() { + async fn poll_until_returns_when_predicate_passes_impl() { let attempts = AtomicUsize::new(0); - let result = block_on(super::poll_until( + let result = super::poll_until( || async { let current = attempts.fetch_add(1, Ordering::SeqCst) + 1; Ok::(current) @@ -65,17 +82,17 @@ mod tests { |value| *value >= 3, Some(10), Some(0), - )) + ) + .await .expect("poll_until should return value"); assert_eq!(result, 3); } - #[test] - fn poll_until_fails_when_attempts_exhausted() { + async fn poll_until_fails_when_attempts_exhausted_impl() { let attempts = AtomicUsize::new(0); - let error = block_on(super::poll_until( + let error = super::poll_until( || async { let current = attempts.fetch_add(1, Ordering::SeqCst) + 1; Ok::(current) @@ -83,9 +100,34 @@ mod tests { |_| false, Some(3), Some(0), - )) + ) + .await .expect_err("poll_until should fail"); assert!(error.contains("Max 3 attempts reached")); } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn poll_until_returns_when_predicate_passes() { + poll_until_returns_when_predicate_passes_impl().await; + } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn poll_until_fails_when_attempts_exhausted() { + poll_until_fails_when_attempts_exhausted_impl().await; + } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test] + async fn poll_until_returns_when_predicate_passes() { + poll_until_returns_when_predicate_passes_impl().await; + } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test] + async fn poll_until_fails_when_attempts_exhausted() { + poll_until_fails_when_attempts_exhausted_impl().await; + } } diff --git a/bee_infra/src/rate_limiter.rs b/bee_infra/src/rate_limiter.rs new file mode 100644 index 0000000..a462e64 --- /dev/null +++ b/bee_infra/src/rate_limiter.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use parking_lot::Mutex; + +/// Simple token-bucket rate limiter. +/// +/// Call `acquire().await` before each network operation. +/// The limiter ensures at most `max_rps` calls per second by sleeping +/// when tokens are exhausted. +/// +/// Thread-safe and Clone-friendly via `Arc` interior. +#[derive(Clone, Debug)] +pub struct RateLimiter { + inner: Arc>, +} + +#[derive(Debug)] +struct TokenBucket { + /// Minimum interval between requests in milliseconds. + interval_ms: u64, + /// Timestamp (ms) when the next request is allowed. + next_allowed_ms: u64, +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +fn now_ms_platform() -> u64 { + js_sys::Date::now() as u64 +} + +#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] +fn now_ms_platform() -> u64 { + now_ms() +} + +impl RateLimiter { + /// Creates a rate limiter allowing `max_rps` requests per second. + /// + /// Panics if `max_rps` is 0. + pub fn new(max_rps: u32) -> Self { + assert!(max_rps > 0, "max_rps must be > 0"); + Self { + inner: Arc::new(Mutex::new(TokenBucket { + interval_ms: 1000 / (max_rps as u64), + next_allowed_ms: 0, + })), + } + } + + /// Convenience: creates `Some(RateLimiter)` if `max_rps` is provided. + pub fn optional(max_rps: Option) -> Option { + max_rps.map(Self::new) + } + + /// Waits until a request is allowed, then reserves a slot. + pub async fn acquire(&self) { + let wait_ms = { + let mut bucket = self.inner.lock(); + let now = now_ms_platform(); + if now >= bucket.next_allowed_ms { + bucket.next_allowed_ms = now + bucket.interval_ms; + 0 + } else { + let wait = bucket.next_allowed_ms - now; + bucket.next_allowed_ms += bucket.interval_ms; + wait + } + }; + + if wait_ms > 0 { + super::sleep_ms(wait_ms).await; + } + } +} + +/// Helper to call `acquire` on an optional rate limiter. +pub async fn maybe_acquire(limiter: Option<&RateLimiter>) { + if let Some(rl) = limiter { + rl.acquire().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limiter_creation() { + let rl = RateLimiter::new(10); + // interval = 1000/10 = 100ms + assert_eq!(rl.inner.lock().interval_ms, 100); + } + + #[test] + fn optional_none() { + assert!(RateLimiter::optional(None).is_none()); + } + + #[test] + fn optional_some() { + assert!(RateLimiter::optional(Some(5)).is_some()); + } + + #[test] + #[should_panic(expected = "max_rps must be > 0")] + fn zero_rps_panics() { + RateLimiter::new(0); + } +} diff --git a/bee_infra/src/retry.rs b/bee_infra/src/retry.rs new file mode 100644 index 0000000..a2e392e --- /dev/null +++ b/bee_infra/src/retry.rs @@ -0,0 +1,318 @@ +//! Classified retry wrapper for HTTP / RPC calls. +//! +//! The transport under us (tvm_client) historically had an unbounded +//! re-connect loop in `query_graphql` that, on multi-endpoint setups, +//! spun without sleep until `max_reconnect_timeout` (default 120 s), +//! turning a single 502 into a sustained ~60 rps storm against the +//! same BM. The shared client construction now sets that knob to 0, +//! so the transport returns the first failure verbatim. This module +//! puts a single, bounded, exponentially-backed-off retry on top. +//! +//! Known gap: `tvm_client` strips response headers before surfacing +//! errors, so we cannot honour `Retry-After` here. Worst case the +//! exponential backoff already gives the server time to recover; if +//! we ever need true `Retry-After` semantics we'd have to patch +//! tvm-sdk upstream. +//! +//! Callers supply: +//! - a [`RetryPolicy`] (attempts cap, total time cap, base/max delay, jitter +//! on/off); +//! - an optional [`RateLimiter`] re-acquired on every attempt so retries +//! actually count against the configured rps; +//! - a `should_retry` predicate over the error type — callers know their domain +//! (TVM contract codes vs HTTP transient classes). + +use std::time::Duration; + +use crate::RateLimiter; + +#[derive(Debug, Clone)] +pub struct RetryPolicy { + /// Total number of attempts including the initial one. + /// Setting this to 1 disables retries. + pub max_attempts: u32, + /// Wall-clock cap across all attempts. `None` = unbounded. + /// The first attempt is always tried even if the budget is + /// already exhausted; the cap gates whether to sleep+retry. + pub max_total: Option, + /// Starting delay for the exponential backoff: `2^(attempt-1) * base`. + pub base_delay: Duration, + /// Per-attempt delay cap (after exponential growth and jitter). + pub max_delay: Duration, + /// Add up to `base_delay` of randomized jitter to each sleep + /// so colliding clients don't all reconnect on the same tick. + pub jitter: bool, +} + +impl RetryPolicy { + /// Default for HTTP-style transient errors. Matches the spec in + /// `docs/bee_engine_issues/retry_policy.md` (5 attempts, 60 s + /// total cap, 500 ms base, 30 s max, jitter on). + pub const fn http_default() -> Self { + Self { + max_attempts: 5, + max_total: Some(Duration::from_secs(60)), + base_delay: Duration::from_millis(500), + max_delay: Duration::from_secs(30), + jitter: true, + } + } + + /// Legacy preset used by `bee_wallet::infra::with_retry` callers + /// (TVM-contract-level transient codes). 3 attempts, constant + /// 1 s delay, no jitter — preserves existing behavior so the + /// migration is a no-op for those call sites. + pub const fn tvm_transient_legacy() -> Self { + Self { + max_attempts: 3, + max_total: None, + base_delay: Duration::from_millis(1000), + max_delay: Duration::from_millis(1000), + jitter: false, + } + } +} + +/// Run `op` under `policy`, re-acquiring `rate_limiter` on every +/// attempt and stopping early if `should_retry` returns false for +/// the observed error or if `max_total` is exceeded. +/// +/// Returns the last error verbatim on exhaustion so callers can +/// preserve their domain-specific error context. +pub async fn with_retry_policy( + policy: &RetryPolicy, + rate_limiter: Option<&RateLimiter>, + should_retry: ShouldRetry, + mut op: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, + ShouldRetry: Fn(&E) -> bool, +{ + assert!(policy.max_attempts >= 1, "RetryPolicy::max_attempts must be >= 1"); + + let start_ms = now_ms(); + let mut last_err: Option = None; + + for attempt in 1..=policy.max_attempts { + crate::maybe_acquire(rate_limiter).await; + + match op().await { + Ok(value) => return Ok(value), + Err(err) => { + if attempt == policy.max_attempts || !should_retry(&err) { + return Err(err); + } + if let Some(cap) = policy.max_total { + if now_ms().saturating_sub(start_ms) >= cap.as_millis() as u64 { + return Err(err); + } + } + last_err = Some(err); + let delay = backoff_delay(policy, attempt); + crate::sleep_ms(delay.as_millis() as u64).await; + } + } + } + + // Loop invariant: we only reach here if max_attempts == 0, which + // the assert above rejects. Keep the panic-free fallback for + // future-proofing. + Err(last_err.expect("retry loop ran at least once")) +} + +fn backoff_delay(policy: &RetryPolicy, attempt: u32) -> Duration { + let base = policy.base_delay.as_millis() as u64; + let cap = policy.max_delay.as_millis() as u64; + let factor = 1u64.checked_shl(attempt.saturating_sub(1)).unwrap_or(u64::MAX); + let grown = base.saturating_mul(factor); + let capped = grown.min(cap); + let jitter_ms = if policy.jitter { jitter_up_to(base) } else { 0 }; + Duration::from_millis(capped.saturating_add(jitter_ms).min(cap)) +} + +/// Pseudo-random `[0, span_ms)` derived from a per-process counter +/// xor'd with the current wall-clock. Good enough to de-correlate +/// retries across colliding clients without pulling in `rand`. +fn jitter_up_to(span_ms: u64) -> u64 { + use std::sync::atomic::AtomicU64; + use std::sync::atomic::Ordering; + static COUNTER: AtomicU64 = AtomicU64::new(0x9E3779B97F4A7C15); + if span_ms == 0 { + return 0; + } + let n = COUNTER.fetch_add(0x9E3779B97F4A7C15, Ordering::Relaxed); + let mixed = n ^ now_ms().rotate_left(13); + let mixed = mixed.wrapping_mul(0xBF58476D1CE4E5B9); + let mixed = mixed ^ (mixed >> 31); + mixed % span_ms +} + +fn now_ms() -> u64 { + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + { + js_sys::Date::now() as u64 + } + #[cfg(not(all(feature = "wasm", target_arch = "wasm32")))] + { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + use super::*; + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn succeeds_on_first_attempt() { + let policy = RetryPolicy::http_default(); + let attempts = AtomicUsize::new(0); + let res: Result = with_retry_policy( + &policy, + None, + |_| true, + || async { + attempts.fetch_add(1, Ordering::SeqCst); + Ok(42) + }, + ) + .await; + assert_eq!(res, Ok(42)); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn retries_then_succeeds() { + let policy = RetryPolicy { + max_attempts: 3, + max_total: None, + base_delay: Duration::from_millis(1), + max_delay: Duration::from_millis(1), + jitter: false, + }; + let attempts = AtomicUsize::new(0); + let res: Result = with_retry_policy( + &policy, + None, + |_| true, + || async { + let n = attempts.fetch_add(1, Ordering::SeqCst); + if n < 2 { + Err("transient") + } else { + Ok(7) + } + }, + ) + .await; + assert_eq!(res, Ok(7)); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn stops_when_classifier_rejects() { + let policy = RetryPolicy { + max_attempts: 5, + max_total: None, + base_delay: Duration::from_millis(1), + max_delay: Duration::from_millis(1), + jitter: false, + }; + let attempts = AtomicUsize::new(0); + let res: Result = with_retry_policy( + &policy, + None, + |_| false, + || async { + attempts.fetch_add(1, Ordering::SeqCst); + Err("non-retryable") + }, + ) + .await; + assert_eq!(res, Err("non-retryable")); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn exhausts_after_max_attempts() { + let policy = RetryPolicy { + max_attempts: 4, + max_total: None, + base_delay: Duration::from_millis(1), + max_delay: Duration::from_millis(1), + jitter: false, + }; + let attempts = AtomicUsize::new(0); + let res: Result = with_retry_policy( + &policy, + None, + |_| true, + || async { + attempts.fetch_add(1, Ordering::SeqCst); + Err("nope") + }, + ) + .await; + assert_eq!(res, Err("nope")); + assert_eq!(attempts.load(Ordering::SeqCst), 4); + } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn honours_max_total_cap() { + let policy = RetryPolicy { + max_attempts: 100, + max_total: Some(Duration::from_millis(40)), + base_delay: Duration::from_millis(20), + max_delay: Duration::from_millis(20), + jitter: false, + }; + let attempts = AtomicUsize::new(0); + let started = std::time::Instant::now(); + let res: Result = with_retry_policy( + &policy, + None, + |_| true, + || async { + attempts.fetch_add(1, Ordering::SeqCst); + Err("slow") + }, + ) + .await; + assert_eq!(res, Err("slow")); + let n = attempts.load(Ordering::SeqCst); + // First attempt costs ~0, then 20ms sleep, then second attempt, + // then the cap (40ms) trips and we bail. So 2-3 attempts max. + assert!(n <= 3, "expected ≤ 3 attempts under 40ms cap, got {n}"); + assert!(started.elapsed() < Duration::from_millis(200)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn backoff_grows_and_caps() { + let policy = RetryPolicy { + max_attempts: 10, + max_total: None, + base_delay: Duration::from_millis(100), + max_delay: Duration::from_millis(800), + jitter: false, + }; + assert_eq!(backoff_delay(&policy, 1).as_millis(), 100); + assert_eq!(backoff_delay(&policy, 2).as_millis(), 200); + assert_eq!(backoff_delay(&policy, 3).as_millis(), 400); + assert_eq!(backoff_delay(&policy, 4).as_millis(), 800); + assert_eq!(backoff_delay(&policy, 5).as_millis(), 800); + assert_eq!(backoff_delay(&policy, 50).as_millis(), 800); + } +} diff --git a/bee_miner/Cargo.toml b/bee_miner/Cargo.toml index e41a608..f50b9e0 100644 --- a/bee_miner/Cargo.toml +++ b/bee_miner/Cargo.toml @@ -1,27 +1,26 @@ [package] -edition = "2021" +edition.workspace = true name = "bee-miner" -version = "0.1.0" +version.workspace = true +license.workspace = true [lib] crate-type = ["cdylib", "rlib"] [features] -default = ["ackinacki-kit/default", "ackinacki-kit/contracts"] +default = ["ackinacki-kit/default"] wasm = [ - "dep:ackinacki-kit", "dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:js-sys", "bee-infra/wasm", "ackinacki-kit/wasm", - "ackinacki-kit/contracts", ] single-wasm = ["wasm"] [dependencies] -ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v2.0.0", optional = true, default-features = false } +ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v3.0.0", default-features = false, features = ["contracts"] } base64 = "0.22.1" blake3.workspace = true borsh.workspace = true @@ -31,7 +30,7 @@ hex.workspace = true js-sys = { optional = true, version = "0.3.83" } rs_merkle.workspace = true bee-shared = { path = "../bee_shared" } -bee-infra = { path = "../bee_infra", default-features = false } +bee-infra = { path = "../bee_infra", default-features = false, features = ["tokio"] } serde.workspace = true serde_json.workspace = true thiserror = "2.0.17" @@ -41,5 +40,5 @@ serde-wasm-bindgen = { optional = true, version = "0.6.5" } web-sys = { version = "0.3.83", features = ["console"] } [dev-dependencies] +tokio = { version = "1.49.0", features = ["rt", "macros"] } wasm-bindgen-test = "0.3.56" -tokio = { version = "1", features = ["macros", "rt"] } diff --git a/bee_miner/src/core/context.rs b/bee_miner/src/core/context.rs index c8c534c..3525ef3 100644 --- a/bee_miner/src/core/context.rs +++ b/bee_miner/src/core/context.rs @@ -70,6 +70,7 @@ impl MiningCore { return None; } } + self.last_now_ms = Some(params.now_ms); let job_index = self.completed.len() as u128; let computed_leaf = self.hash_processor.calc_hash_blake(ParamsOfCalcHash { @@ -117,6 +118,7 @@ impl MiningCore { self.completed.clear(); self.committed = false; self.ends_at_ms = None; + self.last_now_ms = None; } fn deadline_reached_ms(&self, now_ms: u64) -> bool { @@ -126,13 +128,19 @@ impl MiningCore { #[cfg(test)] mod tests { + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::wasm_bindgen_test; + use super::*; + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); fn add_tap(core: &mut MiningCore, x: u32, y: u32, now_ms: u64) -> Option { core.compute(ParamsOfCompute { x, y, now_ms }) } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn sets_deadline_on_first_job_and_honors_it() { let mut core = MiningCore::new("s".to_string(), 15_000, 12); @@ -153,7 +161,8 @@ mod tests { assert!(out.is_none()); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn after_commit_no_more_jobs() { let mut core = MiningCore::new("seed".to_string(), 5_000, 12); let _ = add_tap(&mut core, 1, 1, 0); @@ -167,7 +176,8 @@ mod tests { assert!(out.is_none(), "no jobs after commit"); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn completed_slice_returns_elements_in_order() { let mut core = MiningCore::new("s".to_string(), 5_000, 12); let _ = add_tap(&mut core, 1, 1, 0); @@ -180,7 +190,8 @@ mod tests { assert_eq!(v[1].index, 2); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn reset_clears_state_and_restarts_session() { let mut core = MiningCore::new("seed0".to_string(), 10_000, 12); @@ -198,7 +209,8 @@ mod tests { assert!(core.ends_at_ms.is_some()); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn accepts_last_ms_before_deadline_and_rejects_at_deadline() { let mut core = MiningCore::new("s".to_string(), 2_000, 12); let start = 10_000; @@ -211,7 +223,8 @@ mod tests { assert!(add_tap(&mut core, 1, 1, end).is_none()); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn get_merkle_is_idempotent_and_returns_non_empty_root_after_work() { let mut core = MiningCore::new("seed".to_string(), 5_000, 12); let _ = add_tap(&mut core, 1, 1, 0); @@ -224,7 +237,8 @@ mod tests { assert!(!root1.is_empty(), "merkle root should not be empty after some work"); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn get_proof_bounds() { let mut core = MiningCore::new("s".to_string(), 5_000, 12); let _ = add_tap(&mut core, 1, 1, 0); @@ -245,7 +259,27 @@ mod tests { assert!(bad.is_err()); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn rejects_backwards_timestamps_and_reset_clears_last_now_ms() { + let mut core = MiningCore::new("s".to_string(), 10_000, 12); + assert!(add_tap(&mut core, 1, 1, 1_000).is_some()); + assert!(add_tap(&mut core, 1, 1, 2_000).is_some()); + + // backwards timestamp is rejected + assert!(add_tap(&mut core, 1, 1, 1_500).is_none()); + // equal timestamp is accepted + assert!(add_tap(&mut core, 1, 1, 2_000).is_some()); + // forward timestamp is accepted + assert!(add_tap(&mut core, 1, 1, 3_000).is_some()); + + // reset clears the watermark + core.reset(); + assert!(add_tap(&mut core, 1, 1, 500).is_some()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn merkle_before_any_work_is_empty_but_commits() { let mut core = MiningCore::new("s".to_string(), 5_000, 12); let _ = core.get_merkle(); diff --git a/bee_miner/src/core/keys.rs b/bee_miner/src/core/keys.rs index eb1e3b3..8344910 100644 --- a/bee_miner/src/core/keys.rs +++ b/bee_miner/src/core/keys.rs @@ -55,7 +55,9 @@ impl ResultOfGenMiningKeys { .map_err(|e| format!("Serialize deep link payload ({e})"))?; let payload = URL_SAFE_NO_PAD.encode(payload); - Ok(format!("{DEEPLINK_RESOLVER_URL}/deeplinks/wallet/connect?payload={payload}")) + // Ok(format!("{DEEPLINK_RESOLVER_URL}/deeplinks/wallet/connect? + // payload={payload}")) + Ok(format!("{DEEPLINK_RESOLVER_URL}/deeplinks/wallet/v2/set-mining-keys?payload={payload}")) } } @@ -98,7 +100,7 @@ pub async fn ensure_mining_keys_propagated( ); let miner = Arc::new(Miner::new_default(context, ¶ms.miner_address)); let app_id = params.app_id; - let expected_owner_public = params.expected_owner_public; + let expected_owner_public = normalize_owner_public_for_compare(¶ms.expected_owner_public); bee_infra::poll_until( || { @@ -110,7 +112,13 @@ pub async fn ensure_mining_keys_propagated( .map_err(|e| format!("ensure_mining_keys_propagated: miner.get_details ({e})")) } }, - move |details| details.owner_public.get(&app_id) == Some(&expected_owner_public), + move |details| { + details + .owner_public + .get(&app_id) + .map(|value| normalize_owner_public_for_compare(value) == expected_owner_public) + .unwrap_or(false) + }, params.max_attempts, params.interval_ms, ) @@ -168,14 +176,22 @@ fn resolve_mirror_for_multifactor( .filter(|s| !s.is_empty()) .ok_or_else(|| format!("Invalid multifactor address format: {multifactor_address}"))?; - Mirror::new_default(tvm_client, tail).map_err(|e| format!("Mirror::new ({e})")) + Mirror::new_default(tvm_client, tail).map_err(|e| format!("Mirror::new_default ({e})")) +} + +fn normalize_owner_public_for_compare(value: &str) -> String { + let stripped = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")).unwrap_or(value); + format!("0x{}", stripped.to_ascii_lowercase()) } -#[cfg(all(test, not(feature = "wasm")))] +#[cfg(test)] mod tests { - use ackinacki_kit::tvm_client::ClientConfig; use base64::Engine; use serde::Deserialize; + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::wasm_bindgen_test; + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); use super::DEEPLINK_RESOLVER_URL; use super::URL_SAFE_NO_PAD; @@ -188,14 +204,26 @@ mod tests { app_id: String, } - #[tokio::test(flavor = "current_thread")] - async fn gen_mining_keys_generates_keys_and_valid_deep_link() { + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn normalize_owner_public_for_compare_accepts_prefixed_and_raw() { + let raw = "AaBb"; + let with_prefix = "0xAaBb"; + let upper_prefix = "0XAABB"; + + assert_eq!(super::normalize_owner_public_for_compare(raw), "0xaabb"); + assert_eq!(super::normalize_owner_public_for_compare(with_prefix), "0xaabb"); + assert_eq!(super::normalize_owner_public_for_compare(upper_prefix), "0xaabb"); + } + + async fn gen_mining_keys_generates_keys_and_valid_deep_link_impl() { let result = super::gen_mining_keys(APP_ID).await.expect("keys should be generated"); assert!(!result.keys.public.is_empty(), "public key should not be empty"); assert!(!result.keys.secret.is_empty(), "secret key should not be empty"); - let prefix = format!("{DEEPLINK_RESOLVER_URL}/deeplinks/wallet/connect?payload="); + let prefix = + format!("{DEEPLINK_RESOLVER_URL}/deeplinks/wallet/v2/set-mining-keys?payload="); let payload = result.deep_link.strip_prefix(&prefix).expect("deep link should have payload query"); @@ -208,37 +236,22 @@ mod tests { assert_eq!(decoded_payload.app_id, APP_ID); } + // NOTE: the end-to-end test for `get_miner_address_by_wallet_name` lives in + // `bee_wallet` (tests/integration.rs::test_get_miner_address_by_wallet_name). + // It deploys a fresh wallet and resolves it, so it never rots on a shellnet + // redeploy — unlike the old test here, which pinned `test_t1_*` and broke + // every wipe. It can't live in `bee_miner`: deploying a wallet needs + // `bee_wallet`, and `bee_miner` doesn't depend on it. + + #[cfg(not(target_arch = "wasm32"))] #[tokio::test(flavor = "current_thread")] - async fn get_miner_address_by_wallet_name_for_known_wallets() { - let candidates = ["demo_wallet"]; - let mut last_error = String::new(); - - for wallet_name in candidates { - let mut cfg = ClientConfig::default(); - cfg.network.endpoints = Some(vec!["mainnet.ackinacki.org".to_string()]); - - match super::get_miner_address_by_wallet_name( - super::ParamsOfGetMinerAddressByWalletName { - client_config: cfg, - wallet_name: wallet_name.to_string(), - }, - ) - .await - { - Ok(address) => { - assert!(!address.is_empty(), "resolved miner address should not be empty"); - assert!( - address.contains(':'), - "resolved miner address should look like TVM address, got: {address}" - ); - return; - } - Err(e) => { - last_error = format!("wallet `{wallet_name}`: {e}"); - } - } - } + async fn gen_mining_keys_generates_keys_and_valid_deep_link() { + gen_mining_keys_generates_keys_and_valid_deep_link_impl().await; + } - panic!("Failed to resolve miner address for demo_wallet or wapp_t_1: {last_error}"); + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen_test] + async fn gen_mining_keys_generates_keys_and_valid_deep_link() { + gen_mining_keys_generates_keys_and_valid_deep_link_impl().await; } } diff --git a/bee_miner/src/core/merkle.rs b/bee_miner/src/core/merkle.rs index 43b6304..f5bb251 100644 --- a/bee_miner/src/core/merkle.rs +++ b/bee_miner/src/core/merkle.rs @@ -81,6 +81,10 @@ impl MerkleProcessor { mod tests { use bee_shared::miner::leaf::Leaf; use rs_merkle::MerkleProof; + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::wasm_bindgen_test; + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); use super::*; @@ -97,14 +101,16 @@ mod tests { leaf.compute() } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn empty_tree_has_empty_root_and_no_leaves() { let merkle = MerkleProcessor::new(); assert!(merkle.get_merkle_root().is_empty()); assert!(merkle.get_merkle_leaves().is_empty()); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn leaves_are_blake3_of_input_and_root_is_stable_after_commit() { let mut merkle = MerkleProcessor::new(); merkle.add_leaf(&create_computed_leaf(0)); @@ -119,7 +125,8 @@ mod tests { assert!(!root.is_empty(), "root must not be empty after commit"); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn proof_verifies_for_window() { let mut m = MerkleProcessor::new(); let total = 10usize; @@ -141,7 +148,8 @@ mod tests { assert!(proof.verify(root, &indices, &selected, total)); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn clear_resets_tree_to_empty_state() { let mut merkle = MerkleProcessor::new(); merkle.add_leaf(&create_computed_leaf(0)); @@ -154,7 +162,8 @@ mod tests { assert!(merkle.get_merkle_leaves().is_empty()); } - #[test] + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn order_of_leaves_affects_root() { let mut m1 = MerkleProcessor::new(); m1.add_leaf(&create_computed_leaf(0)); diff --git a/bee_miner/src/graphql/block.rs b/bee_miner/src/graphql/block.rs new file mode 100644 index 0000000..423047d --- /dev/null +++ b/bee_miner/src/graphql/block.rs @@ -0,0 +1,21 @@ +use serde::Deserialize; + +use super::GqlEdge; + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct BlocksBlockchain { + pub blocks: BlocksConnection, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct BlocksConnection { + pub edges: Vec>, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct BlockNode { + pub seq_no: u64, +} diff --git a/bee_miner/src/graphql/mod.rs b/bee_miner/src/graphql/mod.rs new file mode 100644 index 0000000..df46f10 --- /dev/null +++ b/bee_miner/src/graphql/mod.rs @@ -0,0 +1,21 @@ +pub mod block; + +use serde::Deserialize; + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct GqlResponse { + pub data: GqlData, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct GqlData { + pub blockchain: T, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct GqlEdge { + pub node: T, +} diff --git a/bee_miner/src/lib.rs b/bee_miner/src/lib.rs index 921e813..07b3648 100644 --- a/bee_miner/src/lib.rs +++ b/bee_miner/src/lib.rs @@ -1,3 +1,4 @@ pub mod core; +mod graphql; #[cfg(feature = "wasm")] pub mod wasm; diff --git a/bee_miner/src/wasm/miner.rs b/bee_miner/src/wasm/miner.rs index 21e3441..1e95951 100644 --- a/bee_miner/src/wasm/miner.rs +++ b/bee_miner/src/wasm/miner.rs @@ -16,14 +16,13 @@ use ackinacki_kit::shared::traits::guarded::AsyncGuarded; use ackinacki_kit::tvm_client::abi::Signer; use ackinacki_kit::tvm_client::crypto::KeyPair; use ackinacki_kit::tvm_client::net; -use ackinacki_kit::tvm_client::net::OrderBy; -use ackinacki_kit::tvm_client::net::ParamsOfQueryCollection; -use ackinacki_kit::tvm_client::net::SortDirection; use ackinacki_kit::tvm_client::ClientConfig; use ackinacki_kit::tvm_client::ClientContext; use futures::channel::mpsc::unbounded; use wasm_bindgen::prelude::*; +use crate::graphql::block::BlocksBlockchain; +use crate::graphql::GqlResponse; use crate::wasm::get_miner_events; use crate::wasm::worker; use crate::wasm::worker::MinerCommand; @@ -300,29 +299,38 @@ impl Miner { #[wasm_bindgen(js_name = get_current_block)] pub async fn get_current_block(&self) -> Result { - let result = net::query_collection( + let raw = net::query( self.contract.context().clone(), - ParamsOfQueryCollection { - collection: "blocks".to_string(), - filter: None, - result: "seq_no".to_string(), - order: Some(vec![OrderBy { - path: "seq_no".to_string(), - direction: SortDirection::DESC, - }]), - limit: Some(1), + net::ParamsOfQuery { + query: r#" + query { + blockchain { + blocks(last: 1) { + edges { + node { seq_no } + } + } + } + } + "# + .to_string(), + variables: None, }, ) .await .map_err(|e| JsError::new(&format!("Query block seq_no ({e})")))?; - let value = result - .result - .first() - .ok_or_else(|| JsError::new("No results returned in block seq_no query"))?; + let parsed = serde_json::from_value::>(raw.result) + .map_err(|e| JsError::new(&format!("Deserialize blocks response ({e})")))?; - serde_json::from_value::(value.clone()) - .map_err(|e| JsError::new(&format!("Deserialize query result ({e})"))) + parsed + .data + .blockchain + .blocks + .edges + .first() + .map(|edge| GraphqlBlockData { seq_no: edge.node.seq_no }) + .ok_or_else(|| JsError::new("No blocks returned")) } } diff --git a/bee_miner/src/wasm/mod.rs b/bee_miner/src/wasm/mod.rs index 7c31848..12f44c6 100644 --- a/bee_miner/src/wasm/mod.rs +++ b/bee_miner/src/wasm/mod.rs @@ -1,6 +1,6 @@ use ackinacki_kit::contracts::deserialize::deserialize_u128; use ackinacki_kit::contracts::deserialize::deserialize_u64; -use ackinacki_kit::contracts::event::query_events_while; +use ackinacki_kit::contracts::event::query_events; use ackinacki_kit::contracts::mvsystem; use ackinacki_kit::contracts::mvsystem::miner::events::DecodedMinerEvent; use ackinacki_kit::contracts::traits::AddressAccessor; @@ -69,18 +69,13 @@ async fn get_miner_events( contract: &mvsystem::miner::contract::Miner, after_dt: u64, ) -> Result, JsError> { - query_events_while( - contract.context().clone(), - contract.address(), - contract.dapp_id(), - Some(100), - |event| event.created_at > after_dt, - ) - .await - .map_err(|e| JsError::new(&format!("Query miner events ({e})")))? - .iter() - .map(|event| { - DecodedMinerEvent::from_event(event, contract).map_err(|e| JsError::new(&e.to_string())) - }) - .collect::, JsError>>() + query_events(contract.context().clone(), contract.address(), contract.dapp_id(), Some(100)) + .await + .map_err(|e| JsError::new(&format!("Query miner events ({e})")))? + .iter() + .filter(|event| event.created_at > after_dt) + .map(|event| { + DecodedMinerEvent::from_event(event, contract).map_err(|e| JsError::new(&e.to_string())) + }) + .collect::, JsError>>() } diff --git a/bee_miner/src/wasm/worker.rs b/bee_miner/src/wasm/worker.rs index 541e295..8657364 100644 --- a/bee_miner/src/wasm/worker.rs +++ b/bee_miner/src/wasm/worker.rs @@ -115,8 +115,8 @@ pub(crate) async fn run( loop { // Process incoming command and calculate hashes let now_ms = js_sys::Date::now() as u64; - match worker_receiver.try_recv() { - Ok(event) => match event { + match worker_receiver.try_next() { + Ok(Some(event)) => match event { MinerCommand::QueueTap { x, y } => { tap_core.compute(ParamsOfCompute { x, y, now_ms }); send_callback( @@ -129,7 +129,7 @@ pub(crate) async fn run( } _ => {} }, - Err(_) => { + Ok(None) | Err(_) => { time_core.compute(ParamsOfCompute { x: 0, y: 0, now_ms }); } } diff --git a/bee_sdk/Cargo.toml b/bee_sdk/Cargo.toml index 93c9484..cec73a6 100644 --- a/bee_sdk/Cargo.toml +++ b/bee_sdk/Cargo.toml @@ -1,10 +1,14 @@ [package] name = "bee-sdk" -version = "0.1.0" -edition = "2024" +version.workspace = true +license.workspace = true +edition.workspace = true [lib] crate-type = ["cdylib", "rlib"] [dependencies] +bee-connect = { path = "../bee_connect", default-features = false, features=["single-wasm"] } +bee-crypto = { path = "../bee_crypto", default-features = false, features=["single-wasm"] } bee-miner = { path = "../bee_miner", default-features = false, features=["single-wasm"] } +bee-wallet = { path = "../bee_wallet", default-features = false, features=["single-wasm"] } diff --git a/bee_sdk/src/lib.rs b/bee_sdk/src/lib.rs index c0fa426..7974bd1 100644 --- a/bee_sdk/src/lib.rs +++ b/bee_sdk/src/lib.rs @@ -1 +1,4 @@ +pub use bee_connect::wasm as bee_connect; +pub use bee_crypto::wasm as crypto; pub use bee_miner::wasm as miner; +pub use bee_wallet::wasm as wallet; diff --git a/bee_shared/Cargo.toml b/bee_shared/Cargo.toml index 390d744..5ab6068 100644 --- a/bee_shared/Cargo.toml +++ b/bee_shared/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "bee-shared" -version = "0.1.0" -edition = "2024" +version.workspace = true +license.workspace = true +edition.workspace = true [dependencies] blake3.workspace = true diff --git a/bee_shared/src/miner/leaf.rs b/bee_shared/src/miner/leaf.rs index 08a9cc6..f318f1b 100644 --- a/bee_shared/src/miner/leaf.rs +++ b/bee_shared/src/miner/leaf.rs @@ -96,6 +96,12 @@ impl ComputedLeaf { if self.seed != seed.as_ref() || self.complexity != complexity { return false; } + // A threshold for complexity >= 128 would shift u128 past its width + // (panic in debug, masked-shift in release). No hash can satisfy such + // a constraint, so reject up front. + if self.complexity >= 128 { + return false; + } let hash_valid = { let leaf = Leaf::from(self.clone()); @@ -112,8 +118,57 @@ impl ComputedLeaf { } pub fn hash_num(&self) -> u128 { + if self.hash.len() < 16 { + // Malformed hash cannot satisfy any difficulty threshold; return the + // sentinel that compares strictly above every valid threshold. + return u128::MAX; + } let mut hi16 = [0u8; 16]; hi16.copy_from_slice(&self.hash[0..16]); u128::from_be_bytes(hi16) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn leaf_with(complexity: u32, hash: Vec) -> ComputedLeaf { + ComputedLeaf { + index: 0, + nonce: 0, + seed: "seed".to_string(), + x: 0, + y: 0, + timestamp: 0, + complexity, + hash, + } + } + + #[test] + fn verify_rejects_complexity_at_or_above_128_without_panic() { + // A complexity that would shift past u128 width must not panic. + for c in [128u32, 129, 200, u32::MAX] { + let leaf = leaf_with(c, vec![0u8; 32]); + assert!(!leaf.verify("seed", c), "complexity={c} must be rejected"); + } + } + + #[test] + fn hash_num_safe_on_short_hash() { + // Short hash must not panic and must not satisfy any real threshold. + let leaf = leaf_with(10, vec![0u8; 5]); + assert_eq!(leaf.hash_num(), u128::MAX); + assert!(!leaf.verify("seed", 10)); + } + + #[test] + fn hash_num_reads_first_16_bytes_big_endian() { + let mut hash = vec![0u8; 32]; + hash[0] = 0x01; + hash[15] = 0xFF; + let leaf = leaf_with(10, hash); + assert_eq!(leaf.hash_num(), (1u128 << 120) | 0xFF); + } +} diff --git a/bee_shared/src/verifier.rs b/bee_shared/src/verifier.rs index b0cca78..2f219c9 100644 --- a/bee_shared/src/verifier.rs +++ b/bee_shared/src/verifier.rs @@ -38,6 +38,14 @@ impl TryFrom> for ParamsOfVerifySession { type Error = String; fn try_from(bytes: Vec) -> Result { + const HEADER_LEN: usize = 72; + if bytes.len() < HEADER_LEN { + return Err(format!( + "Input too short: expected at least {HEADER_LEN} bytes, got {}", + bytes.len() + )); + } + // Seed let seed = { let slice = &bytes[0..32]; @@ -93,7 +101,7 @@ impl TryFrom> for ParamsOfVerifySession { }; // Rest bytes decode with borsh - let bytes_rest = &bytes[72..]; + let bytes_rest = &bytes[HEADER_LEN..]; let rest = borsh::from_slice::(bytes_rest) .map_err(|e| format!("Decode rest bytes ({e})"))?; @@ -125,3 +133,25 @@ pub fn combine_bytes(args: &[&(impl BorshSerialize + Debug)]) -> Result, Ok(combined) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn try_from_short_input_returns_error_not_panic() { + for len in [0usize, 1, 31, 32, 71] { + let bytes = vec![0u8; len]; + let result = ParamsOfVerifySession::try_from(bytes); + assert!(result.is_err(), "expected Err for len={len}"); + } + } + + #[test] + fn try_from_header_ok_but_borsh_rest_fails_cleanly() { + // 72-byte header is well-formed; borsh tail is empty -> error, not panic. + let bytes = vec![0u8; 72]; + let result = ParamsOfVerifySession::try_from(bytes); + assert!(result.is_err()); + } +} diff --git a/bee_verifier/Cargo.toml b/bee_verifier/Cargo.toml index 1dfd53e..6401a08 100644 --- a/bee_verifier/Cargo.toml +++ b/bee_verifier/Cargo.toml @@ -1,8 +1,9 @@ [package] -edition = "2024" +edition.workspace = true name = "bee-verifier" -version = "0.1.0" +version.workspace = true +license.workspace = true [lib] crate-type = ["lib", "cdylib"] diff --git a/bee_verifier/src/bindings.rs b/bee_verifier/src/bindings.rs index d61c4a0..4133b1a 100644 --- a/bee_verifier/src/bindings.rs +++ b/bee_verifier/src/bindings.rs @@ -127,9 +127,7 @@ macro_rules! __export_bee_engine_impl { #[doc(inline)] pub(crate) use __export_bee_engine_impl as export; #[cfg(target_arch = "wasm32")] -#[unsafe( - link_section = "component-type:wit-bindgen:0.41.0:docs:bee-engine@0.1.0:bee-engine:encoded world" -)] +#[unsafe(link_section = "component-type:wit-bindgen:0.41.0:docs:bee-engine@0.1.0:bee-engine:encoded world")] #[doc(hidden)] #[allow(clippy::octal_escapes)] pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 246] = *b"\ diff --git a/bee_verifier/src/lib.rs b/bee_verifier/src/lib.rs index 8cc278f..62598f0 100644 --- a/bee_verifier/src/lib.rs +++ b/bee_verifier/src/lib.rs @@ -136,12 +136,12 @@ mod tests { use crate::bindings::exports::docs::bee_engine::verifier_interface::Guest; use crate::VerifierGuest; - trait HexDecodeExt { - fn decode_hex(&self) -> Vec; + trait FromHex { + fn from_hex(&self) -> Vec; } - impl HexDecodeExt for str { - fn decode_hex(&self) -> Vec { + impl FromHex for str { + fn from_hex(&self) -> Vec { hex::decode(self).unwrap() } } @@ -153,7 +153,7 @@ mod tests { #[test] fn test_hash() { let computed_leaf = ComputedLeaf { - hash: "0011ffde34458bb9369bd0336a3f437263ffe3edf4a0bdcb5e89ae9a44736275".decode_hex(), + hash: "0011ffde34458bb9369bd0336a3f437263ffe3edf4a0bdcb5e89ae9a44736275".from_hex(), nonce: 2249, index: 10, seed: "0x0000".to_string(), diff --git a/bee_wallet/ARCHITECTURE.md b/bee_wallet/ARCHITECTURE.md new file mode 100644 index 0000000..c353d4a --- /dev/null +++ b/bee_wallet/ARCHITECTURE.md @@ -0,0 +1,44 @@ +# Bee Wallet Architecture + +## Layer Contract + +1. `modules/*` is the orchestration layer. +2. `services/*` is the helper/use-case layer. +3. `adapters/*` is the external API/DTO layer. + +## Responsibilities + +### `modules` + +- Assemble full domain scenarios (multi-step workflows). +- Coordinate retries/polling and transaction sequencing. +- Compose helpers from `services`, `infra`, contracts, and `bee_crypto::Crypto`. + +### `services` + +- Provide focused reusable helpers for domain operations. +- Contain contract call helpers and pure transformations. +- Stay composable and callable from modules without owning whole flows. + +### `adapters` + +- Convert external/native/wasm payloads to module calls. +- Return transport-friendly DTOs. +- Avoid embedding domain orchestration. + +## Dependency Direction + +`adapters -> modules -> services -> infra/contracts/bee_crypto` + +Allowed: +- `modules` can call multiple `services`. +- `services` can call lower-level libraries/utilities. + +Not allowed: +- `services` orchestrating whole cross-domain user flows. +- Cross-domain `pub use` re-exports from service modules for convenience. + +## Crypto Boundary + +- Generic crypto operations come from `bee_crypto::Crypto`. +- Wallet domain crypto (for example multifactor key derivation) stays inside wallet domain services. diff --git a/bee_wallet/Cargo.toml b/bee_wallet/Cargo.toml new file mode 100644 index 0000000..e825e65 --- /dev/null +++ b/bee_wallet/Cargo.toml @@ -0,0 +1,108 @@ + +[package] +edition.workspace = true +name = "bee-wallet" +version.workspace = true +license.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "faucet" +path = "src/bin/faucet.rs" +required-features = ["default"] + +[[bin]] +name = "wallet_info" +path = "src/bin/wallet_info.rs" +required-features = ["default"] + +[features] +default = ["ackinacki-kit/default", "ackinacki-kit/contracts", "tokio", "bee-crypto/default", "bee-errors/default"] +wasm = [ + "dep:wasm-bindgen", + "dep:serde-wasm-bindgen", + "dep:js-sys", + "dep:web-sys", + "console_error_panic_hook", + "dep:wasm-bindgen-futures", + "dep:gloo-timers", + "bee-connect/wasm", + "bee-crypto/wasm", + "bee-infra/wasm", + "ackinacki-kit/wasm", + "ackinacki-kit/contracts", + "bee-errors/wasm", + "tokio", +] +single-wasm = ["wasm"] + +[dependencies] +ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v3.0.0", optional = true, default-features = false } +base64 = "0.22.1" +bech32 = "0.9.1" +bee-errors = { path = "../bee_errors", default-features = false } +bee-infra = { path = "../bee_infra", default-features = false, features = ["tokio"] } +bee-connect = { path = "../bee_connect", default-features = false } +bee-crypto = { path = "../bee_crypto", default-features = false } +chacha20poly1305 = "0.10.1" +console_error_panic_hook = { optional = true, version = "0.1.7" } +ed25519-dalek = "2" +futures = "0.3" +getrandom = { default-features = false, features = [ + "wasm_js", +], version = "0.3.4" } +gloo-timers = { version = "0.3.0", features = ['futures'], optional = true } +gosh_tls_lib = { git = "ssh://git@github.com/gosh-sh/gosh_tls_lib.git", tag = "6.5" } +hex = "0.4.3" +hkdf = "0.12.4" +itertools = "0.14.0" +js-sys = { optional = true, version = "0.3.83" } +num-bigint = { version = "0.4.6", features = ["serde"] } +pbkdf2 = { version = "0.12.2", features = ["simple"] } +rand = "0.9.2" +regex = "1" +reqwest = { version = "0.12.25", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = { optional = true, version = "0.6.5" } +serde_json = "1" +serde_with = "3.15.0" +sha2 = "0.10.9" +subtle = "2.6.1" +thiserror = "2" +url = "2.5.7" +wasm-bindgen = { optional = true, version = "0.2.106", features = [ + "serde-serialize", +] } +wasm-bindgen-futures = { optional = true, version = "0.4.56" } +web-sys = { optional = true, version = "0.3.83", features = ["console"] } +tokio = { optional = true, version = "1.49.0", features = ["rt", "macros"] } + +# DEX contract wrappers (RootPn, PrivateNote, …) moved out of kit at v3.0.0 into +# dodex-backend's `dodex-contracts`, built on kit's trait surface via the new +# `KitModule::External` hook. Native-only: voucher generation isn't exposed on +# wasm, so this keeps the dodex graph out of the wasm build. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +dodex-contracts = { git = "https://github.com/gosh-sh/dodex-backend.git", branch = "feature/NODE-3625-move-dex-contract-wrappers-from-kit", package = "dodex-contracts" } + +[dev-dependencies] +ackinacki-kit = { git = "https://github.com/gosh-sh/ackinacki-kit.git", tag = "v3.0.0", features = ["default", "contracts"] } +base64 = "0.22.1" +bee-connect = { path = "../bee_connect" } +bee-crypto = { path = "../bee_crypto" } +# For test_get_miner_address_by_wallet_name: exercises bee_miner's name→miner +# resolver against a wallet this suite freshly deploys (bee_miner can't deploy). +bee-miner = { path = "../bee_miner" } +serde_json = "1" +tokio = { version = "1.49.0", features = ["rt", "macros", "time"] } + +# DEX façade (`Dex`, halo2 prover, `proof` helpers) for the wallet-coupled +# full-flow tests under `tests/dex_flows/` — the two multifactor flows that +# exercise bee-wallet + DEX together. Native-only: pulls the heavy halo2 graph. +# Raw contract wrappers come from `dodex-contracts` (a regular dep above). +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +dodex-sdk = { git = "https://github.com/gosh-sh/dodex-backend.git", branch = "feature/NODE-3625-move-dex-contract-wrappers-from-kit" } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3.56" diff --git a/bee_wallet/README.md b/bee_wallet/README.md new file mode 100644 index 0000000..f3c7000 --- /dev/null +++ b/bee_wallet/README.md @@ -0,0 +1,75 @@ +# bee_wallet + +Wallet SDK for AckiNacki blockchain. Native Rust + WASM (browser). + +## Build + +```bash +# Native +cargo build -p bee-wallet + +# WASM +wasm-pack build --target web --no-default-features --features wasm +``` + +## Architecture + +Layer boundaries and ownership rules are documented in +[`ARCHITECTURE.md`](./ARCHITECTURE.md). + +## API Overview + +### Token Operations + +| Method | Description | +|--------|-------------| +| `send_tokens(token_root, dest, amount)` | Send ECC (numeric token_root: "1"=NACKL, "2"=SHELL, "3"=USDC) or TIP-3 (address token_root) | +| `migrate_tip3_usdc(token_root, amount_raw)` | Convert TIP-3 USDC → ECC[3] via Exchange (1:1, irreversible) | +| `buy_shells(usdc_amount)` | Send USDC to accumulator, receive Shell | +| `sell_shells(denom)` | Place Shell for sale (denom: 1/10/100/1000), returns order_id | +| `get_my_sell_orders(page_size?, cursor?)` | Paginated list of seller's orders | +| `claim_usdc(denom, order_id)` | Claim USDC for a sold order | +| `redeem_nackl(nackl_amount)` | Burn NACKL, receive USDC (floating rate) | +| `get_nackl_redeem_rate()` | Current NACKL-to-USDC redemption rate | + +### Transaction History + +| Method | Description | +|--------|-------------| +| `get_history(multifactor_address, token_id, page_size?, cursor?, mining_cursor?)` | Paginated ECC/TIP-3 history | + +- Supports hot + archive data sources (archive via optional `archive_endpoints` at construction) +- Known system addresses resolve to names: Giver, Exchange, Accumulator +- Cursors are opaque strings; archive cursors use `a:` prefix internally + +### Connect (bee_connect integration) + +| Message Type | Direction | Description | +|-------------|-----------|-------------| +| `wallet_hello` | w->c | Wallet announces name and address | +| `set_mining_keys` | c->w | Client requests mining key setup | +| `sign_challenge` | c->w | Client sends nonce for backend auth | +| `challenge_response` | w->c | Wallet returns signed nonce + address | +| `client_disconnect` | c->w | Client terminates session | + +### Construction + +```typescript +// WASM +const wallet = new Wallet( + ["mainnet.ackinacki.org"], // hot endpoints + ["archive.mainnet.ackinacki.org"], // archive endpoints (optional) + apiUrl, + appId +); +``` + +## Tools + +### Faucet CLI (shellnet only) + +```bash +cargo run -p bee-wallet --bin faucet +``` + +Interactive CLI to send ECC tokens (NACKL/SHELL/USDC) or mint TIP-3 USDC to any wallet by name on shellnet. diff --git a/bee_wallet/src/adapters/mod.rs b/bee_wallet/src/adapters/mod.rs new file mode 100644 index 0000000..cd1dd05 --- /dev/null +++ b/bee_wallet/src/adapters/mod.rs @@ -0,0 +1,4 @@ +#[cfg(not(target_arch = "wasm32"))] +pub mod native; +#[cfg(feature = "wasm")] +pub mod wasm; diff --git a/bee_wallet/src/adapters/native/dto/balances.rs b/bee_wallet/src/adapters/native/dto/balances.rs new file mode 100644 index 0000000..a5d3b4b --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/balances.rs @@ -0,0 +1,37 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use serde::Serialize; + +use crate::services::balance::ResultOfGetNativeBalances as InnerResultOfGetNativeBalances; +use crate::services::balance::ResultOfGetTokensBalances as InnerResultOfGetTokensBalances; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfGetNativeBalances { + pub popitgame: BTreeMap, + pub ecc: BTreeMap, +} + +impl From for ResultOfGetNativeBalances { + fn from(value: InnerResultOfGetNativeBalances) -> Self { + Self { + popitgame: value + .popitgame + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ecc: value.ecc.into_iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfGetTokensBalances { + pub tokens: BTreeMap, +} + +impl From for ResultOfGetTokensBalances { + fn from(value: InnerResultOfGetTokensBalances) -> Self { + Self { tokens: value.tokens.into_iter().map(|(k, v)| (k, v.to_string())).collect() } + } +} diff --git a/bee_wallet/src/adapters/native/dto/deploy.rs b/bee_wallet/src/adapters/native/dto/deploy.rs new file mode 100644 index 0000000..fa33457 --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/deploy.rs @@ -0,0 +1,36 @@ +use serde::Deserialize; +use serde::Serialize; + +use super::keys::ResultOfGetKeys; +use crate::services::deploy::ResultOfDeployMultifactor as InnerResultOfDeployMultifactor; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfDeployMultifactor { + pub name: String, + pub address: String, + pub message_id: Option, + pub message_ids: Vec, + pub pending_stage: Option, + pub pending_reason: Option, + pub password_hash: String, + pub phrase: String, + pub pubkey: String, + pub signing_keys: ResultOfGetKeys, +} + +impl From for ResultOfDeployMultifactor { + fn from(value: InnerResultOfDeployMultifactor) -> Self { + Self { + name: value.name, + address: value.address, + message_id: value.message_id, + message_ids: value.message_ids, + pending_stage: value.pending_stage, + pending_reason: value.pending_reason, + password_hash: value.password_hash, + phrase: value.phrase, + pubkey: value.pubkey, + signing_keys: ResultOfGetKeys::from(value.signing_keys), + } + } +} diff --git a/bee_wallet/src/adapters/native/dto/keys.rs b/bee_wallet/src/adapters/native/dto/keys.rs new file mode 100644 index 0000000..24edf8d --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/keys.rs @@ -0,0 +1,15 @@ +use ackinacki_kit::tvm_client::crypto::KeyPair; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfGetKeys { + pub public: String, + pub secret: String, +} + +impl From for ResultOfGetKeys { + fn from(value: KeyPair) -> Self { + Self { public: value.public.clone(), secret: value.secret.clone() } + } +} diff --git a/bee_wallet/src/adapters/native/dto/miner.rs b/bee_wallet/src/adapters/native/dto/miner.rs new file mode 100644 index 0000000..ba1bfc4 --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/miner.rs @@ -0,0 +1,23 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use serde::Serialize; + +use crate::services::miner::query::ResultOfGetMinerDetails as InnerResultOfGetMinerDetails; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfGetMinerDetails { + pub address: String, + pub owner_address: String, + pub owner_public: HashMap, +} + +impl From for ResultOfGetMinerDetails { + fn from(value: InnerResultOfGetMinerDetails) -> Self { + Self { + address: value.address, + owner_address: value.details.owner_address, + owner_public: value.details.owner_public, + } + } +} diff --git a/bee_wallet/src/adapters/native/dto/mod.rs b/bee_wallet/src/adapters/native/dto/mod.rs new file mode 100644 index 0000000..3cb9aa8 --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/mod.rs @@ -0,0 +1,7 @@ +pub mod balances; +pub mod deploy; +pub mod keys; +pub mod miner; +pub mod multifactor; +pub mod tx; +pub mod zkp; diff --git a/bee_wallet/src/adapters/native/dto/multifactor.rs b/bee_wallet/src/adapters/native/dto/multifactor.rs new file mode 100644 index 0000000..f9189a0 --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/multifactor.rs @@ -0,0 +1,4 @@ +// Re-export service types directly for native adapter (1:1 mapping, no +// conversion needed) +pub use crate::services::multifactor::query::ResultOfCheckNameAvailability; +pub use crate::services::multifactor::query::ResultOfGetMultifactorDetails; diff --git a/bee_wallet/src/adapters/native/dto/tx.rs b/bee_wallet/src/adapters/native/dto/tx.rs new file mode 100644 index 0000000..2e5a577 --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/tx.rs @@ -0,0 +1,54 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::services::transaction::history::ResultOfGetHistory as InnerResultOfGetHistory; +use crate::services::transaction::history::TxData as InnerTxData; +use crate::services::transaction::history::TxType as InnerTxType; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TxData { + pub id: String, + pub tx_type: String, + pub created_at: String, + pub value: String, + pub src_name: Option, +} + +fn tx_type_to_string(tx_type: &InnerTxType) -> String { + match tx_type { + InnerTxType::Mining => "Mining".to_string(), + InnerTxType::Incoming => "Incoming".to_string(), + InnerTxType::Outgoing => "Outgoing".to_string(), + } +} + +impl From for TxData { + fn from(tx: InnerTxData) -> Self { + Self { + id: tx.id, + tx_type: tx_type_to_string(&tx.tx_type), + created_at: tx.created_at.to_string(), + value: tx.value.to_string(), + src_name: tx.src_name, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfGetHistory { + pub data: Vec, + pub next_cursor: Option, + pub next_mining_cursor: Option, + pub has_next_page: bool, +} + +impl From for ResultOfGetHistory { + fn from(value: InnerResultOfGetHistory) -> Self { + Self { + data: value.data.into_iter().map(TxData::from).collect(), + next_cursor: value.next_cursor, + next_mining_cursor: value.next_mining_cursor, + has_next_page: value.has_next_page, + } + } +} diff --git a/bee_wallet/src/adapters/native/dto/zkp.rs b/bee_wallet/src/adapters/native/dto/zkp.rs new file mode 100644 index 0000000..500daa6 --- /dev/null +++ b/bee_wallet/src/adapters/native/dto/zkp.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; +use serde::Serialize; + +use super::keys::ResultOfGetKeys; +use crate::services::zkp::ResultOfAddZKPFactor as InnerResultOfAddZKPFactor; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfAddZKPFactor { + pub name: String, + pub address: String, + pub message_id: Option, + pub message_ids: Vec, + pub pubkey: String, + pub password_hash: String, + pub signing_keys: ResultOfGetKeys, +} + +impl From for ResultOfAddZKPFactor { + fn from(value: InnerResultOfAddZKPFactor) -> Self { + Self { + name: value.name, + address: value.address, + message_id: value.message_id, + message_ids: value.message_ids, + pubkey: value.pubkey, + password_hash: value.password_hash, + signing_keys: ResultOfGetKeys::from(value.signing_keys), + } + } +} diff --git a/bee_wallet/src/adapters/native/mod.rs b/bee_wallet/src/adapters/native/mod.rs new file mode 100644 index 0000000..284b98e --- /dev/null +++ b/bee_wallet/src/adapters/native/mod.rs @@ -0,0 +1,452 @@ +use ackinacki_kit::tvm_client::processing::ResultOfSendMessage; + +use crate::client::WalletClient; +use crate::client::WalletConfig; +use crate::errors::AppResult; +use crate::modules::names::ResultOfValidateWalletName; + +pub mod dto; + +/// High-level native API for interacting with a multifactor wallet. +/// +/// The API combines network-backed on-chain operations (deploy, balances, +/// mining, ZK-factor operations, and history). +pub struct Wallet { + inner: WalletClient, +} + +impl Wallet { + /// Creates a wallet client from the supplied `WalletConfig`. + pub fn new(config: WalletConfig) -> AppResult { + let inner = WalletClient::new(config)?; + Ok(Self { inner }) + } + + /// Prepares zk-login ephemeral data (nonce/randomness/ephemeral key). + pub fn prepare_zk_login_v1(&self) -> AppResult { + let now_ms = crate::infra::now_ms()?; + self.inner.zkp().prepare_zk_login_v1_with_now_ms(now_ms) + } + + /// Completes zk-login by preparing local payload, fetching prover proofs + /// and finalizing proof compression. + pub async fn complete_zk_login_with_prover_v1( + &self, + params: crate::ZkLoginCompleteWithProverParams, + ) -> AppResult { + self.inner.zkp().complete_zk_login_with_prover_v1(params, None).await + } + + /// Like [`complete_zk_login_with_prover_v1`], but emits `prove` progress + /// milestones (`started` → `finished`) into `progress` as they happen. + /// + /// The caller owns the receiver end (`futures::channel::mpsc::unbounded`) + /// and forwards events to the host (e.g. a Tauri command draining into + /// `window.emit`). Dropping the receiver is safe — events are best-effort. + /// + /// [`complete_zk_login_with_prover_v1`]: Self::complete_zk_login_with_prover_v1 + pub async fn complete_zk_login_with_prover_v1_with_progress( + &self, + params: crate::ZkLoginCompleteWithProverParams, + progress: crate::ProgressSink, + ) -> AppResult { + self.inner.zkp().complete_zk_login_with_prover_v1(params, Some(progress)).await + } + + /// Generic polling helper for native callers (Tauri workers/adapters). + pub async fn poll_until( + &self, + fetch_data: FetchFn, + predicate: VerifyFn, + max_attempts: Option, + interval_ms: Option, + ) -> AppResult + where + FetchFn: Fn() -> FetchFut, + FetchFut: std::future::Future>, + VerifyFn: Fn(&T) -> bool, + { + crate::infra::poll_until(fetch_data, predicate, max_attempts, interval_ms).await + } + + /// Parses a bee-connect payload from raw base64url query value + /// (`payload=`). + pub fn decode_connect_payload_b64url( + &self, + payload_b64: String, + ) -> AppResult { + self.inner.connect().decode_connect_payload_b64url(payload_b64) + } + + /// Accepts a shared-key connect session (Protocol 2): deploys AuthProfile + /// with the shared session owner key and sends the first `wallet_hello`. + pub async fn accept_connect_shared_key( + &self, + params: crate::ParamsOfAcceptSharedKeyConnect, + ) -> AppResult { + self.inner.connect().accept_shared_key_connect(params).await + } + + /// Destroys a connect `AuthProfile` through multifactor internal transfer. + pub async fn destroy_connect_profile( + &self, + params: crate::ParamsOfDestroyConnectProfile, + ) -> AppResult { + self.inner.connect().destroy_connect_profile(params).await + } + + /// Queries connect session messages for one session (`session_id` + + /// `description`). + pub async fn query_connect_session_messages( + &self, + params: crate::ParamsOfQuerySessionMessages, + ) -> AppResult { + self.inner.connect().query_session_messages(params).await + } + + /// Validates a wallet name and returns a typed validation result. + pub fn validate_name(&self, wallet_name: String) -> ResultOfValidateWalletName { + self.inner.names().validate_name(wallet_name) + } + + /// Checks whether a wallet name is available in the indexer. + pub async fn check_name_availability( + &self, + wallet_name: String, + ) -> AppResult { + self.inner.multifactor().check_name_availability(wallet_name).await + } + + /// Returns multifactor metadata for a wallet name. + pub async fn get_multifactor_data_by_name( + &self, + wallet_name: String, + ) -> AppResult> { + self.inner.multifactor().get_multifactor_data_by_name(wallet_name).await + } + + /// Adds a ZKP factor to an existing multifactor wallet. + pub async fn add_zkp_factor( + &self, + params: crate::services::zkp::ParamsOfAddZKPFactor, + ) -> AppResult { + let res = self.inner.zkp().add_zkp_factor(params, None).await?; + Ok(dto::zkp::ResultOfAddZKPFactor::from(res)) + } + + /// Like [`add_zkp_factor`], but emits `add_factor` progress milestones + /// (`jwk?` → `submitted` → `confirming`… → `confirmed`) into `progress`. + /// + /// The caller owns the receiver end (`futures::channel::mpsc::unbounded`) + /// and forwards events to the host. On confirmation timeout the call fails + /// with a terminal `add_factor_timeout`-kind error. + /// + /// [`add_zkp_factor`]: Self::add_zkp_factor + pub async fn add_zkp_factor_with_progress( + &self, + params: crate::services::zkp::ParamsOfAddZKPFactor, + progress: crate::ProgressSink, + ) -> AppResult { + let res = self.inner.zkp().add_zkp_factor(params, Some(progress)).await?; + Ok(dto::zkp::ResultOfAddZKPFactor::from(res)) + } + + /// Starts the seed-phrase change flow and updates recovery-related data. + pub async fn change_seed_phrase( + &self, + params: crate::services::multifactor::cmd::ParamsOfChangeSeedPhrase, + ) -> AppResult { + self.inner.multifactor().change_seed_phrase(params).await + } + + /// Prepares deploy params. + pub async fn prepare_multifactor_deploy_params( + &self, + params: crate::ParamsOfPrepareDeploy, + ) -> AppResult { + self.inner.deploy().prepare_multifactor_deploy_params(params).await + } + + /// Deploys a new multifactor wallet and returns deployment artifacts. + /// + /// Does not deploy miner automatically; use `deploy_miner` separately. + pub async fn deploy_wallet( + &self, + params: crate::services::deploy::ParamsOfDeployMultifactor, + ) -> AppResult { + let res = self.inner.deploy().deploy_multifactor(params, true, None).await?; + Ok(dto::deploy::ResultOfDeployMultifactor::from(res)) + } + + /// Like [`deploy_wallet`], but emits `deploy` progress milestones + /// (`preparing` → `deploying` → `submitted` → `activating` → `active` → + /// `configuring` → `confirmed`/`pending`/`failed`) into `progress`. + /// + /// [`deploy_wallet`]: Self::deploy_wallet + pub async fn deploy_wallet_with_progress( + &self, + params: crate::services::deploy::ParamsOfDeployMultifactor, + progress: crate::ProgressSink, + ) -> AppResult { + let res = self.inner.deploy().deploy_multifactor(params, true, Some(progress)).await?; + Ok(dto::deploy::ResultOfDeployMultifactor::from(res)) + } + + /// Deploys a wallet without updating contract flags (wasm_hash, + /// force_remove_oldest). Useful for testing `update_contract` + /// separately. + pub async fn deploy_wallet_only( + &self, + params: crate::services::deploy::ParamsOfDeployMultifactor, + ) -> AppResult { + let res = self.inner.deploy().deploy_multifactor(params, false, None).await?; + Ok(dto::deploy::ResultOfDeployMultifactor::from(res)) + } + + /// Deploys miner contract for a multifactor wallet (idempotent). + /// + /// This is a separate use case from `deploy_wallet`. + pub async fn deploy_miner( + &self, + params: crate::services::deploy::ParamsOfDeployMiner, + ) -> AppResult { + self.inner.deploy().deploy_miner(params, None).await + } + + /// Like [`deploy_miner`], but emits `deploy_miner` progress milestones + /// (`resolving` → `deploying` → `confirmed`/`pending`/`failed`) into + /// `progress`. + /// + /// [`deploy_miner`]: Self::deploy_miner + pub async fn deploy_miner_with_progress( + &self, + params: crate::services::deploy::ParamsOfDeployMiner, + progress: crate::ProgressSink, + ) -> AppResult { + self.inner.deploy().deploy_miner(params, Some(progress)).await + } + + /// Sets mining owner key for a miner app namespace. + /// + /// This method returns only after the owner key update is observed + /// on-chain. + pub async fn set_mining_keys( + &self, + params: crate::services::miner::ParamsOfSetMiningKeys, + ) -> AppResult { + self.inner.miner().set_mining_keys(params).await + } + + /// Returns miner address and current owner key mapping for a multifactor + /// wallet. + pub async fn get_miner_details_by_multifactor_address( + &self, + multifactor_address: String, + ) -> AppResult { + let res = + self.inner.miner().get_details_by_multifactor_address(multifactor_address).await?; + Ok(dto::miner::ResultOfGetMinerDetails::from(res)) + } + + /// Resolves miner address for a multifactor wallet address. + pub async fn get_miner_address(&self, multifactor_address: &str) -> AppResult { + self.inner.miner().get_miner_address(multifactor_address).await + } + + /// Removes mining owner key for an app namespace. + /// + /// Use `wait = true` in request params to wait until the key disappears + /// from miner details. + pub async fn del_mining_key( + &self, + params: crate::services::miner::ParamsOfDelMiningKey, + ) -> AppResult { + self.inner.miner().del_mining_key(params).await + } + + /// Returns native balances (NACKL/SHELL and Popitgame balances). + pub async fn get_multifactor_balances( + &self, + params: crate::services::balance::ParamsOfGetNativeBalances, + ) -> AppResult { + let res = self.inner.balance().get_native_balances(params).await?; + Ok(dto::balances::ResultOfGetNativeBalances::from(res)) + } + + /// Returns balances for provided TIP-3 token roots. + pub async fn get_tokens_balances( + &self, + params: crate::services::balance::ParamsOfGetTokensBalances, + ) -> AppResult { + let res = self.inner.balance().get_tokens_balances(params).await?; + Ok(dto::balances::ResultOfGetTokensBalances::from(res)) + } + + /// Replaces wallet ZK identity payload (`zkid`, proof, factor, JWK data). + pub async fn update_zk_id( + &self, + params: crate::UpdateMultifactorZkIdReq, + ) -> AppResult { + self.inner.multifactor().update_zk_id(params).await + } + + /// Resolves multifactor address by owner pubkey. + pub async fn get_multifactor_address( + &self, + params: crate::ParamsOfGetMultifactorAddress, + ) -> AppResult { + self.inner.multifactor().get_multifactor_address(params).await + } + + /// Resolves mirror address for an owner pubkey. + pub fn get_mirror_address( + &self, + params: crate::ParamsGetMirrorAddress, + ) -> AppResult { + self.inner.multifactor().get_mirror_address(params) + } + + /// Returns decoded multifactor contract state by address. + pub async fn get_multifactor_info( + &self, + params: crate::ParamsOfGetMultifactorInfo, + ) -> AppResult { + self.inner.multifactor().get_multifactor_info(params).await + } + + /// Returns own and locked balance for a token root. + pub async fn get_balance_by_token_root( + &self, + params: crate::GetBalanceByTokenRootReq, + ) -> AppResult { + self.inner.balance().get_balance_by_token_root(params).await + } + + /// Withdraws pending Popitgame rewards to wallet balances. + pub async fn withdraw_popitgame_rewards( + &self, + params: crate::WithdrawPopitgameRewardsReq, + ) -> AppResult { + self.inner.tokens().withdraw_popitgame_rewards(params).await + } + + /// Buys Shell for USDC via accumulator. + /// Sends ECC[3] (USDC) to ShellAccumulatorRootUSDC. The accumulator + /// automatically sends ECC[2] (Shell) back. + /// `usdc_amount` — whole USDC (no decimals). + pub async fn buy_shells(&self, params: crate::BuyShellsReq) -> AppResult { + self.inner.tokens().buy_shells(params).await + } + + /// Burns NACKL and receives USDC proportional to share of supply. + /// Sends ECC[1] (NACKL) to ShellAccumulatorRootUSDC. + pub async fn redeem_nackl( + &self, + params: crate::RedeemNacklReq, + ) -> AppResult { + self.inner.tokens().redeem_nackl(params).await + } + + /// Migrates TIP-3 USDC to ECC[3] via Exchange contract. + pub async fn migrate_tip3_usdc( + &self, + params: crate::types::MigrateTip3UsdcReq, + ) -> AppResult { + self.inner.tokens().migrate_tip3_usdc(params).await + } + + /// Returns current NACKL → USDC redemption rate. + /// Read-only, no signing required. + pub async fn get_nackl_redeem_rate(&self) -> AppResult { + self.inner.tokens().get_nackl_redeem_rate().await + } + + /// Claims USDC payout for a sold sell order. + /// The SellOrderLot must be sold (bought by a buyer). + /// After claim, the SellOrderLot self-destructs. + pub async fn claim_usdc( + &self, + params: crate::ClaimUsdcReq, + ) -> AppResult { + self.inner.tokens().claim_usdc(params).await + } + + /// Returns paginated sell orders for the wallet (seller). + /// Read-only, no signing required. + pub async fn get_my_sell_orders( + &self, + params: crate::GetMySellOrdersReq, + ) -> AppResult { + self.inner.tokens().get_my_sell_orders(params).await + } + + /// Places Shell for sale via accumulator. + /// Sends ECC[2] (Shell) to ShellAccumulatorRootUSDC. + /// `denom` — denomination (1, 10, 100, 1000). SDK computes the amount. + /// Returns order_id for tracking the order. + pub async fn sell_shells( + &self, + params: crate::SellShellsReq, + ) -> AppResult { + self.inner.tokens().sell_shells(params).await + } + + /// Sends token amount from a multifactor wallet to a destination address. + pub async fn send_tokens( + &self, + params: crate::SendTokensReq, + ) -> AppResult { + self.inner.tokens().send_tokens(params).await + } + + /// Direct transfer of native tokens with explicit flags (uses + /// `sendTransaction`). Only works when security card is off. + pub async fn send_tokens_direct( + &self, + params: crate::SendTokensDirectReq, + ) -> AppResult { + self.inner.tokens().send_tokens_direct(params).await + } + + /// Returns expiration timestamp for a factor identified by EPK. + pub async fn get_epk_expire_at( + &self, + params: crate::GetEPKExpireReq, + ) -> AppResult { + self.inner.multifactor().get_epk_expire_at(params).await + } + + /// Deletes currently used ZKP factor by its own signer key. + pub async fn delete_zkp_factor_by_itself( + &self, + params: crate::DeleteZkpFactorByItselfReq, + ) -> AppResult { + self.inner.zkp().delete_zkp_factor_by_itself(params).await + } + + /// Updates contract code/settings through owner-signed operation. + pub async fn update_contract( + &self, + params: crate::ParamsOfUpdateContract, + ) -> AppResult { + self.inner.multifactor().update_contract(params).await + } + + /// Returns paginated token transfer history for a wallet. + pub async fn get_history( + &self, + params: crate::services::transaction::history::ParamsOfGetHistory, + ) -> AppResult { + let res = self.inner.history().get_history(params).await?; + Ok(dto::tx::ResultOfGetHistory::from(res)) + } + + /// Generate a DEX voucher: adds RootPN to whitelist, then sends ECC tokens + /// from the multifactor wallet to RootPN with `generatevoucher` payload. + pub async fn generate_voucher( + &self, + params: crate::modules::dex::ParamsOfGenerateVoucher, + ) -> AppResult { + self.inner.dex().generate_voucher(params).await + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/balances.rs b/bee_wallet/src/adapters/wasm/dto/balances.rs new file mode 100644 index 0000000..fd949fe --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/balances.rs @@ -0,0 +1,80 @@ +use std::collections::BTreeMap; + +use num_bigint::BigInt; +use serde::Serialize; +use serde_wasm_bindgen::Serializer; +use serde_with::serde_as; +use serde_with::DisplayFromStr; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; + +use crate::services::balance::ResultOfGetNativeBalances as InnerResultOfGetNativeBalances; +use crate::services::balance::ResultOfGetTokensBalances as InnerResultOfGetTokensBalances; +use crate::wasm::dto::TNativeBalancesMap; +use crate::wasm::dto::TTokenBalancesMap; + +#[wasm_bindgen] +#[serde_as] +#[derive(Debug, Serialize)] +pub struct ResultOfGetTokensBalances { + #[serde_as(as = "BTreeMap<_, DisplayFromStr>")] + tokens: BTreeMap, +} + +impl From for ResultOfGetTokensBalances { + fn from(value: InnerResultOfGetTokensBalances) -> Self { + Self { tokens: value.tokens } + } +} + +#[wasm_bindgen] +impl ResultOfGetTokensBalances { + #[wasm_bindgen(getter)] + pub fn tokens(&self) -> TTokenBalancesMap { + stringify_string_bigint_map(&self.tokens).unchecked_into::() + } +} + +fn stringify_string_bigint_map(map: &BTreeMap) -> JsValue { + let obj_map: BTreeMap = + map.iter().map(|(k, v)| (k.clone(), v.to_string())).collect(); + + obj_map.serialize(&Serializer::json_compatible()).unwrap_or(JsValue::NULL) +} + +#[wasm_bindgen] +#[serde_as] +#[derive(Debug, Serialize)] +pub struct ResultOfGetNativeBalances { + #[serde_as(as = "BTreeMap<_, DisplayFromStr>")] + popitgame: BTreeMap, + #[serde_as(as = "BTreeMap<_, DisplayFromStr>")] + ecc: BTreeMap, +} + +impl From for ResultOfGetNativeBalances { + fn from(value: InnerResultOfGetNativeBalances) -> Self { + Self { popitgame: value.popitgame, ecc: value.ecc } + } +} + +#[wasm_bindgen] +impl ResultOfGetNativeBalances { + #[wasm_bindgen(getter)] + pub fn popitgame(&self) -> TNativeBalancesMap { + stringify_u32_bigint_map(&self.popitgame).unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn ecc(&self) -> TNativeBalancesMap { + stringify_u32_bigint_map(&self.ecc).unchecked_into::() + } +} + +fn stringify_u32_bigint_map(map: &BTreeMap) -> JsValue { + let obj_map: BTreeMap = + map.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + + obj_map.serialize(&Serializer::json_compatible()).unwrap_or(JsValue::NULL) +} diff --git a/bee_wallet/src/adapters/wasm/dto/connect.rs b/bee_wallet/src/adapters/wasm/dto/connect.rs new file mode 100644 index 0000000..d3a030a --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/connect.rs @@ -0,0 +1,219 @@ +use js_sys::Array; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; + +use crate::services::connect::ClientDisconnectMessageBody as InnerClientDisconnectMessageBody; +use crate::services::connect::ConnectSessionMessage as InnerConnectSessionMessage; +use crate::services::connect::ResultOfQuerySessionMessages as InnerResultOfQuerySessionMessages; +use crate::services::connect::SetMiningKeysMessageBody as InnerSetMiningKeysMessageBody; +use crate::services::connect::WalletHelloMetadata as InnerWalletHelloMetadata; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ConnectSessionMessage { + event_id: String, + event_created_at: u64, + dir: String, + seq: u64, + msg_type: String, + ts: Option, + raw_message_json: String, + body_json: String, + wallet_name: Option, + wallet_address: Option, + challenge_nonce: Option, + challenge_signature: Option, + challenge_epk_public: Option, + mining_app_id: Option, + mining_owner_public: Option, + disconnect_reason: Option, + session_state_after_json: Option, +} + +impl From for ConnectSessionMessage { + fn from(value: InnerConnectSessionMessage) -> Self { + let ( + wallet_name, + wallet_address, + challenge_nonce, + challenge_signature, + challenge_epk_public, + ) = value + .wallet_hello + .map(|v: InnerWalletHelloMetadata| { + ( + Some(v.wallet_name), + Some(v.wallet_address), + v.challenge_nonce, + v.challenge_signature, + v.challenge_epk_public, + ) + }) + .unwrap_or((None, None, None, None, None)); + let (mining_app_id, mining_owner_public) = value + .set_mining_keys + .map(|v: InnerSetMiningKeysMessageBody| (Some(v.app_id), Some(v.owner_public))) + .unwrap_or((None, None)); + let disconnect_reason = + value.client_disconnect.and_then(|v: InnerClientDisconnectMessageBody| v.reason); + + Self { + event_id: value.event_id, + event_created_at: value.event_created_at, + dir: value.dir, + seq: value.seq, + msg_type: value.msg_type, + ts: value.ts, + raw_message_json: value.raw_message_json, + body_json: value.body_json, + wallet_name, + wallet_address, + challenge_nonce, + challenge_signature, + challenge_epk_public, + mining_app_id, + mining_owner_public, + disconnect_reason, + session_state_after_json: value + .session_state_after + .and_then(|s| serde_json::to_string(&s).ok()), + } + } +} + +#[wasm_bindgen] +impl ConnectSessionMessage { + #[wasm_bindgen(getter)] + pub fn event_id(&self) -> String { + self.event_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn event_created_at(&self) -> u64 { + self.event_created_at + } + + #[wasm_bindgen(getter)] + pub fn dir(&self) -> String { + self.dir.clone() + } + + #[wasm_bindgen(getter)] + pub fn seq(&self) -> u64 { + self.seq + } + + #[wasm_bindgen(getter)] + pub fn msg_type(&self) -> String { + self.msg_type.clone() + } + + #[wasm_bindgen(getter)] + pub fn ts(&self) -> Option { + self.ts + } + + #[wasm_bindgen(getter)] + pub fn raw_message_json(&self) -> String { + self.raw_message_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn body_json(&self) -> String { + self.body_json.clone() + } + + #[wasm_bindgen(getter)] + pub fn wallet_name(&self) -> Option { + self.wallet_name.clone() + } + + #[wasm_bindgen(getter)] + pub fn wallet_address(&self) -> Option { + self.wallet_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn challenge_nonce(&self) -> Option { + self.challenge_nonce.clone() + } + + #[wasm_bindgen(getter)] + pub fn challenge_signature(&self) -> Option { + self.challenge_signature.clone() + } + + #[wasm_bindgen(getter)] + pub fn challenge_epk_public(&self) -> Option { + self.challenge_epk_public.clone() + } + + #[wasm_bindgen(getter)] + pub fn mining_app_id(&self) -> Option { + self.mining_app_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn mining_owner_public(&self) -> Option { + self.mining_owner_public.clone() + } + + #[wasm_bindgen(getter)] + pub fn disconnect_reason(&self) -> Option { + self.disconnect_reason.clone() + } + + /// Session state snapshot taken immediately after `rekey_inbound` for this + /// message. Present only for c2w messages that triggered a successful DH + /// re-key. Use this when responding to the message (e.g. sending + /// `challenge_response` after receiving `sign_challenge`). + #[wasm_bindgen(getter)] + pub fn session_state_after_json(&self) -> Option { + self.session_state_after_json.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfQuerySessionMessages { + profile_address: String, + messages: Vec, + next_before: Option, + updated_session_state_json: Option, +} + +impl From for ResultOfQuerySessionMessages { + fn from(value: InnerResultOfQuerySessionMessages) -> Self { + Self { + profile_address: value.profile_address, + messages: value.messages.into_iter().map(ConnectSessionMessage::from).collect(), + next_before: value.next_before, + updated_session_state_json: value + .updated_session_state + .map(|s| serde_json::to_string(&s).unwrap_or_default()), + } + } +} + +#[wasm_bindgen] +impl ResultOfQuerySessionMessages { + #[wasm_bindgen(getter)] + pub fn profile_address(&self) -> String { + self.profile_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn messages(&self) -> Array { + self.messages.iter().cloned().map(JsValue::from).collect() + } + + #[wasm_bindgen(getter)] + pub fn next_before(&self) -> Option { + self.next_before.clone() + } + + #[wasm_bindgen(getter)] + pub fn updated_session_state_json(&self) -> Option { + self.updated_session_state_json.clone() + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/deploy.rs b/bee_wallet/src/adapters/wasm/dto/deploy.rs new file mode 100644 index 0000000..16fcb4b --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/deploy.rs @@ -0,0 +1,241 @@ +use std::collections::HashMap; + +use ackinacki_kit::contracts::mvsystem::mirror::ParamsOfDeployMultifactor as InnerPreparedDeployParams; +use serde_wasm_bindgen::to_value; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; + +use crate::services::deploy::ResultOfDeployMultifactor as InnerResultOfDeployMultifactor; +use crate::wasm::dto::keys::ResultOfGetKeys; +use crate::wasm::dto::TRootProviderCertificatesMap; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfDeployMultifactor { + name: String, + address: String, + message_id: Option, + message_ids: Vec, + pending_stage: Option, + pending_reason: Option, + password_hash: String, + phrase: String, + pubkey: String, + signing_keys: ResultOfGetKeys, +} + +impl From for ResultOfDeployMultifactor { + fn from(value: InnerResultOfDeployMultifactor) -> Self { + Self { + name: value.name, + address: value.address, + message_id: value.message_id, + message_ids: value.message_ids, + pending_stage: value.pending_stage, + pending_reason: value.pending_reason, + password_hash: value.password_hash, + phrase: value.phrase, + pubkey: value.pubkey, + signing_keys: ResultOfGetKeys::from(value.signing_keys), + } + } +} + +#[wasm_bindgen] +impl ResultOfDeployMultifactor { + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + #[wasm_bindgen(getter)] + pub fn address(&self) -> String { + self.address.clone() + } + + #[wasm_bindgen(getter)] + pub fn message_id(&self) -> Option { + self.message_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn message_ids(&self) -> Vec { + self.message_ids.clone() + } + + #[wasm_bindgen(getter)] + pub fn pending_stage(&self) -> Option { + self.pending_stage.clone() + } + + #[wasm_bindgen(getter)] + pub fn pending_reason(&self) -> Option { + self.pending_reason.clone() + } + + #[wasm_bindgen(getter)] + pub fn password_hash(&self) -> String { + self.password_hash.clone() + } + + #[wasm_bindgen(getter)] + pub fn phrase(&self) -> String { + self.phrase.clone() + } + + #[wasm_bindgen(getter)] + pub fn pubkey(&self) -> String { + self.pubkey.clone() + } + + #[wasm_bindgen(getter)] + pub fn signing_keys(&self) -> ResultOfGetKeys { + self.signing_keys.clone() + } +} + +/// Signed deploy params produced by `prepare_multifactor_deploy_params`. +/// Mirrors `ackinacki_kit::contracts::mvsystem::mirror::ParamsOfDeployMultifactor`. +#[wasm_bindgen] +#[derive(Clone)] +pub struct PreparedDeployParams { + name: String, + zkid: String, + proof: String, + epk: String, + epk_sig: String, + epk_expire_at: u64, + jwk_modulus: String, + kid: String, + jwk_modulus_expire_at: u64, + index_mod_4: u8, + iss_base_64: String, + provider: String, + header_base_64: String, + pub_recovery_key: String, + pub_recovery_key_sig: String, + jwk_update_key: String, + jwk_update_key_sig: String, + root_provider_certificates: HashMap, +} + +impl From for PreparedDeployParams { + fn from(value: InnerPreparedDeployParams) -> Self { + Self { + name: value.name, + zkid: value.zkid, + proof: value.proof, + epk: value.epk, + epk_sig: value.epk_sig, + epk_expire_at: value.epk_expire_at, + jwk_modulus: value.jwk_modulus, + kid: value.kid, + jwk_modulus_expire_at: value.jwk_modulus_expire_at, + index_mod_4: value.index_mod_4, + iss_base_64: value.iss_base_64, + provider: value.provider, + header_base_64: value.header_base_64, + pub_recovery_key: value.pub_recovery_key, + pub_recovery_key_sig: value.pub_recovery_key_sig, + jwk_update_key: value.jwk_update_key, + jwk_update_key_sig: value.jwk_update_key_sig, + root_provider_certificates: value.root_provider_certificates, + } + } +} + +#[wasm_bindgen] +impl PreparedDeployParams { + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + #[wasm_bindgen(getter)] + pub fn zkid(&self) -> String { + self.zkid.clone() + } + + #[wasm_bindgen(getter)] + pub fn proof(&self) -> String { + self.proof.clone() + } + + #[wasm_bindgen(getter)] + pub fn epk(&self) -> String { + self.epk.clone() + } + + #[wasm_bindgen(getter)] + pub fn epk_sig(&self) -> String { + self.epk_sig.clone() + } + + #[wasm_bindgen(getter)] + pub fn epk_expire_at(&self) -> u64 { + self.epk_expire_at + } + + #[wasm_bindgen(getter)] + pub fn jwk_modulus(&self) -> String { + self.jwk_modulus.clone() + } + + #[wasm_bindgen(getter)] + pub fn kid(&self) -> String { + self.kid.clone() + } + + #[wasm_bindgen(getter)] + pub fn jwk_modulus_expire_at(&self) -> u64 { + self.jwk_modulus_expire_at + } + + #[wasm_bindgen(getter)] + pub fn index_mod_4(&self) -> u8 { + self.index_mod_4 + } + + #[wasm_bindgen(getter)] + pub fn iss_base_64(&self) -> String { + self.iss_base_64.clone() + } + + #[wasm_bindgen(getter)] + pub fn provider(&self) -> String { + self.provider.clone() + } + + #[wasm_bindgen(getter)] + pub fn header_base_64(&self) -> String { + self.header_base_64.clone() + } + + #[wasm_bindgen(getter)] + pub fn pub_recovery_key(&self) -> String { + self.pub_recovery_key.clone() + } + + #[wasm_bindgen(getter)] + pub fn pub_recovery_key_sig(&self) -> String { + self.pub_recovery_key_sig.clone() + } + + #[wasm_bindgen(getter)] + pub fn jwk_update_key(&self) -> String { + self.jwk_update_key.clone() + } + + #[wasm_bindgen(getter)] + pub fn jwk_update_key_sig(&self) -> String { + self.jwk_update_key_sig.clone() + } + + #[wasm_bindgen(getter)] + pub fn root_provider_certificates(&self) -> TRootProviderCertificatesMap { + to_value(&self.root_provider_certificates) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/keys.rs b/bee_wallet/src/adapters/wasm/dto/keys.rs new file mode 100644 index 0000000..61b832d --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/keys.rs @@ -0,0 +1,28 @@ +use ackinacki_kit::tvm_client::crypto::KeyPair; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfGetKeys { + public: String, + secret: String, +} + +impl From for ResultOfGetKeys { + fn from(value: KeyPair) -> Self { + Self { public: value.public.clone(), secret: value.secret.clone() } + } +} + +#[wasm_bindgen] +impl ResultOfGetKeys { + #[wasm_bindgen(getter)] + pub fn public(&self) -> String { + self.public.clone() + } + + #[wasm_bindgen(getter)] + pub fn secret(&self) -> String { + self.secret.clone() + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/miner.rs b/bee_wallet/src/adapters/wasm/dto/miner.rs new file mode 100644 index 0000000..90808cb --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/miner.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use serde::Serialize; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; + +use crate::services::miner::query::ResultOfGetMinerDetails as InnerResultOfGetMinerDetails; +use crate::wasm::dto::TMinerOwnerPublicMap; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfGetMinerDetails { + address: String, + owner_address: String, + owner_public: HashMap, +} + +impl From for ResultOfGetMinerDetails { + fn from(value: InnerResultOfGetMinerDetails) -> Self { + Self { + owner_public: value.details.owner_public, + owner_address: value.details.owner_address, + address: value.address, + } + } +} + +#[wasm_bindgen] +impl ResultOfGetMinerDetails { + #[wasm_bindgen(getter)] + pub fn address(&self) -> String { + self.address.clone() + } + + #[wasm_bindgen(getter)] + pub fn owner_address(&self) -> String { + self.owner_address.clone() + } + + #[wasm_bindgen(getter)] + pub fn owner_public(&self) -> TMinerOwnerPublicMap { + let v = self + .owner_public + .serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)) + .unwrap_or(JsValue::NULL); + + v.unchecked_into::() + } +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfGetMinerAddress { + pub multifactor_address: String, +} diff --git a/bee_wallet/src/adapters/wasm/dto/mod.rs b/bee_wallet/src/adapters/wasm/dto/mod.rs new file mode 100644 index 0000000..09b3972 --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/mod.rs @@ -0,0 +1,396 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +pub(crate) mod balances; +pub(crate) mod connect; +pub(crate) mod deploy; +pub(crate) mod keys; +pub(crate) mod miner; +pub(crate) mod multifactor; +pub(crate) mod names; +pub(crate) mod tx; +pub(crate) mod write; +pub(crate) mod zkp; + +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &str = r#" +export type TKeyPair = { + public: string; + secret: string; +}; + +export type TParamsOfDeployMultifactor = { + wallet_name: string; + zkid: string; + password: string; + proof: string; + epk: string; + esk: string; + jwk_modulus: string; + jwk_modulus_expire_at: number; + index_mod_4: number; + iss_base_64: string; + header_base_64: string; + epk_expire_at: number; + kid: string; + sub: string; +}; + +export type TParamsOfDeployMiner = { + multifactor_address: string + signer_keys: TKeyPair, +}; + +export type TParamsOfSetMiningKeys = { + multifactor_address: string + signer_keys: TKeyPair, + mining_pubkey: string, + app_id?: string, + epk_expire_at?: number, +}; + +export type TParamsOfDelMiningKey = { + multifactor_address: string; + signer_keys: TKeyPair; + app_id?: string; + epk_expire_at?: number; + and_wait?: boolean; +}; + +export type TParamsOfAddZKPFactor = { + wallet_name: string; + proof: string; + epk: string; + esk: string; + header_base_64: string; + epk_expire_at: number; + jwk_expires_at: number; + kid: string; + sub: string; + password: string + zkid: string +} + +export type TParamsOfChangeSeedPhrase = { + password: string; + signer_keys: TKeyPair; + new_owner_keys: TKeyPair; + multifactor_address: string; +} + +export type TParamsOfGetMultifactorBalances = { + multifactor_address: string; +}; + +export type TParamsOfGetTokensBalances = { + multifactor_address: string; + token_roots: { token_root: string; token_dapp: string }[]; +}; + +export type TParamsOfGetHistory = { + multifactor_address: string; + token_id: string; + page_size?: number; + cursor?: string; + mining_cursor?: string; +}; + +export type TParamsOfGetMinerAddress = { + multifactor_address: string +}; + +export type TBuyShellsReq = { + multifactor_address: string; + usdc_amount: number; + signer_keys: TKeyPair; + bounce?: boolean; +}; + +export type TRedeemNacklReq = { + multifactor_address: string; + nackl_amount: number; + signer_keys: TKeyPair; + bounce?: boolean; +}; + +export type TMigrateTip3UsdcReq = { + multifactor_address: string; + token_root: string; + token_dapp: string; + amount_raw: string | number; + signer_keys: TKeyPair; + bounce?: boolean; +}; + +export type TClaimUsdcReq = { + denom: number; + order_id: number; + signer_keys: TKeyPair; +}; + +export type TSendTokensDirectReq = { + multifactor_address: string; + destination_address: string; + token_root: string; + amount_raw: string | number; + flags: number; + signer_keys: TKeyPair; + bounce?: boolean; + /** Gas value in nanotons. Default: 1_000_000_000 (1 VMShell). */ + value?: number; + /** ABI-encoded message body (base64). Carries a function call to the destination. */ + payload?: string; +}; + +export type TGetMySellOrdersReq = { + multifactor_address: string; + page_size?: number; + cursor?: string; +}; + +export type TSellShellsReq = { + multifactor_address: string; + denom: number; + signer_keys: TKeyPair; + bounce?: boolean; +}; + +export type TSellShellsResult = { + message_hash: string | null; + order_id: number; + denom: number; + sell_order_address: string; + sold: boolean; + position_in_queue: number; +}; + +/** + * Connect session state — opaque blob passed between WASM calls. + * + * Secret fields (encryption_root, my_dh_secret, signing_secret) are sensitive. + * Store in secure storage (OS keychain), never log in plaintext. + * Pass back to WASM as-is; do not modify fields. + */ +export type TConnectSessionState = { + encryption_root: string; // hex, 32 bytes — DO NOT log + my_dh_secret: string; // hex, 32 bytes — DO NOT log + peer_dh_public: string; // hex, 32 bytes + signing_public: string; // hex, 32 bytes + signing_secret: string; // hex, 32 bytes — DO NOT log + created_at: number; // UNIX epoch seconds, set at handshake + expires_at: number; // UNIX epoch seconds (0 = no expiration, default = created_at + 24h) +}; + +export type TParamsOfQueryConnectSessionMessages = { + session_id: string; + description: string; + session_state?: TConnectSessionState; + created_at_from?: number; + before?: string; + limit?: number; +}; + +/** + * Saved data from `prepare_zk_login_v1` (carry through the OAuth round-trip). + * Field names are camelCase because the underlying Rust struct uses camelCase. + */ +export type TZkLoginTempData = { + maxEpoch: number; + randomness: string; + ephemeralPrivateKey: string; +}; + +export type TZkLoginCompleteWithProverParams = { + savedData: TZkLoginTempData; + jwt: string; + jwtSub: string; + jwtAud: string; + userPassword: string; + proverUrl: string; +}; + +export type TUpdateMultifactorZkIdReq = { + address: string; + zkid: string; + password: string; + proof: string; + epk: string; + esk: string; + jwk_modulus: string; + jwk_modulus_expire_at: number; + index_mod_4: number; + iss_base_64: string; + header_base_64: string; + epk_expire_at: number; + pubkey: string; + secretkey: string; + kid: string; + sub: string; +}; + +export type TDeleteZkpFactorByItselfReq = { + multifactor_address: string; + signer_keys: TKeyPair; +}; + +export type TParamsOfGetMultifactorInfo = { + address: string; +}; + +export type TParamsOfGetMultifactorAddress = { + pubkey: string; +}; + +export type TParamsGetMirrorAddress = { + pubkey: string; +}; + +export type TGetEPKExpireReq = { + epk: string; + multifactor_address: string; +}; + +/** + * Stage 5b — `prepare_multifactor_deploy_params` input. + * Combines zk-login outputs with BIP39 owner keys + future multifactor address. + */ +export type TParamsOfPrepareDeploy = { + zkid: string; + password: string; + proof: string; + epk: string; + esk: string; + jwk_modulus: string; + jwk_modulus_expire_at: number; + index_mod_4: number; + iss_base_64: string; + header_base_64: string; + epk_expire_at: number; + keys: TKeyPair; + kid: string; + wallet_name: string; + multifactor_address: string; + sub: string; +}; + +export type TRootProviderCertificatesMap = Record; + +export type TTokenBalancesMap = Record; +export type TNativeBalancesMap = Record; +export type TMinerOwnerPublicMap = Record; +export type TCandidateOwnerPubkeyExpirationMap = Record | null; +export type TFactorsOrderedByTimestampMap = Record; +export type TJwkData = { + modulus: string; + modulus_expire_at: string; +}; +export type TJwkModulusDataMap = Record; +export type TWhiteListOfAddressMap = Record; + +"#; +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "TParamsOfDeployMultifactor")] + pub type TParamsOfDeployMultifactor; + + #[wasm_bindgen(typescript_type = "TParamsOfDeployMiner")] + pub type TParamsOfDeployMiner; + + #[wasm_bindgen(typescript_type = "TParamsOfSetMiningKeys")] + pub type TParamsOfSetMiningKeys; + + #[wasm_bindgen(typescript_type = "TParamsOfDelMiningKey")] + pub type TParamsOfDelMiningKey; + + #[wasm_bindgen(typescript_type = "TKeyPair")] + pub type TKeyPair; + + #[wasm_bindgen(typescript_type = "TParamsOfAddZKPFactor")] + pub type TParamsOfAddZKPFactor; + + #[wasm_bindgen(typescript_type = "TParamsOfChangeSeedPhrase")] + pub type TParamsOfChangeSeedPhrase; + + #[wasm_bindgen(typescript_type = "TParamsOfGetMultifactorBalances")] + pub type TParamsOfGetMultifactorBalances; + + #[wasm_bindgen(typescript_type = "TParamsOfGetTokensBalances")] + pub type TParamsOfGetTokensBalances; + + #[wasm_bindgen(typescript_type = "TParamsOfGetHistory")] + pub type TParamsOfGetHistory; + + #[wasm_bindgen(typescript_type = "TParamsOfGetMinerAddress")] + pub type TParamsOfGetMinerAddress; + + #[wasm_bindgen(typescript_type = "TBuyShellsReq")] + pub type TBuyShellsReq; + + #[wasm_bindgen(typescript_type = "TRedeemNacklReq")] + pub type TRedeemNacklReq; + + #[wasm_bindgen(typescript_type = "TMigrateTip3UsdcReq")] + pub type TMigrateTip3UsdcReq; + + #[wasm_bindgen(typescript_type = "TClaimUsdcReq")] + pub type TClaimUsdcReq; + + #[wasm_bindgen(typescript_type = "TSendTokensDirectReq")] + pub type TSendTokensDirectReq; + + #[wasm_bindgen(typescript_type = "TGetMySellOrdersReq")] + pub type TGetMySellOrdersReq; + + #[wasm_bindgen(typescript_type = "TSellShellsReq")] + pub type TSellShellsReq; + + #[wasm_bindgen(typescript_type = "TParamsOfQueryConnectSessionMessages")] + pub type TParamsOfQueryConnectSessionMessages; + + #[wasm_bindgen(typescript_type = "TTokenBalancesMap")] + pub type TTokenBalancesMap; + + #[wasm_bindgen(typescript_type = "TNativeBalancesMap")] + pub type TNativeBalancesMap; + + #[wasm_bindgen(typescript_type = "TMinerOwnerPublicMap")] + pub type TMinerOwnerPublicMap; + + #[wasm_bindgen(typescript_type = "TCandidateOwnerPubkeyExpirationMap")] + pub type TCandidateOwnerPubkeyExpirationMap; + + #[wasm_bindgen(typescript_type = "TFactorsOrderedByTimestampMap")] + pub type TFactorsOrderedByTimestampMap; + + #[wasm_bindgen(typescript_type = "TJwkModulusDataMap")] + pub type TJwkModulusDataMap; + + #[wasm_bindgen(typescript_type = "TWhiteListOfAddressMap")] + pub type TWhiteListOfAddressMap; + + #[wasm_bindgen(typescript_type = "TZkLoginCompleteWithProverParams")] + pub type TZkLoginCompleteWithProverParams; + + #[wasm_bindgen(typescript_type = "TUpdateMultifactorZkIdReq")] + pub type TUpdateMultifactorZkIdReq; + + #[wasm_bindgen(typescript_type = "TDeleteZkpFactorByItselfReq")] + pub type TDeleteZkpFactorByItselfReq; + + #[wasm_bindgen(typescript_type = "TParamsOfGetMultifactorInfo")] + pub type TParamsOfGetMultifactorInfo; + + #[wasm_bindgen(typescript_type = "TParamsOfGetMultifactorAddress")] + pub type TParamsOfGetMultifactorAddress; + + #[wasm_bindgen(typescript_type = "TParamsGetMirrorAddress")] + pub type TParamsGetMirrorAddress; + + #[wasm_bindgen(typescript_type = "TGetEPKExpireReq")] + pub type TGetEPKExpireReq; + + #[wasm_bindgen(typescript_type = "TParamsOfPrepareDeploy")] + pub type TParamsOfPrepareDeploy; + + #[wasm_bindgen(typescript_type = "TRootProviderCertificatesMap")] + pub type TRootProviderCertificatesMap; +} diff --git a/bee_wallet/src/adapters/wasm/dto/multifactor.rs b/bee_wallet/src/adapters/wasm/dto/multifactor.rs new file mode 100644 index 0000000..ce3ee4c --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/multifactor.rs @@ -0,0 +1,475 @@ +use std::collections::HashMap; + +use ackinacki_kit::contracts::mvsystem::multifactor::AccountData as InnerMultifactorAccountData; +use ackinacki_kit::contracts::mvsystem::multifactor::JwkData; +use ackinacki_kit::contracts::mvsystem::root::ResultOfGetMvMultifactorAddress as InnerResultOfGetMvMultifactorAddress; +use serde_wasm_bindgen::to_value; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; + +use crate::services::multifactor::query::ResultOfCheckNameAvailability as InnerResultOfCheckNameAvailability; +use crate::services::multifactor::query::ResultOfGetMultifactorDetails as InnerResultOfGetMultifactorDetails; +use crate::wasm::dto::TCandidateOwnerPubkeyExpirationMap; +use crate::wasm::dto::TFactorsOrderedByTimestampMap; +use crate::wasm::dto::TJwkModulusDataMap; +use crate::wasm::dto::TWhiteListOfAddressMap; +use crate::GetEPKExpireRes as InnerGetEPKExpireRes; +use crate::ResultGetMirrorAddress as InnerResultGetMirrorAddress; +use crate::ResultOfGetMultifactorInfo as InnerResultOfGetMultifactorInfo; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfGetMultifactorDetails { + address: String, + candidate_new_owner_pubkey_and_expiration: Option>, + factors_len: String, + factors_ordered_by_timestamp: HashMap, + force_remove_oldest: bool, + index_mod_4: String, + iss_base_64: String, + jwk_modulus_data: HashMap, + jwk_modulus_data_len: String, + m_security_cards_len: String, + m_transactions_len: String, + max_cleanup_txns: String, + min_value: String, + name: String, + owner_pubkey: String, + pub_recovery_key: String, + root: String, + use_security_card: bool, + zkid: String, + jwk_update_key: String, + wasm_hash: String, + white_list_of_address: HashMap, +} + +impl From for ResultOfGetMultifactorDetails { + fn from(value: InnerResultOfGetMultifactorDetails) -> Self { + Self { + address: value.address, + candidate_new_owner_pubkey_and_expiration: value + .candidate_new_owner_pubkey_and_expiration, + factors_len: value.factors_len, + factors_ordered_by_timestamp: value.factors_ordered_by_timestamp, + force_remove_oldest: value.force_remove_oldest, + index_mod_4: value.index_mod_4, + iss_base_64: value.iss_base_64, + jwk_modulus_data: value.jwk_modulus_data, + jwk_modulus_data_len: value.jwk_modulus_data_len, + m_security_cards_len: value.m_security_cards_len, + m_transactions_len: value.m_transactions_len, + max_cleanup_txns: value.max_cleanup_txns, + min_value: value.min_value, + name: value.name, + owner_pubkey: value.owner_pubkey, + pub_recovery_key: value.pub_recovery_key, + root: value.root, + use_security_card: value.use_security_card, + zkid: value.zkid, + jwk_update_key: value.jwk_update_key, + wasm_hash: value.wasm_hash, + white_list_of_address: value.white_list_of_address, + } + } +} + +#[wasm_bindgen] +impl ResultOfGetMultifactorDetails { + #[wasm_bindgen(getter)] + pub fn candidate_new_owner_pubkey_and_expiration(&self) -> TCandidateOwnerPubkeyExpirationMap { + to_value(&self.candidate_new_owner_pubkey_and_expiration) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn factors_ordered_by_timestamp(&self) -> TFactorsOrderedByTimestampMap { + to_value(&self.factors_ordered_by_timestamp) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn jwk_modulus_data(&self) -> TJwkModulusDataMap { + to_value(&self.jwk_modulus_data) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn white_list_of_address(&self) -> TWhiteListOfAddressMap { + to_value(&self.white_list_of_address) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn factors_len(&self) -> String { + self.factors_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn address(&self) -> String { + self.address.clone() + } + + #[wasm_bindgen(getter)] + pub fn force_remove_oldest(&self) -> bool { + self.force_remove_oldest + } + + #[wasm_bindgen(getter)] + pub fn index_mod_4(&self) -> String { + self.index_mod_4.clone() + } + + #[wasm_bindgen(getter)] + pub fn iss_base_64(&self) -> String { + self.iss_base_64.clone() + } + + #[wasm_bindgen(getter)] + pub fn jwk_modulus_data_len(&self) -> String { + self.jwk_modulus_data_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn m_security_cards_len(&self) -> String { + self.m_security_cards_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn m_transactions_len(&self) -> String { + self.m_transactions_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn max_cleanup_txns(&self) -> String { + self.max_cleanup_txns.clone() + } + + #[wasm_bindgen(getter)] + pub fn min_value(&self) -> String { + self.min_value.clone() + } + + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + #[wasm_bindgen(getter)] + pub fn owner_pubkey(&self) -> String { + self.owner_pubkey.clone() + } + + #[wasm_bindgen(getter)] + pub fn pub_recovery_key(&self) -> String { + self.pub_recovery_key.clone() + } + + #[wasm_bindgen(getter)] + pub fn root(&self) -> String { + self.root.clone() + } + + #[wasm_bindgen(getter)] + pub fn use_security_card(&self) -> bool { + self.use_security_card + } + + #[wasm_bindgen(getter)] + pub fn zkid(&self) -> String { + self.zkid.clone() + } + + #[wasm_bindgen(getter)] + pub fn jwk_update_key(&self) -> String { + self.jwk_update_key.clone() + } + + #[wasm_bindgen(getter)] + pub fn wasm_hash(&self) -> String { + self.wasm_hash.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfCheckNameAvailability { + is_available: bool, + multifactor_address: Option, +} + +impl From for ResultOfCheckNameAvailability { + fn from(value: InnerResultOfCheckNameAvailability) -> Self { + Self { is_available: value.is_available, multifactor_address: value.multifactor_address } + } +} + +#[wasm_bindgen] +impl ResultOfCheckNameAvailability { + #[wasm_bindgen(getter)] + pub fn is_available(&self) -> bool { + self.is_available + } + + #[wasm_bindgen(getter)] + pub fn multifactor_address(&self) -> Option { + self.multifactor_address.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfGetMvMultifactorAddress { + address: String, +} + +impl From for ResultOfGetMvMultifactorAddress { + fn from(value: InnerResultOfGetMvMultifactorAddress) -> Self { + Self { address: value.address } + } +} + +#[wasm_bindgen] +impl ResultOfGetMvMultifactorAddress { + #[wasm_bindgen(getter)] + pub fn address(&self) -> String { + self.address.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultGetMirrorAddress { + address: String, +} + +impl From for ResultGetMirrorAddress { + fn from(value: InnerResultGetMirrorAddress) -> Self { + Self { address: value.address } + } +} + +#[wasm_bindgen] +impl ResultGetMirrorAddress { + #[wasm_bindgen(getter)] + pub fn address(&self) -> String { + self.address.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfGetEPKExpireAt { + epk_expire_at: u64, +} + +impl From for ResultOfGetEPKExpireAt { + fn from(value: InnerGetEPKExpireRes) -> Self { + Self { epk_expire_at: value.epk_expire_at } + } +} + +#[wasm_bindgen] +impl ResultOfGetEPKExpireAt { + #[wasm_bindgen(getter)] + pub fn epk_expire_at(&self) -> u64 { + self.epk_expire_at + } +} + +/// Decoded `Multifactor` contract account state. Mirrors +/// `ackinacki_kit::contracts::mvsystem::multifactor::AccountData`. +#[wasm_bindgen] +#[derive(Clone)] +pub struct MultifactorAccountData { + candidate_new_owner_pubkey_and_expiration: Option>, + factors_len: String, + factors_ordered_by_timestamp: HashMap, + force_remove_oldest: bool, + index_mod_4: String, + iss_base_64: String, + jwk_modulus_data: HashMap, + jwk_modulus_data_len: String, + m_security_cards_len: String, + m_transactions_len: String, + max_cleanup_txns: String, + min_value: String, + name: String, + owner_pubkey: String, + pub_recovery_key: String, + root: String, + use_security_card: bool, + zkid: String, + jwk_update_key: String, + wasm_hash: String, + white_list_of_address: HashMap, +} + +impl From for MultifactorAccountData { + fn from(value: InnerMultifactorAccountData) -> Self { + Self { + candidate_new_owner_pubkey_and_expiration: value + .candidate_new_owner_pubkey_and_expiration, + factors_len: value.factors_len, + factors_ordered_by_timestamp: value.factors_ordered_by_timestamp, + force_remove_oldest: value.force_remove_oldest, + index_mod_4: value.index_mod_4, + iss_base_64: value.iss_base_64, + jwk_modulus_data: value.jwk_modulus_data, + jwk_modulus_data_len: value.jwk_modulus_data_len, + m_security_cards_len: value.m_security_cards_len, + m_transactions_len: value.m_transactions_len, + max_cleanup_txns: value.max_cleanup_txns, + min_value: value.min_value, + name: value.name, + owner_pubkey: value.owner_pubkey, + pub_recovery_key: value.pub_recovery_key, + root: value.root, + use_security_card: value.use_security_card, + zkid: value.zkid, + jwk_update_key: value.jwk_update_key, + wasm_hash: value.wasm_hash, + white_list_of_address: value.white_list_of_address, + } + } +} + +#[wasm_bindgen] +impl MultifactorAccountData { + #[wasm_bindgen(getter)] + pub fn candidate_new_owner_pubkey_and_expiration(&self) -> TCandidateOwnerPubkeyExpirationMap { + to_value(&self.candidate_new_owner_pubkey_and_expiration) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn factors_ordered_by_timestamp(&self) -> TFactorsOrderedByTimestampMap { + to_value(&self.factors_ordered_by_timestamp) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn jwk_modulus_data(&self) -> TJwkModulusDataMap { + to_value(&self.jwk_modulus_data) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn white_list_of_address(&self) -> TWhiteListOfAddressMap { + to_value(&self.white_list_of_address) + .unwrap_or(JsValue::NULL) + .unchecked_into::() + } + + #[wasm_bindgen(getter)] + pub fn factors_len(&self) -> String { + self.factors_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn force_remove_oldest(&self) -> bool { + self.force_remove_oldest + } + + #[wasm_bindgen(getter)] + pub fn index_mod_4(&self) -> String { + self.index_mod_4.clone() + } + + #[wasm_bindgen(getter)] + pub fn iss_base_64(&self) -> String { + self.iss_base_64.clone() + } + + #[wasm_bindgen(getter)] + pub fn jwk_modulus_data_len(&self) -> String { + self.jwk_modulus_data_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn m_security_cards_len(&self) -> String { + self.m_security_cards_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn m_transactions_len(&self) -> String { + self.m_transactions_len.clone() + } + + #[wasm_bindgen(getter)] + pub fn max_cleanup_txns(&self) -> String { + self.max_cleanup_txns.clone() + } + + #[wasm_bindgen(getter)] + pub fn min_value(&self) -> String { + self.min_value.clone() + } + + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + #[wasm_bindgen(getter)] + pub fn owner_pubkey(&self) -> String { + self.owner_pubkey.clone() + } + + #[wasm_bindgen(getter)] + pub fn pub_recovery_key(&self) -> String { + self.pub_recovery_key.clone() + } + + #[wasm_bindgen(getter)] + pub fn root(&self) -> String { + self.root.clone() + } + + #[wasm_bindgen(getter)] + pub fn use_security_card(&self) -> bool { + self.use_security_card + } + + #[wasm_bindgen(getter)] + pub fn zkid(&self) -> String { + self.zkid.clone() + } + + #[wasm_bindgen(getter)] + pub fn jwk_update_key(&self) -> String { + self.jwk_update_key.clone() + } + + #[wasm_bindgen(getter)] + pub fn wasm_hash(&self) -> String { + self.wasm_hash.clone() + } +} + +#[wasm_bindgen] +pub struct ResultOfGetMultifactorInfo { + data: Option, +} + +impl From for ResultOfGetMultifactorInfo { + fn from(value: InnerResultOfGetMultifactorInfo) -> Self { + Self { data: value.data.map(MultifactorAccountData::from) } + } +} + +#[wasm_bindgen] +impl ResultOfGetMultifactorInfo { + #[wasm_bindgen(getter)] + pub fn data(&self) -> Option { + self.data.clone() + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/names.rs b/bee_wallet/src/adapters/wasm/dto/names.rs new file mode 100644 index 0000000..f04ae58 --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/names.rs @@ -0,0 +1,52 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::modules::names::ResultOfValidateWalletName as InnerResultOfValidateWalletName; +use crate::modules::names::WalletNameErrorCode as InnerWalletNameErrorCode; + +#[wasm_bindgen] +#[derive(Clone, Copy)] +pub enum WalletNameErrorCode { + InvalidCharacters = 1, + ConsecutiveHyphens = 2, + ConsecutiveUnderscores = 3, + StartsWithSymbol = 4, + TooLong = 5, + TooShort = 6, +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfValidateWalletName { + is_valid: bool, + error_code: Option, +} + +impl From for ResultOfValidateWalletName { + fn from(value: InnerResultOfValidateWalletName) -> Self { + let error_code = value.error_code().map(|code| match code { + InnerWalletNameErrorCode::InvalidCharacters => WalletNameErrorCode::InvalidCharacters, + InnerWalletNameErrorCode::ConsecutiveHyphens => WalletNameErrorCode::ConsecutiveHyphens, + InnerWalletNameErrorCode::ConsecutiveUnderscores => { + WalletNameErrorCode::ConsecutiveUnderscores + } + InnerWalletNameErrorCode::StartsWithSymbol => WalletNameErrorCode::StartsWithSymbol, + InnerWalletNameErrorCode::TooLong => WalletNameErrorCode::TooLong, + InnerWalletNameErrorCode::TooShort => WalletNameErrorCode::TooShort, + }); + + Self { is_valid: value.is_valid(), error_code } + } +} + +#[wasm_bindgen] +impl ResultOfValidateWalletName { + #[wasm_bindgen(getter)] + pub fn is_valid(&self) -> bool { + self.is_valid + } + + #[wasm_bindgen(getter)] + pub fn error_code(&self) -> Option { + self.error_code + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/tx.rs b/bee_wallet/src/adapters/wasm/dto/tx.rs new file mode 100644 index 0000000..19b1766 --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/tx.rs @@ -0,0 +1,105 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::services::transaction::history::ResultOfGetHistory as InnerResultOfGetHistory; +use crate::services::transaction::history::TxData as InnerTxData; +use crate::services::transaction::history::TxType as InnerTxType; + +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct TxData { + id: String, + tx_type: String, + created_at: String, + value: String, + src_name: Option, +} + +fn tx_type_to_string(tx_type: &InnerTxType) -> String { + match tx_type { + InnerTxType::Mining => "Mining".to_string(), + InnerTxType::Incoming => "Incoming".to_string(), + InnerTxType::Outgoing => "Outgoing".to_string(), + } +} + +impl From for TxData { + fn from(tx: InnerTxData) -> Self { + Self { + id: tx.id, + tx_type: tx_type_to_string(&tx.tx_type), + created_at: tx.created_at.to_string(), + value: tx.value.to_string(), + src_name: tx.src_name, + } + } +} + +#[wasm_bindgen] +impl TxData { + #[wasm_bindgen(getter)] + pub fn id(&self) -> String { + self.id.clone() + } + + #[wasm_bindgen(getter)] + pub fn tx_type(&self) -> String { + self.tx_type.clone() + } + + #[wasm_bindgen(getter)] + pub fn value(&self) -> String { + self.value.clone() + } + + #[wasm_bindgen(getter)] + pub fn created_at(&self) -> String { + self.created_at.clone() + } + + #[wasm_bindgen(getter)] + pub fn src_name(&self) -> Option { + self.src_name.clone() + } +} + +#[wasm_bindgen] +pub struct ResultOfGetHistory { + data: Vec, + next_cursor: Option, + next_mining_cursor: Option, + has_next_page: bool, +} + +impl From for ResultOfGetHistory { + fn from(value: InnerResultOfGetHistory) -> Self { + Self { + data: value.data.into_iter().map(TxData::from).collect(), + next_cursor: value.next_cursor, + next_mining_cursor: value.next_mining_cursor, + has_next_page: value.has_next_page, + } + } +} + +#[wasm_bindgen] +impl ResultOfGetHistory { + #[wasm_bindgen(getter)] + pub fn data(&self) -> Vec { + self.data.clone() + } + + #[wasm_bindgen(getter)] + pub fn next_cursor(&self) -> Option { + self.next_cursor.clone() + } + + #[wasm_bindgen(getter)] + pub fn next_mining_cursor(&self) -> Option { + self.next_mining_cursor.clone() + } + + #[wasm_bindgen(getter)] + pub fn has_next_page(&self) -> bool { + self.has_next_page + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/write.rs b/bee_wallet/src/adapters/wasm/dto/write.rs new file mode 100644 index 0000000..4bc85b7 --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/write.rs @@ -0,0 +1,112 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::ResultOfBlockchainWrite as InnerResultOfBlockchainWrite; +use crate::ResultOfSendMessage as InnerResultOfSendMessage; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfBlockchainWrite { + message_ids: Vec, + pending_stage: Option, + pending_reason: Option, +} + +impl From for ResultOfBlockchainWrite { + fn from(value: InnerResultOfBlockchainWrite) -> Self { + Self { + message_ids: value.message_ids, + pending_stage: value.pending_stage, + pending_reason: value.pending_reason, + } + } +} + +#[wasm_bindgen] +impl ResultOfBlockchainWrite { + #[wasm_bindgen(getter)] + pub fn message_ids(&self) -> Vec { + self.message_ids.clone() + } + + #[wasm_bindgen(getter)] + pub fn pending_stage(&self) -> Option { + self.pending_stage.clone() + } + + #[wasm_bindgen(getter)] + pub fn pending_reason(&self) -> Option { + self.pending_reason.clone() + } +} + +/// Wraps `ackinacki_kit::tvm_client::processing::ResultOfSendMessage`. +/// Numeric `exit_code` is signed (i32) and survives the boundary as-is. +#[wasm_bindgen] +pub struct ResultOfSendMessage { + message_hash: Option, + block_hash: Option, + tx_hash: Option, + aborted: Option, + exit_code: Option, + thread_id: Option, + producers: Vec, + current_time: Option, +} + +impl From for ResultOfSendMessage { + fn from(value: InnerResultOfSendMessage) -> Self { + Self { + message_hash: value.message_hash, + block_hash: value.block_hash, + tx_hash: value.tx_hash, + aborted: value.aborted, + exit_code: value.exit_code, + thread_id: value.thread_id, + producers: value.producers, + current_time: value.current_time, + } + } +} + +#[wasm_bindgen] +impl ResultOfSendMessage { + #[wasm_bindgen(getter)] + pub fn message_hash(&self) -> Option { + self.message_hash.clone() + } + + #[wasm_bindgen(getter)] + pub fn block_hash(&self) -> Option { + self.block_hash.clone() + } + + #[wasm_bindgen(getter)] + pub fn tx_hash(&self) -> Option { + self.tx_hash.clone() + } + + #[wasm_bindgen(getter)] + pub fn aborted(&self) -> Option { + self.aborted + } + + #[wasm_bindgen(getter)] + pub fn exit_code(&self) -> Option { + self.exit_code + } + + #[wasm_bindgen(getter)] + pub fn thread_id(&self) -> Option { + self.thread_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn producers(&self) -> Vec { + self.producers.clone() + } + + #[wasm_bindgen(getter)] + pub fn current_time(&self) -> Option { + self.current_time.clone() + } +} diff --git a/bee_wallet/src/adapters/wasm/dto/zkp.rs b/bee_wallet/src/adapters/wasm/dto/zkp.rs new file mode 100644 index 0000000..a14223e --- /dev/null +++ b/bee_wallet/src/adapters/wasm/dto/zkp.rs @@ -0,0 +1,210 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::services::zkp::client_login_complete::IssBase64Details as InnerIssBase64Details; +use crate::services::zkp::ResultOfAddZKPFactor as InnerResultOfAddZKPFactor; +use crate::services::zkp::ZkLoginCompleteWithProverResult as InnerZkLoginCompleteWithProverResult; +use crate::services::zkp::ZkLoginPrepareResult as InnerZkLoginPrepareResult; +use crate::wasm::dto::keys::ResultOfGetKeys; + +// TODO: same as deploy -> phrase should be optional +#[wasm_bindgen] +#[derive(Clone)] +pub struct ResultOfAddZKPFactor { + name: String, + address: String, + message_id: Option, + message_ids: Vec, + pubkey: String, + password_hash: String, + signing_keys: ResultOfGetKeys, +} + +impl From for ResultOfAddZKPFactor { + fn from(value: InnerResultOfAddZKPFactor) -> Self { + Self { + name: value.name, + address: value.address, + message_id: value.message_id, + message_ids: value.message_ids, + pubkey: value.pubkey, + password_hash: value.password_hash, + signing_keys: ResultOfGetKeys::from(value.signing_keys), + } + } +} + +#[wasm_bindgen] +impl ResultOfAddZKPFactor { + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + #[wasm_bindgen(getter)] + pub fn address(&self) -> String { + self.address.clone() + } + + #[wasm_bindgen(getter)] + pub fn message_id(&self) -> Option { + self.message_id.clone() + } + + #[wasm_bindgen(getter)] + pub fn message_ids(&self) -> Vec { + self.message_ids.clone() + } + + #[wasm_bindgen(getter)] + pub fn password_hash(&self) -> String { + self.password_hash.clone() + } + + #[wasm_bindgen(getter)] + pub fn signing_keys(&self) -> ResultOfGetKeys { + self.signing_keys.clone() + } + + #[wasm_bindgen(getter)] + pub fn pubkey(&self) -> String { + self.pubkey.clone() + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct IssBase64Details { + value: String, + index_mod4: u8, +} + +impl From for IssBase64Details { + fn from(value: InnerIssBase64Details) -> Self { + Self { value: value.value, index_mod4: value.index_mod4 } + } +} + +#[wasm_bindgen] +impl IssBase64Details { + #[wasm_bindgen(getter)] + pub fn value(&self) -> String { + self.value.clone() + } + + #[wasm_bindgen(getter)] + pub fn index_mod4(&self) -> u8 { + self.index_mod4 + } +} + +#[wasm_bindgen] +pub struct ZkLoginCompleteWithProverResult { + zkid: String, + max_epoch: u64, + ephemeral_private_key: String, + zk_proof_compressed: String, + iss_base64_details: IssBase64Details, + header_base64: String, + ephemeral_public_key_in_hex: String, + ephemeral_secret_key_in_hex: String, +} + +impl From for ZkLoginCompleteWithProverResult { + fn from(value: InnerZkLoginCompleteWithProverResult) -> Self { + Self { + zkid: value.zkid, + max_epoch: value.max_epoch, + ephemeral_private_key: value.ephemeral_private_key, + zk_proof_compressed: value.zk_proof_compressed, + iss_base64_details: IssBase64Details::from(value.iss_base64_details), + header_base64: value.header_base64, + ephemeral_public_key_in_hex: value.ephemeral_public_key_in_hex, + ephemeral_secret_key_in_hex: value.ephemeral_secret_key_in_hex, + } + } +} + +#[wasm_bindgen] +pub struct ZkLoginPrepareResult { + nonce: String, + max_epoch: u64, + randomness: String, + ephemeral_private_key: String, +} + +impl From for ZkLoginPrepareResult { + fn from(value: InnerZkLoginPrepareResult) -> Self { + Self { + nonce: value.nonce, + max_epoch: value.max_epoch, + randomness: value.randomness, + ephemeral_private_key: value.ephemeral_private_key, + } + } +} + +#[wasm_bindgen] +impl ZkLoginPrepareResult { + #[wasm_bindgen(getter)] + pub fn nonce(&self) -> String { + self.nonce.clone() + } + + #[wasm_bindgen(getter)] + pub fn max_epoch(&self) -> u64 { + self.max_epoch + } + + #[wasm_bindgen(getter)] + pub fn randomness(&self) -> String { + self.randomness.clone() + } + + #[wasm_bindgen(getter)] + pub fn ephemeral_private_key(&self) -> String { + self.ephemeral_private_key.clone() + } +} + +#[wasm_bindgen] +impl ZkLoginCompleteWithProverResult { + #[wasm_bindgen(getter)] + pub fn zkid(&self) -> String { + self.zkid.clone() + } + + #[wasm_bindgen(getter)] + pub fn max_epoch(&self) -> u64 { + self.max_epoch + } + + #[wasm_bindgen(getter)] + pub fn ephemeral_private_key(&self) -> String { + self.ephemeral_private_key.clone() + } + + #[wasm_bindgen(getter)] + pub fn zk_proof_compressed(&self) -> String { + self.zk_proof_compressed.clone() + } + + #[wasm_bindgen(getter)] + pub fn iss_base64_details(&self) -> IssBase64Details { + self.iss_base64_details.clone() + } + + #[wasm_bindgen(getter)] + pub fn header_base64(&self) -> String { + self.header_base64.clone() + } + + #[wasm_bindgen(getter)] + pub fn ephemeral_public_key_in_hex(&self) -> String { + self.ephemeral_public_key_in_hex.clone() + } + + #[wasm_bindgen(getter)] + pub fn ephemeral_secret_key_in_hex(&self) -> String { + self.ephemeral_secret_key_in_hex.clone() + } +} diff --git a/bee_wallet/src/adapters/wasm/mod.rs b/bee_wallet/src/adapters/wasm/mod.rs new file mode 100644 index 0000000..03c1f21 --- /dev/null +++ b/bee_wallet/src/adapters/wasm/mod.rs @@ -0,0 +1,764 @@ +use serde_wasm_bindgen; +use wasm_bindgen::prelude::*; + +use crate::client::WalletClient; +use crate::client::WalletConfig; +use crate::services::balance::ParamsOfGetNativeBalances; +use crate::services::balance::ParamsOfGetTokensBalances; +use crate::services::connect::ParamsOfQuerySessionMessages; +use crate::services::deploy::ParamsOfDeployMiner; +use crate::services::deploy::ParamsOfDeployMultifactor; +use crate::services::deploy::ParamsOfPrepareDeploy; +use crate::services::miner::ParamsOfDelMiningKey; +use crate::services::miner::ParamsOfSetMiningKeys; +use crate::services::multifactor::cmd::ParamsOfChangeSeedPhrase; +use crate::services::transaction::history::ParamsOfGetHistory; +use crate::services::zkp::ParamsOfAddZKPFactor; +use crate::services::zkp::ZkLoginCompleteWithProverParams; +use crate::DeleteZkpFactorByItselfReq; +use crate::GetEPKExpireReq; +use crate::ParamsGetMirrorAddress; +use crate::ParamsOfGetMultifactorAddress; +use crate::ParamsOfGetMultifactorInfo; +use crate::UpdateMultifactorZkIdReq; + +mod dto; + +/// Drains progress events on the wasm event loop and forwards each one to the +/// JS callback as a plain object (`{ op, stage, detail?, pct? }`). The loop +/// ends when the operation drops its sender, so it needs no explicit cleanup. +fn spawn_progress_forwarder( + mut rx: futures::channel::mpsc::UnboundedReceiver, + on_progress: js_sys::Function, +) { + use futures::StreamExt; + wasm_bindgen_futures::spawn_local(async move { + while let Some(event) = rx.next().await { + if let Ok(js) = serde_wasm_bindgen::to_value(&event) { + let _ = on_progress.call1(&JsValue::NULL, &js); + } + } + }); +} + +#[wasm_bindgen] +pub struct Wallet { + inner: WalletClient, +} + +#[wasm_bindgen] +impl Wallet { + #[wasm_bindgen(constructor)] + pub fn new( + endpoints: Vec, + archive_endpoints: Option>, + api_url: String, + app_id: String, + api_token: Option, + max_rps: Option, + ) -> Result { + let inner = WalletClient::new(WalletConfig { + endpoints, + archive_endpoints, + api_url, + app_id, + api_token, + max_rps, + }) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(Self { inner }) + } + + #[wasm_bindgen(js_name = validate_name)] + pub fn validate_name( + &self, + wallet_name: String, + ) -> Result { + let x = self.inner.names().validate_name(wallet_name); + Ok(dto::names::ResultOfValidateWalletName::from(x)) + } + + #[wasm_bindgen(js_name = check_name_availability)] + pub async fn check_name_availability( + &self, + wallet_name: String, + ) -> Result { + let res = self + .inner + .multifactor() + .check_name_availability(wallet_name) + .await + .map_err(|e| JsError::new(&format!("Failed to check name availability: {e:?}")))?; + + Ok(dto::multifactor::ResultOfCheckNameAvailability::from(res)) + } + + #[wasm_bindgen(js_name = get_multifactor_data_by_name)] + pub async fn get_multifactor_data_by_name( + &self, + wallet_name: String, + ) -> Result, JsError> { + let res = self + .inner + .multifactor() + .get_multifactor_data_by_name(wallet_name) + .await + .map_err(|e| JsError::new(&format!("Failed to get multifacotr data: {e:?}")))?; + + match res { + Some(v) => Ok(Some(dto::multifactor::ResultOfGetMultifactorDetails::from(v))), + None => Ok(None), + } + } + + #[wasm_bindgen(js_name = add_zkp_factor)] + pub async fn add_zkp_factor( + &self, + params_js: dto::TParamsOfAddZKPFactor, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfAddZKPFactor = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsOfAddZKPFactor: {e:?}")))?; + let res = self + .inner + .zkp() + .add_zkp_factor(params, None) + .await + .map_err(|e| JsError::new(&format!("failed to add zk factor: {e:?}")))?; + + Ok(dto::zkp::ResultOfAddZKPFactor::from(res)) + } + + /// Like [`add_zkp_factor`], but invokes `on_progress(event)` for each + /// `add_factor` milestone (`jwk?` → `submitted` → `confirming`… → + /// `confirmed`), where `event` is a plain object `{ op, stage, detail?, + /// pct? }`. On confirmation timeout the call rejects with a terminal error. + #[wasm_bindgen(js_name = add_zkp_factor_with_progress)] + pub async fn add_zkp_factor_with_progress( + &self, + params_js: dto::TParamsOfAddZKPFactor, + on_progress: js_sys::Function, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfAddZKPFactor = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsOfAddZKPFactor: {e:?}")))?; + + let (tx, rx) = futures::channel::mpsc::unbounded::(); + spawn_progress_forwarder(rx, on_progress); + + let res = self + .inner + .zkp() + .add_zkp_factor(params, Some(tx)) + .await + .map_err(|e| JsError::new(&format!("failed to add zk factor: {e:?}")))?; + + Ok(dto::zkp::ResultOfAddZKPFactor::from(res)) + } + + #[wasm_bindgen(js_name = change_seed_phrase)] + pub async fn change_seed_phrase( + &self, + params_js: dto::TParamsOfChangeSeedPhrase, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfChangeSeedPhrase = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsOfChangeSeedPhrase: {e:?}")))?; + let res = self + .inner + .multifactor() + .change_seed_phrase(params) + .await + .map_err(|e| JsError::new(&format!("failed to change seed phrase: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(res)) + } + + #[wasm_bindgen(js_name = decode_connect_payload_b64url)] + pub fn decode_connect_payload_b64url(&self, payload_b64: String) -> Result { + let payload = self + .inner + .connect() + .decode_connect_payload_b64url(payload_b64) + .map_err(|e| JsError::new(&format!("failed to decode connect payload: {e:?}")))?; + serde_wasm_bindgen::to_value(&payload) + .map_err(|e| JsError::new(&format!("failed to serialize connect payload: {e:?}"))) + } + + #[wasm_bindgen(js_name = query_connect_session_messages)] + pub async fn query_connect_session_messages( + &self, + params_js: dto::TParamsOfQueryConnectSessionMessages, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfQuerySessionMessages = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| { + JsError::new(&format!("Bad TParamsOfQueryConnectSessionMessages: {e:?}")) + })?; + let result = self.inner.connect().query_session_messages(params).await.map_err(|e| { + JsError::new(&format!("failed to query connect session messages: {e:?}")) + })?; + + Ok(dto::connect::ResultOfQuerySessionMessages::from(result)) + } + + #[wasm_bindgen(js_name = get_miner_details_by_multifactor_address)] + pub async fn get_miner_details_by_multifactor_address( + &self, + multifactor_address: String, + ) -> Result { + let res = self + .inner + .miner() + .get_details_by_multifactor_address(multifactor_address) + .await + .map_err(|e| JsError::new(&format!("failed to get minier details: {e:?}")))?; + + Ok(dto::miner::ResultOfGetMinerDetails::from(res)) + } + + /// Deploy multifactor wallet only (does not deploy miner). + #[wasm_bindgen(js_name = deploy_wallet)] + pub async fn deploy_wallet( + &self, + params_js: dto::TParamsOfDeployMultifactor, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfDeployMultifactor = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfDeployMultifactor: {e:?}")))?; + + let res = self + .inner + .deploy() + .deploy_multifactor(params, true, None) + .await + .map_err(|e| JsError::new(&format!("failed to deploy multifactor: {e:?}")))?; + Ok(dto::deploy::ResultOfDeployMultifactor::from(res)) + } + + /// Like [`deploy_wallet`], but invokes `on_progress(event)` for each + /// `deploy` milestone (`preparing` → `deploying` → `submitted` → + /// `activating` → `active` → `configuring` → `confirmed`/`pending`/ + /// `failed`), where `event` is `{ op, stage, detail?, pct? }`. + #[wasm_bindgen(js_name = deploy_wallet_with_progress)] + pub async fn deploy_wallet_with_progress( + &self, + params_js: dto::TParamsOfDeployMultifactor, + on_progress: js_sys::Function, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfDeployMultifactor = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfDeployMultifactor: {e:?}")))?; + + let (tx, rx) = futures::channel::mpsc::unbounded::(); + spawn_progress_forwarder(rx, on_progress); + + let res = self + .inner + .deploy() + .deploy_multifactor(params, true, Some(tx)) + .await + .map_err(|e| JsError::new(&format!("failed to deploy multifactor: {e:?}")))?; + Ok(dto::deploy::ResultOfDeployMultifactor::from(res)) + } + + /// Deploy miner as a separate use case. + /// Skips deploy if miner is already deployed. + #[wasm_bindgen(js_name = deploy_miner)] + pub async fn deploy_miner( + &self, + params_js: dto::TParamsOfDeployMiner, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfDeployMiner = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfDeployMultifactor: {e:?}")))?; + + let res = self + .inner + .deploy() + .deploy_miner(params, None) + .await + .map_err(|e| JsError::new(&format!("failed to deploy miner: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(res)) + } + + /// Like [`deploy_miner`], but invokes `on_progress(event)` for each + /// `deploy_miner` milestone (`resolving` → `deploying` → `confirmed`/ + /// `pending`/`failed`), where `event` is `{ op, stage, detail?, pct? }`. + #[wasm_bindgen(js_name = deploy_miner_with_progress)] + pub async fn deploy_miner_with_progress( + &self, + params_js: dto::TParamsOfDeployMiner, + on_progress: js_sys::Function, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfDeployMiner = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfDeployMiner: {e:?}")))?; + + let (tx, rx) = futures::channel::mpsc::unbounded::(); + spawn_progress_forwarder(rx, on_progress); + + let res = self + .inner + .deploy() + .deploy_miner(params, Some(tx)) + .await + .map_err(|e| JsError::new(&format!("failed to deploy miner: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(res)) + } + + /// set mining keys for the app_id specified in sdk init + #[wasm_bindgen(js_name = set_mining_keys)] + pub async fn set_mining_keys( + &self, + params_js: dto::TParamsOfSetMiningKeys, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfSetMiningKeys = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfSetMiningKeys: {e:?}")))?; + + let res = self + .inner + .miner() + .set_mining_keys(params) + .await + .map_err(|e| JsError::new(&format!("failed to set mining keys: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(res)) + } + + #[wasm_bindgen(js_name = del_mining_key)] + pub async fn del_mining_key( + &self, + params_js: dto::TParamsOfDelMiningKey, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfDelMiningKey = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfDelMiningKey: {e:?}")))?; + + let res = self + .inner + .miner() + .del_mining_key(params) + .await + .map_err(|e| JsError::new(&format!("failed to del mining key: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(res)) + } + + pub async fn get_multifactor_balances( + &self, + params_js: dto::TParamsOfGetMultifactorBalances, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfGetNativeBalances = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfGetNativeBalances: {e:?}")))?; + let res = self + .inner + .balance() + .get_native_balances(params) + .await + .map_err(|e| JsError::new(&format!("failed to get native balances: {e:?}")))?; + + Ok(dto::balances::ResultOfGetNativeBalances::from(res)) + } + + pub async fn get_tokens_balances( + &self, + params_js: dto::TParamsOfGetTokensBalances, + ) -> Result { + let params_val: JsValue = params_js.into(); + // TODO: try let params: ParamsOfGetTokensBalances = + // wserde_wasm_bindgen::from_value(JsValue::from(params_val)) + let params: ParamsOfGetTokensBalances = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfGetTokensBalances: {e:?}")))?; + + let res = self + .inner + .balance() + .get_tokens_balances(params) + .await + .map_err(|e| JsError::new(&format!("failed to get tokens balances: {e:?}")))?; + + Ok(dto::balances::ResultOfGetTokensBalances::from(res)) + } + + #[wasm_bindgen(js_name = get_history)] + pub async fn get_history( + &self, + params_js: dto::TParamsOfGetHistory, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfGetHistory = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfGetHistory: {e:?}")))?; + + let res = self + .inner + .history() + .get_history(params) + .await + .map_err(|e| JsError::new(&format!("Failed to get history: {e:?}")))?; + + Ok(dto::tx::ResultOfGetHistory::from(res)) + } + + #[wasm_bindgen(js_name = migrate_tip3_usdc)] + pub async fn migrate_tip3_usdc( + &self, + params_js: dto::TMigrateTip3UsdcReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: crate::types::MigrateTip3UsdcReq = + serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TMigrateTip3UsdcReq: {e:?}")))?; + let res = self + .inner + .tokens() + .migrate_tip3_usdc(params) + .await + .map_err(|e| JsError::new(&format!("failed to migrate TIP-3 USDC: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(crate::ResultOfBlockchainWrite { + message_ids: res.message_hash.into_iter().collect(), + pending_stage: None, + pending_reason: None, + })) + } + + #[wasm_bindgen(js_name = redeem_nackl)] + pub async fn redeem_nackl( + &self, + params_js: dto::TRedeemNacklReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: crate::RedeemNacklReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TRedeemNacklReq: {e:?}")))?; + let res = self + .inner + .tokens() + .redeem_nackl(params) + .await + .map_err(|e| JsError::new(&format!("failed to redeem nackl: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(crate::ResultOfBlockchainWrite { + message_ids: res.message_hash.into_iter().collect(), + pending_stage: None, + pending_reason: None, + })) + } + + #[wasm_bindgen(js_name = get_nackl_redeem_rate)] + pub async fn get_nackl_redeem_rate(&self) -> Result { + let res = self + .inner + .tokens() + .get_nackl_redeem_rate() + .await + .map_err(|e| JsError::new(&format!("failed to get nackl redeem rate: {e:?}")))?; + serde_wasm_bindgen::to_value(&res) + .map_err(|e| JsError::new(&format!("failed to serialize NacklRedeemRateResult: {e:?}"))) + } + + #[wasm_bindgen(js_name = claim_usdc)] + pub async fn claim_usdc(&self, params_js: dto::TClaimUsdcReq) -> Result { + let params_val: JsValue = params_js.into(); + let params: crate::ClaimUsdcReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TClaimUsdcReq: {e:?}")))?; + let res = self + .inner + .tokens() + .claim_usdc(params) + .await + .map_err(|e| JsError::new(&format!("failed to claim usdc: {e:?}")))?; + serde_wasm_bindgen::to_value(&res) + .map_err(|e| JsError::new(&format!("failed to serialize ClaimUsdcResult: {e:?}"))) + } + + #[wasm_bindgen(js_name = get_my_sell_orders)] + pub async fn get_my_sell_orders( + &self, + params_js: dto::TGetMySellOrdersReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: crate::GetMySellOrdersReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TGetMySellOrdersReq: {e:?}")))?; + let res = self + .inner + .tokens() + .get_my_sell_orders(params) + .await + .map_err(|e| JsError::new(&format!("failed to get sell orders: {e:?}")))?; + serde_wasm_bindgen::to_value(&res) + .map_err(|e| JsError::new(&format!("failed to serialize sell orders: {e:?}"))) + } + + #[wasm_bindgen(js_name = sell_shells)] + pub async fn sell_shells(&self, params_js: dto::TSellShellsReq) -> Result { + let params_val: JsValue = params_js.into(); + let params: crate::SellShellsReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TSellShellsReq: {e:?}")))?; + let res = self + .inner + .tokens() + .sell_shells(params) + .await + .map_err(|e| JsError::new(&format!("failed to sell shells: {e:?}")))?; + serde_wasm_bindgen::to_value(&res) + .map_err(|e| JsError::new(&format!("failed to serialize SellShellsResult: {e:?}"))) + } + + #[wasm_bindgen(js_name = send_tokens_direct)] + pub async fn send_tokens_direct( + &self, + params_js: dto::TSendTokensDirectReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: crate::SendTokensDirectReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TSendTokensDirectReq: {e:?}")))?; + let res = self + .inner + .tokens() + .send_tokens_direct(params) + .await + .map_err(|e| JsError::new(&format!("failed to send tokens direct: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(crate::ResultOfBlockchainWrite { + message_ids: res.message_hash.into_iter().collect(), + pending_stage: None, + pending_reason: None, + })) + } + + #[wasm_bindgen(js_name = buy_shells)] + pub async fn buy_shells( + &self, + params_js: dto::TBuyShellsReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: crate::BuyShellsReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TBuyShellsReq: {e:?}")))?; + let res = self + .inner + .tokens() + .buy_shells(params) + .await + .map_err(|e| JsError::new(&format!("failed to buy shells: {e:?}")))?; + + Ok(dto::write::ResultOfBlockchainWrite::from(crate::ResultOfBlockchainWrite { + message_ids: res.message_hash.into_iter().collect(), + pending_stage: None, + pending_reason: None, + })) + } + + #[wasm_bindgen(js_name = get_miner_address)] + pub async fn get_miner_address( + &self, + params_js: dto::TParamsOfGetMinerAddress, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: dto::miner::ParamsOfGetMinerAddress = + serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad ParamsOfGetMinerAddress: {e:?}")))?; + + let address = + self.inner.miner().get_miner_address(¶ms.multifactor_address).await.map_err( + |e| JsError::new(&format!("Failed to get resolve miner for multifactor: {e:?}")), + )?; + + Ok(address) + } + + /// Stage 1 of zk-login: generates ephemeral ed25519 keypair and the + /// Poseidon-based `nonce` to bind to the OAuth `nonce=` parameter. + /// Uses `Date.now()` (via js_sys) as the time source. + #[wasm_bindgen(js_name = prepare_zk_login_v1)] + pub fn prepare_zk_login_v1(&self) -> Result { + let now_ms = crate::infra::now_ms() + .map_err(|e| JsError::new(&format!("failed to read now_ms: {e:?}")))?; + let res = self + .inner + .zkp() + .prepare_zk_login_v1_with_now_ms(now_ms) + .map_err(|e| JsError::new(&format!("failed to prepare zk-login: {e:?}")))?; + Ok(dto::zkp::ZkLoginPrepareResult::from(res)) + } + + /// Stage 5b — builds signed `ParamsOfDeployMultifactor` (epk_sig, + /// recovery key + sig, jwk-update key + sig, provider certificates). + /// Does not send anything on-chain. + #[wasm_bindgen(js_name = prepare_multifactor_deploy_params)] + pub async fn prepare_multifactor_deploy_params( + &self, + params_js: dto::TParamsOfPrepareDeploy, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfPrepareDeploy = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsOfPrepareDeploy: {e:?}")))?; + let res = self + .inner + .deploy() + .prepare_multifactor_deploy_params(params) + .await + .map_err(|e| JsError::new(&format!("failed to prepare deploy params: {e:?}")))?; + Ok(dto::deploy::PreparedDeployParams::from(res)) + } + + /// Completes zk-login by preparing local payload, fetching prover proofs + /// and finalizing proof compression. + #[wasm_bindgen(js_name = complete_zk_login_with_prover_v1)] + pub async fn complete_zk_login_with_prover_v1( + &self, + params_js: dto::TZkLoginCompleteWithProverParams, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ZkLoginCompleteWithProverParams = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TZkLoginCompleteWithProverParams: {e:?}")))?; + let res = self + .inner + .zkp() + .complete_zk_login_with_prover_v1(params, None) + .await + .map_err(|e| JsError::new(&format!("failed to complete zk-login: {e:?}")))?; + Ok(dto::zkp::ZkLoginCompleteWithProverResult::from(res)) + } + + /// Like [`complete_zk_login_with_prover_v1`], but invokes + /// `on_progress(event)` for each `prove` milestone (`started` → + /// `finished`), where `event` is `{ op, stage, detail?, pct? }`. + #[wasm_bindgen(js_name = complete_zk_login_with_prover_v1_with_progress)] + pub async fn complete_zk_login_with_prover_v1_with_progress( + &self, + params_js: dto::TZkLoginCompleteWithProverParams, + on_progress: js_sys::Function, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ZkLoginCompleteWithProverParams = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TZkLoginCompleteWithProverParams: {e:?}")))?; + + let (tx, rx) = futures::channel::mpsc::unbounded::(); + spawn_progress_forwarder(rx, on_progress); + + let res = self + .inner + .zkp() + .complete_zk_login_with_prover_v1(params, Some(tx)) + .await + .map_err(|e| JsError::new(&format!("failed to complete zk-login: {e:?}")))?; + Ok(dto::zkp::ZkLoginCompleteWithProverResult::from(res)) + } + + /// Replaces wallet ZK identity payload (`zkid`, proof, factor, JWK data). + #[wasm_bindgen(js_name = update_zk_id)] + pub async fn update_zk_id( + &self, + params_js: dto::TUpdateMultifactorZkIdReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: UpdateMultifactorZkIdReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TUpdateMultifactorZkIdReq: {e:?}")))?; + let res = self + .inner + .multifactor() + .update_zk_id(params) + .await + .map_err(|e| JsError::new(&format!("failed to update zk id: {e:?}")))?; + Ok(dto::write::ResultOfSendMessage::from(res)) + } + + /// Deletes currently used ZKP factor by its own signer key. + #[wasm_bindgen(js_name = delete_zkp_factor_by_itself)] + pub async fn delete_zkp_factor_by_itself( + &self, + params_js: dto::TDeleteZkpFactorByItselfReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: DeleteZkpFactorByItselfReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TDeleteZkpFactorByItselfReq: {e:?}")))?; + let res = self + .inner + .zkp() + .delete_zkp_factor_by_itself(params) + .await + .map_err(|e| JsError::new(&format!("failed to delete zkp factor: {e:?}")))?; + Ok(dto::write::ResultOfSendMessage::from(res)) + } + + /// Returns decoded multifactor contract state by address. `data` is `null` + /// when the contract is not deployed or has no decodable account data. + #[wasm_bindgen(js_name = get_multifactor_info)] + pub async fn get_multifactor_info( + &self, + params_js: dto::TParamsOfGetMultifactorInfo, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfGetMultifactorInfo = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsOfGetMultifactorInfo: {e:?}")))?; + let res = self + .inner + .multifactor() + .get_multifactor_info(params) + .await + .map_err(|e| JsError::new(&format!("failed to get multifactor info: {e:?}")))?; + Ok(dto::multifactor::ResultOfGetMultifactorInfo::from(res)) + } + + /// Resolves multifactor address by owner pubkey. + #[wasm_bindgen(js_name = get_multifactor_address)] + pub async fn get_multifactor_address( + &self, + params_js: dto::TParamsOfGetMultifactorAddress, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsOfGetMultifactorAddress = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsOfGetMultifactorAddress: {e:?}")))?; + let res = self + .inner + .multifactor() + .get_multifactor_address(params) + .await + .map_err(|e| JsError::new(&format!("failed to get multifactor address: {e:?}")))?; + Ok(dto::multifactor::ResultOfGetMvMultifactorAddress::from(res)) + } + + /// Resolves mirror address for an owner pubkey. + #[wasm_bindgen(js_name = get_mirror_address)] + pub fn get_mirror_address( + &self, + params_js: dto::TParamsGetMirrorAddress, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: ParamsGetMirrorAddress = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TParamsGetMirrorAddress: {e:?}")))?; + let res = self + .inner + .multifactor() + .get_mirror_address(params) + .map_err(|e| JsError::new(&format!("failed to get mirror address: {e:?}")))?; + Ok(dto::multifactor::ResultGetMirrorAddress::from(res)) + } + + /// Returns expiration timestamp for a factor identified by EPK. + #[wasm_bindgen(js_name = get_epk_expire_at)] + pub async fn get_epk_expire_at( + &self, + params_js: dto::TGetEPKExpireReq, + ) -> Result { + let params_val: JsValue = params_js.into(); + let params: GetEPKExpireReq = serde_wasm_bindgen::from_value(params_val) + .map_err(|e| JsError::new(&format!("Bad TGetEPKExpireReq: {e:?}")))?; + let res = self + .inner + .multifactor() + .get_epk_expire_at(params) + .await + .map_err(|e| JsError::new(&format!("failed to get epk expire at: {e:?}")))?; + Ok(dto::multifactor::ResultOfGetEPKExpireAt::from(res)) + } +} + +#[cfg(not(feature = "single-wasm"))] +#[wasm_bindgen(start)] +pub fn start() {} diff --git a/bee_wallet/src/bin/faucet.rs b/bee_wallet/src/bin/faucet.rs new file mode 100644 index 0000000..974c9ac --- /dev/null +++ b/bee_wallet/src/bin/faucet.rs @@ -0,0 +1,227 @@ +use std::collections::HashMap; +use std::io::Write; +use std::sync::Arc; + +use ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetIndexer; +use ackinacki_kit::contracts::token::root::TokenRoot; +use ackinacki_kit::contracts::traits::SendMessage; +use ackinacki_kit::tvm_client::abi::CallSet; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::ClientConfig; +use ackinacki_kit::tvm_client::ClientContext; + +const NACKL_ECC_ID: u32 = 1; +const SHELL_ECC_ID: u32 = 2; +const USDC_ECC_ID: u32 = 3; + +const NACKL_DECIMALS: u32 = 9; +const SHELL_DECIMALS: u32 = 9; +const USDC_DECIMALS: u32 = 6; +const TIP3_USDC_DECIMALS: u32 = 6; + +/// TIP-3 USDC TokenRoot on shellnet. +const TIP3_USDC_ROOT: &str = "0:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + +/// Server keys that can mint TIP-3 USDC on shellnet. +const TIP3_MINT_PUBLIC: &str = "e44b1ca07ee19c5c66eca104b9e5372f1fcadfe962b11e565132c66ec5603d91"; +const TIP3_MINT_SECRET: &str = "55c5d4ca4f11c7721e39b1dbe407b7dc787b68d89fc8936fb798e7631f155ea0"; + +fn prompt(msg: &str) -> String { + print!("{msg}"); + std::io::stdout().flush().unwrap(); + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf).unwrap(); + buf.trim().to_string() +} + +fn create_tvm_client(endpoint: &str) -> Arc { + let mut config = ClientConfig::default(); + config.network.endpoints = Some(vec![endpoint.to_string()]); + Arc::new(ClientContext::new(config).expect("failed to create tvm client")) +} + +async fn resolve_address(ctx: &Arc, name: &str) -> Result { + let root = MobileVerifiersRoot::new_default(ctx.clone()); + let indexer = root + .get_indexer(ParamsOfGetIndexer { name: name.to_string() }) + .await + .map_err(|e| format!("get_indexer failed: {e}"))?; + let details = indexer.get_details().await.map_err(|e| format!("get_details failed: {e}"))?; + Ok(details.multifactor_address) +} + +async fn send_ecc( + ctx: &Arc, + address: &str, + ecc_id: u32, + raw_amount: u64, + label: &str, + amount: f64, +) { + let gas: u64 = 1_000_000_000; + + print!("Sending gas... "); + std::io::stdout().flush().unwrap(); + send_currency_with_flag_from_default_giver(ctx.clone(), address, gas, HashMap::new(), 1).await; + println!("OK"); + + let ecc = HashMap::from([(ecc_id, raw_amount)]); + print!("Sending {amount} {label}... "); + std::io::stdout().flush().unwrap(); + send_currency_with_flag_from_default_giver(ctx.clone(), address, gas, ecc, 1).await; + println!("OK"); +} + +async fn mint_tip3_usdc( + ctx: &Arc, + wallet_owner: &str, + raw_amount: u64, + amount: f64, +) { + // Dev faucet: USDC token dApp is unconfirmed; System placeholder is fine + // here (this bin only runs against < 1.0.0 dev nodes). + let usdc_root = TokenRoot::new( + ctx.clone(), + bee_wallet::dapp::token_contract_params( + TIP3_USDC_ROOT, + ackinacki_kit::contracts::dapp::SystemDapp::System, + ), + ); + let keys = + KeyPair { public: TIP3_MINT_PUBLIC.to_string(), secret: TIP3_MINT_SECRET.to_string() }; + + print!("Minting {amount} TIP-3 USDC... "); + std::io::stdout().flush().unwrap(); + + let result = usdc_root + .send_message( + Some(CallSet { + function_name: "mint".to_string(), + header: None, + input: Some(serde_json::json!({ + "value": raw_amount.to_string(), + "walletOwner": wallet_owner, + })), + }), + None, + Signer::Keys { keys }, + ) + .await; + + match result { + Ok(r) => { + let exit_code = r.exit_code.unwrap_or(0); + let aborted = r.aborted.unwrap_or(false); + if aborted || exit_code > 0 { + println!("FAILED (exit_code={exit_code}, aborted={aborted})"); + } else { + println!("OK (msg: {:?})", r.message_hash.as_deref().unwrap_or("?")); + } + } + Err(e) => { + println!("FAILED ({e})"); + } + } +} + +#[tokio::main] +async fn main() { + let endpoint = "shellnet.ackinacki.org"; + println!("Shellnet faucet (giver works only on shellnet)\n"); + let ctx = create_tvm_client(endpoint); + + let name = prompt("Wallet name: "); + if name.is_empty() { + eprintln!("Error: wallet name is empty"); + std::process::exit(1); + } + + print!("Resolving '{name}'... "); + std::io::stdout().flush().unwrap(); + let address = match resolve_address(&ctx, &name).await { + Ok(addr) => { + println!("{addr}"); + addr + } + Err(e) => { + println!("FAILED"); + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + println!("\nCurrency:"); + println!(" 1) NACKL (ECC, decimals=9)"); + println!(" 2) SHELL (ECC, decimals=9)"); + println!(" 3) USDC (ECC, decimals=6)"); + println!(" 4) USDC TIP-3 (token mint, decimals=6)"); + let choice = prompt("Choose [1/2/3/4]: "); + + enum Mode { + Ecc { ecc_id: u32, decimals: u32, label: &'static str }, + Tip3Usdc, + } + + let mode = match choice.as_str() { + "1" | "nackl" | "NACKL" => { + Mode::Ecc { ecc_id: NACKL_ECC_ID, decimals: NACKL_DECIMALS, label: "NACKL" } + } + "2" | "shell" | "SHELL" => { + Mode::Ecc { ecc_id: SHELL_ECC_ID, decimals: SHELL_DECIMALS, label: "SHELL" } + } + "3" | "usdc" | "USDC" => { + Mode::Ecc { ecc_id: USDC_ECC_ID, decimals: USDC_DECIMALS, label: "USDC" } + } + "4" | "tip3" | "TIP3" => Mode::Tip3Usdc, + _ => { + eprintln!("Error: invalid choice '{choice}'"); + std::process::exit(1); + } + }; + + let label = match &mode { + Mode::Ecc { label, .. } => *label, + Mode::Tip3Usdc => "USDC TIP-3", + }; + + let amount_str = prompt(&format!("How many {label}? (e.g. 1 = 1 {label}, 0.5 = half): ")); + let amount: f64 = match amount_str.parse() { + Ok(v) if v > 0.0 => v, + _ => { + eprintln!("Error: invalid amount '{amount_str}'"); + std::process::exit(1); + } + }; + + match mode { + Mode::Ecc { ecc_id, decimals, label } => { + let raw_amount = (amount * 10f64.powi(decimals as i32)) as u64; + println!("\nSending {amount} {label} ({raw_amount} raw) to '{name}' ({address})\n"); + send_ecc(&ctx, &address, ecc_id, raw_amount, label, amount).await; + } + Mode::Tip3Usdc => { + let raw_amount = (amount * 10f64.powi(TIP3_USDC_DECIMALS as i32)) as u64; + println!("\nMinting {amount} TIP-3 USDC ({raw_amount} raw) to '{name}' ({address})\n"); + + // Gas needed for wallet to receive TIP-3 + print!("Sending gas... "); + std::io::stdout().flush().unwrap(); + send_currency_with_flag_from_default_giver( + ctx.clone(), + &address, + 1_000_000_000, + HashMap::new(), + 1, + ) + .await; + println!("OK"); + + mint_tip3_usdc(&ctx, &address, raw_amount, amount).await; + } + } + + println!("\nDone!"); +} diff --git a/bee_wallet/src/bin/wallet_info.rs b/bee_wallet/src/bin/wallet_info.rs new file mode 100644 index 0000000..b567915 --- /dev/null +++ b/bee_wallet/src/bin/wallet_info.rs @@ -0,0 +1,1148 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::accumulator::events::DecodedAccumulatorRootEvent; +use ackinacki_kit::contracts::accumulator::events::DecodedSellOrderLotEvent; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetOrdersBySeller; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetSellOrderAddress; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::SellerOrderInfo as KitSellerOrderInfo; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ShellAccumulatorRootUsdc; +use ackinacki_kit::contracts::accumulator::shell_sell_order_lot::ShellSellOrderLot; +use ackinacki_kit::contracts::event::Event as KitEvent; +use ackinacki_kit::contracts::mvsystem::mirror::Mirror; +use ackinacki_kit::contracts::mvsystem::mirror::ParamsOfGetMinerAddress; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetIndexer; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetPopitgame; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::contracts::traits::FromEvent; +use ackinacki_kit::shared::traits::guarded::AsyncGuarded; +use ackinacki_kit::tvm_client::net::query as gql_query; +use ackinacki_kit::tvm_client::net::ParamsOfQuery as GqlParams; +use ackinacki_kit::tvm_client::ClientConfig; +use ackinacki_kit::tvm_client::ClientContext; + +const NACKL_ECC_ID: u32 = 1; +const SHELL_ECC_ID: u32 = 2; +const USDC_ECC_ID: u32 = 3; +const NACKL_DECIMALS: u32 = 9; +const SHELL_DECIMALS: u32 = 9; +const USDC_DECIMALS: u32 = 6; +const MAINNET_ENDPOINT: &str = "https://mainnet.ackinacki.org"; +const MAINNET_ARCHIVE_ENDPOINT: &str = "https://archive.mainnet.ackinacki.org"; +const SHELLNET_ENDPOINT: &str = "shellnet.ackinacki.org"; + +fn create_tvm_client(endpoint: &str) -> Arc { + let mut config = ClientConfig::default(); + config.network.endpoints = Some(vec![endpoint.to_string()]); + Arc::new(ClientContext::new(config).expect("failed to create tvm client")) +} + +fn format_balance(raw: &num_bigint::BigInt, decimals: u32) -> String { + use num_bigint::Sign; + let divisor = num_bigint::BigInt::from(10u64.pow(decimals)); + let whole = raw / &divisor; + let rem = raw % &divisor; + let frac = if rem.sign() == Sign::Minus { -rem } else { rem }; + let frac_str = format!("{frac}"); + let padded = format!("{:0>width$}", frac_str, width = decimals as usize); + let trimmed = padded.trim_end_matches('0'); + if trimmed.is_empty() { + format!("{whole}") + } else { + format!("{whole}.{trimmed}") + } +} + +async fn resolve_address(ctx: &Arc, name: &str) -> Result { + let root = MobileVerifiersRoot::new_default(ctx.clone()); + let indexer = root + .get_indexer(ParamsOfGetIndexer { name: name.to_string() }) + .await + .map_err(|e| format!("get_indexer failed: {e}"))?; + let details = indexer.get_details().await.map_err(|e| format!("get_details failed: {e}"))?; + Ok(details.multifactor_address) +} + +async fn cmd_info(endpoint: &str, name: &str) { + let ctx = create_tvm_client(endpoint); + + let address = match resolve_address(&ctx, name).await { + Ok(a) => a, + Err(e) => { + eprintln!("Error: {e}"); + return; + } + }; + + println!("\n=== {name} ({endpoint}) ===\n"); + println!("multifactor: {address}"); + + // Multifactor balances + let mf = Multifactor::new_default(ctx.clone(), &address); + if let Err(e) = mf.fetch_account().await { + eprintln!("fetch multifactor failed: {e}"); + return; + } + let balance = mf.async_guarded(|acc| acc.balance.clone()).await; + let ecc = mf.async_guarded(|acc| acc.ecc.clone()).await; + + if let Some(b) = &balance { + println!("vmshell: {} VMShell", format_balance(b, 9)); + } + if let Some(nackl) = ecc.get(&NACKL_ECC_ID) { + println!("nackl: {} NACKL", format_balance(nackl, NACKL_DECIMALS)); + } + if let Some(shell) = ecc.get(&SHELL_ECC_ID) { + println!("shell: {} SHELL", format_balance(shell, SHELL_DECIMALS)); + } + if let Some(usdc) = ecc.get(&USDC_ECC_ID) { + println!("usdc: {} USDC", format_balance(usdc, USDC_DECIMALS)); + } + + // Popitgame + let root = MobileVerifiersRoot::new_default(ctx.clone()); + match root.get_popitgame(ParamsOfGetPopitgame { multifactor_address: address.clone() }).await { + Ok(pg) => { + println!("\npopitgame: {}", pg.address()); + if pg.fetch_account().await.is_ok() { + let pg_ecc = pg.async_guarded(|acc| acc.ecc.clone()).await; + if let Some(nackl) = pg_ecc.get(&NACKL_ECC_ID) { + println!(" rewards: {} NACKL", format_balance(nackl, NACKL_DECIMALS)); + } + } + match pg.get_details().await { + Ok(d) => println!(" boost: {}", d.boosts_address), + Err(e) => println!(" boost: error ({e})"), + } + } + Err(e) => println!("\npopitgame: error ({e})"), + } + + // Miner + let tail = address.rsplit(':').next().unwrap(); + match Mirror::new_default(ctx.clone(), tail) { + Ok(mirror) => { + match mirror + .get_miner(ParamsOfGetMinerAddress { multifactor_address: address.clone() }) + .await + { + Ok(miner) => { + println!("\nminer: {}", miner.address()); + if miner.fetch_account().await.is_ok() { + let deployed = miner.async_guarded(|acc| acc.is_deployed()).await; + println!(" deployed: {deployed}"); + if deployed { + match miner.get_details().await { + Ok(d) => { + println!( + " mbi_cur: {}", + d.mbi_cur.map(|v| v.to_string()).unwrap_or("none".into()) + ); + } + Err(e) => println!(" details: error ({e})"), + } + } + } + } + Err(e) => println!("\nminer: error ({e})"), + } + } + Err(e) => println!("\nminer: error ({e})"), + } + + println!(); +} + +fn create_wallet(endpoint: &str, archive_endpoint: Option<&str>) -> bee_wallet::Wallet { + bee_wallet::Wallet::new(bee_wallet::WalletConfig { + endpoints: vec![endpoint.to_string()], + archive_endpoints: archive_endpoint.map(|a| vec![a.to_string()]), + api_url: "https://app-backend.ackinacki.org/api".to_string(), + app_id: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), + ..Default::default() + }) + .expect("create wallet") +} + +async fn cmd_mining(endpoint: &str, archive_endpoint: Option<&str>, name: &str, count: usize) { + use bee_wallet::ParamsOfGetHistory; + + let wallet = create_wallet(endpoint, archive_endpoint); + + let mf_data = match wallet.get_multifactor_data_by_name(name.to_string()).await { + Ok(Some(d)) => d, + Ok(None) => { + eprintln!("wallet '{name}' not found"); + return; + } + Err(e) => { + eprintln!("error: {e:?}"); + return; + } + }; + + println!("=== mining: {name} ({endpoint}) ==="); + println!("multifactor: {}\n", mf_data.address); + + let result = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: mf_data.address, + token_id: "1".to_string(), + page_size: count as u32, + cursor: None, + mining_cursor: None, + }) + .await; + + match result { + Ok(history) => { + let mut total = 0.0f64; + let mut mining_count = 0; + for tx in &history.data { + if tx.tx_type == "Mining" { + let val: f64 = tx.value.parse().unwrap_or(0.0) / 1e9; + total += val; + mining_count += 1; + println!(" {} | {:>12.6} NACKL", tx.created_at, val); + } + } + println!( + "\n{mining_count} rewards, total: {total:.6} NACKL, has_next: {}", + history.has_next_page + ); + } + Err(e) => eprintln!("error: {e:?}"), + } +} + +fn prompt(msg: &str) -> String { + use std::io::Write; + print!("{msg}"); + std::io::stdout().flush().unwrap(); + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf).unwrap(); + buf.trim().to_string() +} + +fn pick_endpoint() -> (&'static str, Option<&'static str>) { + println!("Network:"); + println!(" 1) mainnet"); + println!(" 2) shellnet"); + let choice = prompt("Choose [1/2]: "); + match choice.as_str() { + "2" | "shell" | "shellnet" => (SHELLNET_ENDPOINT, None), + _ => (MAINNET_ENDPOINT, Some(MAINNET_ARCHIVE_ENDPOINT)), + } +} + +/// Outcome of a query that has both hot and archive sources. +/// Distinguishes "really empty" (a definitive answer) from "everything failed". +enum Sourced { + /// Data successfully fetched (may be an empty vec — that's a definitive + /// "nothing here"). + Ok { data: T, source: &'static str }, + /// All sources errored. Verdict must NOT treat this as "nothing happened". + Inconclusive { hot_err: Option, arc_err: Option }, +} + +impl Sourced { + fn is_inconclusive(&self) -> bool { + matches!(self, Sourced::Inconclusive { .. }) + } + + fn print_status(&self, label: &str) { + match self { + Sourced::Ok { source, .. } => println!(" [{label}] source: {source}"), + Sourced::Inconclusive { hot_err, arc_err } => { + println!(" [{label}] INCONCLUSIVE — all sources failed"); + if let Some(e) = hot_err { + println!(" hot: {e}"); + } + if let Some(e) = arc_err { + println!(" archive: {e}"); + } + } + } + } +} + +/// Thin CLI-friendly wrapper around [`bee_infra::with_retry_policy`]. +/// Folds the raw error into a `String` (CLI prints diagnostics — it +/// does not need to preserve typed errors) and prints a per-attempt +/// progress hint so the operator sees the retry happening on a slow +/// pool. 3 attempts × 500/1000 ms — matches the legacy local helper. +async fn with_retry(label: &str, make: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, + E: std::fmt::Debug, +{ + let policy = bee_infra::RetryPolicy { + max_attempts: 3, + max_total: None, + base_delay: std::time::Duration::from_millis(500), + max_delay: std::time::Duration::from_millis(1000), + jitter: false, + }; + bee_infra::with_retry_policy( + &policy, + None, + |e: &E| { + // Cli retries on any error; the underlying tvm_client no + // longer storms (`max_reconnect_timeout = 0` is set in + // wallet/dex constructors), so each Err is one real miss. + eprintln!(" [{label}] attempt failed: {e:?}"); + true + }, + make, + ) + .await + .map_err(|e| format!("{e:?}")) +} + +async fn cmd_audit_sell(endpoint: &str, archive_endpoint: Option<&str>, name: &str) { + let wallet = create_wallet(endpoint, archive_endpoint); + + let mf_data = match wallet.get_multifactor_data_by_name(name.to_string()).await { + Ok(Some(d)) => d, + Ok(None) => { + eprintln!("wallet '{name}' not found"); + return; + } + Err(e) => { + eprintln!("error: {e:?}"); + return; + } + }; + let mf = mf_data.address; + + println!("\n=== audit-sell: {name} ({endpoint}) ==="); + println!("multifactor: {mf}"); + println!("accumulator: {}", ShellAccumulatorRootUsdc::DEFAULT_ADDRESS); + if let Some(a) = archive_endpoint { + println!("archive: {a}"); + } + + let hot_ctx = create_tvm_client(endpoint); + let arc_ctx: Option> = archive_endpoint.map(create_tvm_client); + + // Probe: is the accumulator at the expected address actually responsive? + // If both hot and archive can't read getDetails, every subsequent answer + // is meaningless — bail out loudly instead of pretending all is well. + let acc_health = probe_accumulator(&hot_ctx, arc_ctx.as_ref()).await; + println!("\nAccumulator health:"); + acc_health.print_status("getDetails"); + if acc_health.is_inconclusive() { + println!("\nVerdict:"); + println!(" INCONCLUSIVE: cannot reach accumulator — investigation aborted"); + println!(); + return; + } + + // Queue state per denom — needed to understand why kit's filter + // `order_id >= queue_state.next_id` might drop a known order_id. + println!("\nQueue state per denom:"); + let acc_hot = ShellAccumulatorRootUsdc::new_default(hot_ctx.clone()); + for denom in [1u16, 10, 100, 1000] { + use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetQueueState; + match acc_hot.get_queue_state(ParamsOfGetQueueState { d: denom }).await { + Ok(s) => println!( + " denom={:<5} next_id={:<6} available={:<6} sold_prefix={:<6} owed_count={}", + denom, s.next_id, s.available, s.sold_prefix, s.owed_count + ), + Err(e) => println!(" denom={denom}: error {e:?}"), + } + } + + // 1. Sell orders snapshot — call get_orders_by_seller directly so we can swap + // ctx between hot and archive on failure. + // Diagnostic: explicitly run kit's get_orders_by_seller on hot AND + // archive separately to compare. Helps catch the case where SDK fix + // routes to archive but archive's run still returns empty for some + // hidden reason (e.g. lot.get_details fails inside kit and order gets + // silently skipped). + { + use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetOrdersBySeller; + let acc_hot = ShellAccumulatorRootUsdc::new_default(hot_ctx.clone()); + match acc_hot + .get_orders_by_seller(ParamsOfGetOrdersBySeller { + seller: mf.clone(), + limit: Some(50), + cursor: None, + }) + .await + { + Ok(r) => println!( + " [diag] kit.get_orders_by_seller(HOT only) → {} orders", + r.orders.len() + ), + Err(e) => println!(" [diag] kit.get_orders_by_seller(HOT only) → ERROR: {e:?}"), + } + if let Some(arc) = arc_ctx.as_ref() { + let acc_arc = ShellAccumulatorRootUsdc::new_default(arc.clone()); + match acc_arc + .get_orders_by_seller(ParamsOfGetOrdersBySeller { + seller: mf.clone(), + limit: Some(50), + cursor: None, + }) + .await + { + Ok(r) => { + println!( + " [diag] kit.get_orders_by_seller(ARCHIVE only) → {} orders", + r.orders.len() + ); + for o in &r.orders { + println!( + " denom={} order_id={} sold={} claimed={} pos={} addr={}", + o.denom, + o.order_id, + o.sold, + o.claimed, + o.position_in_queue, + o.sell_order_address + ); + } + } + Err(e) => { + println!(" [diag] kit.get_orders_by_seller(ARCHIVE only) → ERROR: {e:?}") + } + } + } + } + + let orders_outcome = fetch_orders_by_seller(&hot_ctx, arc_ctx.as_ref(), &mf).await; + println!("\nSell orders (per accumulator state):"); + orders_outcome.print_status("get_orders_by_seller"); + let orders: Vec = match &orders_outcome { + Sourced::Ok { data, .. } => { + if data.is_empty() { + println!(" (none reported by accumulator for this seller)"); + } + for o in data { + let state = if o.claimed { + "CLAIMED".to_string() + } else if o.sold { + "SOLD (claim_usdc not called)".to_string() + } else { + format!("ACTIVE (queue pos {})", o.position_in_queue) + }; + println!( + " denom={:<5} order_id={:<6} {:<32} {}", + o.denom, o.order_id, state, o.sell_order_address + ); + } + data.clone() + } + Sourced::Inconclusive { .. } => Vec::new(), + }; + + // 2. Accumulator events filtered by seller — definitive proof of activity. + let acc_events_outcome = fetch_accumulator_events(&hot_ctx, arc_ctx.as_ref(), &mf).await; + println!("\nAccumulator events for this seller:"); + acc_events_outcome.print_status("query_events"); + if let Sourced::Ok { data, .. } = &acc_events_outcome { + if data.is_empty() { + println!( + " (none — accumulator never emitted SellOrderCreated/UsdcClaimed for this wallet)" + ); + } else { + for ev in data.iter().rev() { + println!(" {}", format_acc_event(ev)); + } + } + } + + // 3. Per-lot drill-down. Union the lots we know about: those reported by + // get_orders_by_seller (current state) PLUS any (denom,order_id) pairs we + // saw in the SellOrderCreated event log (catches orders older than hot's 24h + // window which kit's get_orders_by_seller misses internally). + let mut lots_to_inspect: Vec<(u16, u64, Option)> = + orders.iter().map(|o| (o.denom, o.order_id, Some(o.sell_order_address.clone()))).collect(); + if let Sourced::Ok { data: events, .. } = &acc_events_outcome { + for ev in events { + if let DecodedAccumulatorRootEvent::SellOrderCreated { data, .. } = ev { + if !lots_to_inspect + .iter() + .any(|(d, oid, _)| *d == data.denom && *oid == data.order_id) + { + lots_to_inspect.push((data.denom, data.order_id, None)); + } + } + } + } + + if !lots_to_inspect.is_empty() { + println!("\nLot drill-down:"); + let acc = ShellAccumulatorRootUsdc::new_default(hot_ctx.clone()); + for (denom, order_id, known_addr) in &lots_to_inspect { + // Resolve the lot address. If we already had it from + // get_orders_by_seller, use that; otherwise compute via the + // accumulator getter (cheap, current-state, no archive needed). + let address = match known_addr { + Some(a) => a.clone(), + None => match acc + .get_sell_order_address(ParamsOfGetSellOrderAddress { + d: *denom, + order_id: *order_id, + }) + .await + { + Ok(r) => r.sell_order_addr, + Err(e) => { + println!( + "\n -- denom={denom} order_id={order_id} addr=? (cannot derive: {e:?})" + ); + continue; + } + }, + }; + println!("\n -- denom={denom} order_id={order_id} addr={address}"); + let state_outcome = fetch_lot_state(&hot_ctx, arc_ctx.as_ref(), &address).await; + state_outcome.print_status("lot.fetchAccount"); + if let Sourced::Ok { data, .. } = &state_outcome { + print_lot_state(data); + } + + let lot_events_outcome = fetch_lot_events(&hot_ctx, arc_ctx.as_ref(), &address).await; + lot_events_outcome.print_status("lot.queryEvents"); + if let Sourced::Ok { data, .. } = &lot_events_outcome { + if data.is_empty() { + println!(" events: (none)"); + } else { + for ev in data.iter().rev() { + println!(" {}", format_lot_event(ev)); + } + } + } + } + } + + // 4. Verdict — only fire conclusions for sources that actually returned. + println!("\nVerdict:"); + print_verdict(&orders_outcome, &orders, &acc_events_outcome); + + println!(); +} + +async fn probe_accumulator( + hot: &Arc, + arc: Option<&Arc>, +) -> Sourced<()> { + let hot_acc = ShellAccumulatorRootUsdc::new_default(hot.clone()); + let hot_res = with_retry("acc.getDetails(hot)", || hot_acc.get_details()).await; + if hot_res.is_ok() { + return Sourced::Ok { data: (), source: "hot" }; + } + let hot_err = hot_res.err(); + + if let Some(arc) = arc { + let arc_acc = ShellAccumulatorRootUsdc::new_default(arc.clone()); + let arc_res = with_retry("acc.getDetails(archive)", || arc_acc.get_details()).await; + if arc_res.is_ok() { + return Sourced::Ok { data: (), source: "archive" }; + } + return Sourced::Inconclusive { hot_err, arc_err: arc_res.err() }; + } + Sourced::Inconclusive { hot_err, arc_err: None } +} + +async fn fetch_orders_by_seller( + hot: &Arc, + arc: Option<&Arc>, + seller: &str, +) -> Sourced> { + async fn paginate( + ctx: &Arc, + seller: &str, + label: &'static str, + ) -> Result, String> { + let acc = ShellAccumulatorRootUsdc::new_default(ctx.clone()); + let mut out = Vec::new(); + let mut cursor: Option = None; + loop { + let params = ParamsOfGetOrdersBySeller { + seller: seller.to_string(), + limit: Some(50), + cursor: cursor.clone(), + }; + let page = with_retry(label, || acc.get_orders_by_seller(params.clone())).await?; + out.extend(page.orders); + if !page.has_next_page { + break; + } + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(out) + } + + let hot_res = paginate(hot, seller, "get_orders_by_seller(hot)").await; + match hot_res { + Ok(v) => return Sourced::Ok { data: v, source: "hot" }, + Err(hot_err) => { + if let Some(arc) = arc { + match paginate(arc, seller, "get_orders_by_seller(archive)").await { + Ok(v) => return Sourced::Ok { data: v, source: "archive (hot failed)" }, + Err(arc_err) => { + return Sourced::Inconclusive { + hot_err: Some(hot_err), + arc_err: Some(arc_err), + } + } + } + } + Sourced::Inconclusive { hot_err: Some(hot_err), arc_err: None } + } + } +} + +/// Hand-rolled GraphQL query for `account.events`, lenient to a `null` events +/// field on the response (kit's strict deserializer chokes on that — +/// archive.mainnet.ackinacki.org returns `{"events": null}` when the account +/// has no events, instead of `{"events": {"edges": []}}`). +async fn lenient_query_account_events( + ctx: &Arc, + address: &str, + page_size: u32, +) -> Result, String> { + const GQL: &str = r#" + query($address: String!, $last: Int!, $before: String) { + blockchain { + account(address: $address) { + events(last: $last, before: $before) { + edges { + cursor + node { msg_id created_at dst body } + } + } + } + } + } + "#; + const GQL_V3: &str = r#" + query($account_id: String!, $dapp_id: String!, $last: Int!, $before: String) { + blockchain { + account(account_id: $account_id, dapp_id: $dapp_id) { + events(last: $last, before: $before) { + edges { + cursor + node { msg_id created_at dst body } + } + } + } + } + } + "#; + + // Diagnostic accounts queried here are mvsystem (Mobile Verifiers dApp). + let v3 = bee_wallet::dapp::server_uses_dapp_id(ctx).await.map_err(|e| e.to_string())?; + let dapp_id = ackinacki_kit::contracts::dapp::SystemDapp::MobileVerifiers.dapp_id(); + let mut all = Vec::new(); + let before: Option = None; + loop { + let raw = gql_query( + ctx.clone(), + GqlParams { + query: if v3 { GQL_V3 } else { GQL }.to_string(), + variables: Some(if v3 { + serde_json::json!({ + "account_id": bee_wallet::dapp::account_id(address), + "dapp_id": dapp_id, + "last": page_size, + "before": before, + }) + } else { + serde_json::json!({ + "address": address, + "last": page_size, + "before": before, + }) + }), + }, + ) + .await + .map_err(|e| format!("{e:?}"))?; + + // Walk the JSON manually so a `null` at events doesn't blow up. + let events = raw + .result + .get("data") + .and_then(|v| v.get("blockchain")) + .and_then(|v| v.get("account")) + .and_then(|v| v.get("events")); + let edges = match events { + None | Some(serde_json::Value::Null) => break, + Some(v) => v.get("edges").and_then(|e| e.as_array()).cloned().unwrap_or_default(), + }; + if edges.is_empty() { + break; + } + + let next_before = edges + .first() + .and_then(|e| e.get("cursor")) + .and_then(|c| c.as_str()) + .map(|s| s.to_string()); + + for edge in &edges { + let Some(node) = edge.get("node") else { continue }; + let msg_id = + node.get("msg_id").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let dst = node.get("dst").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let body = node.get("body").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let created_at = node.get("created_at").and_then(|v| v.as_u64()).unwrap_or_default(); + all.push(KitEvent { id: msg_id, dst, created_at, body }); + } + + // Fetched a single page only — `limit` semantics in our caller mean + // "give me up to this many"; one page covers it for our use case. + let _ = next_before; + break; + } + Ok(all) +} + +async fn fetch_accumulator_events( + hot: &Arc, + arc: Option<&Arc>, + seller: &str, +) -> Sourced> { + async fn run( + ctx: &Arc, + seller: &str, + label: &'static str, + ) -> Result, String> { + let acc = ShellAccumulatorRootUsdc::new_default(ctx.clone()); + let raw_events = with_retry(label, || { + lenient_query_account_events(ctx, ShellAccumulatorRootUsdc::DEFAULT_ADDRESS, 500) + }) + .await?; + let mut decoded = Vec::new(); + let mut all_sellers_count = std::collections::HashMap::::new(); + let mut total_sell_order_created = 0usize; + for ev in raw_events { + if let Ok(d) = DecodedAccumulatorRootEvent::from_event(&ev, &acc) { + if let DecodedAccumulatorRootEvent::SellOrderCreated { data, .. } = &d { + total_sell_order_created += 1; + *all_sellers_count.entry(data.seller.clone()).or_default() += 1; + } + if event_concerns_seller(&d, seller) { + decoded.push(d); + } + } + } + if decoded.is_empty() && total_sell_order_created > 0 { + eprintln!( + " [debug] {total_sell_order_created} SellOrderCreated event(s) from {} unique sellers; none match '{seller}'", + all_sellers_count.len() + ); + let mut sellers_sorted: Vec<(String, usize)> = + all_sellers_count.iter().map(|(k, v)| (k.clone(), *v)).collect(); + sellers_sorted.sort_by(|a, b| b.1.cmp(&a.1)); + for (s, c) in &sellers_sorted { + eprintln!(" {c:>3}x {s}"); + } + } + Ok(decoded) + } + // Hot mainnet keeps only ~24h of message history. Empty on hot is NOT a + // definitive answer for "no activity ever" — fall back to archive on both + // error AND empty for event queries. (Getters that read current contract + // state are different — see fetch_orders_by_seller; both nodes return the + // same current state, so empty there is conclusive.) + let hot_res = run(hot, seller, "acc.queryEvents(hot)").await; + match (&hot_res, arc) { + (Ok(v), _) if !v.is_empty() => { + return Sourced::Ok { data: hot_res.unwrap(), source: "hot" } + } + (Ok(_), None) => { + return Sourced::Ok { data: hot_res.unwrap(), source: "hot (no archive available)" } + } + (Ok(_), Some(arc_ctx)) => match run(arc_ctx, seller, "acc.queryEvents(archive)").await { + Ok(v) => Sourced::Ok { data: v, source: "archive (hot was empty)" }, + Err(arc_err) => Sourced::Inconclusive { + hot_err: Some("hot returned empty".to_string()), + arc_err: Some(arc_err), + }, + }, + (Err(_), None) => Sourced::Inconclusive { hot_err: hot_res.err(), arc_err: None }, + (Err(_), Some(arc_ctx)) => match run(arc_ctx, seller, "acc.queryEvents(archive)").await { + Ok(v) => Sourced::Ok { data: v, source: "archive (hot failed)" }, + Err(arc_err) => { + Sourced::Inconclusive { hot_err: hot_res.err(), arc_err: Some(arc_err) } + } + }, + } +} + +async fn fetch_lot_state( + hot: &Arc, + arc: Option<&Arc>, + address: &str, +) -> Sourced { + async fn read( + ctx: &Arc, + address: &str, + label: &'static str, + ) -> Result { + let lot = ShellSellOrderLot::new_default(ctx.clone(), address); + with_retry(label, || lot.fetch_account()).await?; + let deployed = lot.async_guarded(|acc| acc.is_deployed()).await; + let balance = lot.async_guarded(|acc| acc.balance.clone()).await; + let ecc = lot.async_guarded(|acc| acc.ecc.clone()).await; + Ok(LotState { + deployed, + vmshell: balance, + shell: ecc.get(&SHELL_ECC_ID).cloned(), + usdc: ecc.get(&USDC_ECC_ID).cloned(), + }) + } + match read(hot, address, "lot.fetchAccount(hot)").await { + Ok(v) => Sourced::Ok { data: v, source: "hot" }, + Err(hot_err) => { + if let Some(arc) = arc { + match read(arc, address, "lot.fetchAccount(archive)").await { + Ok(v) => Sourced::Ok { data: v, source: "archive (hot failed)" }, + Err(arc_err) => { + Sourced::Inconclusive { hot_err: Some(hot_err), arc_err: Some(arc_err) } + } + } + } else { + Sourced::Inconclusive { hot_err: Some(hot_err), arc_err: None } + } + } + } +} + +async fn fetch_lot_events( + hot: &Arc, + arc: Option<&Arc>, + address: &str, +) -> Sourced> { + async fn run( + ctx: &Arc, + address: &str, + label: &'static str, + ) -> Result, String> { + let lot = ShellSellOrderLot::new_default(ctx.clone(), address); + let raw_events = + with_retry(label, || lenient_query_account_events(ctx, address, 50)).await?; + let mut decoded = Vec::new(); + for ev in raw_events { + if let Ok(d) = DecodedSellOrderLotEvent::from_event(&ev, &lot) { + decoded.push(d); + } + } + Ok(decoded) + } + // Same retention concern as accumulator events — fall back on empty too. + let hot_res = run(hot, address, "lot.queryEvents(hot)").await; + match (&hot_res, arc) { + (Ok(v), _) if !v.is_empty() => Sourced::Ok { data: hot_res.unwrap(), source: "hot" }, + (Ok(_), None) => { + Sourced::Ok { data: hot_res.unwrap(), source: "hot (no archive available)" } + } + (Ok(_), Some(arc_ctx)) => match run(arc_ctx, address, "lot.queryEvents(archive)").await { + Ok(v) => Sourced::Ok { data: v, source: "archive (hot was empty)" }, + Err(arc_err) => Sourced::Inconclusive { + hot_err: Some("hot returned empty".to_string()), + arc_err: Some(arc_err), + }, + }, + (Err(_), None) => Sourced::Inconclusive { hot_err: hot_res.err(), arc_err: None }, + (Err(_), Some(arc_ctx)) => match run(arc_ctx, address, "lot.queryEvents(archive)").await { + Ok(v) => Sourced::Ok { data: v, source: "archive (hot failed)" }, + Err(arc_err) => { + Sourced::Inconclusive { hot_err: hot_res.err(), arc_err: Some(arc_err) } + } + }, + } +} + +fn format_acc_event(ev: &DecodedAccumulatorRootEvent) -> String { + match ev { + DecodedAccumulatorRootEvent::SellOrderCreated { event, data, .. } => format!( + "{} SellOrderCreated denom={} order_id={} shells={}", + event.created_at, data.denom, data.order_id, data.shell_amount + ), + DecodedAccumulatorRootEvent::UsdcClaimed { event, data, .. } => format!( + "{} UsdcClaimed denom={} order_id={} payout={}", + event.created_at, data.denom, data.order_id, data.payout + ), + DecodedAccumulatorRootEvent::ShellPurchased { event, data, .. } => format!( + "{} ShellPurchased buyer={} usdc={} from_sellers={} minted={}", + event.created_at, + data.buyer, + data.usdc_amount, + data.shell_from_sellers, + data.shell_minted + ), + DecodedAccumulatorRootEvent::NacklRedeemed { event, data, .. } => format!( + "{} NacklRedeemed recipient={} burn={} payout={}", + event.created_at, data.recipient, data.burn_amount, data.payout + ), + DecodedAccumulatorRootEvent::MatchedOrders { event, data, .. } => format!( + "{} MatchedOrders d1={} d10={} d100={} d1000={}", + event.created_at, + data.last_sold_1, + data.last_sold_10, + data.last_sold_100, + data.last_sold_1000 + ), + } +} + +fn format_lot_event(ev: &DecodedSellOrderLotEvent) -> String { + match ev { + DecodedSellOrderLotEvent::ClaimInitiated { event, .. } => { + format!("{} ClaimInitiated", event.created_at) + } + DecodedSellOrderLotEvent::OrderDestroyed { event, .. } => { + format!("{} OrderDestroyed", event.created_at) + } + } +} + +fn event_concerns_seller(ev: &DecodedAccumulatorRootEvent, seller: &str) -> bool { + match ev { + DecodedAccumulatorRootEvent::SellOrderCreated { data, .. } => data.seller == seller, + DecodedAccumulatorRootEvent::UsdcClaimed { data, .. } => data.seller == seller, + DecodedAccumulatorRootEvent::NacklRedeemed { data, .. } => data.recipient == seller, + // Buys / matches don't carry the seller; they're network-wide signals. + // Drop them from the per-seller timeline. + _ => false, + } +} + +struct LotState { + deployed: bool, + vmshell: Option, + shell: Option, + usdc: Option, +} + +fn print_lot_state(state: &LotState) { + println!( + " state: deployed={} vmshell={} shell={} usdc={}", + state.deployed, + state.vmshell.as_ref().map(|b| format_balance(b, 9)).unwrap_or_else(|| "-".into()), + state + .shell + .as_ref() + .map(|b| format_balance(b, SHELL_DECIMALS)) + .unwrap_or_else(|| "-".into()), + state.usdc.as_ref().map(|b| format_balance(b, USDC_DECIMALS)).unwrap_or_else(|| "-".into()), + ); +} + +fn print_verdict( + orders_outcome: &Sourced>, + orders: &[KitSellerOrderInfo], + acc_events_outcome: &Sourced>, +) { + // Hard rule: any inconclusive source means we don't know — never say CLEAN. + if orders_outcome.is_inconclusive() || acc_events_outcome.is_inconclusive() { + println!(" INCONCLUSIVE: at least one data source is unreachable."); + if orders_outcome.is_inconclusive() { + println!(" - get_orders_by_seller failed on all sources"); + } + if acc_events_outcome.is_inconclusive() { + println!(" - accumulator queryEvents failed on all sources"); + } + println!(" Re-run later or use a different node before drawing conclusions."); + return; + } + + // Both sources answered — derive counts from outcomes (which own the data). + let acc_events_data = match acc_events_outcome { + Sourced::Ok { data, .. } => data.as_slice(), + Sourced::Inconclusive { .. } => &[][..], + }; + + let active = orders.iter().filter(|o| !o.sold && !o.claimed).count(); + let pending_claim = orders.iter().filter(|o| o.sold && !o.claimed).count(); + let claimed = orders.iter().filter(|o| o.claimed).count(); + + let created_in_events = acc_events_data + .iter() + .filter(|e| matches!(e, DecodedAccumulatorRootEvent::SellOrderCreated { .. })) + .count(); + let claimed_in_events = acc_events_data + .iter() + .filter(|e| matches!(e, DecodedAccumulatorRootEvent::UsdcClaimed { .. })) + .count(); + + if pending_claim > 0 { + println!(" ACTION: {pending_claim} order(s) sold but USDC not claimed — call claim_usdc"); + } + if active > 0 { + println!(" INFO: {active} order(s) still active in queue (awaiting buyer)"); + } + if claimed > 0 { + println!(" INFO: {claimed} order(s) already claimed"); + } + if orders.is_empty() && created_in_events == 0 { + println!( + " CLEAN: no orders and no SellOrderCreated/UsdcClaimed events — wallet never interacted with accumulator" + ); + } + if created_in_events > orders.len() { + // This isn't an anomaly: kit's get_orders_by_seller queries + // SellOrderCreated events through hot's 24h window internally, so + // older orders that DO show in our archive-backed event scan won't + // appear in its result. Trust the event log + lot drill-down for + // ground truth on those. + let missing = created_in_events - orders.len(); + println!( + " NOTE: archive shows {created_in_events} SellOrderCreated event(s); get_orders_by_seller only saw {} — {missing} order(s) older than hot's 24h window. Inspect lot drill-down for actual state.", + orders.len() + ); + if claimed_in_events == 0 { + println!( + " ACTION: no UsdcClaimed event(s) for those — either still active, or sold-but-not-claimed (lot drill-down distinguishes which). If sold, call claim_usdc." + ); + } + } + if claimed_in_events > 0 + && pending_claim == 0 + && claimed == 0 + && created_in_events == orders.len() + { + println!( + " NOTE: {claimed_in_events} UsdcClaimed event(s) seen but no claimed orders surfaced — claimed lots typically self-destruct, so this is consistent." + ); + } +} + +/// Calls `Wallet.get_my_sell_orders` exactly as the mobile/web app would, +/// twice: once with the archive wired in (the new behavior after the SDK +/// fix), once without (the old behavior). Prints both results side-by-side +/// so you can see whether the archive is the thing producing/missing orders. +async fn cmd_sdk_orders(endpoint: &str, archive_endpoint: Option<&str>, name: &str) { + use bee_wallet::GetMySellOrdersReq; + + // First resolve the multifactor address via the same path the app uses. + let wallet_with_arc = create_wallet(endpoint, archive_endpoint); + let mf = match wallet_with_arc.get_multifactor_data_by_name(name.to_string()).await { + Ok(Some(d)) => d.address, + Ok(None) => { + eprintln!("wallet '{name}' not found"); + return; + } + Err(e) => { + eprintln!("get_multifactor_data_by_name error: {e:?}"); + return; + } + }; + + println!("\n=== sdk-orders: {name} ==="); + println!("multifactor: {mf}"); + println!("endpoint: {endpoint}"); + println!("archive: {}", archive_endpoint.unwrap_or("(none — running hot-only test only)")); + + async fn one_pass(wallet: &bee_wallet::Wallet, mf: &str, label: &str) { + println!("\n--- {label} ---"); + let mut total = 0usize; + let mut cursor: Option = None; + let mut page = 0; + loop { + page += 1; + let req = GetMySellOrdersReq { + multifactor_address: mf.to_string(), + page_size: 50, + cursor: cursor.clone(), + }; + match wallet.get_my_sell_orders(req).await { + Ok(r) => { + println!( + "page {page}: {} orders, has_next={}", + r.orders.len(), + r.has_next_page + ); + for o in &r.orders { + let state = if o.claimed { + "CLAIMED".to_string() + } else if o.sold { + "SOLD (claim_usdc not called)".to_string() + } else { + format!("ACTIVE (pos {})", o.position_in_queue) + }; + println!( + " denom={:<5} order_id={:<6} {:<32} {}", + o.denom, o.order_id, state, o.sell_order_address + ); + } + total += r.orders.len(); + if !r.has_next_page { + break; + } + cursor = r.next_cursor; + if cursor.is_none() { + break; + } + } + Err(e) => { + println!("page {page}: ERROR — {e:?}"); + break; + } + } + } + println!("total: {total} orders"); + } + + one_pass(&wallet_with_arc, &mf, "WITH archive (what the app sees now after SDK fix)").await; + + if archive_endpoint.is_some() { + let wallet_hot_only = create_wallet(endpoint, None); + one_pass(&wallet_hot_only, &mf, "WITHOUT archive (what the app used to see)").await; + } + + println!(); +} + +#[tokio::main] +async fn main() { + println!("Wallet Info\n"); + + let (endpoint, archive_endpoint) = pick_endpoint(); + println!(); + + loop { + let name = prompt("wallet name (or 'q' to quit): "); + if name.is_empty() { + continue; + } + if name == "q" || name == "quit" || name == "exit" { + break; + } + + println!(" 1) info"); + println!(" 2) mining"); + println!(" 3) audit-sell"); + println!(" 4) sdk-orders (simulate app)"); + let choice = prompt(" choose [1/2/3/4]: "); + + match choice.as_str() { + "2" | "mining" => { + let count_str = prompt(" how many rewards? [20]: "); + let count: usize = count_str.parse().unwrap_or(20); + cmd_mining(endpoint, archive_endpoint, &name, count).await; + } + "3" | "audit" | "audit-sell" => { + cmd_audit_sell(endpoint, archive_endpoint, &name).await; + } + "4" | "sdk" | "sdk-orders" => { + cmd_sdk_orders(endpoint, archive_endpoint, &name).await; + } + _ => { + cmd_info(endpoint, &name).await; + } + } + } +} diff --git a/bee_wallet/src/client.rs b/bee_wallet/src/client.rs new file mode 100644 index 0000000..fe70cd4 --- /dev/null +++ b/bee_wallet/src/client.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::ClientConfig; +use ackinacki_kit::tvm_client::ClientContext; + +use crate::errors::AppResult; +use crate::modules; + +/// Configuration passed to `Wallet::new`. All fields have empty/`None` +/// defaults so embedding apps can use struct-update syntax and only set +/// what they need: +/// +/// ```ignore +/// Wallet::new(WalletConfig { +/// endpoints: vec!["https://shellnet.ackinacki.org".into()], +/// api_token: Some(env!("BM_API_TOKEN").into()), +/// ..Default::default() +/// })?; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct WalletConfig { + /// TVM GraphQL endpoints for primary traffic. + pub endpoints: Vec, + /// Optional archive endpoints used for historical queries that fall + /// off the regular node's retention window. + pub archive_endpoints: Option>, + /// bee-infra backend URL (DEX vouchers, name resolution, etc.). + pub api_url: String, + /// App identifier for bee-infra backend. + pub app_id: String, + /// Bearer token sent as `Authorization: Bearer ` on every + /// TVM GraphQL request (primary + archive). `None` disables auth. + pub api_token: Option, + /// Cap on outbound TVM requests per second. `None` disables + /// rate-limiting. + pub max_rps: Option, +} + +pub(crate) struct WalletContext { + pub(crate) tvm_client: Arc, + /// Optional TVM client pointing to the archive data endpoint. + pub(crate) archive_tvm_client: Option>, + pub(crate) api_url: String, + pub(crate) app_id: String, + pub(crate) rate_limiter: Option, +} + +impl WalletContext { + pub(crate) fn new(config: WalletConfig) -> AppResult { + #[cfg(feature = "wasm")] + console_error_panic_hook::set_once(); + + let WalletConfig { endpoints, archive_endpoints, api_url, app_id, api_token, max_rps } = + config; + + let mut tvm_config = ClientConfig::default(); + tvm_config.network.endpoints = Some(endpoints); + // Disable tvm_client's internal `query_graphql` re-connect loop — + // it spins without sleep on multi-endpoint setups and turns a + // single 502 into a sustained ~60 rps storm against the same BM. + // We do bounded, classified retries one layer up via + // `bee_infra::retry::with_retry_policy`. + tvm_config.network.max_reconnect_timeout = 0; + tvm_config.network.api_token = api_token.clone(); + let client = ClientContext::new(tvm_config).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to create tvm client") + })?; + + let archive_client = archive_endpoints + .map(|eps| { + let mut cfg = ClientConfig::default(); + cfg.network.endpoints = Some(eps); + cfg.network.max_reconnect_timeout = 0; + cfg.network.api_token = api_token.clone(); + ClientContext::new(cfg).map_err(|e| { + crate::errors::AppError::from(e) + .with_context("failed to create archive tvm client") + }) + }) + .transpose()?; + + Ok(Self { + tvm_client: Arc::new(client), + archive_tvm_client: archive_client.map(Arc::new), + api_url, + app_id, + rate_limiter: bee_infra::RateLimiter::optional(max_rps), + }) + } + + pub(crate) async fn acquire(&self) { + bee_infra::maybe_acquire(self.rate_limiter.as_ref()).await; + } +} + +pub(crate) struct WalletClient { + ctx: WalletContext, +} + +impl WalletClient { + pub(crate) fn new(config: WalletConfig) -> AppResult { + Ok(Self { ctx: WalletContext::new(config)? }) + } + + pub(crate) fn deploy(&self) -> modules::deploy::DeployModule<'_> { + modules::deploy::DeployModule::new(&self.ctx) + } + + pub(crate) fn multifactor(&self) -> modules::multifactor::MultifactorModule<'_> { + modules::multifactor::MultifactorModule::new(&self.ctx) + } + + pub(crate) fn miner(&self) -> modules::miner::MinerModule<'_> { + modules::miner::MinerModule::new(&self.ctx) + } + + pub(crate) fn balance(&self) -> modules::balance::BalanceModule<'_> { + modules::balance::BalanceModule::new(&self.ctx) + } + + pub(crate) fn tokens(&self) -> modules::tokens::TokensModule<'_> { + modules::tokens::TokensModule::new(&self.ctx) + } + + pub(crate) fn history(&self) -> modules::history::HistoryModule<'_> { + modules::history::HistoryModule::new(&self.ctx) + } + + pub(crate) fn zkp(&self) -> modules::zkp::ZkpModule<'_> { + modules::zkp::ZkpModule::new(&self.ctx) + } + + pub(crate) fn connect(&self) -> modules::connect::ConnectModule<'_> { + modules::connect::ConnectModule::new(&self.ctx) + } + + pub(crate) fn names(&self) -> modules::names::NamesModule<'_> { + modules::names::NamesModule::new(&self.ctx) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn dex(&self) -> modules::dex::DexModule<'_> { + modules::dex::DexModule::new(&self.ctx) + } +} diff --git a/bee_wallet/src/dapp.rs b/bee_wallet/src/dapp.rs new file mode 100644 index 0000000..6360e1f --- /dev/null +++ b/bee_wallet/src/dapp.rs @@ -0,0 +1,68 @@ +//! Single source of truth for dApp IDs we must pass to the v3 kit, plus helpers +//! for our OWN raw GraphQL queries that address an account. +//! +//! On gql-server `< 1.0.0` (current shellnet/mainnet) `dapp_id` is ignored, so +//! a wrong value is harmless *today* — but it travels into the GraphQL query on +//! `>= 1.0.0` servers, where a wrong/placeholder dApp makes account-bearing +//! calls fail (`518 DappIdRequired` / query miss). Keep the real values here so +//! flipping them later is a one-line change, not a repo-wide hunt. + +use std::sync::Arc; + +use ackinacki_kit::contracts::account::ParamsOfNewContract; +use ackinacki_kit::contracts::dapp::supports_dapp_id; +use ackinacki_kit::contracts::dapp::SystemDapp; +use ackinacki_kit::contracts::error::KitModule; +use ackinacki_kit::tvm_client::ClientContext; + +use crate::errors::AppError; +use crate::errors::AppResult; + +/// Build `ParamsOfNewContract` for a TIP-3 token-family contract +/// (`TokenRoot` / `TokenWallet` / `TokenTransaction`) at `address`. +/// +/// Each token has its OWN dApp, so there is no single correct constant — the +/// caller supplies `dapp_id` (it arrives from the API request next to the token +/// root). Ignored on `< 1.0.0` servers; must be the token's real dApp on +/// `>= 1.0.0`. +pub fn token_contract_params( + address: impl Into, + dapp_id: impl Into, +) -> ParamsOfNewContract { + ParamsOfNewContract::new(address, dapp_id) +} + +/// dApp for DEX contracts (`RootPn` / `RootOracle` / `Oracle` / `PrivateNote` / +/// `Pmp` / ...). +/// +/// TODO(dapp): DEX dApp is **UNCONFIRMED**. The kit dropped its `System` +/// placeholder for DEX (`new_default` removed — "Skip dex defaults"), so we +/// pass it explicitly. Same deal as [`TOKEN_DAPP`]: harmless on `< 1.0.0`, must +/// be the real value before any `>= 1.0.0` server. +pub const DEX_DAPP: SystemDapp = SystemDapp::System; + +/// Build `ParamsOfNewContract` for a DEX-family contract at `address`. +pub fn dex_contract_params(address: impl Into) -> ParamsOfNewContract { + ParamsOfNewContract::new(address, DEX_DAPP) +} + +/// Whether the server reached via `ctx` speaks the v3 (`>= 1.0.0`) GraphQL that +/// addresses accounts by `account_id` + `dapp_id` instead of legacy +/// `account(address:)`. +/// +/// Use this to pick the query form for our OWN raw `net::query` calls — the +/// kit's own contract methods switch internally and need no help. The SDK +/// caches the parsed server version on the endpoint, so call it ONCE and hoist +/// the result out of paging loops. +pub async fn server_uses_dapp_id(ctx: &Arc) -> AppResult { + supports_dapp_id(ctx, KitModule::Event) + .await + .map_err(|e| AppError::from(e).with_context("detect GraphQL server version")) +} + +/// Bare account-id for the v3 `account_id` GraphQL arg: drops the `"0:"` +/// workchain prefix (`"0:hex"` -> `"hex"`). Mirrors the kit's private +/// `account_id_from_address`. +pub fn account_id(address: &str) -> &str { + address.rsplit_once(':').map(|(_, id)| id).unwrap_or(address) +} diff --git a/bee_wallet/src/errors.rs b/bee_wallet/src/errors.rs new file mode 100644 index 0000000..19c2b15 --- /dev/null +++ b/bee_wallet/src/errors.rs @@ -0,0 +1,11 @@ +pub use bee_errors::AppError; +pub use bee_errors::AppResult; + +// Crate-specific From impls that can't go in bee_errors. +use crate::services::zkp::utils::error::ZkCryptoError; + +impl From for AppError { + fn from(e: ZkCryptoError) -> Self { + AppError::new(e.to_string()).with_kind("zk") + } +} diff --git a/bee_wallet/src/infra/mod.rs b/bee_wallet/src/infra/mod.rs new file mode 100644 index 0000000..826dab6 --- /dev/null +++ b/bee_wallet/src/infra/mod.rs @@ -0,0 +1,215 @@ +pub use bee_infra::poll_until; + +/// Like `poll_until`, but calls `rate_limiter.acquire()` before each fetch. +pub async fn poll_until_rated( + rate_limiter: Option<&bee_infra::RateLimiter>, + fetch_data: FetchFn, + predicate: VerifyFn, + max_attempts: Option, + interval_ms: Option, +) -> Result +where + FetchFn: Fn() -> FetchFut, + FetchFut: std::future::Future>, + VerifyFn: Fn(&T) -> bool, + E: From, +{ + poll_until( + || async { + bee_infra::maybe_acquire(rate_limiter).await; + fetch_data().await + }, + predicate, + max_attempts, + interval_ms, + ) + .await +} + +fn is_retryable_tvm_transient(err: &crate::errors::AppError) -> bool { + if err.error_code.as_deref() == Some("52") + && matches!(err.kind.as_deref(), Some("tvm_exit") | Some("tvm")) + { + return true; + } + + if matches!(err.error_code.as_deref(), Some("621") | Some("623")) + && err.kind.as_deref() == Some("tvm") + { + return true; + } + + err.message.contains("exit_code=52") + || err.details.as_deref().is_some_and(|details| { + details.contains("exit_code=52") + || details.contains("tvm_code=621") + || details.contains("tvm_code=623") + }) +} + +pub fn is_confirmation_pending_error(err: &crate::errors::AppError) -> bool { + if err.message.contains("Wait for property. Max") && err.message.contains("attempts reached") { + return true; + } + + err.details.as_deref().is_some_and(|details| { + (details.contains("Wait for property. Max") && details.contains("attempts reached")) + || details.contains("timed out") + || details.contains("timeout") + }) || err.message.contains("timed out") + || err.message.contains("timeout") +} + +/// Classifier for transient HTTP / network errors surfaced by tvm_client. +/// +/// Mirrors the ticket's retryable-class table: retry on 5xx / 408 / 425 / +/// 429 / connect errors / TCP resets / DNS / timeouts. Fail fast on 4xx +/// outside that subset. +/// +/// We work with [`AppError`](crate::errors::AppError) — a freeform string +/// envelope — because tvm_client strips response headers and only the +/// numeric `server_code` (from GraphQL extensions) and the message text +/// reliably propagate. `Retry-After` cannot be observed at this layer +/// (see `bee_infra::retry` doc). +/// +/// Available for opt-in use by callers that want HTTP-transient retries +/// (combine with [`with_retry`] via the `should_retry` parameter). Not +/// wired into any high-level read method by default — that decision +/// belongs to the caller per the retry-policy ticket. +#[allow(dead_code)] +pub fn is_retryable_http_transient(err: &crate::errors::AppError) -> bool { + // tvm_client::ErrorCode::HttpRequestSendError == 11. + if err.error_code.as_deref() == Some("11") { + return true; + } + + let has_transient_network_hint = |s: &str| { + s.contains("connection reset by peer") + || s.contains("dns error") + || s.contains("failed to lookup address information") + || s.contains("client error (SendRequest)") + || s.contains("client error (Connect)") + || s.contains("timed out") + || s.contains("timeout") + || s.contains("server_code: 408") + || s.contains("server_code: 425") + || s.contains("server_code: 429") + || s.contains("server_code: 500") + || s.contains("server_code: 502") + || s.contains("server_code: 503") + || s.contains("server_code: 504") + }; + + has_transient_network_hint(&err.message) + || err.details.as_deref().is_some_and(has_transient_network_hint) +} + +/// Bounded, classified retry around `op`. Preserves the legacy +/// signature so existing callers (deploy, multifactor flows) don't +/// have to change — internally delegates to +/// [`bee_infra::retry::with_retry_policy`]. +/// +/// Built-in classifier: TVM exit codes 52 / 621 / 623 (contract-level +/// transients). Callers can OR in their own `should_retry` predicate +/// — typical use is HTTP-transient hints via +/// [`is_retryable_http_transient`]. +pub async fn with_retry( + op: Op, + max_attempts: u32, + delay_ms: u64, + should_retry: Option, +) -> crate::errors::AppResult +where + Op: FnMut() -> Fut, + Fut: std::future::Future>, + ShouldRetry: Fn(&crate::errors::AppError) -> bool, +{ + if max_attempts == 0 { + return Err(crate::errors::AppError::new("with_retry: max_attempts must be > 0")); + } + + let policy = bee_infra::RetryPolicy { + max_attempts, + max_total: None, + base_delay: std::time::Duration::from_millis(delay_ms), + max_delay: std::time::Duration::from_millis(delay_ms), + jitter: false, + }; + bee_infra::with_retry_policy( + &policy, + None, + |err: &crate::errors::AppError| { + is_retryable_tvm_transient(err) || should_retry.as_ref().is_some_and(|r| r(err)) + }, + op, + ) + .await +} + +pub fn now_ms() -> crate::errors::AppResult { + #[cfg(feature = "wasm")] + { + Ok(js_sys::Date::now() as u64) + } + #[cfg(not(feature = "wasm"))] + { + use std::time::SystemTime; + use std::time::UNIX_EPOCH; + + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| { + crate::errors::AppError::new(format!("SystemTime before UNIX_EPOCH ({e})")) + })? + .as_millis() as u64) + } +} + +pub fn now_secs() -> u64 { + { + #[cfg(feature = "wasm")] + { + (js_sys::Date::now() / 1000.0) as u64 + } + #[cfg(not(feature = "wasm"))] + { + use std::time::SystemTime; + use std::time::UNIX_EPOCH; + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn_bg(fut: F) +where + F: std::future::Future + Send + 'static, +{ + tokio::spawn(fut); +} + +#[cfg(target_arch = "wasm32")] +pub fn spawn_bg(fut: F) +where + F: std::future::Future + 'static, +{ + wasm_bindgen_futures::spawn_local(fut); +} + +/// Checks that a transaction was not aborted and has zero exit code. +/// Returns the original result on success, or an AppError on failure. +pub fn ensure_tx_success( + result: ackinacki_kit::tvm_client::processing::ResultOfSendMessage, + context: &str, +) -> crate::errors::AppResult { + let aborted = result.aborted.unwrap_or(false); + let exit_code = result.exit_code.unwrap_or(0); + + if aborted || exit_code > 0 { + return Err(crate::errors::AppError::new(format!( + "{context}: tx aborted={aborted}, exit_code={exit_code}", + ))); + } + + Ok(result) +} diff --git a/bee_wallet/src/lib.rs b/bee_wallet/src/lib.rs new file mode 100644 index 0000000..b6ae030 --- /dev/null +++ b/bee_wallet/src/lib.rs @@ -0,0 +1,98 @@ +mod adapters; +mod client; +pub mod dapp; +pub mod errors; +mod infra; +mod modules; +mod progress; +mod services; +mod types; + +pub use ackinacki_kit::contracts::mvsystem::mirror::ParamsOfDeployMultifactor as PreparedDeployParams; +pub use ackinacki_kit::contracts::mvsystem::root::ResultOfGetMvMultifactorAddress; +pub use ackinacki_kit::tvm_client::processing::ResultOfSendMessage; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::balances::ResultOfGetNativeBalances; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::balances::ResultOfGetTokensBalances; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::deploy::ResultOfDeployMultifactor; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::miner::ResultOfGetMinerDetails; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::multifactor::ResultOfCheckNameAvailability; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::multifactor::ResultOfGetMultifactorDetails; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::tx::ResultOfGetHistory; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::dto::zkp::ResultOfAddZKPFactor; +#[cfg(not(target_arch = "wasm32"))] +pub use adapters::native::Wallet; +#[cfg(feature = "wasm")] +pub use adapters::wasm; +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub use adapters::wasm::Wallet; +pub use client::WalletConfig; +pub use infra::now_secs; +pub use progress::ProgressEvent; +pub use progress::ProgressSink; +pub use types::BuyShellsReq; +pub use types::ClaimUsdcReq; +pub use types::ClaimUsdcResult; +pub use types::DeleteZkpFactorByItselfReq; +pub use types::GetBalanceByTokenRootReq; +pub use types::GetEPKExpireReq; +pub use types::GetEPKExpireRes; +pub use types::GetMySellOrdersReq; +pub use types::GetMySellOrdersResult; +pub use types::MigrateTip3UsdcReq; +pub use types::NacklRedeemRateResult; +pub use types::ParamsGetMirrorAddress; +pub use types::ParamsOfGetMultifactorAddress; +pub use types::ParamsOfGetMultifactorInfo; +pub use types::ParamsOfUpdateContract; +pub use types::RedeemNacklReq; +pub use types::ResultGetMirrorAddress; +pub use types::ResultOfBlockchainWrite; +pub use types::ResultOfGetBalanceByTokenRoot; +pub use types::ResultOfGetMultifactorInfo; +pub use types::SellOrderInfo; +pub use types::SellShellsReq; +pub use types::SellShellsResult; +pub use types::SendTokensDirectReq; +pub use types::SendTokensReq; +pub use types::UpdateMultifactorZkIdReq; +pub use types::WithdrawPopitgameRewardsReq; + +#[cfg(not(target_arch = "wasm32"))] +pub use crate::modules::dex::ParamsOfGenerateVoucher; +pub use crate::modules::names::ResultOfValidateWalletName; +pub use crate::modules::names::WalletNameErrorCode; +pub use crate::services::balance::ParamsOfGetNativeBalances; +pub use crate::services::balance::ParamsOfGetTokensBalances; +pub use crate::services::balance::TokenRef; +pub use crate::services::connect::encode_challenge_response_message; +pub use crate::services::connect::ChallengeResponseBody; +pub use crate::services::connect::ClientDisconnectMessageBody; +pub use crate::services::connect::ConnectPayload; +pub use crate::services::connect::ConnectSessionMessage; +pub use crate::services::connect::ParamsOfAcceptSharedKeyConnect; +pub use crate::services::connect::ParamsOfDestroyConnectProfile; +pub use crate::services::connect::ParamsOfQuerySessionMessages; +pub use crate::services::connect::ResultOfAcceptConnect; +pub use crate::services::connect::ResultOfQuerySessionMessages; +pub use crate::services::connect::SetMiningKeysMessageBody; +pub use crate::services::connect::SignChallengeBody; +pub use crate::services::connect::WalletHelloMetadata; +pub use crate::services::deploy::ParamsOfDeployMiner; +pub use crate::services::deploy::ParamsOfDeployMultifactor; +pub use crate::services::deploy::ParamsOfPrepareDeploy; +pub use crate::services::miner::ParamsOfDelMiningKey; +pub use crate::services::miner::ParamsOfSetMiningKeys; +pub use crate::services::multifactor::cmd::ParamsOfChangeSeedPhrase; +pub use crate::services::transaction::history::ParamsOfGetHistory; +pub use crate::services::zkp::ParamsOfAddZKPFactor; +pub use crate::services::zkp::ZkLoginCompleteWithProverParams; +pub use crate::services::zkp::ZkLoginCompleteWithProverResult; +pub use crate::services::zkp::ZkLoginPrepareResult; diff --git a/bee_wallet/src/modules/balance.rs b/bee_wallet/src/modules/balance.rs new file mode 100644 index 0000000..43ca611 --- /dev/null +++ b/bee_wallet/src/modules/balance.rs @@ -0,0 +1,75 @@ +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::services; +use crate::services::balance::ParamsOfGetNativeBalances; +use crate::services::balance::ParamsOfGetTokensBalances; +use crate::services::balance::ResultOfGetNativeBalances; +use crate::services::balance::ResultOfGetTokensBalances; +use crate::types::GetBalanceByTokenRootReq; +use crate::types::ResultOfGetBalanceByTokenRoot; + +pub(crate) struct BalanceModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> BalanceModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub async fn get_native_balances( + &self, + params: ParamsOfGetNativeBalances, + ) -> AppResult { + self.ctx.acquire().await; + services::balance::get_native_balances(self.ctx.tvm_client.clone(), params).await + } + + pub async fn get_tokens_balances( + &self, + params: ParamsOfGetTokensBalances, + ) -> AppResult { + self.ctx.acquire().await; + services::balance::get_tokens_balances(self.ctx.tvm_client.clone(), params).await + } + + pub async fn get_balance_by_token_root( + &self, + params: GetBalanceByTokenRootReq, + ) -> AppResult { + self.ctx.acquire().await; + match params.token_root.as_ref() { + // NACKL + "1" => { + let (own_balance, locked_balance) = services::balance::get_nackl_balance( + self.ctx.tvm_client.clone(), + params.multifactor_address, + ) + .await?; + Ok(ResultOfGetBalanceByTokenRoot { + own_balance, + locked_balance: Some(locked_balance), + }) + } + // SHELL + "2" => { + let own_balance = services::balance::get_shell_balance( + self.ctx.tvm_client.clone(), + params.multifactor_address, + ) + .await?; + Ok(ResultOfGetBalanceByTokenRoot { own_balance, locked_balance: None }) + } + token_root => { + let balance = services::balance::get_token_balance( + self.ctx.tvm_client.clone(), + params.multifactor_address, + token_root.to_string(), + params.token_dapp, + ) + .await?; + Ok(ResultOfGetBalanceByTokenRoot { own_balance: balance, locked_balance: None }) + } + } + } +} diff --git a/bee_wallet/src/modules/connect.rs b/bee_wallet/src/modules/connect.rs new file mode 100644 index 0000000..c2d7c32 --- /dev/null +++ b/bee_wallet/src/modules/connect.rs @@ -0,0 +1,426 @@ +use ackinacki_kit::contracts::account::AccountStatus; +use ackinacki_kit::contracts::account::ParamsOfWaitAccount; +use ackinacki_kit::contracts::authservice::profile::AuthProfile; +use ackinacki_kit::contracts::authservice::profile::ParamsOfQueryProfileEvents; +use ackinacki_kit::contracts::authservice::root::AuthServiceRoot; +use ackinacki_kit::contracts::authservice::root::ParamsOfDeployProfileByIdentity; +use ackinacki_kit::contracts::authservice::root::ParamsOfGetProfileAddress; +use ackinacki_kit::contracts::authservice::root::ParamsOfHashMultifactor; +use ackinacki_kit::contracts::authservice::root::ParamsOfHashPubkey; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSubmitTransaction; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::tvm_client::abi::Signer; + +use crate::client::WalletContext; +use crate::errors::AppError; +use crate::errors::AppResult; +use crate::services; + +const DESTROY_PROFILE_TRANSFER_NANO: u128 = 1_000_000_000; + +pub(crate) struct ConnectModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> ConnectModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub fn decode_connect_payload_b64url( + &self, + payload_b64: impl AsRef, + ) -> AppResult { + services::connect::decode_connect_payload_b64url(payload_b64) + } + + pub async fn accept_shared_key_connect( + &self, + params: services::connect::ParamsOfAcceptSharedKeyConnect, + ) -> AppResult { + self.ctx.acquire().await; + services::connect::validate_shared_key_connect_payload( + ¶ms.payload, + crate::infra::now_secs(), + )?; + + self.accept_connect_common( + params.payload, + params.wallet_name, + params.wallet_address, + params.client_dh_public, + params.max_attempts, + params.interval_ms, + params.challenge_signature, + params.challenge_epk_public, + ) + .await + } + + pub async fn destroy_connect_profile( + &self, + params: services::connect::ParamsOfDestroyConnectProfile, + ) -> AppResult { + self.ctx.acquire().await; + let profile = + AuthProfile::new_default(self.ctx.tvm_client.clone(), params.profile_address.clone()); + if !profile.is_deployed().await { + return Ok(crate::ResultOfBlockchainWrite::default()); + } + + let root = connect_auth_root(self.ctx); + let details = profile + .get_details() + .await + .map_err(|e| AppError::from(e).with_context("connect destroy profile.get_details"))?; + let expected_multifactor_hash = root + .hash_multifactor(ParamsOfHashMultifactor { + multifactor: params.multifactor_address.clone(), + }) + .await + .map_err(|e| AppError::from(e).with_context("connect destroy hash_multifactor"))? + .hash; + + if !details.multifactor_hash.eq_ignore_ascii_case(&expected_multifactor_hash) { + return Err(AppError::new(format!( + "AuthProfile multifactor mismatch: expected `{expected_multifactor_hash}`, got `{}`", + details.multifactor_hash + )) + .with_kind("connect")); + } + + let multifactor = + Multifactor::new_default(self.ctx.tvm_client.clone(), params.multifactor_address); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public), + }) + .await + .map_err(|e| AppError::from(e).with_context("connect destroy get_epk_expire_at"))? + .epk_expire_at; + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: params.profile_address, + value: DESTROY_PROFILE_TRANSFER_NANO, + bounce: false, + all_balance: false, + epk_expire_at, + payload: String::new(), + ..Default::default() + }, + Signer::Keys { keys: params.signer_keys }, + ) + .await + .map_err(|e| { + AppError::from(e).with_context("connect destroy multifactor.submit_transaction") + })?; + let result = crate::infra::ensure_tx_success(result, "connect destroy profile")?; + + let message_ids = result.message_hash.into_iter().collect(); + Ok(crate::ResultOfBlockchainWrite { message_ids, ..Default::default() }) + } + + pub async fn query_session_messages( + &self, + params: services::connect::ParamsOfQuerySessionMessages, + ) -> AppResult { + self.ctx.acquire().await; + services::connect::validate_session_message_params( + ¶ms.session_id, + ¶ms.description, + )?; + + let root = connect_auth_root(self.ctx); + let profile_address = root + .get_profile_address(ParamsOfGetProfileAddress { + description: params.description.clone(), + }) + .await + .map_err(|e| AppError::from(e).with_context("connect query get_profile_address"))? + .profile; + let profile = AuthProfile::new_default(self.ctx.tvm_client.clone(), &profile_address); + + if !profile.is_deployed().await { + return Ok(services::connect::ResultOfQuerySessionMessages { + profile_address, + messages: Vec::new(), + next_before: None, + updated_session_state: None, + }); + } + + let limit = services::connect::normalize_query_session_messages_limit(params.limit); + let query_result = profile + .query_context_added_events(ParamsOfQueryProfileEvents { + created_at_from: params.created_at_from, + limit: Some(limit), + before: params.before, + }) + .await + .map_err(|e| AppError::from(e).with_context("connect query_context_added_events"))?; + let session_id = params.session_id.clone(); + let next_before = query_result.page_info.cursor; + + // Mutable re-key state for c2w messages. + // Track whether any new message was successfully re-keyed + decrypted. + let mut current_state = params.session_state.clone(); + let mut had_successful_rekey = false; + + let mut messages = Vec::new(); + for record in query_result.events { + // Pre-parse envelope to check for dh_public on c2w messages + let envelope_opt = serde_json::from_str::(&record.data.text).ok(); + + let needs_rekey = envelope_opt.as_ref().is_some_and(|envelope| { + let is_c2w = envelope.get("dir").and_then(|v| v.as_str()) == Some("c2w"); + let has_dh = envelope.get("dh_public").and_then(|v| v.as_str()).is_some(); + let matches = + envelope.get("session_id").and_then(|v| v.as_str()) == Some(&session_id); + is_c2w && has_dh && matches + }); + + if needs_rekey { + if let Some(state) = ¤t_state { + let envelope = envelope_opt.as_ref().unwrap(); + let peer_dh_pub = envelope.get("dh_public").and_then(|v| v.as_str()).unwrap(); + let envelope_seq = envelope.get("seq").and_then(|v| v.as_u64()).unwrap_or(0); + + // Try rekey → decrypt with new root + if let Ok(rekey) = bee_connect::dh::rekey_inbound( + state, + peer_dh_pub, + &session_id, + envelope_seq, + ) { + // Try decrypt with re-keyed root + let parsed_with_rekey = + services::connect::parse_connect_session_message_with_secret( + &record.data.text, + &session_id, + Some(&rekey.message_encryption_root), + record.event.id.clone(), + record.event.created_at, + ); + + if let Some(mut msg) = parsed_with_rekey { + // parse returns Some for any valid envelope, even + // when body decrypt fails. Only advance the DH + // state if the body was actually decrypted — at + // least one typed field must be populated. + let body_decrypted = msg.wallet_hello.is_some() + || msg.set_mining_keys.is_some() + || msg.client_disconnect.is_some() + || msg.sign_challenge.is_some() + || msg.challenge_response.is_some(); + + if body_decrypted { + msg.session_state_after = Some(rekey.updated_state.clone()); + current_state = Some(rekey.updated_state); + had_successful_rekey = true; + messages.push(msg); + continue; + } + } + // Body not decrypted after rekey → message already + // processed or corrupted. Do NOT advance state — + // fall through to best-effort parse without rekey. + } + } + } + + // Non-c2w message or already-processed c2w: parse with current root + let encryption_root = current_state.as_ref().map(|s| s.encryption_root.as_str()); + let parsed = if let Some(secret) = encryption_root { + services::connect::parse_connect_session_message_with_secret( + &record.data.text, + &session_id, + Some(secret), + record.event.id, + record.event.created_at, + ) + } else { + services::connect::parse_connect_session_message( + &record.data.text, + &session_id, + record.event.id, + record.event.created_at, + ) + }; + if let Some(msg) = parsed { + messages.push(msg); + } + } + + // Only return updated state if we actually re-keyed for new messages + let updated_session_state = + if had_successful_rekey { current_state } else { params.session_state }; + + Ok(services::connect::ResultOfQuerySessionMessages { + profile_address, + messages, + next_before, + updated_session_state, + }) + } +} + +impl<'a> ConnectModule<'a> { + #[allow(clippy::too_many_arguments)] + async fn accept_connect_common( + &self, + payload: services::connect::ConnectPayload, + wallet_name: String, + wallet_address: String, + client_dh_public: String, + max_attempts: Option, + interval_ms: Option, + challenge_signature: Option, + challenge_epk_public: Option, + ) -> AppResult { + // ── DH key exchange ── + let wallet_dh = bee_connect::dh::generate_dh_keypair() + .map_err(|e| AppError::new(e).with_kind("connect"))?; + let shared_secret = + bee_connect::dh::compute_shared_secret(&wallet_dh.secret_hex, &client_dh_public) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + let session_keys = + bee_connect::dh::derive_session_keys(&shared_secret, &payload.session_id) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + + // Derived signing keypair for on-chain operations + let owner_keys = ackinacki_kit::tvm_client::crypto::KeyPair { + public: session_keys.signing_public_hex.clone(), + secret: session_keys.signing_secret_hex.to_string(), + }; + + let root = connect_auth_root(self.ctx); + let description = payload.description.clone(); + let profile_address = root + .get_profile_address(ParamsOfGetProfileAddress { description: description.clone() }) + .await + .map_err(|e| AppError::from(e).with_context("connect get_profile_address"))? + .profile; + let profile = AuthProfile::new_default(self.ctx.tvm_client.clone(), &profile_address); + + let pubkey_hash = root + .hash_pubkey(ParamsOfHashPubkey { pubkey: format!("0x{}", owner_keys.public) }) + .await + .map_err(|e| AppError::from(e).with_context("connect hash_pubkey"))? + .hash; + let multifactor_hash = root + .hash_multifactor(ParamsOfHashMultifactor { multifactor: wallet_address.clone() }) + .await + .map_err(|e| AppError::from(e).with_context("connect hash_multifactor"))? + .hash; + + let mut deploy_message_id = None; + if !profile.is_deployed().await { + let deploy_result = root + .deploy_profile_by_identity( + ParamsOfDeployProfileByIdentity { + pubkey: format!("0x{}", owner_keys.public), + multifactor_address: wallet_address.clone(), + description, + }, + Signer::None, + ) + .await + .map_err(|e| { + AppError::from(e).with_context("connect deploy_profile_by_identity") + })?; + + let deploy_result = crate::infra::ensure_tx_success( + deploy_result, + "connect deploy_profile_by_identity", + )?; + deploy_message_id = deploy_result.message_hash.clone(); + + wait_profile_active(&profile, max_attempts, interval_ms).await?; + } + + let details = profile + .get_details() + .await + .map_err(|e| AppError::from(e).with_context("connect profile.get_details"))?; + if details.pubkey_hash != pubkey_hash { + return Err(AppError::new(format!( + "AuthProfile owner mismatch: expected pubkey_hash `{pubkey_hash}`, got `{}`", + details.pubkey_hash + )) + .with_kind("connect")); + } + if !details.multifactor_hash.eq_ignore_ascii_case(&multifactor_hash) { + return Err(AppError::new(format!( + "AuthProfile multifactor mismatch: expected multifactor_hash `{multifactor_hash}`, got `{}`", + details.multifactor_hash + )) + .with_kind("connect")); + } + + let hello_seq = crate::infra::now_ms().map_err(|e| { + AppError::new(format!("wallet_hello now_ms ({e})")).with_kind("connect") + })?; + + let wallet_hello_json = services::connect::encode_wallet_hello_message( + &payload.session_id, + &wallet_name, + &wallet_address, + &session_keys.encryption_root_hex, + &wallet_dh.public_hex, + hello_seq, + payload.nonce.as_deref(), + challenge_signature.as_deref(), + challenge_epk_public.as_deref(), + )?; + + let hello_result = profile + .add_context_text(&wallet_hello_json, Signer::Keys { keys: owner_keys.clone() }) + .await + .map_err(|e| AppError::from(e).with_context("connect add_context_text wallet_hello"))?; + let hello_result = + crate::infra::ensure_tx_success(hello_result, "connect wallet_hello add_context")?; + + let mut session_state = bee_connect::dh::create_initial_state( + &session_keys, + &wallet_dh.secret_hex, + &client_dh_public, + ); + session_state.last_sent_seq = hello_seq; + + Ok(services::connect::ResultOfAcceptConnect { + profile_address, + deploy_message_id, + wallet_hello_message_id: hello_result.message_hash, + wallet_hello_json, + session_state, + }) + } +} + +async fn wait_profile_active( + profile: &AuthProfile, + max_attempts: Option, + interval_ms: Option, +) -> AppResult<()> { + let attempts = max_attempts.unwrap_or(60).min(u8::MAX as u32) as u8; + let attempts_timeout = interval_ms.unwrap_or(1_000); + + profile + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::Active, + attempts: Some(attempts), + attempts_timeout: Some(attempts_timeout), + }) + .await + .map_err(|e| AppError::from(e).with_context("connect wait profile active"))?; + + Ok(()) +} + +fn connect_auth_root(ctx: &WalletContext) -> AuthServiceRoot { + AuthServiceRoot::new_default(ctx.tvm_client.clone()) +} diff --git a/bee_wallet/src/modules/deploy.rs b/bee_wallet/src/modules/deploy.rs new file mode 100644 index 0000000..f009ddf --- /dev/null +++ b/bee_wallet/src/modules/deploy.rs @@ -0,0 +1,370 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::account::AccountStatus; +use ackinacki_kit::contracts::account::ParamsOfWaitAccount; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetMvMultifactor; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use bee_crypto::Crypto as BeeCrypto; + +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::infra::spawn_bg; +use crate::progress::ProgressEvent; +use crate::progress::ProgressSink; +use crate::services; +use crate::services::boost::ParamsOfUpdateBoost; +use crate::services::deploy::prepare_deploy_params; +pub use crate::services::deploy::ParamsOfDeployMiner; +pub use crate::services::deploy::ParamsOfDeployMultifactor; +pub use crate::services::deploy::ResultOfDeployMultifactor; +use crate::services::resolvers; +use crate::services::resolvers::MirrorSelector; +use crate::services::validate_name::WalletNameError; + +pub(crate) struct DeployModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> DeployModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub async fn prepare_multifactor_deploy_params( + &self, + params: prepare_deploy_params::ParamsOfPrepareDeploy, + ) -> AppResult { + self.ctx.acquire().await; + let (deploy_params, _wasm_hash) = + prepare_deploy_params::prepare_multifactor_deploy(self.ctx.tvm_client.clone(), params) + .await?; + Ok(deploy_params) + } + + pub async fn deploy_multifactor( + &self, + params: ParamsOfDeployMultifactor, + should_update_contract_flags: bool, + progress: Option, + ) -> AppResult { + self.ctx.acquire().await; + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy", "preparing")); + + let result = self + .run_deploy_multifactor(params, should_update_contract_flags, progress.clone()) + .await; + if let Ok(ref r) = result { + emit_deploy_outcome( + progress.as_ref(), + "deploy", + r.pending_stage.as_deref(), + r.pending_reason.as_deref(), + ); + } + crate::progress::report_failure(progress.as_ref(), "deploy", &result, None); + result + } + + async fn run_deploy_multifactor( + &self, + mut params: ParamsOfDeployMultifactor, + should_update_contract_flags: bool, + progress: Option, + ) -> AppResult { + params.wallet_name = params.wallet_name.trim().to_lowercase(); + services::validate_name::verify_wallet_name(¶ms.wallet_name).map_err(|err| { + let details = match err { + WalletNameError::InvalidCharacters => { + "name must contain only lowercase latin letters, digits, '_' or '-'" + } + WalletNameError::ConsecutiveHyphens => "name cannot contain consecutive hyphens", + WalletNameError::ConsecutiveUnderscores => { + "name cannot contain consecutive underscores" + } + WalletNameError::StartsWithSymbol => "name cannot start with '-' or '_'", + WalletNameError::TooLong => "name must be at most 39 characters", + WalletNameError::TooShort => "name must be at least 4 characters", + }; + + crate::errors::AppError::new("Invalid wallet name") + .with_code("INVALID_WALLET_NAME") + .with_details(details) + })?; + + let crypto = BeeCrypto::from_client_context(self.ctx.tvm_client.clone()); + let generated = crypto + .gen_mnemonic_and_derive_keys() + .map_err(|e| e.with_context("Failed to gen seed"))?; + let phrase = generated.phrase; + let owner_keys = KeyPair { + public: generated.keys.public.clone(), + secret: generated.keys.secret.clone(), + }; + + let root_contract = MobileVerifiersRoot::new_default(self.ctx.tvm_client.clone()); + let multifactor = root_contract + .get_mv_multifactor(ParamsOfGetMvMultifactor { + public: format!("0x{}", owner_keys.public.clone()), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Failed to get multifactor address") + })?; + + let multifactor_arc = Arc::new(multifactor); + let (deploy_params, _wasm_hash) = prepare_deploy_params::prepare_multifactor_deploy( + self.ctx.tvm_client.clone(), + prepare_deploy_params::ParamsOfPrepareDeploy { + zkid: params.zkid, + password: params.password.clone(), + proof: params.proof, + epk: params.epk.clone(), + esk: params.esk.clone(), + jwk_modulus: params.jwk_modulus, + jwk_modulus_expire_at: params.jwk_modulus_expire_at, + index_mod_4: params.index_mod_4, + iss_base_64: params.iss_base_64, + header_base_64: params.header_base_64, + epk_expire_at: params.epk_expire_at, + keys: owner_keys.clone(), + kid: params.kid, + wallet_name: params.wallet_name, + multifactor_address: multifactor_arc.address().to_string(), + sub: params.sub, + }, + ) + .await + .map_err(|e| e.with_context("Failed to prepare params"))?; + + let deploy_multifactor_mirror = resolvers::resolve_mirror( + self.ctx.tvm_client.clone(), + MirrorSelector::ForPubkey(&owner_keys.public), + ) + .map_err(|e| e.with_context("Failed to get mirror"))?; + + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy", "deploying")); + let deploy_result = deploy_multifactor_mirror + .deploy_multifactor(deploy_params.clone(), Signer::Keys { keys: owner_keys.clone() }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to deploy"))?; + + let deploy_result = crate::infra::ensure_tx_success(deploy_result, "deploy multifactor")?; + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy", "submitted")); + #[cfg(not(target_arch = "wasm32"))] + let password_hash = crypto.hash_password(params.password)?; + #[cfg(target_arch = "wasm32")] + let password_hash = crypto.hash_password(params.password).await?; + let mut pending_stage = None; + let mut pending_reason = None; + + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy", "activating")); + let wait_multifactor_result = multifactor_arc + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::Active, + attempts: Some(255), + attempts_timeout: Some(200), + }) + .await; + if let Err(e) = wait_multifactor_result { + let err = crate::errors::AppError::from(e).with_context("Wait multifactor active"); + if crate::infra::is_confirmation_pending_error(&err) { + pending_stage = Some("multifactor_activation".to_string()); + pending_reason = Some(err.message.clone()); + } else { + return Err(err); + } + } else { + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy", "active")); + } + let mut message_ids = Vec::new(); + if let Some(message_id) = deploy_result.message_hash.clone() { + message_ids.push(message_id); + } + if pending_stage.is_none() && should_update_contract_flags { + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy", "configuring")); + let mf = multifactor_arc.clone(); + let update_contract_message_ids = crate::infra::with_retry( + || { + let mf = mf.clone(); + let owner_keys = owner_keys.clone(); + async move { + crate::modules::multifactor::update_contract_flags(mf, owner_keys, true) + .await + } + }, + 3, + 1_000, + None:: bool>, + ) + .await; + match update_contract_message_ids { + Ok(ids) => message_ids.extend(ids), + Err(err) if crate::infra::is_confirmation_pending_error(&err) => { + pending_stage = Some("contract_flags".to_string()); + pending_reason = Some(err.message.clone()); + } + Err(err) => return Err(err), + } + } + + Ok(ResultOfDeployMultifactor { + name: deploy_params.name.clone(), + address: multifactor_arc.address().to_string(), + message_id: deploy_result.message_hash.clone(), + message_ids, + pending_stage, + pending_reason, + password_hash, + phrase, + pubkey: owner_keys.public.clone(), + signing_keys: KeyPair { public: params.epk, secret: params.esk }, + }) + } + + pub async fn deploy_miner( + &self, + params: ParamsOfDeployMiner, + progress: Option, + ) -> AppResult { + self.ctx.acquire().await; + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy_miner", "resolving")); + let result = self.run_deploy_miner(params, progress.clone()).await; + if let Ok(ref r) = result { + emit_deploy_outcome( + progress.as_ref(), + "deploy_miner", + r.pending_stage.as_deref(), + r.pending_reason.as_deref(), + ); + } + crate::progress::report_failure(progress.as_ref(), "deploy_miner", &result, None); + result + } + + async fn run_deploy_miner( + &self, + params: ParamsOfDeployMiner, + progress: Option, + ) -> AppResult { + let miner = resolvers::resolve_miner_for_multifactor( + self.ctx.tvm_client.clone(), + ¶ms.multifactor_address, + ) + .await + .map_err(|e| e.with_context("Failed to resolve miner for multifactor"))?; + + if miner.is_deployed().await { + return Ok(crate::ResultOfBlockchainWrite::default()); + } + + let ephemeral_signer = Signer::Keys { keys: params.signer_keys.clone() }; + let multifactor = Multifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + ); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + let multifactor_arc = Arc::new(multifactor); + + crate::progress::emit(progress.as_ref(), ProgressEvent::new("deploy_miner", "deploying")); + let deploy_miner_result = services::miner::cmd::deploy_miner( + self.ctx.tvm_client.clone(), + &multifactor_arc, + &ephemeral_signer, + epk_expire_at, + ) + .await + .map_err(|e| e.with_context("Failed to deploy miner"))?; + + let api_url = self.ctx.api_url.clone(); + spawn_bg(async move { + let _ = services::boost::update_boost_code(ParamsOfUpdateBoost { + api_url, + multifactor_address: multifactor_arc.address().to_string(), + }) + .await; + }); + + Ok(crate::ResultOfBlockchainWrite { + message_ids: deploy_miner_result.message_ids, + pending_stage: deploy_miner_result.pending_stage, + pending_reason: deploy_miner_result.pending_reason, + }) + } +} + +/// Map a deploy result's pending markers to a terminal progress event: +/// `:pending` (carrying the reason, or the stage as a fallback) when the +/// chain didn't confirm within the allotted wait, otherwise `:confirmed`. +fn emit_deploy_outcome( + progress: Option<&ProgressSink>, + op: &str, + pending_stage: Option<&str>, + pending_reason: Option<&str>, +) { + match pending_stage { + Some(stage) => crate::progress::emit( + progress, + ProgressEvent::new(op, "pending") + .with_detail(pending_reason.unwrap_or(stage).to_string()), + ), + None => crate::progress::emit(progress, ProgressEvent::new(op, "confirmed")), + } +} + +#[cfg(test)] +mod tests { + use futures::StreamExt; + + use super::emit_deploy_outcome; + use crate::progress::ProgressEvent; + + #[tokio::test] + async fn outcome_confirmed_when_not_pending() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + emit_deploy_outcome(Some(&tx), "deploy", None, None); + drop(tx); + let events: Vec = rx.collect().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].op, "deploy"); + assert_eq!(events[0].stage, "confirmed"); + } + + #[tokio::test] + async fn outcome_pending_carries_reason() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + emit_deploy_outcome( + Some(&tx), + "deploy", + Some("multifactor_activation"), + Some("still waiting"), + ); + drop(tx); + let events: Vec = rx.collect().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].stage, "pending"); + assert_eq!(events[0].detail.as_deref(), Some("still waiting")); + } + + #[tokio::test] + async fn outcome_pending_falls_back_to_stage_when_no_reason() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + emit_deploy_outcome(Some(&tx), "deploy_miner", Some("contract_flags"), None); + drop(tx); + let events: Vec = rx.collect().await; + assert_eq!(events[0].op, "deploy_miner"); + assert_eq!(events[0].stage, "pending"); + assert_eq!(events[0].detail.as_deref(), Some("contract_flags")); + } +} diff --git a/bee_wallet/src/modules/dex.rs b/bee_wallet/src/modules/dex.rs new file mode 100644 index 0000000..a25e02b --- /dev/null +++ b/bee_wallet/src/modules/dex.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::processing::ResultOfSendMessage; + +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::services; + +pub struct ParamsOfGenerateVoucher { + pub multifactor_address: String, + pub token_type: u32, + pub amount: u64, + pub is_fee: bool, + /// `skUCommit` to embed in the `RootPN.generateVoucher` payload. For the + /// halo2-bound production flow pass `format!("0x{}", poseidon_hex)`. Pass + /// `"0"` when the call doesn't need to bind to a specific halo2 prover. + pub sk_u_commit: String, + pub signer_keys: KeyPair, +} + +pub(crate) struct DexModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> DexModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub async fn generate_voucher( + &self, + params: ParamsOfGenerateVoucher, + ) -> AppResult { + self.ctx.acquire().await; + + let multifactor = Multifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + ); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let multifactor_arc = Arc::new(multifactor); + + services::dex::generate_voucher( + self.ctx.tvm_client.clone(), + &multifactor_arc, + services::dex::ParamsOfGenerateVoucher { + multifactor_address: params.multifactor_address, + token_type: params.token_type, + amount: params.amount, + is_fee: params.is_fee, + sk_u_commit: params.sk_u_commit, + signer_keys: params.signer_keys, + epk_expire_at, + mirror_index: 0, + }, + ) + .await + } +} diff --git a/bee_wallet/src/modules/history.rs b/bee_wallet/src/modules/history.rs new file mode 100644 index 0000000..1fdb6df --- /dev/null +++ b/bee_wallet/src/modules/history.rs @@ -0,0 +1,27 @@ +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::services; +use crate::services::transaction::history::ParamsOfGetHistory; +use crate::services::transaction::history::ResultOfGetHistory; + +pub(crate) struct HistoryModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> HistoryModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + /// Unified history: routes to ECC or TIP-3 based on token_id + pub async fn get_history(&self, params: ParamsOfGetHistory) -> AppResult { + self.ctx.acquire().await; + services::transaction::history::get_history( + self.ctx.tvm_client.clone(), + self.ctx.archive_tvm_client.clone(), + params, + ) + .await + .map_err(|e| e.with_context("Failed to get history")) + } +} diff --git a/bee_wallet/src/modules/miner.rs b/bee_wallet/src/modules/miner.rs new file mode 100644 index 0000000..dc9e610 --- /dev/null +++ b/bee_wallet/src/modules/miner.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::abi::Signer; + +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::services; +use crate::services::miner::query::ParamsOfGetMinerDetails; +use crate::services::miner::query::ResultOfGetMinerDetails; + +pub(crate) struct MinerModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> MinerModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub async fn get_details_by_multifactor_address( + &self, + multifactor_address: String, + ) -> AppResult { + self.ctx.acquire().await; + services::miner::query::get_details_by_multifactor_address( + self.ctx.tvm_client.clone(), + ParamsOfGetMinerDetails { multifactor_address }, + ) + .await + } + + pub async fn get_miner_address(&self, multifactor_address: &str) -> AppResult { + self.ctx.acquire().await; + let miner = services::resolvers::resolve_miner_for_multifactor( + self.ctx.tvm_client.clone(), + multifactor_address, + ) + .await?; + Ok(miner.address().to_string()) + } + + pub async fn set_mining_keys( + &self, + params: crate::services::miner::ParamsOfSetMiningKeys, + ) -> AppResult { + self.ctx.acquire().await; + let miner = services::resolvers::resolve_miner_for_multifactor( + self.ctx.tvm_client.clone(), + ¶ms.multifactor_address, + ) + .await + .map_err(|e| e.with_context("Failed to resolve miner for multifactor"))?; + + if !miner.is_deployed().await { + return Err(crate::errors::AppError::new("Miner is not deployed")); + } + + let ephemeral_signer = Signer::Keys { keys: params.signer_keys.clone() }; + let multifactor = Multifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + ); + let miner_arc = Arc::new(miner); + let multifactor_arc = Arc::new(multifactor); + + let epk_expire_at = match params.epk_expire_at { + Some(v) => v, + None => { + multifactor_arc + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Get EPK expire at") + })? + .epk_expire_at + } + }; + let app_id = if params.app_id.is_empty() { self.ctx.app_id.clone() } else { params.app_id }; + + let message_ids = services::miner::cmd::set_mining_keys( + self.ctx.tvm_client.clone(), + &miner_arc, + &multifactor_arc, + params.mining_pubkey.clone(), + epk_expire_at, + app_id, + &ephemeral_signer, + ) + .await + .map_err(|e| e.with_context("Failed to set miner owner pubkey"))?; + + Ok(crate::ResultOfBlockchainWrite { message_ids, ..Default::default() }) + } + + pub async fn del_mining_key( + &self, + params: crate::services::miner::ParamsOfDelMiningKey, + ) -> AppResult { + self.ctx.acquire().await; + let miner = services::resolvers::resolve_miner_for_multifactor( + self.ctx.tvm_client.clone(), + ¶ms.multifactor_address, + ) + .await + .map_err(|e| e.with_context("Failed to resolve miner for multifactor"))?; + + if !miner.is_deployed().await { + return Err(crate::errors::AppError::new("Miner is not deployed")); + } + + let ephemeral_signer = Signer::Keys { keys: params.signer_keys.clone() }; + let multifactor = Multifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + ); + let miner_arc = Arc::new(miner); + let multifactor_arc = Arc::new(multifactor); + + let epk_expire_at = match params.epk_expire_at { + Some(v) => v, + None => { + multifactor_arc + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Get EPK expire at") + })? + .epk_expire_at + } + }; + let app_id = if params.app_id.is_empty() { self.ctx.app_id.clone() } else { params.app_id }; + + let message_ids = services::miner::cmd::del_mining_key( + self.ctx.tvm_client.clone(), + &miner_arc, + &multifactor_arc, + epk_expire_at, + app_id, + params.wait, + &ephemeral_signer, + ) + .await + .map_err(|e| e.with_context("Failed to del mining key"))?; + + Ok(crate::ResultOfBlockchainWrite { message_ids, ..Default::default() }) + } +} diff --git a/bee_wallet/src/modules/mod.rs b/bee_wallet/src/modules/mod.rs new file mode 100644 index 0000000..cfa2f9b --- /dev/null +++ b/bee_wallet/src/modules/mod.rs @@ -0,0 +1,23 @@ +//! Domain orchestration layer. +//! +//! Responsibilities: +//! - compose multi-step wallet scenarios; +//! - call service helpers and infra primitives; +//! - define operation boundaries visible to adapters/API. +//! +//! Non-responsibilities: +//! - low-level crypto/math utils; +//! - direct data-shape conversion for transport DTOs. + +pub(crate) mod balance; +pub(crate) mod connect; +pub(crate) mod deploy; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod dex; +pub(crate) mod history; +pub(crate) mod miner; +pub(crate) mod multifactor; +pub mod names; /* pub because ResultOfValidateWalletName and WalletNameErrorCode are + * re-exported from lib.rs */ +pub(crate) mod tokens; +pub(crate) mod zkp; diff --git a/bee_wallet/src/modules/multifactor.rs b/bee_wallet/src/modules/multifactor.rs new file mode 100644 index 0000000..4e059d9 --- /dev/null +++ b/bee_wallet/src/modules/multifactor.rs @@ -0,0 +1,431 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor as MvMultifactor; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetMvMultifactor; +use ackinacki_kit::contracts::mvsystem::root::ResultOfGetMvMultifactorAddress; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::contracts::traits::DecodeAccountData; +use ackinacki_kit::shared::traits::guarded::AsyncGuarded; +use bee_crypto::Crypto as BeeCrypto; + +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::services; +use crate::services::multifactor::cmd::ParamsOfChangeSeedPhrase; +use crate::services::multifactor::query::ResultOfCheckNameAvailability; +use crate::services::multifactor::query::ResultOfGetMultifactorDetails; +use crate::services::resolvers::resolve_mirror; +use crate::services::resolvers::MirrorSelector; +use crate::types::ParamsGetMirrorAddress; +use crate::types::ParamsOfGetMultifactorAddress; +use crate::types::ParamsOfGetMultifactorInfo; +use crate::types::ResultGetMirrorAddress; +use crate::types::ResultOfGetMultifactorInfo; + +pub(crate) struct MultifactorModule<'a> { + ctx: &'a WalletContext, +} + +fn is_retryable_check_name_availability_error(err: &crate::errors::AppError) -> bool { + let is_account_get_error = + err.module.as_deref() == Some("account") && err.error_code.as_deref() == Some("205"); + if !is_account_get_error { + return false; + } + + let has_transient_network_hint = |s: &str| { + s.contains("connection reset by peer") + || s.contains("dns error") + || s.contains("failed to lookup address information") + || s.contains("client error (SendRequest)") + || s.contains("client error (Connect)") + || s.contains("timed out") + || s.contains("timeout") + }; + + has_transient_network_hint(&err.message) + || err.details.as_deref().is_some_and(has_transient_network_hint) +} + +impl<'a> MultifactorModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub async fn check_name_availability( + &self, + wallet_name: String, + ) -> AppResult { + self.ctx.acquire().await; + let tvm_client = self.ctx.tvm_client.clone(); + crate::infra::with_retry( + || { + let tvm_client = tvm_client.clone(); + let wallet_name = wallet_name.clone(); + async move { + services::multifactor::query::check_name_availability(tvm_client, wallet_name) + .await + } + }, + 3, + 1_000, + Some( + is_retryable_check_name_availability_error as fn(&crate::errors::AppError) -> bool, + ), + ) + .await + } + + pub async fn get_multifactor_data_by_name( + &self, + wallet_name: String, + ) -> AppResult> { + self.ctx.acquire().await; + services::multifactor::query::get_multifactor_decoded_data_by_name( + self.ctx.tvm_client.clone(), + services::multifactor::query::ParamsOfGetMultifactorDataByName { wallet_name }, + ) + .await + } + + pub async fn change_seed_phrase( + &self, + params: ParamsOfChangeSeedPhrase, + ) -> AppResult { + self.ctx.acquire().await; + let multifactor_contract = MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + ); + let multifactor = Arc::new(multifactor_contract); + let epk_expire_at = multifactor + .get_epk_expire_at( + ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + let result = services::multifactor::cmd::change_seed_phrase( + self.ctx.tvm_client.clone(), + &multifactor, + epk_expire_at, + params, + ) + .await?; + Ok(crate::ResultOfBlockchainWrite { message_ids: result.message_ids, ..Default::default() }) + } + + pub async fn get_multifactor_address( + &self, + params: ParamsOfGetMultifactorAddress, + ) -> AppResult { + self.ctx.acquire().await; + let contract = MobileVerifiersRoot::new_default(self.ctx.tvm_client.clone()); + let result = contract + .get_mv_multifactor(ParamsOfGetMvMultifactor { public: params.pubkey }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to get_multifactor_address") + })?; + + Ok(ResultOfGetMvMultifactorAddress { address: result.address().to_string() }) + } + + pub fn get_mirror_address( + &self, + params: ParamsGetMirrorAddress, + ) -> AppResult { + let mirror = + resolve_mirror(self.ctx.tvm_client.clone(), MirrorSelector::ForPubkey(¶ms.pubkey)) + .map_err(|e| e.with_context("Get mirror"))?; + Ok(ResultGetMirrorAddress { address: mirror.address().to_string() }) + } + + pub async fn get_multifactor_info( + &self, + params: ParamsOfGetMultifactorInfo, + ) -> AppResult { + self.ctx.acquire().await; + let contract = + MvMultifactor::new_default(self.ctx.tvm_client.clone(), params.address.clone()); + + if !contract.is_deployed().await { + return Ok(ResultOfGetMultifactorInfo { data: None }); + } + let Some(data) = contract.async_guarded(|acc| acc.data.clone()).await else { + return Ok(ResultOfGetMultifactorInfo { data: None }); + }; + let acc_data = contract.decode_account_data(data).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to decode_account_data") + })?; + + Ok(ResultOfGetMultifactorInfo { data: Some(acc_data) }) + } + + pub async fn get_epk_expire_at( + &self, + params: crate::types::GetEPKExpireReq, + ) -> AppResult { + self.ctx.acquire().await; + let multifactor = MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + ); + let epk_expire_at = multifactor + .get_epk_expire_at( + ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire { + epk: format!("0x{}", params.epk), + }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + Ok(crate::types::GetEPKExpireRes { epk_expire_at }) + } + + pub async fn update_zk_id( + &self, + params: crate::UpdateMultifactorZkIdReq, + ) -> AppResult { + self.ctx.acquire().await; + use ackinacki_kit::contracts::mvsystem::multifactor::AccountData; + use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfUpdateRecoveryPhrase; + use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfUpdateZkId; + use ackinacki_kit::tvm_client::abi::Signer; + use ackinacki_kit::tvm_client::crypto::KeyPair; + use gosh_tls_lib::tls_connect::get_root_certs_map; + + use crate::infra::poll_until_rated; + use crate::services::multifactor::key_derivation::build_recovery_keys; + use crate::services::multifactor::key_derivation::build_update_jwk_keys; + use crate::services::multifactor::key_derivation::ParamsOfBuildRecoveryKeys; + use crate::services::multifactor::key_derivation::ParamsOfBuildUpdateJwkKeys; + use crate::services::zkp::auth::get_auth_provider; + use crate::services::zkp::auth::get_domain_by_auth_provider; + use crate::services::zkp::auth::Claim; + + let provider = get_auth_provider(Claim { + value: params.iss_base_64.clone(), + index_mod_4: params.index_mod_4 as u8, + })?; + let domain = get_domain_by_auth_provider(&provider)?; + let root_provider_certificates = get_root_certs_map(&domain).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to get root certificates") + })?; + + let update_jwk_keys = build_update_jwk_keys( + self.ctx.tvm_client.clone(), + ParamsOfBuildUpdateJwkKeys { + password: params.password.clone(), + sub: params.sub.clone(), + }, + ) + .await?; + let crypto = BeeCrypto::from_client_context(self.ctx.tvm_client.clone()); + let epk_sig = crypto.sign_detached_hex(params.epk.clone(), params.esk)?; + let update_params = ParamsOfUpdateZkId { + zkid: params.zkid.clone(), + proof: params.proof, + epk: format!("0x{}", params.epk), + epk_sig, + epk_expire_at: params.epk_expire_at, + jwk_modulus: params.jwk_modulus, + kid: params.kid, + jwk_modulus_expire_at: params.jwk_modulus_expire_at, + index_mod_4: params.index_mod_4, + iss_base_64: params.iss_base_64, + header_base_64: params.header_base_64, + owner_pubkey: format!("0x{}", params.pubkey), + root_provider_certificates, + provider: provider.to_string(), + jwk_update_key: update_jwk_keys.pub_jwk_update_key.clone(), + jwk_update_key_sig: update_jwk_keys.pub_jwk_update_sig.clone(), + }; + let recovery_keys = build_recovery_keys( + self.ctx.tvm_client.clone(), + ParamsOfBuildRecoveryKeys { + password: params.password.clone(), + multifactor_address: params.address.clone(), + }, + ) + .await?; + + let contract_raw = + MvMultifactor::new_default(self.ctx.tvm_client.clone(), params.address.clone()); + let contract = Arc::new(contract_raw); + + let update_zk_id_result = contract + .update_zk_id( + update_params, + Signer::Keys { + keys: KeyPair { + public: params.pubkey.clone(), + secret: params.secretkey.clone(), + }, + }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("update_zk_id failed"))?; + + let update_zk_id_result = + crate::infra::ensure_tx_success(update_zk_id_result, "update_zk_id")?; + + poll_until_rated( + self.ctx.rate_limiter.as_ref(), + || async { + services::multifactor::query::get_multifactor_decoded_data(&contract) + .await + .map_err(|e| e.with_context("Multifactor get details")) + }, + move |data: &AccountData| params.zkid == data.zkid, + None, + None, + ) + .await?; + + let update_recovery_phrase_result = contract + .update_recovery_phrase( + ParamsOfUpdateRecoveryPhrase { + new_pub_recovery_key: recovery_keys.pub_recovery_key.clone(), + new_pub_recovery_key_sig: recovery_keys.pub_recovery_key_sig, + }, + Signer::Keys { + keys: KeyPair { + public: params.pubkey.clone(), + secret: params.secretkey.clone(), + }, + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("update_recovery_phrase failed") + })?; + + let _update_recovery_phrase_result = crate::infra::ensure_tx_success( + update_recovery_phrase_result, + "update_recovery_phrase", + )?; + + poll_until_rated( + self.ctx.rate_limiter.as_ref(), + || async { + services::multifactor::query::get_multifactor_decoded_data(&contract) + .await + .map_err(|e| e.with_context("Multifactor get details")) + }, + move |data: &AccountData| recovery_keys.pub_recovery_key == data.pub_recovery_key, + None, + None, + ) + .await?; + + Ok(update_zk_id_result) + } + + pub async fn update_contract( + &self, + params: crate::ParamsOfUpdateContract, + ) -> AppResult { + self.ctx.acquire().await; + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + let message_ids = update_contract_flags(multifactor, params.keys, true).await?; + Ok(crate::ResultOfBlockchainWrite { message_ids, ..Default::default() }) + } +} + +/// Ensure multifactor contract flags (`force_remove_oldest`, `wasm_hash`) are +/// up to date. +/// +/// When `wait == true`, polls until changes are confirmed on-chain. +/// When `wait == false`, sends messages and returns immediately +/// (fire-and-forget). +pub(crate) async fn update_contract_flags( + multifactor: Arc, + keys: ackinacki_kit::tvm_client::crypto::KeyPair, + wait: bool, +) -> AppResult> { + use crate::services::multifactor::cmd::set_remove_oldest; + use crate::services::multifactor::cmd::set_wasm_hash; + use crate::services::multifactor::query::get_multifactor_decoded_data; + use crate::services::multifactor::WASM_HASH; + + let current_data = get_multifactor_decoded_data(&multifactor).await?; + + let need_remove_oldest = !current_data.force_remove_oldest; + let need_wasm_hash = current_data.wasm_hash != WASM_HASH; + + if !need_remove_oldest && !need_wasm_hash { + return Ok(Vec::new()); + } + + let mut message_ids = Vec::new(); + + if need_remove_oldest { + let mf = multifactor.clone(); + let k = keys.clone(); + let message_id = crate::infra::with_retry( + move || { + let mf = mf.clone(); + let k = k.clone(); + async move { set_remove_oldest(&mf, k, true).await } + }, + 3, + 3000, + None:: bool>, + ) + .await + .map_err(|e| e.with_context("Set force to remove oldest failure"))?; + if let Some(id) = message_id { + message_ids.push(id); + } + } + + if need_wasm_hash { + let mf = multifactor.clone(); + let k = keys.clone(); + let message_id = crate::infra::with_retry( + move || { + let mf = mf.clone(); + let k = k.clone(); + async move { set_wasm_hash(&mf, k, WASM_HASH).await } + }, + 3, + 3000, + None:: bool>, + ) + .await + .map_err(|e| e.with_context("Set wasm hash failure"))?; + if let Some(id) = message_id { + message_ids.push(id); + } + } + + if wait { + use crate::infra::poll_until; + + let expected_hash = WASM_HASH.to_string(); + poll_until( + || async { + get_multifactor_decoded_data(&multifactor) + .await + .map_err(|e| e.with_context("Multifactor get details")) + }, + move |data| { + (!need_remove_oldest || data.force_remove_oldest) + && (!need_wasm_hash || data.wasm_hash == expected_hash) + }, + None, + None, + ) + .await?; + } + + Ok(message_ids) +} diff --git a/bee_wallet/src/modules/names.rs b/bee_wallet/src/modules/names.rs new file mode 100644 index 0000000..c7abca3 --- /dev/null +++ b/bee_wallet/src/modules/names.rs @@ -0,0 +1,64 @@ +use crate::client::WalletContext; +use crate::services; +use crate::services::validate_name::WalletNameError; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum WalletNameErrorCode { + InvalidCharacters = 1, + ConsecutiveHyphens = 2, + ConsecutiveUnderscores = 3, + StartsWithSymbol = 4, + TooLong = 5, + TooShort = 6, +} + +#[derive(Clone)] +pub struct ResultOfValidateWalletName { + is_valid: bool, + error_code: Option, +} + +impl ResultOfValidateWalletName { + pub fn new(is_valid: bool, error_code: Option) -> Self { + Self { is_valid, error_code } + } + + pub fn is_valid(&self) -> bool { + self.is_valid + } + + pub fn error_code(&self) -> Option { + self.error_code + } +} + +pub(crate) struct NamesModule<'a> { + #[allow(dead_code)] + ctx: &'a WalletContext, +} + +impl<'a> NamesModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub fn validate_name(&self, wallet_name: String) -> ResultOfValidateWalletName { + match services::validate_name::verify_wallet_name(wallet_name) { + Ok(()) => ResultOfValidateWalletName::new(true, None), + Err(err) => { + let code = match err { + WalletNameError::InvalidCharacters => WalletNameErrorCode::InvalidCharacters, + WalletNameError::ConsecutiveHyphens => WalletNameErrorCode::ConsecutiveHyphens, + WalletNameError::ConsecutiveUnderscores => { + WalletNameErrorCode::ConsecutiveUnderscores + } + WalletNameError::StartsWithSymbol => WalletNameErrorCode::StartsWithSymbol, + WalletNameError::TooLong => WalletNameErrorCode::TooLong, + WalletNameError::TooShort => WalletNameErrorCode::TooShort, + }; + + ResultOfValidateWalletName::new(false, Some(code)) + } + } + } +} diff --git a/bee_wallet/src/modules/tokens.rs b/bee_wallet/src/modules/tokens.rs new file mode 100644 index 0000000..92a7e97 --- /dev/null +++ b/bee_wallet/src/modules/tokens.rs @@ -0,0 +1,419 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor as MvMultifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSubmitTransaction; +use ackinacki_kit::contracts::mvsystem::popitgame::ParamsOfEncodeWithdraw; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetPopitgame; +use ackinacki_kit::contracts::mvsystem::ContractIndex; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::contracts::traits::EncodeMessage; +use ackinacki_kit::shared::traits::guarded::AsyncGuarded; +use ackinacki_kit::tvm_client::abi::CallSet; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::processing::ResultOfSendMessage; +use rand::random_range; +use serde_json::json; + +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::services; +use crate::types::ClaimUsdcReq; +use crate::types::ClaimUsdcResult; +use crate::types::GetMySellOrdersReq; +use crate::types::MigrateTip3UsdcReq; +use crate::types::SellShellsResult; +use crate::BuyShellsReq; +use crate::RedeemNacklReq; +use crate::SellShellsReq; +use crate::SendTokensDirectReq; +use crate::SendTokensReq; +use crate::WithdrawPopitgameRewardsReq; + +/// Returns `true` if `token_root` is a numeric ECC currency_id (e.g. "1", "2", +/// "3"), `false` if it's a TIP-3 token root address (e.g. "0:ffff..."). +fn is_native_ecc(token_root: &str) -> bool { + token_root.parse::().is_ok() +} + +pub(crate) struct TokensModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> TokensModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub async fn send_tokens(&self, params: SendTokensReq) -> AppResult { + self.ctx.acquire().await; + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let signer = Signer::Keys { keys: params.signer_keys }; + + let res = if is_native_ecc(¶ms.token_root) { + services::tokens::send_native_tokens( + &multifactor, + epk_expire_at, + params.destination_address, + params.token_root, + params.amount_raw, + signer, + params.bounce, + ) + .await? + } else { + services::tokens::send_other_tokens( + self.ctx.tvm_client.clone(), + &multifactor, + epk_expire_at, + params.destination_address, + params.token_root, + params.token_dapp, + params.amount_raw, + signer, + params.bounce, + ) + .await? + }; + + Ok(res) + } + + pub async fn send_tokens_direct( + &self, + params: SendTokensDirectReq, + ) -> AppResult { + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let signer = Signer::Keys { keys: params.signer_keys }; + + services::tokens::send_native_tokens_direct( + &multifactor, + epk_expire_at, + params.destination_address, + params.token_root, + params.amount_raw, + params.flags, + params.value, + params.payload, + signer, + params.bounce, + ) + .await + } + + pub async fn buy_shells(&self, params: BuyShellsReq) -> AppResult { + self.ctx.acquire().await; + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let signer = Signer::Keys { keys: params.signer_keys }; + + services::tokens::buy_shells( + &multifactor, + epk_expire_at, + params.usdc_amount, + signer, + params.bounce, + ) + .await + } + + pub async fn sell_shells(&self, params: SellShellsReq) -> AppResult { + self.ctx.acquire().await; + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let signer = Signer::Keys { keys: params.signer_keys }; + + services::tokens::sell_shells( + self.ctx.tvm_client.clone(), + &multifactor, + epk_expire_at, + params.denom, + signer, + params.bounce, + ) + .await + } + + pub async fn claim_usdc(&self, params: ClaimUsdcReq) -> AppResult { + self.ctx.acquire().await; + let signer = Signer::Keys { keys: params.signer_keys }; + + services::tokens::claim_usdc( + self.ctx.tvm_client.clone(), + params.denom, + params.order_id, + signer, + ) + .await + } + + pub async fn get_my_sell_orders( + &self, + params: GetMySellOrdersReq, + ) -> AppResult { + services::tokens::get_my_sell_orders( + self.ctx.tvm_client.clone(), + self.ctx.archive_tvm_client.clone(), + self.ctx.rate_limiter.clone(), + ¶ms.multifactor_address, + params.page_size, + params.cursor, + ) + .await + } + + pub async fn redeem_nackl(&self, params: RedeemNacklReq) -> AppResult { + self.ctx.acquire().await; + if params.nackl_amount == 0 { + return Err(crate::errors::AppError::new("redeem_nackl: nackl_amount must be > 0")); + } + + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let signer = Signer::Keys { keys: params.signer_keys }; + + services::tokens::redeem_nackl( + &multifactor, + epk_expire_at, + params.nackl_amount, + signer, + params.bounce, + ) + .await + } + + pub async fn migrate_tip3_usdc( + &self, + params: MigrateTip3UsdcReq, + ) -> AppResult { + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let signer = Signer::Keys { keys: params.signer_keys }; + + services::tokens::migrate_tip3_usdc( + self.ctx.tvm_client.clone(), + &multifactor, + epk_expire_at, + params.token_root, + params.token_dapp, + params.amount_raw, + signer, + params.bounce, + ) + .await + } + + pub async fn get_nackl_redeem_rate(&self) -> AppResult { + services::tokens::get_nackl_redeem_rate(self.ctx.tvm_client.clone()).await + } + + pub async fn withdraw_popitgame_rewards( + &self, + params: WithdrawPopitgameRewardsReq, + ) -> AppResult { + let verifiers_root = MobileVerifiersRoot::new_default(self.ctx.tvm_client.clone()); + let multifactor = Arc::new(MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + )); + + let popitgame = verifiers_root + .get_popitgame(ParamsOfGetPopitgame { + multifactor_address: multifactor.address().to_string(), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get popitgame"))?; + + popitgame.fetch_account().await.map_err(|e| { + crate::errors::AppError::from(e).with_context("fetch account popitgame") + })?; + let popitgame_ecc = popitgame.async_guarded(|acc| acc.ecc.clone()).await; + + // TODO: beautify + let value: u128 = match popitgame_ecc.get(&1) { + Some(v) => v.to_string().parse::().unwrap_or_default(), + None => 0, + }; + + let message = popitgame + .encode_message_body( + CallSet { + function_name: "withdraw".to_string(), + header: None, + input: Some(json!(ParamsOfEncodeWithdraw { + recipient: params.multifactor_address, + amount: value + })), + }, + true, + Signer::None, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Encode `activate` message") + })?; + + let epk_expire_at = multifactor + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + services::multifactor::whitelist::update_multifactor_whitelist_and_wait( + &multifactor, + services::multifactor::whitelist::ParamsOfUpdateMultifactorWhiteList { + epk_expire_at, + payload_destination: ContractIndex::PopitGame, + target_address: popitgame.address().to_string(), + mirror_index: random_range(0..999), + whitelisted_name: None, + }, + &Signer::Keys { keys: params.signer_keys.clone() }, + ) + .await + .map_err(|e| e.with_context("withdraw_popitgame_rewards: update whitelist"))?; + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: popitgame.address().to_string(), + epk_expire_at, + payload: message.body, + bounce: params.bounce.unwrap_or(true), + all_balance: false, + ..Default::default() + }, + Signer::Keys { keys: params.signer_keys }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "Submit multifactor `{}` transaction", + multifactor.address() + )) + })?; + + crate::infra::ensure_tx_success(result, "submit_transaction (withdraw popitgame)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_native_ecc_nackl() { + assert!(is_native_ecc("1")); + } + + #[test] + fn is_native_ecc_shell() { + assert!(is_native_ecc("2")); + } + + #[test] + fn is_native_ecc_usdc() { + assert!(is_native_ecc("3")); + } + + #[test] + fn is_native_ecc_future_currency() { + assert!(is_native_ecc("4")); + assert!(is_native_ecc("100")); + } + + #[test] + fn is_not_native_ecc_tip3_address() { + assert!(!is_native_ecc( + "0:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + )); + } + + #[test] + fn is_not_native_ecc_hex_address() { + assert!(!is_native_ecc( + "0:1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a" + )); + } + + #[test] + fn is_not_native_ecc_empty() { + assert!(!is_native_ecc("")); + } + + #[test] + fn is_not_native_ecc_text() { + assert!(!is_native_ecc("nackl")); + } + + #[test] + fn is_not_native_ecc_negative() { + assert!(!is_native_ecc("-1")); + } +} diff --git a/bee_wallet/src/modules/zkp.rs b/bee_wallet/src/modules/zkp.rs new file mode 100644 index 0000000..aa65b17 --- /dev/null +++ b/bee_wallet/src/modules/zkp.rs @@ -0,0 +1,253 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor as MvMultifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfDeleteZKPFactorByItself; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfGetEpkExpire; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetIndexer; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::contracts::traits::DecodeAccountData; +use ackinacki_kit::shared::traits::guarded::AsyncGuarded; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::processing::ResultOfSendMessage; +use bee_crypto::Crypto as BeeCrypto; + +use crate::client::WalletContext; +use crate::errors::AppResult; +use crate::infra::now_secs; +use crate::infra::poll_until_rated; +use crate::progress::ProgressEvent; +use crate::progress::ProgressSink; +use crate::services; +use crate::services::multifactor::cmd::AddZkpFactorReq; +use crate::services::multifactor::jwk::AddJwkModulusReq; +use crate::services::multifactor::query::get_multifactor_decoded_data; +pub use crate::services::zkp::ParamsOfAddZKPFactor; +pub use crate::services::zkp::ResultOfAddZKPFactor; +pub use crate::services::zkp::ZkLoginCompleteWithProverParams; +pub use crate::services::zkp::ZkLoginCompleteWithProverResult; +pub use crate::services::zkp::ZkLoginPrepareResult; +use crate::DeleteZkpFactorByItselfReq; + +pub(crate) struct ZkpModule<'a> { + ctx: &'a WalletContext, +} + +impl<'a> ZkpModule<'a> { + pub fn new(ctx: &'a WalletContext) -> Self { + Self { ctx } + } + + pub fn prepare_zk_login_v1_with_now_ms(&self, now_ms: u64) -> AppResult { + services::zkp::client_login_prepare::prepare_zk_login_with_now_ms(now_ms) + } + + pub async fn complete_zk_login_with_prover_v1( + &self, + params: ZkLoginCompleteWithProverParams, + progress: Option, + ) -> AppResult { + self.ctx.acquire().await; + services::zkp::client_login_complete::zk_login_complete_with_prover(params, progress).await + } + + pub async fn add_zkp_factor( + &self, + params: ParamsOfAddZKPFactor, + progress: Option, + ) -> AppResult { + self.ctx.acquire().await; + // Several on-chain reads (indexer → details → account data) run before + // the first write; mark the start so the UI isn't blank during them. + crate::progress::emit(progress.as_ref(), ProgressEvent::new("add_factor", "resolving")); + + let result = self.run_add_zkp_factor(params, progress.clone()).await; + // Report a terminal failure into the stream too (not just the rejected + // promise). `timed_out` is already emitted by the confirm phase, so + // suppress it here to avoid double-reporting. + crate::progress::report_failure( + progress.as_ref(), + "add_factor", + &result, + Some("add_factor_timeout"), + ); + result + } + + async fn run_add_zkp_factor( + &self, + params: ParamsOfAddZKPFactor, + progress: Option, + ) -> AppResult { + let mut message_ids = Vec::new(); + let crypto = BeeCrypto::from_client_context(self.ctx.tvm_client.clone()); + let root_contract = MobileVerifiersRoot::new_default(self.ctx.tvm_client.clone()); + let indexer = root_contract + .get_indexer(ParamsOfGetIndexer { name: params.wallet_name.clone() }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to get_indexer_address") + })?; + + let indexer_details = indexer.get_details().await.map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to get_indexer_details") + })?; + + let multifactor_contract = MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + indexer_details.multifactor_address.clone(), + ); + let multifactor = Arc::new(multifactor_contract); + + if !multifactor.is_deployed().await { + return Err(crate::errors::AppError::new("contract is not deployed")); + } + let Some(data) = multifactor.async_guarded(|acc| acc.data.clone()).await else { + return Err(crate::errors::AppError::new("failed to multifactor data")); + }; + + let acc_data = multifactor.decode_account_data(data).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to decode_account_data") + })?; + + if params.zkid != acc_data.zkid { + return Err(crate::errors::AppError::new("Bad password or social account provided")); + } + + let hashed_kid = crypto + .get_boc_hash(params.kid.clone()) + .map_err(|e| e.with_context("failed to hashed kid"))?; + let existing_jwk_key = format!("0x{}", hashed_kid.clone()); + let existing_jwk = acc_data.jwk_modulus_data.get(&existing_jwk_key); + + let now = now_secs(); + let min_life = 300; + let now_plus_life = now + min_life + 1; + let new_ok = params.jwk_expires_at > now_plus_life; + let should_add_jwk_modulus = match existing_jwk { + Some(jwk) => { + let old = jwk.modulus_expire_at.parse::().unwrap_or(0); + let old_expires_soon = old < now_plus_life; + new_ok && old_expires_soon && params.jwk_expires_at > old + } + None => new_ok, + }; + + if should_add_jwk_modulus { + // Extra on-chain step before the factor itself — tell the UI so the + // bar doesn't look frozen during the JWK modulus round-trip. + crate::progress::emit( + progress.as_ref(), + ProgressEvent::new("add_factor", "jwk").with_detail("adding json web key modulus"), + ); + let add_jwk_result = services::multifactor::jwk::add_jwk_modulus( + self.ctx.tvm_client.clone(), + &multifactor, + &acc_data, + AddJwkModulusReq { + kid: params.kid.clone(), + password: params.password.clone(), + sub: params.sub.clone(), + kid_boc_hash: hashed_kid, + }, + ) + .await + .map_err(|e| e.with_context("Failed to add jwk modulus"))?; + if let Some(message_id) = add_jwk_result { + message_ids.push(message_id); + } + crate::progress::emit( + progress.as_ref(), + ProgressEvent::new("add_factor", "jwk_confirmed"), + ); + } + + let add_zkp_result = services::multifactor::cmd::add_zkp_factor( + &multifactor, + &acc_data, + AddZkpFactorReq { + kid: params.kid.clone(), + header_base_64: params.header_base_64.clone(), + proof: params.proof.clone(), + epk_expire_at: params.epk_expire_at as i64, + epk: params.epk.clone(), + esk: params.esk.clone(), + }, + progress, + ) + .await + .map_err(|e| e.with_context("Failed to add jwk modulus"))?; + if let Some(message_id) = add_zkp_result.message_id.clone() { + message_ids.push(message_id); + } + + #[cfg(not(target_arch = "wasm32"))] + let password_hash = crypto.hash_password(params.password)?; + #[cfg(target_arch = "wasm32")] + let password_hash = crypto.hash_password(params.password).await?; + + Ok(ResultOfAddZKPFactor { + name: params.wallet_name.clone(), + address: multifactor.address().to_string(), + message_id: add_zkp_result.message_id, + message_ids, + password_hash, + pubkey: acc_data.owner_pubkey.clone(), + signing_keys: KeyPair { public: params.epk, secret: params.esk }, + }) + } + + pub async fn delete_zkp_factor_by_itself( + &self, + params: DeleteZkpFactorByItselfReq, + ) -> AppResult { + self.ctx.acquire().await; + let contract_raw = MvMultifactor::new_default( + self.ctx.tvm_client.clone(), + params.multifactor_address.clone(), + ); + let contract = Arc::new(contract_raw); + let epk_expire_at = contract + .get_epk_expire_at(ParamsOfGetEpkExpire { + epk: format!("0x{}", params.signer_keys.public.clone()), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get EPK expire at"))? + .epk_expire_at; + + let multifactor_data = get_multifactor_decoded_data(&contract).await?; + + let current_factors_length = multifactor_data.factors_len.parse().unwrap_or(0); + let result = contract + .delete_zkp_factor_by_itself( + ParamsOfDeleteZKPFactorByItself { epk_expire_at }, + Signer::Keys { keys: params.signer_keys.clone() }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("delete_zkp_factor_by_itself") + })?; + + let result = crate::infra::ensure_tx_success(result, "delete_zkp_factor_by_itself")?; + + poll_until_rated( + self.ctx.rate_limiter.as_ref(), + || async { + services::multifactor::query::get_multifactor_decoded_data(&contract) + .await + .map_err(|e| e.with_context("Multifactor get details")) + }, + move |data: &ackinacki_kit::contracts::mvsystem::multifactor::AccountData| { + let new_factors_length = data.factors_len.parse().unwrap_or(0); + new_factors_length == current_factors_length - 1 + }, + None, + None, + ) + .await?; + + Ok(result) + } +} diff --git a/bee_wallet/src/progress.rs b/bee_wallet/src/progress.rs new file mode 100644 index 0000000..0f122bc --- /dev/null +++ b/bee_wallet/src/progress.rs @@ -0,0 +1,161 @@ +//! Coarse progress milestones for long-running wallet operations. +//! +//! Two commands are opaque to the wallet UI because they take a variable, +//! often long time and return only on full completion: the ZK proof fetch +//! (`complete_zk_login_with_prover_v1`) and `add_zkp_factor` (on-chain +//! build → submit → confirm). This module gives callers an opt-in channel +//! to observe milestones as they happen. +//! +//! Transport is [`futures::channel::mpsc`] (not `tokio` or a boxed callback) +//! so the exact same sink type compiles on native and `wasm32` without +//! `Send`/lifetime divergence. The adapter layer bridges it to the host: +//! a Tauri command drains the receiver into `window.emit`, and the wasm +//! adapter forwards each event to a JS callback. + +use serde::Serialize; + +/// A single progress milestone. Serialized shape matches what the wallet +/// listens for: `{ op, stage, detail?, pct? }`. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProgressEvent { + /// Operation this event belongs to: `"prove"` | `"add_factor"`. + pub op: String, + /// Milestone within the operation (e.g. `"started"`, `"submitted"`, + /// `"confirming"`, `"confirmed"`, `"finished"`). + pub stage: String, + /// Optional human-readable detail (a hint or a failure reason). + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// Optional coarse percentage in `[0, 100]`, when one is meaningful. + #[serde(skip_serializing_if = "Option::is_none")] + pub pct: Option, +} + +impl ProgressEvent { + /// A bare milestone with no detail/pct. + pub fn new(op: &str, stage: &str) -> Self { + Self { op: op.to_string(), stage: stage.to_string(), detail: None, pct: None } + } + + /// Attaches a human-readable detail string. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Attaches a coarse percentage. + pub fn with_pct(mut self, pct: u8) -> Self { + self.pct = Some(pct); + self + } +} + +/// Caller-supplied sink for [`ProgressEvent`]s. The operation runs identically +/// whether or not a sink is provided; events are sent best-effort. +pub type ProgressSink = futures::channel::mpsc::UnboundedSender; + +/// Fire-and-forget emit. A closed or absent receiver is never an error — +/// progress delivery must not block or fail the underlying operation. +pub(crate) fn emit(sink: Option<&ProgressSink>, event: ProgressEvent) { + if let Some(tx) = sink { + let _ = tx.unbounded_send(event); + } +} + +/// Emit a terminal `:failed` event (error message as `detail`) when +/// `result` is `Err`, unless the error's kind equals `suppress_kind` — used to +/// avoid double-reporting a more specific terminal (e.g. `*_timeout`) that the +/// caller already emitted itself. +pub(crate) fn report_failure( + sink: Option<&ProgressSink>, + op: &str, + result: &Result, + suppress_kind: Option<&str>, +) { + if let Err(e) = result { + let suppressed = suppress_kind.is_some() && e.kind.as_deref() == suppress_kind; + if !suppressed { + emit(sink, ProgressEvent::new(op, "failed").with_detail(e.message.clone())); + } + } +} + +#[cfg(test)] +mod tests { + use futures::StreamExt; + + use super::*; + use crate::errors::AppError; + + #[test] + fn serializes_camelcase_and_skips_none() { + let ev = ProgressEvent::new("add_factor", "submitted"); + assert_eq!( + serde_json::to_string(&ev).unwrap(), + r#"{"op":"add_factor","stage":"submitted"}"# + ); + + let ev = ProgressEvent::new("prove", "proving").with_detail("x").with_pct(50); + assert_eq!( + serde_json::to_string(&ev).unwrap(), + r#"{"op":"prove","stage":"proving","detail":"x","pct":50}"# + ); + } + + #[test] + fn emit_none_is_noop() { + emit(None, ProgressEvent::new("add_factor", "submitted")); + } + + #[test] + fn emit_closed_receiver_does_not_panic() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + drop(rx); + emit(Some(&tx), ProgressEvent::new("add_factor", "submitted")); + } + + #[tokio::test] + async fn emit_some_delivers() { + let (tx, mut rx) = futures::channel::mpsc::unbounded::(); + emit(Some(&tx), ProgressEvent::new("add_factor", "confirmed")); + drop(tx); + let got = rx.next().await.unwrap(); + assert_eq!(got.stage, "confirmed"); + assert_eq!(got.op, "add_factor"); + } + + #[tokio::test] + async fn report_failure_emits_failed_with_reason() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + let result: Result<(), AppError> = Err(AppError::new("boom")); + report_failure(Some(&tx), "deploy", &result, None); + drop(tx); + let events: Vec = rx.collect().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].op, "deploy"); + assert_eq!(events[0].stage, "failed"); + assert_eq!(events[0].detail.as_deref(), Some("boom")); + } + + #[tokio::test] + async fn report_failure_suppresses_matching_kind() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + let result: Result<(), AppError> = + Err(AppError::new("timed out").with_kind("add_factor_timeout")); + report_failure(Some(&tx), "add_factor", &result, Some("add_factor_timeout")); + drop(tx); + let events: Vec = rx.collect().await; + assert!(events.is_empty(), "matching kind should be suppressed"); + } + + #[tokio::test] + async fn report_failure_noop_on_ok() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + let result: Result = Ok(1); + report_failure(Some(&tx), "add_factor", &result, None); + drop(tx); + let events: Vec = rx.collect().await; + assert!(events.is_empty()); + } +} diff --git a/bee_wallet/src/services/balance/mod.rs b/bee_wallet/src/services/balance/mod.rs new file mode 100644 index 0000000..176ff89 --- /dev/null +++ b/bee_wallet/src/services/balance/mod.rs @@ -0,0 +1,212 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetPopitgame; +use ackinacki_kit::contracts::token::root::ParamsOfGetWalletAddress; +use ackinacki_kit::contracts::token::root::TokenRoot; +use ackinacki_kit::contracts::token::wallet::TokenWallet; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::shared::traits::guarded::AsyncGuarded; +use ackinacki_kit::tvm_client::ClientContext; +use num_bigint::BigInt; +use serde::Deserialize; +use serde::Serialize; +use serde_with::serde_as; +use serde_with::DisplayFromStr; + +use crate::types::ResultOfGetBalance; + +#[derive(Debug, Deserialize)] +pub struct ParamsOfGetNativeBalances { + pub multifactor_address: String, +} + +#[serde_as] +#[derive(Debug, Serialize)] +pub struct ResultOfGetNativeBalances { + #[serde_as(as = "BTreeMap<_, DisplayFromStr>")] + pub popitgame: BTreeMap, + #[serde_as(as = "BTreeMap<_, DisplayFromStr>")] + pub ecc: BTreeMap, +} + +pub async fn get_native_balances( + tvm_client: Arc, + params: ParamsOfGetNativeBalances, +) -> crate::errors::AppResult { + let multifactor_contract = + Multifactor::new_default(tvm_client.clone(), params.multifactor_address.clone()); + let root_contract = MobileVerifiersRoot::new_default(tvm_client.clone()); + // multifactor own balance + multifactor_contract + .fetch_account() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Fetch multifactor account"))?; + let multifactor_ecc = multifactor_contract.async_guarded(|acc| acc.ecc.clone()).await; + + // popitgame balance + let popitgame_instance = root_contract + .get_popitgame(ParamsOfGetPopitgame { + multifactor_address: params.multifactor_address.clone(), + }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get popitgame"))?; + popitgame_instance + .fetch_account() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get popitgame account"))?; + + let popitgame_ecc = popitgame_instance.async_guarded(|acc| acc.ecc.clone()).await; + + Ok(ResultOfGetNativeBalances { popitgame: popitgame_ecc, ecc: multifactor_ecc }) +} + +#[serde_as] +#[derive(Debug, Serialize)] +pub struct ResultOfGetTokensBalances { + #[serde_as(as = "BTreeMap<_, DisplayFromStr>")] + pub tokens: BTreeMap, +} + +/// A TIP-3 token to query, with its own dApp (per-token; from the caller). +#[derive(Debug, Deserialize)] +pub struct TokenRef { + pub token_root: String, + /// Bare 64-hex dApp ID of `token_root` (ignored on gql-server < 1.0.0). + pub token_dapp: String, +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfGetTokensBalances { + pub multifactor_address: String, + pub token_roots: Vec, +} + +pub async fn get_tokens_balances( + tvm_client: Arc, + params: ParamsOfGetTokensBalances, +) -> crate::errors::AppResult { + let mut tokens = BTreeMap::new(); + for token in params.token_roots { + let token_root = TokenRoot::new( + tvm_client.clone(), + crate::dapp::token_contract_params( + token.token_root.as_str(), + token.token_dapp.as_str(), + ), + ); + let token_wallet_address_res = token_root + .get_wallet_address(ParamsOfGetWalletAddress { + owner_address: params.multifactor_address.clone(), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("get_wallet_address failed") + })?; + + let token_wallet = TokenWallet::new( + tvm_client.clone(), + crate::dapp::token_contract_params( + token_wallet_address_res.wallet_address, + token.token_dapp.as_str(), + ), + ); + let balance = match token_wallet.is_deployed().await { + true => { + let details = token_wallet.get_details().await.map_err(|e| { + crate::errors::AppError::from(e).with_context("token_wallet get_details failed") + })?; + + BigInt::from(details.balance) + } + false => BigInt::ZERO, + }; + + tokens.insert(token.token_root, balance); + } + + Ok(ResultOfGetTokensBalances { tokens }) +} + +pub async fn get_nackl_balance( + tvm_client: Arc, + multifactor_address: String, +) -> crate::errors::AppResult<(ResultOfGetBalance, ResultOfGetBalance)> { + let multifactor_contract = + Multifactor::new_default(tvm_client.clone(), multifactor_address.clone()); + let verifiers_root = MobileVerifiersRoot::new_default(tvm_client.clone()); + multifactor_contract + .fetch_account() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Fetch multifactor account"))?; + let multifactor_ecc = multifactor_contract.async_guarded(|acc| acc.ecc.clone()).await; + let zero = BigInt::ZERO; + let own_balance = multifactor_ecc.get(&1).unwrap_or(&zero); + let popitgame_instance = verifiers_root + .get_popitgame(ParamsOfGetPopitgame { multifactor_address }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get popitgame"))?; + popitgame_instance + .fetch_account() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get popitgame account"))?; + let popitgame_ecc = popitgame_instance.async_guarded(|acc| acc.ecc.clone()).await; + let locked_balance = popitgame_ecc.get(&1).unwrap_or(&zero); + + Ok(( + ResultOfGetBalance { decimals: BigInt::from(9), value: own_balance.clone() }, + ResultOfGetBalance { decimals: BigInt::from(9), value: locked_balance.clone() }, + )) +} + +pub async fn get_shell_balance( + tvm_client: Arc, + multifactor_address: String, +) -> crate::errors::AppResult { + let multifactor_contract = Multifactor::new_default(tvm_client, multifactor_address); + multifactor_contract + .fetch_account() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Fetch multifactor account"))?; + let multifactor_ecc = multifactor_contract.async_guarded(|acc| acc.ecc.clone()).await; + let zero = BigInt::ZERO; + let own_balance = multifactor_ecc.get(&2).unwrap_or(&zero); + Ok(ResultOfGetBalance { decimals: BigInt::from(9), value: own_balance.clone() }) +} + +pub async fn get_token_balance( + tvm_client: Arc, + multifactor_address: String, + token_root: String, + token_dapp: String, +) -> crate::errors::AppResult { + let token_root = TokenRoot::new( + tvm_client.clone(), + crate::dapp::token_contract_params(token_root, token_dapp.as_str()), + ); + let token_root_details = token_root + .get_details() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get details"))?; + let token_wallet_address_res = token_root + .get_wallet_address(ParamsOfGetWalletAddress { owner_address: multifactor_address }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get wallet address"))?; + let token_wallet = TokenWallet::new( + tvm_client, + crate::dapp::token_contract_params( + token_wallet_address_res.wallet_address, + token_dapp.as_str(), + ), + ); + let details = token_wallet + .get_details() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Get wallet details"))?; + Ok(ResultOfGetBalance { + decimals: BigInt::from(token_root_details.decimals), + value: BigInt::from(details.balance), + }) +} diff --git a/bee_wallet/src/services/boost.rs b/bee_wallet/src/services/boost.rs new file mode 100644 index 0000000..dd5894b --- /dev/null +++ b/bee_wallet/src/services/boost.rs @@ -0,0 +1,40 @@ +use serde_json::json; +use url::Url; + +pub struct ParamsOfUpdateBoost { + pub api_url: String, + pub multifactor_address: String, +} + +pub async fn update_boost_code(params: ParamsOfUpdateBoost) -> crate::errors::AppResult<()> { + let base = Url::parse(¶ms.api_url) + .map_err(|e| crate::errors::AppError::from(e).with_context("Invalid api_url"))?; + + let url = base + .join("blockchain/update-boost") + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to build url"))?; + let client = reqwest::Client::new(); + + let response = client + .post(url) + .json(&json!({ + "multifactor_address": params.multifactor_address + })) + .send() + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Failed to send update boost request") + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + return Err(crate::errors::AppError::new(format!( + "Failed to update boost (status: {}, body: {})", + status, body + ))); + } + + Ok(()) +} diff --git a/bee_wallet/src/services/connect.rs b/bee_wallet/src/services/connect.rs new file mode 100644 index 0000000..cd1d76f --- /dev/null +++ b/bee_wallet/src/services/connect.rs @@ -0,0 +1,903 @@ +use ackinacki_kit::tvm_client::crypto::KeyPair; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use bee_connect::message::connect_message_aad; +use bee_connect::message::decrypt_connect_body; +use bee_connect::message::encrypt_connect_body; +use bee_connect::message::normalize_owner_public_hex; +use bee_connect::message::normalize_uint256_hex; +pub use bee_connect::message::CONNECT_DEEPLINK_VERSION; +use bee_connect::message::CONNECT_MESSAGE_ENC_NONE; +use bee_connect::message::CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256; +pub use bee_connect::message::CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE; +pub use bee_connect::message::CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT; +pub use bee_connect::message::CONNECT_MESSAGE_TYPE_SET_MINING_KEYS; +pub use bee_connect::message::CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE; +pub use bee_connect::message::CONNECT_MESSAGE_TYPE_WALLET_HELLO; +use bee_connect::message::CONNECT_MESSAGE_VERSION; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +use crate::errors::AppError; +use crate::errors::AppResult; +const DEFAULT_QUERY_SESSION_MESSAGES_LIMIT: u32 = 50; +const MAX_QUERY_SESSION_MESSAGES_LIMIT: u32 = 200; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectPayload { + pub v: String, + pub session_id: String, + pub description: String, + pub expires_at: u64, + pub app_id: String, + /// Optional challenge nonce for inline wallet ownership verification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParamsOfAcceptSharedKeyConnect { + pub payload: ConnectPayload, + pub wallet_name: String, + pub wallet_address: String, + /// Client's X25519 DH public key (hex), received from the deeplink URL. + pub client_dh_public: String, + pub max_attempts: Option, + pub interval_ms: Option, + /// Pre-computed signature of `payload.nonce` (hex). When present, included + /// in the `wallet_hello` body for inline challenge verification. + #[serde(default)] + pub challenge_signature: Option, + /// EPK public key (hex) used to produce `challenge_signature`. + #[serde(default)] + pub challenge_epk_public: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfAcceptConnect { + pub profile_address: String, + pub deploy_message_id: Option, + pub wallet_hello_message_id: Option, + pub wallet_hello_json: String, + /// Initial session state after DH key exchange. + /// Contains signing keys, encryption root, and DH keys. + /// Caller MUST persist this for `query_session_messages`. + pub session_state: bee_connect::dh::ConnectSessionState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParamsOfDestroyConnectProfile { + pub profile_address: String, + pub multifactor_address: String, + pub signer_keys: KeyPair, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParamsOfQuerySessionMessages { + pub session_id: String, + pub description: String, + /// Session state for decrypting and re-keying incoming c2w messages. + pub session_state: Option, + pub created_at_from: Option, + pub before: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletHelloMetadata { + pub wallet_name: String, + pub wallet_address: String, + /// Inline challenge nonce (present when wallet responded to nonce in + /// deeplink). + #[serde(default, rename = "nonce", skip_serializing_if = "Option::is_none")] + pub challenge_nonce: Option, + /// Inline challenge signature. + #[serde(default, rename = "signature", skip_serializing_if = "Option::is_none")] + pub challenge_signature: Option, + /// EPK public key used to sign the nonce. + #[serde(default, rename = "epk_public", skip_serializing_if = "Option::is_none")] + pub challenge_epk_public: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetMiningKeysMessageBody { + pub app_id: String, + pub owner_public: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ClientDisconnectMessageBody { + #[serde(default)] + pub reason: Option, +} + +pub use bee_connect::ChallengeResponseBody; +pub use bee_connect::SignChallengeBody; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectSessionMessage { + pub event_id: String, + pub event_created_at: u64, + pub dir: String, + pub seq: u64, + pub msg_type: String, + pub ts: Option, + pub raw_message_json: String, + pub body_json: String, + pub wallet_hello: Option, + pub set_mining_keys: Option, + pub client_disconnect: Option, + pub sign_challenge: Option, + pub challenge_response: Option, + /// Session state snapshot taken immediately after `rekey_inbound` was + /// applied for this specific message. Present only for c2w messages + /// that triggered a successful DH re-key. Callers that need to respond + /// to a message (e.g. `challenge_response` after `sign_challenge`) + /// should use this state rather than the batch-level + /// `ResultOfQuerySessionMessages::updated_session_state`. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_state_after: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfQuerySessionMessages { + pub profile_address: String, + pub messages: Vec, + pub next_before: Option, + /// Updated session state after re-keying all c2w messages. + /// `None` if no `session_state` was provided in params. + /// Caller SHOULD persist this for the next `query_session_messages` call. + pub updated_session_state: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConnectMessageEnvelope { + v: String, + session_id: String, + dir: String, + seq: u64, + #[serde(rename = "type")] + msg_type: String, + #[serde(default)] + ts: Option, + #[serde(default)] + #[allow(dead_code)] // Read via serde_json::Value pre-parse in query_session_messages + dh_public: Option, + #[serde(default)] + enc: Option, + #[serde(default)] + body: Value, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConnectMessageEnc { + alg: String, + #[serde(default)] + nonce: Option, + #[serde(default)] + salt: Option, +} + +pub fn decode_connect_payload_b64url(payload_b64: impl AsRef) -> AppResult { + let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64.as_ref()).map_err(|e| { + AppError::new(format!("Decode connect payload base64url ({e})")).with_kind("connect") + })?; + let payload_json = String::from_utf8(payload_bytes).map_err(|e| { + AppError::new(format!("Decode connect payload utf8 ({e})")).with_kind("connect") + })?; + let payload: ConnectPayload = serde_json::from_str(&payload_json).map_err(|e| { + AppError::new(format!("Deserialize connect payload json ({e})")).with_kind("connect") + })?; + validate_connect_payload_shape(&payload)?; + Ok(payload) +} + +#[allow(clippy::too_many_arguments)] +pub fn encode_wallet_hello_message( + session_id: impl AsRef, + wallet_name: impl AsRef, + wallet_address: impl AsRef, + encryption_root: &str, + wallet_dh_public: &str, + seq: u64, + challenge_nonce: Option<&str>, + challenge_signature: Option<&str>, + challenge_epk_public: Option<&str>, +) -> AppResult { + let session_id = session_id.as_ref(); + if session_id.is_empty() { + return Err(AppError::new("wallet_hello session_id is empty").with_kind("connect")); + } + if seq == 0 { + return Err(AppError::new("wallet_hello seq must be > 0").with_kind("connect")); + } + + let ts = crate::infra::now_secs(); + + let mut body = serde_json::json!({ + "wallet_name": wallet_name.as_ref(), + "wallet_address": wallet_address.as_ref(), + }); + if let Some(nonce) = challenge_nonce { + body["nonce"] = serde_json::Value::String(nonce.to_string()); + } + if let Some(sig) = challenge_signature { + body["signature"] = serde_json::Value::String(sig.to_string()); + } + if let Some(epk) = challenge_epk_public { + body["epk_public"] = serde_json::Value::String(epk.to_string()); + } + + let aad = connect_message_aad(session_id, "w2c", seq, "wallet_hello", ts) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + let encrypted = encrypt_connect_body(&body, encryption_root, &aad) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + + let envelope = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": session_id, + "dir": "w2c", + "seq": seq, + "type": "wallet_hello", + "ts": ts, + "dh_public": wallet_dh_public, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": encrypted.nonce_b64url, + "salt": encrypted.salt_b64url, + }, + "body": encrypted.ciphertext_b64url, + }); + + serde_json::to_string(&envelope).map_err(|e| { + AppError::new(format!("Serialize wallet_hello message ({e})")).with_kind("connect") + }) +} + +/// Encodes an encrypted `challenge_response` (w2c) message. +/// Called by the wallet after signing the nonce from `sign_challenge`. +/// +/// `epk_public` — optional EPK public key (hex) used to sign the nonce. +/// When provided, the backend can verify the signature without fetching +/// the key from the chain first, then confirm the key is registered via +/// `get_epk_expire_at(wallet_address, epk_public)`. +#[allow(clippy::too_many_arguments)] +pub fn encode_challenge_response_message( + session_id: impl AsRef, + nonce: impl AsRef, + signature: impl AsRef, + wallet_address: impl AsRef, + epk_public: Option<&str>, + encryption_root: &str, + wallet_dh_public: &str, + seq: u64, +) -> AppResult { + let session_id = session_id.as_ref(); + if session_id.is_empty() { + return Err(AppError::new("challenge_response session_id is empty").with_kind("connect")); + } + if seq == 0 { + return Err(AppError::new("challenge_response seq must be > 0").with_kind("connect")); + } + + let ts = crate::infra::now_secs(); + + let mut body = serde_json::json!({ + "nonce": nonce.as_ref(), + "signature": signature.as_ref(), + "wallet_address": wallet_address.as_ref(), + }); + if let Some(epk) = epk_public { + body["epk_public"] = serde_json::Value::String(epk.to_string()); + } + + let aad = + connect_message_aad(session_id, "w2c", seq, CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, ts) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + let encrypted = encrypt_connect_body(&body, encryption_root, &aad) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + + let envelope = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": session_id, + "dir": "w2c", + "seq": seq, + "type": CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, + "ts": ts, + "dh_public": wallet_dh_public, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": encrypted.nonce_b64url, + "salt": encrypted.salt_b64url, + }, + "body": encrypted.ciphertext_b64url, + }); + + serde_json::to_string(&envelope).map_err(|e| { + AppError::new(format!("Serialize challenge_response message ({e})")).with_kind("connect") + }) +} + +pub fn validate_shared_key_connect_payload( + payload: &ConnectPayload, + now_secs: u64, +) -> AppResult<()> { + validate_connect_payload(payload, now_secs)?; + Ok(()) +} + +fn validate_connect_payload(payload: &ConnectPayload, now_secs: u64) -> AppResult<()> { + validate_connect_payload_shape(payload)?; + if payload.expires_at <= now_secs { + return Err(AppError::new(format!( + "Connect payload expired at {}, now {}", + payload.expires_at, now_secs + )) + .with_kind("connect")); + } + Ok(()) +} + +fn validate_connect_payload_shape(payload: &ConnectPayload) -> AppResult<()> { + if payload.v != CONNECT_DEEPLINK_VERSION { + return Err(AppError::new(format!( + "Unsupported connect deeplink version `{}` (expected `{}`)", + payload.v, CONNECT_DEEPLINK_VERSION + )) + .with_kind("connect")); + } + if payload.session_id.trim().is_empty() { + return Err(AppError::new("Connect payload session_id is empty").with_kind("connect")); + } + if payload.description.trim().is_empty() { + return Err(AppError::new("Connect payload description is empty").with_kind("connect")); + } + let _ = normalize_uint256_hex(&payload.app_id) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + Ok(()) +} + +pub fn validate_session_message_params( + session_id: impl AsRef, + description: impl AsRef, +) -> AppResult<()> { + if session_id.as_ref().trim().is_empty() { + return Err(AppError::new("connect session_id is empty").with_kind("connect")); + } + if description.as_ref().trim().is_empty() { + return Err(AppError::new("connect description is empty").with_kind("connect")); + } + Ok(()) +} + +pub fn normalize_query_session_messages_limit(limit: Option) -> u32 { + limit.unwrap_or(DEFAULT_QUERY_SESSION_MESSAGES_LIMIT).clamp(1, MAX_QUERY_SESSION_MESSAGES_LIMIT) +} + +pub fn parse_connect_session_message( + raw_message_json: impl AsRef, + expected_session_id: impl AsRef, + event_id: String, + event_created_at: u64, +) -> Option { + parse_connect_session_message_with_secret( + raw_message_json, + expected_session_id, + None, + event_id, + event_created_at, + ) +} + +pub fn parse_connect_session_message_with_secret( + raw_message_json: impl AsRef, + expected_session_id: impl AsRef, + encryption_secret: Option<&str>, + event_id: String, + event_created_at: u64, +) -> Option { + let expected_session_id = expected_session_id.as_ref().trim(); + if expected_session_id.is_empty() { + return None; + } + + let raw_message_json = raw_message_json.as_ref(); + let envelope: ConnectMessageEnvelope = serde_json::from_str(raw_message_json).ok()?; + if envelope.v != CONNECT_MESSAGE_VERSION { + return None; + } + if envelope.session_id != expected_session_id { + return None; + } + if envelope.dir.trim().is_empty() { + return None; + } + + let decoded_body = + decode_connect_message_body(&envelope, encryption_secret, expected_session_id).ok(); + let body_json = + decoded_body.as_ref().and_then(|value| serde_json::to_string(value).ok()).unwrap_or_else( + || serde_json::to_string(&envelope.body).unwrap_or_else(|_| "null".to_string()), + ); + let mut message = ConnectSessionMessage { + event_id, + event_created_at, + dir: envelope.dir, + seq: envelope.seq, + msg_type: envelope.msg_type.clone(), + ts: envelope.ts, + raw_message_json: raw_message_json.to_string(), + body_json, + wallet_hello: None, + set_mining_keys: None, + client_disconnect: None, + sign_challenge: None, + challenge_response: None, + session_state_after: None, + }; + + match envelope.msg_type.as_str() { + CONNECT_MESSAGE_TYPE_WALLET_HELLO => { + if let Some(body) = decoded_body + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + message.wallet_hello = Some(body); + } + } + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS => { + if let Some(body) = decoded_body + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + let Some(app_id) = normalize_uint256_hex(&body.app_id).ok() else { + return Some(message); + }; + let Some(owner_public) = normalize_owner_public_hex(&body.owner_public).ok() else { + return Some(message); + }; + message.set_mining_keys = Some(SetMiningKeysMessageBody { app_id, owner_public }); + } + } + CONNECT_MESSAGE_TYPE_CLIENT_DISCONNECT => { + if let Ok(mut body) = serde_json::from_value::( + decoded_body.as_ref().cloned().unwrap_or_else(|| envelope.body.clone()), + ) { + body.reason = + body.reason.and_then(|v| (!v.trim().is_empty()).then(|| v.trim().to_string())); + message.client_disconnect = Some(body); + } + } + CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE => { + if let Some(body) = decoded_body + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + message.sign_challenge = Some(body); + } + } + CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE => { + if let Some(body) = decoded_body + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + message.challenge_response = Some(body); + } + } + _ => {} + } + + Some(message) +} + +fn decode_connect_message_body( + envelope: &ConnectMessageEnvelope, + encryption_secret: Option<&str>, + expected_session_id: &str, +) -> AppResult { + let alg = envelope.enc.as_ref().map(|v| v.alg.as_str()).unwrap_or(CONNECT_MESSAGE_ENC_NONE); + if alg == CONNECT_MESSAGE_ENC_NONE { + return Err(AppError::new("Unencrypted connect messages are no longer accepted") + .with_kind("connect")); + } + if alg != CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256 { + return Err(AppError::new(format!("Unsupported connect message enc alg `{alg}`")) + .with_kind("connect")); + } + + let secret = encryption_secret.map(str::trim).filter(|v| !v.is_empty()).ok_or_else(|| { + AppError::new("Encrypted connect message requires encryption secret").with_kind("connect") + })?; + + let enc = envelope.enc.as_ref().ok_or_else(|| { + AppError::new("Encrypted connect message misses `enc`").with_kind("connect") + })?; + let nonce_b64url = enc.nonce.as_deref().ok_or_else(|| { + AppError::new("Encrypted connect message misses nonce").with_kind("connect") + })?; + let salt_b64url = enc.salt.as_deref().ok_or_else(|| { + AppError::new("Encrypted connect message misses salt").with_kind("connect") + })?; + let ciphertext_b64url = envelope.body.as_str().ok_or_else(|| { + AppError::new("Encrypted connect message body must be base64url string") + .with_kind("connect") + })?; + let ts = envelope + .ts + .ok_or_else(|| AppError::new("Encrypted connect message misses ts").with_kind("connect"))?; + let aad = connect_message_aad( + expected_session_id, + &envelope.dir, + envelope.seq, + &envelope.msg_type, + ts, + ) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + let plaintext = + decrypt_connect_body(ciphertext_b64url, nonce_b64url, salt_b64url, secret, &aad) + .map_err(|e| AppError::new(e).with_kind("connect"))?; + serde_json::from_slice::(&plaintext).map_err(|e| { + AppError::new(format!("Deserialize decrypted connect body ({e})")).with_kind("connect") + }) +} + +#[cfg(test)] +mod tests { + use bee_connect::message::derive_connect_message_key; + use chacha20poly1305::aead::Aead; + use chacha20poly1305::aead::KeyInit; + use chacha20poly1305::aead::Payload; + use chacha20poly1305::XChaCha20Poly1305; + use chacha20poly1305::XNonce; + + use super::*; + + #[test] + fn parse_set_mining_keys_message() { + let encryption_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let ts = 100u64; + let seq = 1_700_000_000_000u64; + let nonce = [5u8; 24]; + let salt = [6u8; 32]; + let body = serde_json::json!({ + "app_id": "0x1", + "owner_public": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }); + let aad = + connect_message_aad("sess_1", "c2w", seq, CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, ts) + .expect("aad"); + let key = derive_connect_message_key(encryption_secret, &salt).expect("key"); + let cipher = XChaCha20Poly1305::new((&key).into()); + let body_bytes = serde_json::to_vec(&body).expect("body"); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: &body_bytes, aad: &aad }) + .expect("encrypt"); + let raw = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": "sess_1", + "dir": "c2w", + "seq": seq, + "type": CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + "ts": ts, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": URL_SAFE_NO_PAD.encode(nonce), + "salt": URL_SAFE_NO_PAD.encode(salt), + }, + "body": URL_SAFE_NO_PAD.encode(ciphertext), + }) + .to_string(); + + let parsed = parse_connect_session_message_with_secret( + raw, + "sess_1", + Some(encryption_secret), + "evt_1".to_string(), + 123, + ) + .expect("parse"); + let set = parsed.set_mining_keys.expect("set_mining_keys body"); + assert_eq!( + set.app_id, + "0x0000000000000000000000000000000000000000000000000000000000000001" + ); + assert_eq!( + set.owner_public, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + + #[test] + fn parse_unknown_message_keeps_envelope() { + let raw = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": "sess_1", + "dir": "c2w", + "seq": 1_700_000_007_000u64, + "type": "custom_event", + "body": { "k": "v" } + }) + .to_string(); + + let parsed = + parse_connect_session_message(raw, "sess_1", "evt_7".to_string(), 700).expect("parse"); + assert_eq!(parsed.msg_type, "custom_event"); + assert_eq!(parsed.seq, 1_700_000_007_000); + assert!(parsed.wallet_hello.is_none()); + assert!(parsed.set_mining_keys.is_none()); + assert!(parsed.client_disconnect.is_none()); + } + + #[test] + fn parse_encrypted_set_mining_keys_message_with_secret() { + let encryption_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let ts = 100u64; + let nonce = [7u8; 24]; + let salt = [9u8; 32]; + let body = serde_json::json!({ + "app_id": "0x1", + "owner_public": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }); + let aad = connect_message_aad( + "sess_1", + "c2w", + 1_700_000_000_000u64, + CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + ts, + ) + .expect("aad"); + let key = derive_connect_message_key(encryption_secret, &salt).expect("key"); + let cipher = XChaCha20Poly1305::new((&key).into()); + let body_bytes = serde_json::to_vec(&body).expect("body"); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: &body_bytes, aad: &aad }) + .expect("encrypt"); + let raw = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": "sess_1", + "dir": "c2w", + "seq": 1_700_000_000_000u64, + "type": CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + "ts": ts, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": URL_SAFE_NO_PAD.encode(nonce), + "salt": URL_SAFE_NO_PAD.encode(salt), + }, + "body": URL_SAFE_NO_PAD.encode(ciphertext), + }) + .to_string(); + + let parsed = parse_connect_session_message_with_secret( + raw, + "sess_1", + Some(encryption_secret), + "evt_9".to_string(), + 900, + ) + .expect("parse"); + let set = parsed.set_mining_keys.expect("set_mining_keys body"); + assert_eq!( + set.app_id, + "0x0000000000000000000000000000000000000000000000000000000000000001" + ); + assert_eq!( + set.owner_public, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + + #[test] + fn parse_encrypted_set_mining_keys_without_secret_keeps_message_untyped() { + let raw = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": "sess_1", + "dir": "c2w", + "seq": 1_700_000_000_000u64, + "type": CONNECT_MESSAGE_TYPE_SET_MINING_KEYS, + "ts": 100u64, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": URL_SAFE_NO_PAD.encode([1u8; 24]), + "salt": URL_SAFE_NO_PAD.encode([2u8; 32]), + }, + "body": URL_SAFE_NO_PAD.encode([3u8; 48]), + }) + .to_string(); + + let parsed = parse_connect_session_message(raw, "sess_1", "evt_10".to_string(), 1000) + .expect("parse"); + assert_eq!(parsed.msg_type, CONNECT_MESSAGE_TYPE_SET_MINING_KEYS); + assert!(parsed.set_mining_keys.is_none()); + } + + #[test] + fn encode_wallet_hello_with_dh_public() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let wallet_dh_pub = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let json = encode_wallet_hello_message( + "sess_1", + "TestWallet", + "0:addr123", + secret, + wallet_dh_pub, + 1_700_000_000_000, + None, + None, + None, + ) + .expect("encode"); + let envelope: ConnectMessageEnvelope = serde_json::from_str(&json).expect("envelope"); + assert_eq!(envelope.msg_type, "wallet_hello"); + assert_eq!(envelope.dh_public.as_deref(), Some(wallet_dh_pub)); + let enc = envelope.enc.as_ref().expect("enc"); + assert_eq!(enc.alg, CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256); + let body = decode_connect_message_body(&envelope, Some(secret), "sess_1").expect("decode"); + let hello: WalletHelloMetadata = serde_json::from_value(body).expect("parse"); + assert_eq!(hello.wallet_name, "TestWallet"); + assert_eq!(hello.wallet_address, "0:addr123"); + assert_eq!(hello.challenge_nonce, None); + assert_eq!(hello.challenge_signature, None); + assert_eq!(hello.challenge_epk_public, None); + } + + #[test] + fn encode_wallet_hello_with_challenge_includes_fields() { + let secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let wallet_dh_pub = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let json = encode_wallet_hello_message( + "sess_1", + "ChallengeWallet", + "0:chal", + secret, + wallet_dh_pub, + 1_700_000_000_000, + Some("deadbeef"), + Some("sig123"), + Some("epk456"), + ) + .expect("encode"); + + let envelope: ConnectMessageEnvelope = serde_json::from_str(&json).expect("envelope"); + let body = decode_connect_message_body(&envelope, Some(secret), "sess_1").expect("decode"); + let hello: WalletHelloMetadata = serde_json::from_value(body).expect("parse"); + assert_eq!(hello.wallet_name, "ChallengeWallet"); + assert_eq!(hello.wallet_address, "0:chal"); + assert_eq!(hello.challenge_nonce.as_deref(), Some("deadbeef")); + assert_eq!(hello.challenge_signature.as_deref(), Some("sig123")); + assert_eq!(hello.challenge_epk_public.as_deref(), Some("epk456")); + } + + #[test] + fn normalize_query_limit_clamps() { + assert_eq!(normalize_query_session_messages_limit(None), 50); + assert_eq!(normalize_query_session_messages_limit(Some(0)), 1); + assert_eq!(normalize_query_session_messages_limit(Some(500)), 200); + } + + // ── Tests: sign_challenge / challenge_response parsing ────────── + + #[test] + fn parse_sign_challenge_message() { + let encryption_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let ts = 200u64; + let seq = 1_700_000_002_000u64; + let nonce = [11u8; 24]; + let salt = [12u8; 32]; + let body = serde_json::json!({ "nonce": "deadbeef42" }); + let aad = + connect_message_aad("sess_1", "c2w", seq, CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE, ts) + .expect("aad"); + let key = derive_connect_message_key(encryption_secret, &salt).expect("key"); + let cipher = XChaCha20Poly1305::new((&key).into()); + let body_bytes = serde_json::to_vec(&body).expect("body"); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: &body_bytes, aad: &aad }) + .expect("encrypt"); + let raw = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": "sess_1", + "dir": "c2w", + "seq": seq, + "type": CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE, + "ts": ts, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": URL_SAFE_NO_PAD.encode(nonce), + "salt": URL_SAFE_NO_PAD.encode(salt), + }, + "body": URL_SAFE_NO_PAD.encode(ciphertext), + }) + .to_string(); + + let parsed = parse_connect_session_message_with_secret( + raw, + "sess_1", + Some(encryption_secret), + "evt_sc".to_string(), + 200, + ) + .expect("parse"); + assert_eq!(parsed.msg_type, CONNECT_MESSAGE_TYPE_SIGN_CHALLENGE); + let sc = parsed.sign_challenge.expect("sign_challenge body"); + assert_eq!(sc.nonce, "deadbeef42"); + assert!(parsed.challenge_response.is_none()); + } + + #[test] + fn parse_challenge_response_message() { + let encryption_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let ts = 201u64; + let seq = 1_700_000_003_000u64; + let nonce = [13u8; 24]; + let salt = [14u8; 32]; + let sig = "cc".repeat(64); + let body = serde_json::json!({ + "nonce": "deadbeef42", + "signature": sig, + "wallet_address": "0:abcdef", + }); + let aad = + connect_message_aad("sess_1", "w2c", seq, CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, ts) + .expect("aad"); + let key = derive_connect_message_key(encryption_secret, &salt).expect("key"); + let cipher = XChaCha20Poly1305::new((&key).into()); + let body_bytes = serde_json::to_vec(&body).expect("body"); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: &body_bytes, aad: &aad }) + .expect("encrypt"); + let raw = serde_json::json!({ + "v": CONNECT_MESSAGE_VERSION, + "session_id": "sess_1", + "dir": "w2c", + "seq": seq, + "type": CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE, + "ts": ts, + "enc": { + "alg": CONNECT_MESSAGE_ENC_XCHACHA20POLY1305_HKDF_SHA256, + "nonce": URL_SAFE_NO_PAD.encode(nonce), + "salt": URL_SAFE_NO_PAD.encode(salt), + }, + "body": URL_SAFE_NO_PAD.encode(ciphertext), + }) + .to_string(); + + let parsed = parse_connect_session_message_with_secret( + raw, + "sess_1", + Some(encryption_secret), + "evt_cr".to_string(), + 201, + ) + .expect("parse"); + assert_eq!(parsed.msg_type, CONNECT_MESSAGE_TYPE_CHALLENGE_RESPONSE); + let cr = parsed.challenge_response.expect("challenge_response body"); + assert_eq!(cr.nonce, "deadbeef42"); + assert_eq!(cr.signature, sig); + assert_eq!(cr.wallet_address, "0:abcdef"); + assert!(parsed.sign_challenge.is_none()); + } + + #[test] + fn encode_wallet_hello_rejects_seq_zero() { + let secret = "aa".repeat(32); + let dh_pub = "bb".repeat(32); + let err = encode_wallet_hello_message( + "sess_1", "W", "0:addr", &secret, &dh_pub, 0, None, None, None, + ) + .unwrap_err(); + assert!(err.message.contains("seq must be > 0"), "got: {}", err.message); + } + + #[test] + fn encode_challenge_response_rejects_seq_zero() { + let secret = "aa".repeat(32); + let dh_pub = "bb".repeat(32); + let err = encode_challenge_response_message( + "sess_1", "deadbeef", "sigHex", "0:addr", None, &secret, &dh_pub, 0, + ) + .unwrap_err(); + assert!(err.message.contains("seq must be > 0"), "got: {}", err.message); + } +} diff --git a/bee_wallet/src/services/deploy/mod.rs b/bee_wallet/src/services/deploy/mod.rs new file mode 100644 index 0000000..2134600 --- /dev/null +++ b/bee_wallet/src/services/deploy/mod.rs @@ -0,0 +1,44 @@ +use ackinacki_kit::tvm_client::crypto::KeyPair; +use serde::Deserialize; +use serde::Serialize; + +pub mod prepare_deploy_params; +pub use prepare_deploy_params::ParamsOfPrepareDeploy; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ParamsOfDeployMultifactor { + pub wallet_name: String, + pub zkid: String, + pub password: String, + pub proof: String, + pub epk: String, + pub esk: String, + pub jwk_modulus: String, + pub jwk_modulus_expire_at: u64, + pub index_mod_4: u8, + pub iss_base_64: String, + pub header_base_64: String, + pub epk_expire_at: u64, + pub kid: String, + pub sub: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ResultOfDeployMultifactor { + pub name: String, + pub address: String, + pub message_id: Option, + pub message_ids: Vec, + pub pending_stage: Option, + pub pending_reason: Option, + pub password_hash: String, + pub phrase: String, + pub pubkey: String, + pub signing_keys: KeyPair, +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfDeployMiner { + pub multifactor_address: String, + pub signer_keys: KeyPair, +} diff --git a/bee_wallet/src/services/deploy/prepare_deploy_params.rs b/bee_wallet/src/services/deploy/prepare_deploy_params.rs new file mode 100644 index 0000000..50a954b --- /dev/null +++ b/bee_wallet/src/services/deploy/prepare_deploy_params.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::mirror::ParamsOfDeployMultifactor; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::ClientContext; +use bee_crypto::Crypto as BeeCrypto; +use gosh_tls_lib::tls_connect::get_root_certs_map; +use serde::Deserialize; +use serde::Serialize; + +use crate::services::multifactor::key_derivation::build_recovery_keys; +use crate::services::multifactor::key_derivation::build_update_jwk_keys; +use crate::services::multifactor::key_derivation::ParamsOfBuildRecoveryKeys; +use crate::services::multifactor::key_derivation::ParamsOfBuildUpdateJwkKeys; +use crate::services::multifactor::WASM_HASH; +use crate::services::zkp::auth::get_auth_provider; +use crate::services::zkp::auth::get_domain_by_auth_provider; +use crate::services::zkp::auth::Claim; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ParamsOfPrepareDeploy { + pub zkid: String, + pub password: String, + pub proof: String, + pub epk: String, + pub esk: String, + pub jwk_modulus: String, + pub jwk_modulus_expire_at: u64, + pub index_mod_4: u8, + pub iss_base_64: String, + pub header_base_64: String, + pub epk_expire_at: u64, + pub keys: KeyPair, + pub kid: String, + pub wallet_name: String, + pub multifactor_address: String, + pub sub: String, +} + +pub async fn prepare_multifactor_deploy( + tvm_context: Arc, + params: ParamsOfPrepareDeploy, +) -> crate::errors::AppResult<(ParamsOfDeployMultifactor, String)> { + let crypto = BeeCrypto::from_client_context(tvm_context.clone()); + let provider = get_auth_provider(Claim { + value: params.iss_base_64.clone(), + index_mod_4: params.index_mod_4, + })?; + + let domain = get_domain_by_auth_provider(&provider)?; + + let root_provider_certificates = get_root_certs_map(&domain).map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to get root certificates") + })?; + + let (recovery_keys_fn, update_jwk_keys_fn) = tokio::join!( + build_recovery_keys( + tvm_context.clone(), + ParamsOfBuildRecoveryKeys { + password: params.password.clone(), + multifactor_address: params.multifactor_address.clone(), + } + ), + build_update_jwk_keys( + tvm_context.clone(), + ParamsOfBuildUpdateJwkKeys { + password: params.password.clone(), + sub: params.sub.clone(), + } + ) + ); + + let recovery_keys = recovery_keys_fn?; + let update_jwk_keys = update_jwk_keys_fn?; + let epk_sig = crypto.sign_detached_hex(params.epk.clone(), params.esk)?; + + let deploy_params = ParamsOfDeployMultifactor { + name: params.wallet_name, + zkid: params.zkid, + proof: params.proof, + epk: format!("0x{}", params.epk), + epk_sig, + epk_expire_at: params.epk_expire_at, + jwk_modulus: params.jwk_modulus, + kid: params.kid, + jwk_modulus_expire_at: params.jwk_modulus_expire_at, + index_mod_4: params.index_mod_4, + iss_base_64: params.iss_base_64, + header_base_64: params.header_base_64, + pub_recovery_key: recovery_keys.pub_recovery_key, + pub_recovery_key_sig: recovery_keys.pub_recovery_key_sig, + // owner_pubkey: format!("0x{}", params.keys.public.clone()), + root_provider_certificates, + provider: provider.to_string(), + jwk_update_key: update_jwk_keys.pub_jwk_update_key, + jwk_update_key_sig: update_jwk_keys.pub_jwk_update_sig, + }; + + Ok((deploy_params, WASM_HASH.to_string())) +} diff --git a/bee_wallet/src/services/dex.rs b/bee_wallet/src/services/dex.rs new file mode 100644 index 0000000..8fc4b0f --- /dev/null +++ b/bee_wallet/src/services/dex.rs @@ -0,0 +1,105 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSubmitTransaction; +use ackinacki_kit::contracts::mvsystem::ContractIndex; +use ackinacki_kit::contracts::traits::AbiAccessor; +use ackinacki_kit::tvm_client::abi::CallSet; +use ackinacki_kit::tvm_client::abi::ParamsOfEncodeMessageBody; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::processing::ResultOfSendMessage; +use ackinacki_kit::tvm_client::ClientContext; +use dodex_contracts::dex::root_pn::ParamsOfGenerateVoucher as KitParamsOfGenerateVoucher; +use dodex_contracts::dex::root_pn::RootPn; +use serde_json::json; + +use crate::errors::AppError; +use crate::errors::AppResult; + +#[allow(dead_code)] +pub struct ParamsOfGenerateVoucher { + pub multifactor_address: String, + pub token_type: u32, + pub amount: u64, + pub is_fee: bool, + /// `skUCommit` to embed in the `RootPN.generateVoucher` payload — for the + /// production halo2 flow this must match `poseidon([sk_u, 0])` of the + /// `sk_u` the prover will use. Pass `"0"` when no halo2 binding is needed. + pub sk_u_commit: String, + pub signer_keys: ackinacki_kit::tvm_client::crypto::KeyPair, + pub epk_expire_at: u64, + pub mirror_index: u128, +} + +/// Add RootPN to whitelist, encode `generatevoucher` payload, and send ECC +/// tokens from the multifactor to RootPN via `submitTransaction`. +pub async fn generate_voucher( + tvm_client: Arc, + multifactor: &Arc, + params: ParamsOfGenerateVoucher, +) -> AppResult { + let signer = Signer::Keys { keys: params.signer_keys }; + + // 1. Add RootPN to whitelist + super::multifactor::whitelist::update_multifactor_whitelist_and_wait( + multifactor, + super::multifactor::whitelist::ParamsOfUpdateMultifactorWhiteList { + epk_expire_at: params.epk_expire_at, + payload_destination: ContractIndex::RootPn, + target_address: RootPn::DEFAULT_ADDRESS.to_string(), + mirror_index: params.mirror_index, + whitelisted_name: None, + }, + &signer, + ) + .await + .map_err(|e| e.with_context("generate_voucher: add RootPN to whitelist"))?; + + // 2. Encode generatevoucher payload using RootPn ABI + let root_pn = + RootPn::new(tvm_client.clone(), crate::dapp::dex_contract_params(RootPn::DEFAULT_ADDRESS)); + let payload = ackinacki_kit::tvm_client::abi::encode_message_body( + tvm_client, + ParamsOfEncodeMessageBody { + abi: root_pn.abi().clone(), + call_set: CallSet { + function_name: "generateVoucher".to_string(), + header: None, + input: Some(json!(KitParamsOfGenerateVoucher { + sk_u_commit: params.sk_u_commit.clone(), + is_fee: params.is_fee, + })), + }, + is_internal: true, + signer: Signer::None, + processing_try_index: None, + address: None, + signature_id: None, + }, + ) + .await + .map_err(|e| AppError::from(e).with_context("encode generatevoucher payload"))? + .body; + + // 3. Submit transaction: send ECC + 2 vmshell native (compute fuel for + // `RootPN.generateVoucher`) to RootPN with the encoded payload. + let ecc = HashMap::from([(params.token_type, params.amount)]); + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: RootPn::DEFAULT_ADDRESS.to_string(), + epk_expire_at: params.epk_expire_at, + value: 2_000_000_000, + cc: ecc, + bounce: true, + all_balance: false, + payload, + }, + signer, + ) + .await + .map_err(|e| AppError::from(e).with_context("generate_voucher: submit_transaction"))?; + + crate::infra::ensure_tx_success(result, "generate_voucher") +} diff --git a/bee_wallet/src/services/miner/cmd.rs b/bee_wallet/src/services/miner/cmd.rs new file mode 100644 index 0000000..41535f1 --- /dev/null +++ b/bee_wallet/src/services/miner/cmd.rs @@ -0,0 +1,288 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use ackinacki_kit::contracts::account::AccountStatus; +use ackinacki_kit::contracts::account::ParamsOfWaitAccount; +use ackinacki_kit::contracts::mvsystem::miner::contract::Miner; +use ackinacki_kit::contracts::mvsystem::miner::contract::ParamsOfEncodeRemoveOwnerPublic; +use ackinacki_kit::contracts::mvsystem::miner::contract::ParamsOfEncodeSetOwnerPublic; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSubmitTransaction; +use ackinacki_kit::contracts::mvsystem::ContractIndex; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::ClientContext; + +use crate::services; +use crate::services::multifactor::whitelist::ParamsOfUpdateMultifactorWhiteList; +use crate::services::resolvers::resolve_miner_for_multifactor; +use crate::services::resolvers::resolve_mirror; +use crate::services::resolvers::MirrorSelector; + +const TX_VALUE: u128 = 100_000_000; + +pub struct ResultOfDeployMiner { + pub message_ids: Vec, + pub pending_stage: Option, + pub pending_reason: Option, +} + +pub async fn deploy_miner( + tvm_client: Arc, + multifactor: &Arc, + signer: &Signer, + epk_expire_at: u64, +) -> crate::errors::AppResult { + let mirror = resolve_mirror( + tvm_client.clone(), + MirrorSelector::ForMultifactorAddr(multifactor.address()), + )?; + + let miner = resolve_miner_for_multifactor(tvm_client, multifactor.address()).await?; + let miner = Arc::new(miner); + + let whitelist_result = services::multifactor::whitelist::update_multifactor_whitelist_and_wait( + multifactor, + ParamsOfUpdateMultifactorWhiteList { + epk_expire_at, + payload_destination: ContractIndex::Mirror, + target_address: mirror.address().to_string(), + whitelisted_name: None, + mirror_index: mirror.index() - 1, + }, + signer, + ) + .await + .map_err(|e| e.with_context("miner.deploy_miner: add mirror to whitelist"))?; + let mut message_ids = whitelist_result.message_ids; + let deploy_miner_message = mirror.deploy_miner_message().await.map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.deploy_miner: mirror.deploy_miner_message") + })?; + + let res = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: mirror.address().to_string(), + value: TX_VALUE, + cc: HashMap::new(), + bounce: true, + all_balance: false, + epk_expire_at, + payload: deploy_miner_message, + }, + signer.clone(), + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.deploy_miner: multifactor.submit_transaction") + })?; + + let res = crate::infra::ensure_tx_success(res, "deploy_miner")?; + if let Some(message_id) = res.message_hash { + message_ids.push(message_id); + } + let wait_miner_result = miner + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::Active, + attempts: Some(100), + attempts_timeout: Some(100), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("miner.deploy_miner: miner.wait_account") + }); + + let (pending_stage, pending_reason) = match wait_miner_result { + Ok(()) => (None, None), + Err(err) if crate::infra::is_confirmation_pending_error(&err) => { + (Some("miner_activation".to_string()), Some(err.message.clone())) + } + Err(err) => return Err(err), + }; + + Ok(ResultOfDeployMiner { message_ids, pending_stage, pending_reason }) +} + +pub async fn set_mining_keys( + tvm_client: Arc, + miner: &Arc, + multifactor: &Arc, + mining_pubkey: String, + epk_expire_at: u64, + app_id: String, + signer: &Signer, +) -> crate::errors::AppResult> { + let mirror = + resolve_mirror(tvm_client, MirrorSelector::ForMultifactorAddr(multifactor.address()))?; + + let whitelist_result = services::multifactor::whitelist::update_multifactor_whitelist_and_wait( + multifactor, + ParamsOfUpdateMultifactorWhiteList { + epk_expire_at, + payload_destination: ContractIndex::Miner, + target_address: miner.address().to_string(), + whitelisted_name: None, + mirror_index: mirror.index() - 1, + }, + signer, + ) + .await + .map_err(|e| e.with_context("miner.set_mining_keys: add miner to whitelist"))?; + let mut message_ids = whitelist_result.message_ids; + + let expected_owner_public = format!("0x{}", mining_pubkey); + + let msg = miner + .set_owner_public_message(ParamsOfEncodeSetOwnerPublic { + public: expected_owner_public.clone(), + app_id: app_id.clone(), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.set_mining_keys: miner.set_owner_public_message") + })?; + + let res = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: miner.address().to_string(), + value: TX_VALUE, + cc: HashMap::new(), + bounce: true, + all_balance: false, + epk_expire_at, + payload: msg, + }, + signer.clone(), + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.set_mining_keys: multifactor.submit_transaction") + })?; + + let res = crate::infra::ensure_tx_success(res, "set_mining_keys")?; + if let Some(message_id) = res.message_hash { + message_ids.push(message_id); + } + + let miner = miner.clone(); + let app_id_for_wait = app_id.clone(); + let _ = crate::infra::poll_until( + move || { + let miner = miner.clone(); + async move { + miner.get_details().await.map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.set_mining_keys: miner.get_details") + }) + } + }, + move |details| { + details + .owner_public + .get(app_id_for_wait.as_str()) + .map(|value| value == &expected_owner_public) + .unwrap_or(false) + }, + Some(200), + Some(250), + ) + .await + .map_err(|e| e.with_context("miner.set_mining_keys: wait owner_public set"))?; + + Ok(message_ids) +} + +pub async fn del_mining_key( + tvm_client: Arc, + miner: &Arc, + multifactor: &Arc, + epk_expire_at: u64, + app_id: String, + wait: bool, + signer: &Signer, +) -> crate::errors::AppResult> { + let mirror = + resolve_mirror(tvm_client, MirrorSelector::ForMultifactorAddr(multifactor.address()))?; + + let whitelist_result = services::multifactor::whitelist::update_multifactor_whitelist_and_wait( + multifactor, + ParamsOfUpdateMultifactorWhiteList { + epk_expire_at, + payload_destination: ContractIndex::Miner, + target_address: miner.address().to_string(), + whitelisted_name: None, + mirror_index: mirror.index() - 1, + }, + signer, + ) + .await + .map_err(|e| e.with_context("miner.del_mining_key: add miner to whitelist"))?; + let mut message_ids = whitelist_result.message_ids; + + let msg = miner + .remove_owner_public_message(ParamsOfEncodeRemoveOwnerPublic { app_id: app_id.clone() }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.del_mining_key: miner.remove_owner_public_message") + })?; + + let res = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: miner.address().to_string(), + value: TX_VALUE, + cc: HashMap::new(), + bounce: true, + all_balance: false, + epk_expire_at, + payload: msg, + }, + signer.clone(), + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.del_mining_key: multifactor.submit_transaction") + })?; + + let res = crate::infra::ensure_tx_success(res, "del_mining_key")?; + if let Some(message_id) = res.message_hash { + message_ids.push(message_id); + } + + if wait { + let miner = miner.clone(); + let app_id_for_wait = app_id.clone(); + let _ = crate::infra::poll_until( + || { + let miner = miner.clone(); + async move { + miner.get_details().await.map_err(|e| { + crate::errors::AppError::from(e) + .with_context("miner.del_mining_key: miner.get_details") + }) + } + }, + move |details| { + !details + .owner_public + .get(app_id_for_wait.as_str()) + .map(|value| !value.is_empty()) + .unwrap_or(false) + }, + None, + None, + ) + .await + .map_err(|e| e.with_context("miner.del_mining_key: wait owner_public removal"))?; + } + + Ok(message_ids) +} diff --git a/bee_wallet/src/services/miner/mod.rs b/bee_wallet/src/services/miner/mod.rs new file mode 100644 index 0000000..ddfa3ef --- /dev/null +++ b/bee_wallet/src/services/miner/mod.rs @@ -0,0 +1,26 @@ +pub mod cmd; +pub mod query; + +use ackinacki_kit::tvm_client::crypto::KeyPair; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct ParamsOfSetMiningKeys { + pub multifactor_address: String, + pub signer_keys: KeyPair, + pub mining_pubkey: String, + #[serde(default)] + pub app_id: String, + pub epk_expire_at: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfDelMiningKey { + pub multifactor_address: String, + pub signer_keys: KeyPair, + #[serde(default)] + pub app_id: String, + pub epk_expire_at: Option, + #[serde(default)] + pub wait: bool, +} diff --git a/bee_wallet/src/services/miner/query.rs b/bee_wallet/src/services/miner/query.rs new file mode 100644 index 0000000..01faa70 --- /dev/null +++ b/bee_wallet/src/services/miner/query.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::miner::contract::ResultOfGetDetails as MinerDetails; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::ClientContext; +use serde::Deserialize; + +use crate::services; + +#[derive(Debug, Deserialize)] +pub struct ParamsOfGetMinerDetails { + pub multifactor_address: String, +} + +#[derive(Debug, Deserialize)] +pub struct ResultOfGetMinerDetails { + pub address: String, + pub details: MinerDetails, +} + +pub async fn get_details_by_multifactor_address( + tvm_client: Arc, + params: ParamsOfGetMinerDetails, +) -> crate::errors::AppResult { + let miner = + services::resolvers::resolve_miner_for_multifactor(tvm_client, ¶ms.multifactor_address) + .await?; + + let details = miner + .get_details() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Miner get details"))?; + + Ok(ResultOfGetMinerDetails { address: miner.address().to_string(), details }) +} diff --git a/bee_wallet/src/services/mod.rs b/bee_wallet/src/services/mod.rs new file mode 100644 index 0000000..e66ba5a --- /dev/null +++ b/bee_wallet/src/services/mod.rs @@ -0,0 +1,25 @@ +//! Thin service/helper layer. +//! +//! Responsibilities: +//! - encapsulate reusable domain helpers and contract call helpers; +//! - keep logic focused and composable for modules. +//! +//! Non-responsibilities: +//! - cross-domain orchestration of full user flows (belongs to `modules`); +//! - transport/API shape ownership (belongs to adapters/types). + +pub mod balance; +pub mod boost; +pub mod connect; +pub mod deploy; +// DEX wrappers live in `dodex-contracts` (native-only dep); voucher generation +// isn't exposed on wasm. +#[cfg(not(target_arch = "wasm32"))] +pub mod dex; +pub mod miner; +pub mod multifactor; +pub mod resolvers; +pub mod tokens; +pub mod transaction; +pub mod validate_name; +pub mod zkp; diff --git a/bee_wallet/src/services/multifactor/cmd.rs b/bee_wallet/src/services/multifactor/cmd.rs new file mode 100644 index 0000000..a4e450c --- /dev/null +++ b/bee_wallet/src/services/multifactor/cmd.rs @@ -0,0 +1,507 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::AccountData as MultifactorData; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor as MvMultifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfAcceptCandidateSeedPhrase; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfAddZkpFactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfChangeSeedPhrase as KitParamsOfChangeSeedPhrase; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfDeleteCandidateSeedPhrase; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSetForceRemoveOldest; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSetWasmHash; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::ClientContext; +use bee_crypto::Crypto as BeeCrypto; +use serde::Deserialize; + +use crate::infra::poll_until; +use crate::progress::ProgressEvent; +use crate::progress::ProgressSink; +use crate::services::multifactor::key_derivation::resolve_recovery_signer; +use crate::services::multifactor::key_derivation::ParamsOfBuildRecoveryKeys; + +const MAX_NUM_OF_FACTORS: i32 = 10; + +/// Bounded confirm-poll for `add_zkp_factor` (~30s = 60 × 500ms). On +/// exhaustion the caller gets a typed terminal `add_factor_timeout` error +/// instead of a UI stuck waiting on an increment that never comes. +const ADD_FACTOR_CONFIRM_MAX_ATTEMPTS: u32 = 60; +const ADD_FACTOR_CONFIRM_INTERVAL_MS: u64 = 500; + +#[derive(Debug)] +pub struct AddZkpFactorReq { + pub header_base_64: String, + pub proof: String, + pub epk_expire_at: i64, + pub epk: String, + pub esk: String, + pub kid: String, +} + +#[derive(Debug)] +pub struct ResultOfAddZkpFactor { + pub message_id: Option, +} + +#[derive(Debug)] +pub struct ResultOfChangeSeedPhrase { + pub message_ids: Vec, +} + +fn push_message_id(message_ids: &mut Vec, message_id: Option) { + if let Some(message_id) = message_id { + message_ids.push(message_id); + } +} + +fn has_candidate_seed_phrase(data: &MultifactorData) -> bool { + data.candidate_new_owner_pubkey_and_expiration.as_ref().map(|m| !m.is_empty()).unwrap_or(false) +} + +async fn get_multifactor_decoded_data( + multifactor: &Arc, +) -> crate::errors::AppResult { + super::query::get_multifactor_decoded_data(multifactor).await +} + +async fn wait_until_candidate_seed_phrase_absent( + multifactor: &Arc, +) -> crate::errors::AppResult<()> { + let mf = Arc::clone(multifactor); + let _ = poll_until( + || { + let mf = mf.clone(); + async move { + super::query::get_multifactor_decoded_data(&mf) + .await + .map_err(|e| e.with_context("Multifactor get details")) + } + }, + move |data: &MultifactorData| !has_candidate_seed_phrase(data), + None, + None, + ) + .await?; + + Ok(()) +} + +async fn wait_until_candidate_seed_phrase_present( + multifactor: &Arc, +) -> crate::errors::AppResult<()> { + let mf = Arc::clone(multifactor); + let _ = poll_until( + || { + let mf = mf.clone(); + async move { + super::query::get_multifactor_decoded_data(&mf) + .await + .map_err(|e| e.with_context("Multifactor get details")) + } + }, + move |data: &MultifactorData| { + // TODO: double check what exactly we have here -> should we look for new owner + // pub key? + has_candidate_seed_phrase(data) + }, + None, + None, + ) + .await?; + + Ok(()) +} + +async fn wait_until_owner_pubkey( + multifactor: &Arc, + expected_owner_pubkey: String, +) -> crate::errors::AppResult<()> { + let mf = Arc::clone(multifactor); + let _ = poll_until( + || { + let mf = mf.clone(); + async move { + super::query::get_multifactor_decoded_data(&mf) + .await + .map_err(|e| e.with_context("Multifactor get details")) + } + }, + move |data: &MultifactorData| data.owner_pubkey == expected_owner_pubkey, + None, + None, + ) + .await?; + + Ok(()) +} + +async fn delete_candidate_seed_phrase_and_wait( + multifactor: &Arc, + epk_expire_at: u64, + signer_keys: KeyPair, +) -> crate::errors::AppResult> { + let result = multifactor + .delete_candidate_seed_phrase( + ParamsOfDeleteCandidateSeedPhrase { epk_expire_at }, + Signer::Keys { keys: signer_keys }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("[delete_candidate_seed_phrase]") + })?; + + let result = crate::infra::ensure_tx_success(result, "delete_candidate_seed_phrase")?; + wait_until_candidate_seed_phrase_absent(multifactor).await?; + + Ok(result.message_hash) +} + +async fn submit_change_seed_phrase( + multifactor: &Arc, + epk_expire_at: u64, + new_owner_pubkey_sig: String, + new_owner_pubkey: String, + signer_keys: KeyPair, +) -> crate::errors::AppResult> { + let result = multifactor + .change_seed_phrase( + KitParamsOfChangeSeedPhrase { epk_expire_at, new_owner_pubkey_sig, new_owner_pubkey }, + Signer::Keys { keys: signer_keys }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("[change_seed_phrase]"))?; + + let result = crate::infra::ensure_tx_success(result, "change_seed_phrase")?; + wait_until_candidate_seed_phrase_present(multifactor).await?; + + Ok(result.message_hash) +} + +async fn accept_candidate_seed_phrase_and_wait( + multifactor: &Arc, + new_owner_pubkey: String, + signer_keys: KeyPair, +) -> crate::errors::AppResult> { + let result = multifactor + .accept_candidate_seed_phrase( + ParamsOfAcceptCandidateSeedPhrase { new_owner_pubkey: new_owner_pubkey.clone() }, + Signer::Keys { keys: signer_keys }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("[accept_candidate_seed_phrase]") + })?; + + let result = crate::infra::ensure_tx_success(result, "accept_candidate_seed_phrase")?; + wait_until_owner_pubkey(multifactor, new_owner_pubkey).await?; + + Ok(result.message_hash) +} + +/// Confirm phase shared by `add_zkp_factor`: polls `fetch` until `predicate` +/// holds, emitting an `add_factor:confirming` heartbeat on every attempt, a +/// final `add_factor:confirmed` on success, or `add_factor:timed_out` (plus a +/// terminal `add_factor_timeout` error) when attempts are exhausted. Generic +/// over the fetched data so it stays unit-testable without a live contract. +async fn confirm_factor_increment( + fetch: FetchFn, + predicate: VerifyFn, + progress: Option, + max_attempts: u32, + interval_ms: u64, +) -> crate::errors::AppResult +where + FetchFn: Fn() -> FetchFut, + FetchFut: std::future::Future>, + VerifyFn: Fn(&T) -> bool, +{ + let heartbeat = progress.clone(); + let polled = poll_until( + || { + let heartbeat = heartbeat.clone(); + let fut = fetch(); + async move { + // One heartbeat per poll attempt = "still working" liveness. + crate::progress::emit( + heartbeat.as_ref(), + ProgressEvent::new("add_factor", "confirming"), + ); + fut.await + } + }, + predicate, + Some(max_attempts), + Some(interval_ms), + ) + .await; + + let data = match polled { + Ok(data) => data, + Err(e) => { + if crate::infra::is_confirmation_pending_error(&e) { + crate::progress::emit( + progress.as_ref(), + ProgressEvent::new("add_factor", "timed_out"), + ); + return Err(crate::errors::AppError::new( + "add_zkp_factor: on-chain confirmation did not arrive in time", + ) + .with_kind("add_factor_timeout")); + } + return Err(e); + } + }; + + crate::progress::emit(progress.as_ref(), ProgressEvent::new("add_factor", "confirmed")); + Ok(data) +} + +pub async fn add_zkp_factor( + multifactor: &Arc, + multifactor_data: &MultifactorData, + params: AddZkpFactorReq, + progress: Option, +) -> crate::errors::AppResult { + let current_factors_length = multifactor_data.factors_len.parse().unwrap_or(0); + let result = multifactor + .add_zkp_factor( + ParamsOfAddZkpFactor { + proof: params.proof, + epk: format!("0x{}", params.epk.clone()), + kid: params.kid, + header_base_64: params.header_base_64, + epk_expire_at: params.epk_expire_at, + }, + Signer::Keys { keys: KeyPair { public: params.epk, secret: params.esk } }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to add zkp factor"))?; + + let result = crate::infra::ensure_tx_success(result, "add_zkp_factor")?; + let message_id = result.message_hash.clone(); + + // Message accepted by the network; now wait for the factor count to tick up. + crate::progress::emit(progress.as_ref(), ProgressEvent::new("add_factor", "submitted")); + + let mf = Arc::clone(multifactor); + let target_factors_len = (current_factors_length + 1).min(MAX_NUM_OF_FACTORS); + confirm_factor_increment( + move || { + let mf = mf.clone(); + async move { + super::query::get_multifactor_decoded_data(&mf) + .await + .map_err(|e| e.with_context("Multifactor get details")) + } + }, + move |data: &MultifactorData| data.factors_len.parse().unwrap_or(0) == target_factors_len, + progress, + ADD_FACTOR_CONFIRM_MAX_ATTEMPTS, + ADD_FACTOR_CONFIRM_INTERVAL_MS, + ) + .await?; + + Ok(ResultOfAddZkpFactor { message_id }) +} + +pub async fn set_remove_oldest( + multifactor: &Arc, + owner_keys: KeyPair, + flag: bool, +) -> crate::errors::AppResult> { + let result = multifactor + .set_force_remove_oldest( + ParamsOfSetForceRemoveOldest { flag }, + Signer::Keys { keys: owner_keys }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Set force to remove oldest failure") + })?; + + let result = crate::infra::ensure_tx_success(result, "set_force_remove_oldest")?; + + Ok(result.message_hash) +} + +pub async fn set_wasm_hash( + multifactor: &Arc, + owner_keys: KeyPair, + wasm_hash: impl AsRef, +) -> crate::errors::AppResult> { + let result = multifactor + .set_wasm_hash( + ParamsOfSetWasmHash { wasm_hash: wasm_hash.as_ref().to_string() }, + Signer::Keys { keys: owner_keys.clone() }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Set wasm hash failure"))?; + + let result = crate::infra::ensure_tx_success(result, "set_wasm_hash")?; + + Ok(result.message_hash) +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfChangeSeedPhrase { + pub password: String, + pub signer_keys: KeyPair, + pub new_owner_keys: KeyPair, + pub multifactor_address: String, +} + +// TODO(refactor/change_seed_phrase): Keep this as one domain operation +// ("replace seed"), but split the internal orchestration into private step +// helpers to reduce function size. +pub async fn change_seed_phrase( + tvm_ctx: Arc, + multifactor: &Arc, + epk_expire_at: u64, + params: ParamsOfChangeSeedPhrase, +) -> crate::errors::AppResult { + let mut message_ids = Vec::new(); + let crypto = BeeCrypto::from_client_context(tvm_ctx.clone()); + + let new_owner_pubkey_sig = crypto.sign_detached_hex( + params.new_owner_keys.public.clone(), + params.new_owner_keys.secret.clone(), + )?; + let new_owner_pubkey = format!("0x{}", params.new_owner_keys.public); + + let multifactor_data = get_multifactor_decoded_data(multifactor).await?; + let has_candidate = has_candidate_seed_phrase(&multifactor_data); + + if has_candidate { + let message_id = delete_candidate_seed_phrase_and_wait( + multifactor, + epk_expire_at, + params.signer_keys.clone(), + ) + .await?; + push_message_id(&mut message_ids, message_id); + } + + let message_id = { + let mf = Arc::clone(multifactor); + let sig = new_owner_pubkey_sig.clone(); + let pubkey = new_owner_pubkey.clone(); + let keys = params.signer_keys.clone(); + crate::infra::with_retry( + move || { + let mf = mf.clone(); + let sig = sig.clone(); + let pubkey = pubkey.clone(); + let keys = keys.clone(); + async move { + submit_change_seed_phrase(&mf, epk_expire_at, sig, pubkey, keys).await + } + }, + 3, + 3000, + None:: bool>, + ) + .await + .map_err(|e| e.with_context("change_seed_phrase step 2: submit"))? + }; + push_message_id(&mut message_ids, message_id); + + // `acceptCandidateSeedPhrase` must be signed by the recovery key. Pre-v3 + // wallets hold it on the legacy (396) HD path, so resolve against the + // on-chain `pub_recovery_key` instead of re-deriving on the current path + // (which would fail with ERR_NOT_OWNER). `pub_recovery_key` is not mutated + // by the delete/submit steps above, so the earlier read is still valid. + let (recovery_signer, _resolved_path) = resolve_recovery_signer( + tvm_ctx.clone(), + ParamsOfBuildRecoveryKeys { + password: params.password.clone(), + multifactor_address: multifactor.address().to_string(), + }, + &multifactor_data.pub_recovery_key, + )?; + + let message_id = { + let mf = Arc::clone(multifactor); + let pubkey = format!("0x{}", params.new_owner_keys.public); + let keys = recovery_signer; + crate::infra::with_retry( + move || { + let mf = mf.clone(); + let pubkey = pubkey.clone(); + let keys = keys.clone(); + async move { accept_candidate_seed_phrase_and_wait(&mf, pubkey, keys).await } + }, + 3, + 3000, + None:: bool>, + ) + .await + .map_err(|e| e.with_context("change_seed_phrase step 3: accept"))? + }; + push_message_id(&mut message_ids, message_id); + + Ok(ResultOfChangeSeedPhrase { message_ids }) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicU32; + use std::sync::atomic::Ordering; + use std::sync::Arc; + + use futures::StreamExt; + + use super::confirm_factor_increment; + use crate::errors::AppError; + use crate::progress::ProgressEvent; + + #[tokio::test] + async fn confirm_emits_heartbeat_per_attempt_then_confirmed() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + let calls = Arc::new(AtomicU32::new(0)); + let calls_fetch = calls.clone(); + + let res = confirm_factor_increment( + move || { + let calls_fetch = calls_fetch.clone(); + async move { + // satisfies the predicate on the 3rd attempt + let n = calls_fetch.fetch_add(1, Ordering::SeqCst) + 1; + Ok::(n) + } + }, + |n: &u32| *n >= 3, + Some(tx), + 10, + 1, + ) + .await; + + assert!(res.is_ok()); + let events: Vec = rx.collect().await; + let stages: Vec<&str> = events.iter().map(|e| e.stage.as_str()).collect(); + assert_eq!(stages, ["confirming", "confirming", "confirming", "confirmed"]); + assert!(events.iter().all(|e| e.op == "add_factor")); + } + + #[tokio::test] + async fn confirm_times_out_with_terminal_error_and_emits_timed_out() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + + let res = confirm_factor_increment( + || async { Ok::(0) }, + |n: &u32| *n == 99, + Some(tx), + 3, + 1, + ) + .await; + + let err = res.expect_err("should time out"); + assert_eq!(err.kind.as_deref(), Some("add_factor_timeout")); + + let events: Vec = rx.collect().await; + let stages: Vec<&str> = events.iter().map(|e| e.stage.as_str()).collect(); + assert_eq!(stages, ["confirming", "confirming", "confirming", "timed_out"]); + } +} diff --git a/bee_wallet/src/services/multifactor/jwk.rs b/bee_wallet/src/services/multifactor/jwk.rs new file mode 100644 index 0000000..e608287 --- /dev/null +++ b/bee_wallet/src/services/multifactor/jwk.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::AccountData as MultifactorData; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfAddJwkModulus; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::ClientContext; +use serde::Deserialize; +use serde::Serialize; + +use crate::infra::poll_until; +use crate::services::multifactor::key_derivation::resolve_jwk_update_signer; +use crate::services::multifactor::key_derivation::ParamsOfBuildUpdateJwkKeys; + +#[derive(Debug)] +pub struct AddJwkModulusReq { + pub kid: String, + pub password: String, + pub sub: String, + pub kid_boc_hash: String, +} + +pub async fn add_jwk_modulus( + tvm_ctx: Arc, + multifactor: &Arc, + multifactor_data: &MultifactorData, + params: AddJwkModulusReq, +) -> crate::errors::AppResult> { + let tls_res = + fetch_tls(multifactor_data.iss_base_64.clone(), multifactor_data.index_mod_4.clone()) + .await + .map_err(|e| e.with_context("failed to fetch tls"))?; + + let lv_kid = to_lv_kid(¶ms.kid)?; + + // `addJwkModulus` is gated by `onlyOwnerPubkey(_jwk_update_key)`, so we must + // sign with the key that matches the wallet's on-chain `jwk_update_key`. + // Pre-v3 wallets hold it on the legacy (396) HD path; resolving against the + // on-chain value lets them sign instead of failing with 402/ERR_NOT_OWNER. + let (jwk_signer, _resolved_path) = resolve_jwk_update_signer( + tvm_ctx.clone(), + ParamsOfBuildUpdateJwkKeys { password: params.password.clone(), sub: params.sub.clone() }, + &multifactor_data.jwk_update_key, + )?; + let res = multifactor + .add_jwk_modulus( + ParamsOfAddJwkModulus { + lv_kid, + root_cert_sn: tls_res.root_cert_sn_hex, + tls_data: tls_res.tls_data, + }, + Signer::Keys { keys: jwk_signer }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to add jwk modulus"))?; + + let res = crate::infra::ensure_tx_success(res, "add_jwk_modulus")?; + let message_id = res.message_hash.clone(); + + let boc_hash = params.kid_boc_hash.clone(); + let mf = Arc::clone(multifactor); + let _details = poll_until( + || { + let mf = mf.clone(); + async move { + super::query::get_multifactor_decoded_data(&mf) + .await + .map_err(|e| e.with_context("Multifactor get details")) + } + }, + move |data: &MultifactorData| { + let existing_contract_jwk = data.jwk_modulus_data.get(&format!("0x{}", boc_hash)); + existing_contract_jwk.is_some() + }, + None, + None, + ) + .await?; + + Ok(message_id) +} + +#[derive(Deserialize)] +pub struct TLSRes { + pub root_cert_sn_hex: String, + pub tls_data: String, +} + +#[derive(Serialize)] +struct GetTlsReq { + pub iss_base_64: String, + pub index_mod_4: String, +} + +async fn fetch_tls(iss_base_64: String, index_mod_4: String) -> crate::errors::AppResult { + let client = reqwest::Client::new(); + + let response = client + .post("https://oauth.gosh.sh/v1/tls") + .json(&GetTlsReq { iss_base_64, index_mod_4 }) + .send() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Send fetch tls request"))?; + + if !response.status().is_success() { + return Err(crate::errors::AppError::new(format!( + "Fetch tls response (status: {})", + response.status() + ))); + } + + let res = response + .json::() + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to deserialize"))?; + + Ok(res) +} + +fn to_lv_kid(kid_hex: &str) -> crate::errors::AppResult { + let kid = hex::decode(kid_hex) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to decode kid_hex"))?; + let len = kid.len() as u8; + let mut out = Vec::with_capacity(1 + kid.len()); + out.push(len); + out.extend_from_slice(&kid); + + Ok(hex::encode(out)) +} diff --git a/bee_wallet/src/services/multifactor/key_derivation.rs b/bee_wallet/src/services/multifactor/key_derivation.rs new file mode 100644 index 0000000..bde07e4 --- /dev/null +++ b/bee_wallet/src/services/multifactor/key_derivation.rs @@ -0,0 +1,488 @@ +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::crypto::{self as tvm_crypto}; +use ackinacki_kit::tvm_client::ClientContext; +use base64::Engine; +use bee_crypto::Crypto as BeeCrypto; +use pbkdf2::password_hash::PasswordHasher; +use pbkdf2::password_hash::SaltString; +use pbkdf2::Params; +use pbkdf2::Pbkdf2; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; + +/// Current tvm-sdk default HD derivation path (v3.0.0.an, commit 4841c29). +/// +/// Pinned explicitly: every multifactor key we *write* on-chain is derived +/// here, so a future change to the SDK's default path can never again silently +/// re-derive (and brick) freshly created wallets. This equals today's SDK +/// default, so pinning is a no-op for current wallets — guarded by the +/// `current_path_matches_sdk_default` test. +pub const CURRENT_DERIVATION_PATH: &str = "m/44'/1331'/0'/0/0"; + +/// Legacy HD path of wallets created on tvm-sdk v2 (coin type 396). +/// +/// Their multifactor keys (owner / jwk-update / recovery) live here. We never +/// write new keys on this path; we only *resolve* against it so a pre-v3 wallet +/// can still sign with the key it actually holds on-chain (match-and-sign). +/// Drop once telemetry shows the 396 population has fully migrated. +pub const LEGACY_DERIVATION_PATH: &str = "m/44'/396'/0'/0/0"; + +/// HD paths tried, in order, when resolving which key a wallet holds on-chain. +/// Current first so post-v3 wallets match on the first attempt. +const CANDIDATE_PATHS: [&str; 2] = [CURRENT_DERIVATION_PATH, LEGACY_DERIVATION_PATH]; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ParamsOfBuildRecoveryKeys { + pub password: String, + pub multifactor_address: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ParamsOfBuildUpdateJwkKeys { + pub password: String, + pub sub: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RecoveryKeys { + pub pub_recovery_key: String, + pub pub_recovery_key_sig: String, + pub keys: KeyPair, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JwkUpdateKeys { + pub pub_jwk_update_key: String, + pub pub_jwk_update_sig: String, + pub keys: KeyPair, +} + +/// Derive an ed25519 signing keypair from a 24-word `phrase` on an explicit HD +/// `path`. Unlike `path: None`, this never depends on the SDK's mutable +/// default. +fn derive_sign_keys( + tvm_context: Arc, + phrase: String, + path: &str, +) -> crate::errors::AppResult { + tvm_crypto::mnemonic_derive_sign_keys( + tvm_context, + tvm_crypto::ParamsOfMnemonicDeriveSignKeys { + phrase, + path: Some(path.to_string()), + dictionary: None, + word_count: Some(24), + }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to derive sign keys")) +} + +/// True if `derived_public` (bare hex, as returned by the SDK) is the same key +/// as `on_chain_pubkey` (which the contract stores `0x`-prefixed). +fn pubkey_matches(derived_public: &str, on_chain_pubkey: &str) -> bool { + let on_chain = on_chain_pubkey.strip_prefix("0x").unwrap_or(on_chain_pubkey); + derived_public.eq_ignore_ascii_case(on_chain) +} + +/// Match-and-sign: derive the keypair for `phrase` on whichever known HD path +/// produces the public key the contract currently stores in `on_chain_pubkey`. +/// +/// A wallet created on tvm-sdk v2 holds its multifactor keys on +/// [`LEGACY_DERIVATION_PATH`]; v3 wallets use [`CURRENT_DERIVATION_PATH`]. We +/// try each, compare against the on-chain pubkey, and return the one that +/// matches — so legacy wallets keep signing with the key they really have +/// instead of one re-derived on the wrong path (the ERR_NOT_OWNER / 402 bug). +/// +/// Returns the matching keypair and the path it was found on (for telemetry). +/// Errors only if *no* known path matches, which indicates a genuine +/// password/seed mismatch rather than a path-generation gap. +pub fn resolve_sign_keys( + tvm_context: Arc, + phrase: String, + on_chain_pubkey: &str, +) -> crate::errors::AppResult<(KeyPair, &'static str)> { + for path in CANDIDATE_PATHS { + let keys = derive_sign_keys(Arc::clone(&tvm_context), phrase.clone(), path)?; + if pubkey_matches(&keys.public, on_chain_pubkey) { + return Ok((keys, path)); + } + } + + Err(crate::errors::AppError::new( + "no known HD derivation path matches the on-chain key (wrong password or social account?)", + ) + .with_kind("key_path_resolution")) +} + +/// Build the recovery keypair that is *written* on-chain (new state). Always +/// derived on [`CURRENT_DERIVATION_PATH`]; used by the seed-change / +/// zkid-update flows that establish fresh recovery material. +pub async fn build_recovery_keys( + tvm_context: Arc, + params: ParamsOfBuildRecoveryKeys, +) -> crate::errors::AppResult { + let password_sha256 = sha256(tvm_context.clone(), params.password)?; + let crypto = BeeCrypto::from_client_context(tvm_context.clone()); + + let recovery_mnemonic = build_recovery_password( + Arc::clone(&tvm_context), + password_sha256, + params.multifactor_address, + )?; + let recovery_keys = + derive_sign_keys(Arc::clone(&tvm_context), recovery_mnemonic, CURRENT_DERIVATION_PATH) + .map_err(|e| e.with_context("failed to create recovery keys"))?; + let pub_recovery_key_sig = + crypto.sign_detached_hex(recovery_keys.public.clone(), recovery_keys.secret.clone())?; + + Ok(RecoveryKeys { + pub_recovery_key: format!("0x{}", recovery_keys.public.clone()), + pub_recovery_key_sig, + keys: recovery_keys, + }) +} + +/// Build the jwk-update keypair that is *written* on-chain (new state). Always +/// derived on [`CURRENT_DERIVATION_PATH`]. +pub async fn build_update_jwk_keys( + tvm_context: Arc, + params: ParamsOfBuildUpdateJwkKeys, +) -> crate::errors::AppResult { + let password_sha256 = sha256(tvm_context.clone(), params.password)?; + let crypto = BeeCrypto::from_client_context(tvm_context.clone()); + + let password_secret_mnemonic = + build_update_jwk(Arc::clone(&tvm_context), password_sha256, params.sub)?; + let keys = derive_sign_keys( + Arc::clone(&tvm_context), + password_secret_mnemonic, + CURRENT_DERIVATION_PATH, + ) + .map_err(|e| e.with_context("failed to create jwk update keys"))?; + let pub_jwk_update_sig = crypto.sign_detached_hex(keys.public.clone(), keys.secret.clone())?; + + Ok(JwkUpdateKeys { + pub_jwk_update_key: format!("0x{}", keys.public.clone()), + pub_jwk_update_sig, + keys, + }) +} + +/// Resolve the jwk-update keypair that matches the wallet's on-chain +/// `jwk_update_key`, trying current then legacy HD paths (match-and-sign). +/// +/// Used to *sign* `addJwkModulus`, which the contract gates with +/// `onlyOwnerPubkey(_jwk_update_key)` — so a pre-v3 wallet must sign with its +/// legacy (396) jwk key, not one re-derived on the current path. +pub fn resolve_jwk_update_signer( + tvm_context: Arc, + params: ParamsOfBuildUpdateJwkKeys, + on_chain_jwk_update_key: &str, +) -> crate::errors::AppResult<(KeyPair, &'static str)> { + let password_sha256 = sha256(tvm_context.clone(), params.password)?; + let phrase = build_update_jwk(Arc::clone(&tvm_context), password_sha256, params.sub)?; + resolve_sign_keys(tvm_context, phrase, on_chain_jwk_update_key) +} + +/// Resolve the recovery keypair that matches the wallet's on-chain +/// `pub_recovery_key`, trying current then legacy HD paths (match-and-sign). +/// +/// Used to *sign* owner-rotation acceptance (`acceptCandidateSeedPhrase`), +/// which the contract requires be signed by the recovery key — so a pre-v3 +/// wallet must sign with its legacy (396) recovery key. +pub fn resolve_recovery_signer( + tvm_context: Arc, + params: ParamsOfBuildRecoveryKeys, + on_chain_pub_recovery_key: &str, +) -> crate::errors::AppResult<(KeyPair, &'static str)> { + let password_sha256 = sha256(tvm_context.clone(), params.password)?; + let phrase = build_recovery_password( + Arc::clone(&tvm_context), + password_sha256, + params.multifactor_address, + )?; + resolve_sign_keys(tvm_context, phrase, on_chain_pub_recovery_key) +} + +fn sha256(tvm_context: Arc, data: String) -> crate::errors::AppResult { + let result = tvm_crypto::sha256( + tvm_context, + tvm_crypto::ParamsOfHash { data: base64::engine::general_purpose::STANDARD.encode(data) }, + ) + .map_err(|e| crate::errors::AppError::from(e).with_context("Failed to hash data"))?; + + Ok(result.hash) +} + +fn build_update_jwk( + tvm_context: Arc, + password_sha256: String, + address: String, +) -> crate::errors::AppResult { + let entropy = create_salted_hash(address, password_sha256)?; + let password_secret_mnemonic = tvm_crypto::mnemonic_from_entropy( + Arc::clone(&tvm_context), + tvm_crypto::ParamsOfMnemonicFromEntropy { dictionary: None, word_count: Some(24), entropy }, + ) + .map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to create password secret") + })?; + + Ok(password_secret_mnemonic.phrase) +} + +fn build_recovery_password( + tvm_context: Arc, + password_sha256: String, + address: String, +) -> crate::errors::AppResult { + let entropy = create_salted_hash(password_sha256, address)?; + let recovery_mnemonic = tvm_crypto::mnemonic_from_entropy( + Arc::clone(&tvm_context), + tvm_crypto::ParamsOfMnemonicFromEntropy { dictionary: None, word_count: Some(24), entropy }, + ) + .map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to create recovery password") + })?; + + Ok(recovery_mnemonic.phrase) +} + +fn create_salted_hash(password: String, salt_str: String) -> crate::errors::AppResult { + let salt_bytes = Sha256::digest(salt_str.as_bytes()); + let salt = SaltString::encode_b64(&salt_bytes[..16]) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to create salt"))?; + // SEC1-F02 won't fix: 100k rounds — mobile UX tradeoff, AEAD integrity + // preserved. Cannot change without on-chain migration of recovery keys. + let params = Params { rounds: 100_000, output_length: 32 }; + let hash = Pbkdf2 + .hash_password_customized(password.as_bytes(), None, None, params, &salt) + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to hash"))?; + let res = hash.hash.ok_or_else(|| "hash missing".to_string())?; + + Ok(hex::encode(res.as_bytes())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicFromRandom; + use ackinacki_kit::tvm_client::ClientContext; + + use super::*; + + fn ctx() -> Arc { + Arc::new(ClientContext::new(Default::default()).unwrap()) + } + + fn random_phrase(ctx: &Arc) -> String { + tvm_crypto::mnemonic_from_random( + ctx.clone(), + ParamsOfMnemonicFromRandom { dictionary: None, word_count: Some(24) }, + ) + .unwrap() + .phrase + } + + /// Pinning the write path is only safe while [`CURRENT_DERIVATION_PATH`] + /// equals the SDK's default. If a future SDK bump moves the default, this + /// fails loudly here instead of silently re-deriving keys in production. + #[test] + fn current_path_matches_sdk_default() { + let ctx = ctx(); + let phrase = random_phrase(&ctx); + + let via_default = tvm_crypto::mnemonic_derive_sign_keys( + ctx.clone(), + tvm_crypto::ParamsOfMnemonicDeriveSignKeys { + phrase: phrase.clone(), + path: None, + dictionary: None, + word_count: Some(24), + }, + ) + .unwrap(); + let via_pinned = derive_sign_keys(ctx, phrase, CURRENT_DERIVATION_PATH).unwrap(); + + assert_eq!( + via_default.public, via_pinned.public, + "CURRENT_DERIVATION_PATH drifted from the tvm-sdk default — update the constant \ + and plan a key migration before shipping" + ); + } + + #[test] + fn legacy_and_current_paths_differ() { + let ctx = ctx(); + let phrase = random_phrase(&ctx); + + let current = + derive_sign_keys(ctx.clone(), phrase.clone(), CURRENT_DERIVATION_PATH).unwrap(); + let legacy = derive_sign_keys(ctx, phrase, LEGACY_DERIVATION_PATH).unwrap(); + + assert_ne!(current.public, legacy.public); + } + + #[test] + fn resolve_picks_legacy_when_onchain_is_legacy() { + let ctx = ctx(); + let phrase = random_phrase(&ctx); + + let legacy = derive_sign_keys(ctx.clone(), phrase.clone(), LEGACY_DERIVATION_PATH).unwrap(); + let on_chain = format!("0x{}", legacy.public); // contract stores 0x-prefixed + + let (resolved, path) = resolve_sign_keys(ctx, phrase, &on_chain).unwrap(); + + assert_eq!(path, LEGACY_DERIVATION_PATH); + assert_eq!(resolved.public, legacy.public); + assert_eq!(resolved.secret, legacy.secret); + } + + #[test] + fn resolve_picks_current_first() { + let ctx = ctx(); + let phrase = random_phrase(&ctx); + + let current = + derive_sign_keys(ctx.clone(), phrase.clone(), CURRENT_DERIVATION_PATH).unwrap(); + let on_chain = format!("0x{}", current.public); + + let (resolved, path) = resolve_sign_keys(ctx, phrase, &on_chain).unwrap(); + + assert_eq!(path, CURRENT_DERIVATION_PATH); + assert_eq!(resolved.public, current.public); + } + + #[test] + fn resolve_errors_when_no_path_matches() { + let ctx = ctx(); + let phrase = random_phrase(&ctx); + let bogus = format!("0x{}", "ab".repeat(32)); + + assert!(resolve_sign_keys(ctx, phrase, &bogus).is_err()); + } + + #[test] + fn pubkey_matches_normalizes_prefix_and_case() { + assert!(pubkey_matches("ABCD", "0xabcd")); + assert!(pubkey_matches("abcd", "ABCD")); + assert!(pubkey_matches("abcd", "0xABCD")); + assert!(!pubkey_matches("abcd", "0xabce")); + } + + // Fixed inputs for the synthetic-key tests below. The multifactor address + // doubles as the recovery salt; `sub` is the OAuth subject for jwk keys. + const KAT_PASSWORD: &str = "Hello!23"; + const KAT_SUB: &str = "104824947798240050903"; + const KAT_ADDRESS: &str = "0:cfa74aad2b19d5f8721dc5ec0bd05c6ca7d03d6666493293a1310d8289d6fb81"; + + /// `add_jwk_modulus` (the login fix) signs via `resolve_jwk_update_signer`. + /// A pre-v3 wallet holds `jwk_update_key` on the legacy path; resolving + /// against that on-chain value must return the legacy keypair, not a + /// current-path one. Exercises the full password → synthetic mnemonic → + /// resolve wiring, not just the low-level primitive. + #[test] + fn resolve_jwk_update_signer_matches_legacy_onchain() { + let ctx = ctx(); + let params = + ParamsOfBuildUpdateJwkKeys { password: KAT_PASSWORD.into(), sub: KAT_SUB.into() }; + + // Simulate a pre-v3 wallet: its jwk_update_key was derived on 396. + let pw = sha256(ctx.clone(), params.password.clone()).unwrap(); + let phrase = build_update_jwk(ctx.clone(), pw, params.sub.clone()).unwrap(); + let legacy = derive_sign_keys(ctx.clone(), phrase, LEGACY_DERIVATION_PATH).unwrap(); + let on_chain = format!("0x{}", legacy.public); + + let (signer, path) = resolve_jwk_update_signer(ctx, params, &on_chain).unwrap(); + assert_eq!(path, LEGACY_DERIVATION_PATH); + assert_eq!(signer.public, legacy.public); + assert_eq!(signer.secret, legacy.secret); + } + + /// `change_seed_phrase` accept signs via `resolve_recovery_signer`. Same + /// guarantee for the recovery key of a pre-v3 wallet. + #[test] + fn resolve_recovery_signer_matches_legacy_onchain() { + let ctx = ctx(); + let params = ParamsOfBuildRecoveryKeys { + password: KAT_PASSWORD.into(), + multifactor_address: KAT_ADDRESS.into(), + }; + + let pw = sha256(ctx.clone(), params.password.clone()).unwrap(); + let phrase = + build_recovery_password(ctx.clone(), pw, params.multifactor_address.clone()).unwrap(); + let legacy = derive_sign_keys(ctx.clone(), phrase, LEGACY_DERIVATION_PATH).unwrap(); + let on_chain = format!("0x{}", legacy.public); + + let (signer, path) = resolve_recovery_signer(ctx, params, &on_chain).unwrap(); + assert_eq!(path, LEGACY_DERIVATION_PATH); + assert_eq!(signer.public, legacy.public); + assert_eq!(signer.secret, legacy.secret); + } + + /// A current-path wallet must resolve on the current path (first + /// candidate). + #[test] + fn resolve_jwk_update_signer_matches_current_onchain() { + let ctx = ctx(); + let params = + ParamsOfBuildUpdateJwkKeys { password: KAT_PASSWORD.into(), sub: KAT_SUB.into() }; + + let pw = sha256(ctx.clone(), params.password.clone()).unwrap(); + let phrase = build_update_jwk(ctx.clone(), pw, params.sub.clone()).unwrap(); + let current = derive_sign_keys(ctx.clone(), phrase, CURRENT_DERIVATION_PATH).unwrap(); + let on_chain = format!("0x{}", current.public); + + let (signer, path) = resolve_jwk_update_signer(ctx, params, &on_chain).unwrap(); + assert_eq!(path, CURRENT_DERIVATION_PATH); + assert_eq!(signer.public, current.public); + } + + /// Known-answer vector: freezes the entire synthetic-key derivation + /// (pbkdf2 rounds + salt construction + mnemonic-from-entropy + HD path) + /// for a fixed (password, sub/address). The on-chain keys of every existing + /// wallet depend on these bytes being stable — any change here would brick + /// them, so this test must fail loudly if the derivation ever drifts. + #[test] + fn synthetic_key_derivation_is_frozen() { + let ctx = ctx(); + + let jwk_pw = sha256(ctx.clone(), KAT_PASSWORD.into()).unwrap(); + let jwk_phrase = build_update_jwk(ctx.clone(), jwk_pw, KAT_SUB.into()).unwrap(); + let jwk_396 = + derive_sign_keys(ctx.clone(), jwk_phrase.clone(), LEGACY_DERIVATION_PATH).unwrap(); + let jwk_1331 = derive_sign_keys(ctx.clone(), jwk_phrase, CURRENT_DERIVATION_PATH).unwrap(); + + let rec_pw = sha256(ctx.clone(), KAT_PASSWORD.into()).unwrap(); + let rec_phrase = build_recovery_password(ctx.clone(), rec_pw, KAT_ADDRESS.into()).unwrap(); + let rec_396 = + derive_sign_keys(ctx.clone(), rec_phrase.clone(), LEGACY_DERIVATION_PATH).unwrap(); + let rec_1331 = derive_sign_keys(ctx, rec_phrase, CURRENT_DERIVATION_PATH).unwrap(); + + // Pinned vectors for KAT_PASSWORD / KAT_SUB / KAT_ADDRESS. If any of + // these change, the synthetic derivation drifted — do NOT just update + // the constants: existing wallets' on-chain keys would no longer match. + assert_eq!( + jwk_396.public, + "59ced9e475646fe323493066b31a1459975b2d021ef1466535376696da71dece" + ); + assert_eq!( + jwk_1331.public, + "b215cd32f87666d39152064df5462aec7996c26428bd948ccb15b37a1827f1c0" + ); + assert_eq!( + rec_396.public, + "93a3adede9765b05e1cdb57dbfea1dedeb5771eaa77ca8ec13c755c512021c46" + ); + assert_eq!( + rec_1331.public, + "2818aecc050af7613a5deb2753adae1b938f2e361d2d72ee3c1a878f35dd2749" + ); + } +} diff --git a/bee_wallet/src/services/multifactor/mod.rs b/bee_wallet/src/services/multifactor/mod.rs new file mode 100644 index 0000000..f1e5751 --- /dev/null +++ b/bee_wallet/src/services/multifactor/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod cmd; +pub(crate) mod jwk; +pub(crate) mod key_derivation; +pub(crate) mod query; +pub(crate) mod whitelist; + +pub const WASM_HASH: &str = "343268736f6dbb5a075a477fb1146b3c25c114d341b41c142e6609a7d1a90a2c"; diff --git a/bee_wallet/src/services/multifactor/query.rs b/bee_wallet/src/services/multifactor/query.rs new file mode 100644 index 0000000..488af1b --- /dev/null +++ b/bee_wallet/src/services/multifactor/query.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use ackinacki_kit::contracts::error::KitErrorCode; +use ackinacki_kit::contracts::mvsystem::multifactor::AccountData as MultifactorData; +use ackinacki_kit::contracts::mvsystem::multifactor::JwkData; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor as MvMultifactor; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetIndexer; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::contracts::traits::DecodeAccountData; +use ackinacki_kit::shared::traits::guarded::AsyncGuarded; +use ackinacki_kit::tvm_client::ClientContext; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfGetMultifactorDetails { + pub address: String, + pub candidate_new_owner_pubkey_and_expiration: Option>, + pub factors_len: String, + pub factors_ordered_by_timestamp: HashMap, + pub force_remove_oldest: bool, + pub index_mod_4: String, + pub iss_base_64: String, + pub jwk_modulus_data: HashMap, + pub jwk_modulus_data_len: String, + pub m_security_cards_len: String, + pub m_transactions_len: String, + pub max_cleanup_txns: String, + pub min_value: String, + pub name: String, + pub owner_pubkey: String, + pub pub_recovery_key: String, + pub root: String, + pub use_security_card: bool, + pub zkid: String, + pub jwk_update_key: String, + pub wasm_hash: String, + pub white_list_of_address: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfGetMultifactorDataByName { + pub wallet_name: String, +} + +pub async fn get_multifactor_decoded_data_by_name( + tvm_client: Arc, + params: ParamsOfGetMultifactorDataByName, +) -> crate::errors::AppResult> { + let root_contract = MobileVerifiersRoot::new_default(tvm_client.clone()); + let indexer = + root_contract.get_indexer(ParamsOfGetIndexer { name: params.wallet_name }).await.map_err( + |e| crate::errors::AppError::from(e).with_context("failed to get_indexer_address"), + )?; + + let indexer_details = indexer.get_details().await.map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to get_indexer_details") + })?; + + let multifactor_contract = + MvMultifactor::new_default(tvm_client.clone(), indexer_details.multifactor_address.clone()); + let multifactor = Arc::new(multifactor_contract); + + let acc_data = get_multifactor_decoded_data(&multifactor) + .await + .map_err(|e| e.with_context("failed to get decoded data"))?; + + let res = ResultOfGetMultifactorDetails { + address: indexer_details.multifactor_address, + candidate_new_owner_pubkey_and_expiration: acc_data + .candidate_new_owner_pubkey_and_expiration, + factors_len: acc_data.factors_len, + factors_ordered_by_timestamp: acc_data.factors_ordered_by_timestamp, + force_remove_oldest: acc_data.force_remove_oldest, + index_mod_4: acc_data.index_mod_4, + iss_base_64: acc_data.iss_base_64, + jwk_modulus_data: acc_data.jwk_modulus_data, + jwk_modulus_data_len: acc_data.jwk_modulus_data_len, + m_security_cards_len: acc_data.m_security_cards_len, + m_transactions_len: acc_data.m_transactions_len, + max_cleanup_txns: acc_data.max_cleanup_txns, + min_value: acc_data.min_value, + name: acc_data.name, + owner_pubkey: acc_data.owner_pubkey, + pub_recovery_key: acc_data.pub_recovery_key, + root: acc_data.root, + use_security_card: acc_data.use_security_card, + zkid: acc_data.zkid, + jwk_update_key: acc_data.jwk_update_key, + wasm_hash: acc_data.wasm_hash, + white_list_of_address: acc_data.white_list_of_address, + }; + + Ok(Some(res)) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfCheckNameAvailability { + pub is_available: bool, + pub multifactor_address: Option, +} + +pub async fn check_name_availability( + tvm_client: Arc, + wallet_name: String, +) -> crate::errors::AppResult { + let root_contract = MobileVerifiersRoot::new_default(tvm_client.clone()); + let indexer = + root_contract.get_indexer(ParamsOfGetIndexer { name: wallet_name }).await.map_err(|e| { + crate::errors::AppError::from(e).with_context("failed to get_indexer_address") + })?; + + let details = match indexer.get_details().await { + Ok(details) => Ok(Some(details)), + Err(e) => { + if e.code == KitErrorCode::AccountIsNotActive { + Ok(None) + } else { + Err(e) + } + } + } + .map_err(|e| crate::errors::AppError::from(e).with_context("failed to get_indexer_details"))?; + + Ok(ResultOfCheckNameAvailability { + is_available: details.is_none(), + multifactor_address: details.map(|d| d.multifactor_address), + }) +} + +pub async fn get_multifactor_decoded_data( + multifactor: &Arc, +) -> crate::errors::AppResult { + if !multifactor.is_deployed().await { + return Err(crate::errors::AppError::new(format!( + "account {} is not deployed", + multifactor.address() + ))); + } + + let Some(data) = multifactor.async_guarded(|acc| acc.data.clone()).await else { + return Err(crate::errors::AppError::new(format!( + "account data {} not found", + multifactor.address() + ))); + }; + + multifactor.decode_account_data(data).map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("failed to decode account data for {}", multifactor.address())) + }) +} diff --git a/bee_wallet/src/services/multifactor/whitelist.rs b/bee_wallet/src/services/multifactor/whitelist.rs new file mode 100644 index 0000000..f8f584f --- /dev/null +++ b/bee_wallet/src/services/multifactor/whitelist.rs @@ -0,0 +1,131 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::multifactor::AccountData as MultifactorData; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfCleanWhitelist; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfUpdateWhitelist; +use ackinacki_kit::contracts::mvsystem::ContractIndex; +use ackinacki_kit::tvm_client::abi::Signer; + +use crate::infra::poll_until; + +const EXPECTED_AFTER_CLEAN: usize = 1; +const WHITELIST_MAX: usize = 15; + +#[derive(Debug)] +pub struct ParamsOfUpdateMultifactorWhiteList { + pub epk_expire_at: u64, + pub payload_destination: ContractIndex, + pub target_address: String, + pub mirror_index: u128, + pub whitelisted_name: Option, +} + +#[derive(Debug)] +pub struct ResultOfUpdateMultifactorWhiteList { + pub message_ids: Vec, +} + +async fn update_multifactor_whitelist( + multifactor: &Arc, + params: ParamsOfUpdateMultifactorWhiteList, + signer: &Signer, +) -> crate::errors::AppResult> { + if matches!(params.payload_destination, ContractIndex::PopCoinWallet) + && params.whitelisted_name.is_none() + { + return Err(crate::errors::AppError::new( + "whitelisted_name should be set in case of destionation is popcoin wallet", + )); + } + let update_white_list_result = multifactor + .update_whitelist( + ParamsOfUpdateWhitelist { + epk_expire_at: params.epk_expire_at, + payload_destination: params.payload_destination, + name: params.whitelisted_name.unwrap_or("".to_string()), + mirror_index: params.mirror_index, + }, + signer.clone(), + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Update mutifactor whitelist") + })?; + + let update_white_list_result = + crate::infra::ensure_tx_success(update_white_list_result, "update_whitelist")?; + + Ok(update_white_list_result.message_hash) +} + +pub async fn update_multifactor_whitelist_and_wait( + multifactor: &Arc, + params: ParamsOfUpdateMultifactorWhiteList, + signer: &Signer, +) -> crate::errors::AppResult { + let data = super::query::get_multifactor_decoded_data(multifactor).await?; + let mut message_ids = Vec::new(); + + if data.white_list_of_address.contains_key(¶ms.target_address) { + return Ok(ResultOfUpdateMultifactorWhiteList { message_ids }); + } + let mf = Arc::clone(multifactor); + if data.white_list_of_address.len() > WHITELIST_MAX { + let result = multifactor + .clean_whitelist( + ParamsOfCleanWhitelist { epk_expire_at: params.epk_expire_at }, + signer.clone(), + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("clean_whitelist"))?; + let result = crate::infra::ensure_tx_success(result, "clean_whitelist")?; + if let Some(message_id) = result.message_hash { + message_ids.push(message_id); + } + let _ = poll_until( + || { + let mf = mf.clone(); + async move { + super::query::get_multifactor_decoded_data(&mf) + .await + .map_err(|e| e.with_context("Multifactor get details")) + } + }, + move |data: &MultifactorData| { + let white_list = data.white_list_of_address.keys().cloned(); + white_list.len() == EXPECTED_AFTER_CLEAN + }, + None, + None, + ) + .await?; + } + + let target_address = params.target_address.clone(); + if let Some(message_id) = update_multifactor_whitelist(multifactor, params, signer).await? { + message_ids.push(message_id); + } + + let details = poll_until( + || { + let mf = mf.clone(); + async move { + super::query::get_multifactor_decoded_data(&mf) + .await + .map_err(|e| e.with_context("Multifactor get details")) + } + }, + move |data: &MultifactorData| { + let multifactor_whitelist = data.white_list_of_address.keys().collect::>(); + + multifactor_whitelist.contains(&&target_address) + }, + None, + None, + ) + .await?; + + let _ = details; + Ok(ResultOfUpdateMultifactorWhiteList { message_ids }) +} diff --git a/bee_wallet/src/services/resolvers/mod.rs b/bee_wallet/src/services/resolvers/mod.rs new file mode 100644 index 0000000..023157e --- /dev/null +++ b/bee_wallet/src/services/resolvers/mod.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use ackinacki_kit::contracts::mvsystem::miner::contract::Miner; +use ackinacki_kit::contracts::mvsystem::mirror::Mirror; +use ackinacki_kit::contracts::mvsystem::mirror::ParamsOfGetMinerAddress; +use ackinacki_kit::tvm_client::ClientContext; + +pub enum MirrorSelector<'a> { + ForMultifactorAddr(&'a str), + ForPubkey(&'a str), +} + +pub fn resolve_mirror( + tvm_client: Arc, + selector: MirrorSelector<'_>, +) -> crate::errors::AppResult { + match selector { + MirrorSelector::ForMultifactorAddr(multifactor_addres) => { + let tail = address_tail(multifactor_addres)?; + mirror_from_seed(tvm_client, tail) + } + + MirrorSelector::ForPubkey(pubkey) => { + let seed = normalize_pubkey(pubkey); + mirror_from_seed(tvm_client, &seed) + } + } +} + +pub async fn resolve_miner_for_multifactor( + tvm_client: Arc, + multifactor_address: &str, +) -> crate::errors::AppResult { + let mirror = + resolve_mirror(tvm_client, MirrorSelector::ForMultifactorAddr(multifactor_address))?; + + mirror + .get_miner(ParamsOfGetMinerAddress { multifactor_address: multifactor_address.to_string() }) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("Resolve miner for multifactor")) +} + +fn address_tail(addr: &str) -> crate::errors::AppResult<&str> { + addr.rsplit(':') + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("invalid address format: {addr}").into()) +} + +fn normalize_pubkey(s: &str) -> String { + s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s).to_string() +} + +fn mirror_from_seed( + tvm_client: Arc, + seed: &str, +) -> crate::errors::AppResult { + Mirror::new_default(tvm_client, seed).map_err(|e| { + crate::errors::AppError::from(e).with_context(format!("Mirror::new_default(seed={seed})")) + }) +} diff --git a/bee_wallet/src/services/tokens/mod.rs b/bee_wallet/src/services/tokens/mod.rs new file mode 100644 index 0000000..af796d8 --- /dev/null +++ b/bee_wallet/src/services/tokens/mod.rs @@ -0,0 +1,572 @@ +use std::collections::HashMap; +use std::sync::Arc; + +mod sell_orders; + +use ackinacki_kit::contracts::account::ParamsOfWaitAccount; +use ackinacki_kit::contracts::exchange::exchange_contract::Exchange; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSendTransaction; +use ackinacki_kit::contracts::mvsystem::multifactor::ParamsOfSubmitTransaction; +use ackinacki_kit::contracts::token::root::ParamsOfGetWalletAddress; +use ackinacki_kit::contracts::token::root::TokenRoot; +use ackinacki_kit::contracts::token::transaction::TokenTransaction; +use ackinacki_kit::contracts::token::wallet::ParamsOfDeployTransaction; +use ackinacki_kit::contracts::token::wallet::ParamsOfGetTransactionAddress; +use ackinacki_kit::contracts::token::wallet::TokenWallet; +use ackinacki_kit::contracts::token::wallet::Transaction; +use ackinacki_kit::contracts::traits::AccountAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::ClientContext; + +// Low-level token operation: send native tokens (NACKL, SHELL) +pub async fn send_native_tokens( + multifactor: &Arc, + epk_expire_at: u64, + destination_address: String, + token_root: String, + amount_raw: u64, + signer: Signer, + bounce: Option, +) -> crate::errors::AppResult { + let token_root_u: u32 = token_root.parse().map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Parse token root failure `{token_root}` transaction")) + })?; + let ecc_nano = HashMap::from([(token_root_u, amount_raw)]); + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: destination_address, + epk_expire_at, + value: 0, + cc: ecc_nano, + bounce: bounce.unwrap_or(true), + all_balance: false, + ..Default::default() + }, + signer, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Submit multifactor `{}` transaction", multifactor.address())) + })?; + + crate::infra::ensure_tx_success(result, "submit_transaction (send native tokens)") +} + +/// Direct transfer of native tokens with explicit flags (uses +/// `sendTransaction`). Only works when security card is off. +/// Default gas value for `sendTransaction`: 1 VMShell. +const SEND_TX_DEFAULT_VALUE: u128 = 1_000_000_000; + +#[allow(clippy::too_many_arguments)] +pub async fn send_native_tokens_direct( + multifactor: &Arc, + epk_expire_at: u64, + destination_address: String, + token_root: String, + amount_raw: u64, + flags: u8, + value: Option, + payload: Option, + signer: Signer, + bounce: Option, +) -> crate::errors::AppResult { + let token_root_u: u32 = token_root.parse().map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Parse token root failure `{token_root}` transaction")) + })?; + let ecc_nano = HashMap::from([(token_root_u, amount_raw)]); + + let result = multifactor + .send_transaction( + ParamsOfSendTransaction { + dest: destination_address, + epk_expire_at, + value: value.unwrap_or(SEND_TX_DEFAULT_VALUE), + cc: ecc_nano, + bounce: bounce.unwrap_or(true), + flags, + payload: payload.unwrap_or_default(), + }, + signer, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Send multifactor `{}` transaction", multifactor.address())) + })?; + + crate::infra::ensure_tx_success(result, "send_transaction (send native tokens direct)") +} + +use ackinacki_kit::contracts::accumulator::is_valid_denom; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetQueueState; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetSellOrderAddress; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ShellAccumulatorRootUsdc; +use ackinacki_kit::contracts::accumulator::shell_amount_for_denom; +use ackinacki_kit::contracts::accumulator::NACKL_ECC_ID; +use ackinacki_kit::contracts::accumulator::SHELL_ECC_ID; +use ackinacki_kit::contracts::accumulator::USDC_DECIMALS_FACTOR; +use ackinacki_kit::contracts::accumulator::USDC_ECC_ID; + +/// Sends ECC[3] (USDC) to ShellAccumulatorRootUSDC for buying Shell. +/// `usdc_whole` — whole USDC (no decimals). Internally multiplied by +/// USDC_DECIMALS_FACTOR. +pub async fn buy_shells( + multifactor: &Arc, + epk_expire_at: u64, + usdc_whole: u64, + signer: Signer, + bounce: Option, +) -> crate::errors::AppResult { + if usdc_whole == 0 { + return Err(crate::errors::AppError::new("buy_shells: usdc_amount must be > 0")); + } + + let usdc_micro = usdc_whole * (USDC_DECIMALS_FACTOR as u64); + let ecc = HashMap::from([(USDC_ECC_ID, usdc_micro)]); + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: ShellAccumulatorRootUsdc::DEFAULT_ADDRESS.to_string(), + epk_expire_at, + value: 1_000_000_000, + cc: ecc, + bounce: bounce.unwrap_or(true), + all_balance: false, + ..Default::default() + }, + signer, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "Submit multifactor `{}` buy_shells transaction", + multifactor.address() + )) + })?; + + crate::infra::ensure_tx_success(result, "submit_transaction (buy_shells)") +} + +/// Places Shell for sale in the accumulator. +/// `denom` — denomination (1, 10, 100, 1000). SDK computes Shell amount. +/// Returns order_id assigned by the accumulator. +pub async fn sell_shells( + tvm_client: Arc, + multifactor: &Arc, + epk_expire_at: u64, + denom: u16, + signer: Signer, + bounce: Option, +) -> crate::errors::AppResult { + let shell_amount = shell_amount_for_denom(denom).ok_or_else(|| { + crate::errors::AppError::new(format!( + "sell_shells: invalid denom `{denom}`. Expected one of: 1, 10, 100, 1000" + )) + })?; + + // Remember nextId BEFORE sending — this will be our order_id. + let accumulator = ShellAccumulatorRootUsdc::new_default(tvm_client.clone()); + let queue_before = + accumulator.get_queue_state(ParamsOfGetQueueState { d: denom }).await.map_err(|e| { + crate::errors::AppError::from(e).with_context("sell_shells: getQueueState before send") + })?; + let order_id = queue_before.next_id; + + let ecc = HashMap::from([(SHELL_ECC_ID, shell_amount as u64)]); + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: ShellAccumulatorRootUsdc::DEFAULT_ADDRESS.to_string(), + epk_expire_at, + value: 1_000_000_000, + cc: ecc, + bounce: bounce.unwrap_or(true), + all_balance: false, + ..Default::default() + }, + signer, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "Submit multifactor `{}` sell_shells transaction", + multifactor.address() + )) + })?; + + let result = crate::infra::ensure_tx_success(result, "submit_transaction (sell_shells)")?; + + // Poll getQueueState until nextId grows (order accepted by accumulator). + let queue_after = crate::infra::poll_until( + || { + let acc = ShellAccumulatorRootUsdc::new_default(tvm_client.clone()); + async move { + acc.get_queue_state(ParamsOfGetQueueState { d: denom }).await.map_err(|e| { + crate::errors::AppError::from(e).with_context("sell_shells: poll getQueueState") + }) + } + }, + |state| state.next_id > order_id, + Some(30), + Some(500), + ) + .await + .map_err(|e| e.with_context("sell_shells: timed out waiting for order confirmation"))?; + + // Address is deterministic — does not depend on contract deployment. + let sell_order_address = accumulator + .get_sell_order_address(ParamsOfGetSellOrderAddress { d: denom, order_id }) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("sell_shells: getSellOrderAddress") + })? + .sell_order_addr; + + let sold = order_id <= queue_after.sold_prefix; + let position_in_queue = if sold { 0 } else { order_id - queue_after.sold_prefix }; + + Ok(crate::types::SellShellsResult { + message_hash: result.message_hash, + order_id, + denom, + sell_order_address, + sold, + position_in_queue, + }) +} + +/// Returns paginated sell orders for the given multifactor address (seller). +/// +/// Implementation lives in `sell_orders.rs` — it splits event lookups +/// (hot→archive failover) from state getters (hot only), which avoids the +/// failure mode where a flaky archive getter call breaks the whole pipeline. +/// See module docs there for the full strategy. +pub async fn get_my_sell_orders( + tvm_client: Arc, + archive_tvm_client: Option>, + rate_limiter: Option, + multifactor_address: &str, + page_size: u32, + cursor: Option, +) -> crate::errors::AppResult { + sell_orders::get_my_sell_orders( + tvm_client, + archive_tvm_client, + rate_limiter, + multifactor_address, + page_size, + cursor, + ) + .await +} + +/// Claims USDC payout for a sold sell order. +/// Uses `claim_for_order` which calls `SellOrderLot.claim()` directly (external +/// msg). The lot internally calls `AccumulatorRoot.claimUSDC()` to transfer +/// USDC to seller. +pub async fn claim_usdc( + tvm_client: Arc, + denom: u16, + order_id: u64, + signer: Signer, +) -> crate::errors::AppResult { + if !is_valid_denom(denom) { + return Err(crate::errors::AppError::new(format!( + "claim_usdc: invalid denom `{denom}`. Expected one of: 1, 10, 100, 1000" + ))); + } + + let accumulator = ShellAccumulatorRootUsdc::new_default(tvm_client); + let result = accumulator.claim_for_order(denom, order_id, signer).await.map_err(|e| { + crate::errors::AppError::from(e).with_context("claim_usdc: claim_for_order") + })?; + + let usdc_payout = (denom as u64) * (USDC_DECIMALS_FACTOR as u64); + + Ok(crate::types::ClaimUsdcResult { message_hash: result.message_hash, usdc_payout }) +} + +/// Returns current NACKL → USDC redemption rate from accumulator state. +pub async fn get_nackl_redeem_rate( + tvm_client: Arc, +) -> crate::errors::AppResult { + let accumulator = ShellAccumulatorRootUsdc::new_default(tvm_client); + + let details = accumulator.get_details().await.map_err(|e| { + crate::errors::AppError::from(e).with_context("get_nackl_redeem_rate: getDetails") + })?; + + let nackl_info = accumulator.get_nackl_info().await.map_err(|e| { + crate::errors::AppError::from(e).with_context("get_nackl_redeem_rate: getNacklInfo") + })?; + + let redeemable_usdc = details.usdc_balance.saturating_sub(details.owed_total); + let current_nackl_supply = nackl_info.supply.saturating_sub(nackl_info.burned); + + let usdc_per_nackl = if current_nackl_supply == 0 || redeemable_usdc == 0 { + 0 + } else { + // Scale by 1e18 to preserve precision at low ratios. + // Client divides by 1e18 to get micro-USDC per 1 nanoNACKL, + // or by 1e9 to get micro-USDC per 1 whole NACKL. + redeemable_usdc * 1_000_000_000_000_000_000 / current_nackl_supply + }; + + Ok(crate::types::NacklRedeemRateResult { + redeemable_usdc, + current_nackl_supply, + usdc_per_nackl, + }) +} + +/// Burns NACKL by sending ECC[1] to accumulator. Returns USDC proportional to +/// share of supply. +pub async fn redeem_nackl( + multifactor: &Arc, + epk_expire_at: u64, + nackl_amount: u64, + signer: Signer, + bounce: Option, +) -> crate::errors::AppResult { + if nackl_amount == 0 { + return Err(crate::errors::AppError::new("redeem_nackl: nackl_amount must be > 0")); + } + + let ecc = HashMap::from([(NACKL_ECC_ID, nackl_amount)]); + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: ShellAccumulatorRootUsdc::DEFAULT_ADDRESS.to_string(), + epk_expire_at, + value: 1_000_000_000, + cc: ecc, + bounce: bounce.unwrap_or(true), + all_balance: false, + ..Default::default() + }, + signer, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "Submit multifactor `{}` redeem_nackl transaction", + multifactor.address() + )) + })?; + + crate::infra::ensure_tx_success(result, "submit_transaction (redeem_nackl)") +} + +// Low-level token operation: send other tokens (non-native) +#[allow(clippy::too_many_arguments)] +pub async fn send_other_tokens( + tvm_client: Arc, + multifactor: &Arc, + epk_expire_at: u64, + destination_address: String, + token_root: String, + token_dapp: String, + amount_raw: u64, + signer: Signer, + bounce: Option, +) -> crate::errors::AppResult { + let token_root = TokenRoot::new( + tvm_client.clone(), + crate::dapp::token_contract_params(token_root, token_dapp.as_str()), + ); + let token_wallet_address_res = token_root + .get_wallet_address(ParamsOfGetWalletAddress { + owner_address: multifactor.address().to_string(), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("get_wallet_address `{}`", multifactor.address())) + })?; + + let token_wallet = TokenWallet::new( + tvm_client.clone(), + crate::dapp::token_contract_params( + token_wallet_address_res.wallet_address, + token_dapp.as_str(), + ), + ); + let result = token_wallet + .deploy_transaction( + ParamsOfDeployTransaction::from(Transaction::Transfer { + value: amount_raw as u128, + destination_owner: destination_address.clone(), + }), + signer.clone(), + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "deploy_transaction, token_wallet `{}`", + token_wallet.address() + )) + })?; + + crate::infra::ensure_tx_success(result, "deploy_transaction (token wallet)")?; + + let res_of_tx_addr = token_wallet + .get_transaction_address(ParamsOfGetTransactionAddress::from(Transaction::Transfer { + value: amount_raw as u128, + destination_owner: destination_address, + })) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "get_transaction_address, token_wallet `{}`", + token_wallet.address() + )) + })?; + + let tx = TokenTransaction::new( + tvm_client, + crate::dapp::token_contract_params(res_of_tx_addr.transaction_address, token_dapp.as_str()), + ); + tx.wait_account(ParamsOfWaitAccount::default()).await.map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Wait for transaction `{}` active status", tx.address())) + })?; + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: tx.address().to_string(), + epk_expire_at, + value: 1_000_000_000, + bounce: bounce.unwrap_or(true), + all_balance: false, + ..Default::default() + }, + signer, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Submit multifactor `{}` transaction", multifactor.address())) + })?; + + crate::infra::ensure_tx_success(result, "submit_transaction (send other tokens)") +} + +/// Gas for Exchange callback chain (onTransferReceived → mint ECC[3] → send). +const EXCHANGE_CALLBACK_VALUE: u128 = 5_000_000_000; + +/// Migrates TIP-3 USDC to ECC[3] via Exchange contract. +/// +/// Sends TIP-3 USDC to Exchange (`0:1a1a...`), which triggers +/// `onTransferReceived` → mints ECC[3] → sends to sender. +#[allow(clippy::too_many_arguments)] +pub async fn migrate_tip3_usdc( + tvm_client: Arc, + multifactor: &Arc, + epk_expire_at: u64, + token_root: String, + token_dapp: String, + amount_raw: u64, + signer: Signer, + bounce: Option, +) -> crate::errors::AppResult { + if amount_raw == 0 { + return Err(crate::errors::AppError::new("migrate_tip3_usdc: amount_raw must be > 0")); + } + + let exchange_address = Exchange::DEFAULT_ADDRESS; + + let token_root_contract = TokenRoot::new( + tvm_client.clone(), + crate::dapp::token_contract_params(token_root, token_dapp.as_str()), + ); + let token_wallet_address_res = token_root_contract + .get_wallet_address(ParamsOfGetWalletAddress { + owner_address: multifactor.address().to_string(), + }) + .await + .map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("get_wallet_address `{}`", multifactor.address())) + })?; + + let token_wallet = TokenWallet::new( + tvm_client.clone(), + crate::dapp::token_contract_params( + token_wallet_address_res.wallet_address, + token_dapp.as_str(), + ), + ); + + let result = token_wallet + .deploy_transaction( + ParamsOfDeployTransaction::from(Transaction::Transfer { + value: amount_raw as u128, + destination_owner: exchange_address.to_string(), + }), + signer.clone(), + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "deploy_transaction, token_wallet `{}`", + token_wallet.address() + )) + })?; + + crate::infra::ensure_tx_success(result, "deploy_transaction (migrate_tip3_usdc)")?; + + let res_of_tx_addr = token_wallet + .get_transaction_address(ParamsOfGetTransactionAddress::from(Transaction::Transfer { + value: amount_raw as u128, + destination_owner: exchange_address.to_string(), + })) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "get_transaction_address, token_wallet `{}`", + token_wallet.address() + )) + })?; + + let tx = TokenTransaction::new( + tvm_client, + crate::dapp::token_contract_params(res_of_tx_addr.transaction_address, token_dapp.as_str()), + ); + tx.wait_account(ParamsOfWaitAccount::default()).await.map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Wait for transaction `{}` active status", tx.address())) + })?; + + let result = multifactor + .submit_transaction( + ParamsOfSubmitTransaction { + dest: tx.address().to_string(), + epk_expire_at, + value: EXCHANGE_CALLBACK_VALUE, + bounce: bounce.unwrap_or(true), + all_balance: false, + ..Default::default() + }, + signer, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "Submit multifactor `{}` migrate_tip3_usdc transaction", + multifactor.address() + )) + })?; + + crate::infra::ensure_tx_success(result, "submit_transaction (migrate_tip3_usdc)") +} diff --git a/bee_wallet/src/services/tokens/sell_orders.rs b/bee_wallet/src/services/tokens/sell_orders.rs new file mode 100644 index 0000000..397428b --- /dev/null +++ b/bee_wallet/src/services/tokens/sell_orders.rs @@ -0,0 +1,557 @@ +//! Self-contained pipeline for `get_my_sell_orders`. +//! +//! kit's `get_orders_by_seller` is a black box that mixes GraphQL event lookups +//! (need archive for >24h history) with contract getters (need current state +//! from hot). When archive blips on a getter call, the whole kit call errors +//! out and we lose visibility into perfectly legitimate orders. This SDK +//! pipeline splits the two concerns: +//! +//! - **Event lookups** (`SellOrderCreated`, `UsdcClaimed`) — try hot first; if +//! hot returns nothing (event older than retention window), fall back to +//! archive. Same pattern as `services::transaction::history`. +//! - **Getter calls** (`get_queue_state`, `get_sell_order_address`, +//! `lot.get_details`) — always on hot. They read current contract state, so +//! hot is both correct and more reliable. +//! +//! All network calls retry up to 3 times with exponential backoff. After 3 +//! failures the error bubbles up to the caller — the app decides what to do +//! (retry later, show stale cache, etc.). + +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::sync::Arc; + +use ackinacki_kit::contracts::accumulator::events::AccumulatorRootEvent; +use ackinacki_kit::contracts::accumulator::events::DecodedAccumulatorRootEvent; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetQueueState; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetSellOrderAddress; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ResultOfGetQueueState; +use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ShellAccumulatorRootUsdc; +use ackinacki_kit::contracts::accumulator::shell_sell_order_lot::ShellSellOrderLot; +use ackinacki_kit::contracts::event::Event as KitEvent; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::contracts::traits::FromEvent; +use ackinacki_kit::tvm_client::net::query as gql_query; +use ackinacki_kit::tvm_client::net::ParamsOfQuery as GqlParams; +use ackinacki_kit::tvm_client::ClientContext; +use bee_infra::RateLimiter; +use futures::stream::FuturesUnordered; +use futures::stream::StreamExt; +use serde_json::json; + +use crate::errors::AppError; +use crate::errors::AppResult; +use crate::types::GetMySellOrdersResult; +use crate::types::SellOrderInfo; + +const VALID_DENOMS: [u16; 4] = [1, 10, 100, 1000]; +const EVENT_PAGE_SIZE: i32 = 100; +const GETTER_CONCURRENCY: usize = 8; +const MAX_EVENT_PAGES: u32 = 50; + +/// Bounded exponential-backoff retry policy for the read-only GraphQL +/// queries used by the sell-orders pipeline. Matches the legacy local +/// behaviour (3 attempts, 300ms / 600ms backoff) but goes through the +/// shared `bee_infra::retry` engine and adds jitter to de-correlate +/// retries across colliding clients. Retries on any error — the +/// underlying tvm_client transport is configured to fail fast, so +/// every error reaching this layer is already a real transport miss. +fn sell_orders_retry_policy() -> bee_infra::RetryPolicy { + bee_infra::RetryPolicy { + max_attempts: 3, + max_total: None, + base_delay: std::time::Duration::from_millis(300), + max_delay: std::time::Duration::from_millis(600), + jitter: true, + } +} + +const GQL_EVENTS_BY_DST: &str = r#" + query($address: String!, $dst: String!, $last: Int!, $before: String) { + blockchain { + account(address: $address) { + events(dst: $dst, last: $last, before: $before) { + edges { + cursor + node { msg_id created_at dst body } + } + pageInfo { hasPreviousPage } + } + } + } + } +"#; + +/// v3 (`>= 1.0.0`) form of [`GQL_EVENTS_BY_DST`] — addresses the accumulator by +/// `account_id` + `dapp_id` instead of `address`. +const GQL_EVENTS_BY_DST_V3: &str = r#" + query($account_id: String!, $dapp_id: String!, $dst: String!, $last: Int!, $before: String) { + blockchain { + account(account_id: $account_id, dapp_id: $dapp_id) { + events(dst: $dst, last: $last, before: $before) { + edges { + cursor + node { msg_id created_at dst body } + } + pageInfo { hasPreviousPage } + } + } + } + } +"#; + +// kit's normalize_address / addresses_equal are private. The accumulator emits +// addresses as canonical "0:hex" — a simple case-insensitive trim is enough for +// equality against the seller we're given. +fn addresses_equal(a: &str, b: &str) -> bool { + a.trim().eq_ignore_ascii_case(b.trim()) +} + +// Internal "0:hex" → external ":hex" form used as dst filter for per-seller +// event channel. +fn internal_to_external_address(addr: &str) -> String { + match addr.find(':') { + Some(i) => format!(":{}", &addr[i + 1..]), + None => format!(":{}", addr), + } +} + +/// One paginated GraphQL events-by-dst pull, lenient to a `null` events field +/// in the response (some archive nodes return that for empty results instead +/// of `{edges: []}`). +async fn fetch_events_one_endpoint( + ctx: &Arc, + rate_limiter: Option<&RateLimiter>, + accumulator_addr: &str, + dst: &str, +) -> AppResult> { + // Pick the GraphQL wire form once (cached per-endpoint by the SDK). The + // accumulator lives under the Mobile Verifiers dApp; `dapp_id` is only + // consumed by `>= 1.0.0` servers but must be correct now. + let v3 = crate::dapp::server_uses_dapp_id(ctx).await?; + let dapp_id = ackinacki_kit::contracts::dapp::SystemDapp::MobileVerifiers.dapp_id(); + let mut all = Vec::new(); + let mut before: Option = None; + for _ in 0..MAX_EVENT_PAGES { + let variables = if v3 { + json!({ + "account_id": crate::dapp::account_id(accumulator_addr), + "dapp_id": dapp_id, + "dst": dst, + "last": EVENT_PAGE_SIZE, + "before": before, + }) + } else { + json!({ + "address": accumulator_addr, + "dst": dst, + "last": EVENT_PAGE_SIZE, + "before": before, + }) + }; + let ctx_clone = ctx.clone(); + let result_value = bee_infra::with_retry_policy( + &sell_orders_retry_policy(), + rate_limiter, + |_: &AppError| true, + || { + let ctx_clone = ctx_clone.clone(); + let variables = variables.clone(); + async move { + gql_query( + ctx_clone, + GqlParams { + query: if v3 { GQL_EVENTS_BY_DST_V3 } else { GQL_EVENTS_BY_DST } + .to_string(), + variables: Some(variables), + }, + ) + .await + .map(|r| r.result) + .map_err(|e| AppError::from(e).with_context("events GraphQL query")) + } + }, + ) + .await?; + + let events_field = result_value + .get("data") + .and_then(|v| v.get("blockchain")) + .and_then(|v| v.get("account")) + .and_then(|v| v.get("events")); + let edges = match events_field { + None | Some(serde_json::Value::Null) => break, + Some(v) => v.get("edges").and_then(|e| e.as_array()).cloned().unwrap_or_default(), + }; + if edges.is_empty() { + break; + } + + let mut next_before: Option = None; + for edge in &edges { + if next_before.is_none() { + next_before = edge.get("cursor").and_then(|c| c.as_str()).map(|s| s.to_string()); + } + let Some(node) = edge.get("node") else { continue }; + let msg_id = + node.get("msg_id").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let dst_v = node.get("dst").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let body = node.get("body").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let created_at = node.get("created_at").and_then(|v| v.as_u64()).unwrap_or_default(); + all.push(KitEvent { id: msg_id, dst: dst_v, created_at, body }); + } + + let has_prev = events_field + .and_then(|v| v.get("pageInfo")) + .and_then(|v| v.get("hasPreviousPage")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !has_prev { + break; + } + match next_before { + Some(c) => before = Some(c), + None => break, + } + } + Ok(all) +} + +fn decode_and_filter( + events: &[KitEvent], + accumulator: &ShellAccumulatorRootUsdc, + seller: &str, + mut pick: F, +) -> BTreeSet<(u16, u64)> +where + F: FnMut(&DecodedAccumulatorRootEvent, &str) -> Option<(u16, u64)>, +{ + let mut out = BTreeSet::new(); + for ev in events { + let Ok(decoded) = DecodedAccumulatorRootEvent::from_event(ev, accumulator) else { + continue; + }; + if let Some(pair) = pick(&decoded, seller) { + out.insert(pair); + } + } + out +} + +/// On a single endpoint: try the per-seller dst first (server-side filtered +/// so result is small and authoritative). If it returns nothing for this +/// seller, fall back to the legacy global dst and filter the payload +/// client-side. +#[allow(clippy::too_many_arguments)] +async fn fetch_seller_pairs_on_endpoint( + ctx: &Arc, + rate_limiter: Option<&RateLimiter>, + accumulator: &ShellAccumulatorRootUsdc, + accumulator_addr: &str, + seller: &str, + primary_dst: &str, + legacy_dst: &str, + picker: F, +) -> AppResult> +where + F: FnMut(&DecodedAccumulatorRootEvent, &str) -> Option<(u16, u64)> + Copy, +{ + if primary_dst != legacy_dst { + let primary = + fetch_events_one_endpoint(ctx, rate_limiter, accumulator_addr, primary_dst).await?; + let pairs = decode_and_filter(&primary, accumulator, seller, picker); + if !pairs.is_empty() { + return Ok(pairs); + } + } + let legacy = fetch_events_one_endpoint(ctx, rate_limiter, accumulator_addr, legacy_dst).await?; + Ok(decode_and_filter(&legacy, accumulator, seller, picker)) +} + +/// Hot-first → archive-on-empty for a seller's (denom, order_id) pairs. +/// `archive` is consulted only if hot returned nothing for this seller — a +/// non-empty hot result means the seller's events are within retention. +#[allow(clippy::too_many_arguments)] +async fn fetch_seller_pairs_with_archive_fallback( + hot: &Arc, + archive: Option<&Arc>, + rate_limiter: Option<&RateLimiter>, + accumulator_hot: &ShellAccumulatorRootUsdc, + accumulator_archive: Option<&ShellAccumulatorRootUsdc>, + accumulator_addr: &str, + seller: &str, + primary_dst: &str, + legacy_dst: &str, + picker: F, +) -> AppResult> +where + F: FnMut(&DecodedAccumulatorRootEvent, &str) -> Option<(u16, u64)> + Copy, +{ + let hot_pairs = fetch_seller_pairs_on_endpoint( + hot, + rate_limiter, + accumulator_hot, + accumulator_addr, + seller, + primary_dst, + legacy_dst, + picker, + ) + .await?; + if !hot_pairs.is_empty() { + return Ok(hot_pairs); + } + if let (Some(arc_ctx), Some(acc_arc)) = (archive, accumulator_archive) { + return fetch_seller_pairs_on_endpoint( + arc_ctx, + rate_limiter, + acc_arc, + accumulator_addr, + seller, + primary_dst, + legacy_dst, + picker, + ) + .await; + } + Ok(hot_pairs) +} + +fn encode_cursor(denom: u16, order_id: u64) -> String { + format!("{denom}:{order_id}") +} + +fn parse_cursor(s: &str) -> Option<(u16, u64)> { + let mut parts = s.splitn(2, ':'); + let d: u16 = parts.next()?.parse().ok()?; + let o: u64 = parts.next()?.parse().ok()?; + Some((d, o)) +} + +/// Returns paginated sell orders for `seller`. See module docs for strategy. +pub(crate) async fn get_my_sell_orders( + hot: Arc, + archive: Option>, + rate_limiter: Option, + seller: &str, + page_size: u32, + cursor: Option, +) -> AppResult { + let accumulator_hot = ShellAccumulatorRootUsdc::new_default(hot.clone()); + let accumulator_addr = accumulator_hot.address().to_string(); + let accumulator_archive = + archive.as_ref().map(|a| ShellAccumulatorRootUsdc::new_default(a.clone())); + let rl = rate_limiter.as_ref(); + + let seller_ext = internal_to_external_address(seller); + let created_legacy_dst = AccumulatorRootEvent::SellOrderCreated.to_external_address(); + let claimed_legacy_dst = AccumulatorRootEvent::UsdcClaimed.to_external_address(); + + // 1. SellOrderCreated for this seller: per-seller dst first (server-side + // scoped), legacy fallback (client-side filter). Hot first; if hot is empty + // for this seller, go to archive. + let created_pairs = fetch_seller_pairs_with_archive_fallback( + &hot, + archive.as_ref(), + rl, + &accumulator_hot, + accumulator_archive.as_ref(), + &accumulator_addr, + seller, + &seller_ext, + &created_legacy_dst, + |ev, who| match ev { + DecodedAccumulatorRootEvent::SellOrderCreated { data, .. } => { + if addresses_equal(&data.seller, who) { + Some((data.denom, data.order_id)) + } else { + None + } + } + _ => None, + }, + ) + .await?; + + // 2. UsdcClaimed: no per-seller dst in the current contract, so legacy is both + // primary and fallback. Filter by seller in payload. + let claimed_pairs = fetch_seller_pairs_with_archive_fallback( + &hot, + archive.as_ref(), + rl, + &accumulator_hot, + accumulator_archive.as_ref(), + &accumulator_addr, + seller, + &claimed_legacy_dst, + &claimed_legacy_dst, + |ev, who| match ev { + DecodedAccumulatorRootEvent::UsdcClaimed { data, .. } => { + if addresses_equal(&data.seller, who) { + Some((data.denom, data.order_id)) + } else { + None + } + } + _ => None, + }, + ) + .await?; + + // 3. Pull queue_state per denom on hot (current state). Sequential — only 4 + // calls and they're cheap getters. + let mut queue_states: HashMap = HashMap::with_capacity(4); + for d in VALID_DENOMS { + let acc = ShellAccumulatorRootUsdc::new_default(hot.clone()); + let qs = bee_infra::with_retry_policy( + &sell_orders_retry_policy(), + rl, + |_: &ackinacki_kit::contracts::error::KitError| true, + || { + let acc = acc.clone(); + async move { acc.get_queue_state(ParamsOfGetQueueState { d }).await } + }, + ) + .await + .map_err(|e: ackinacki_kit::contracts::error::KitError| { + AppError::from(e).with_context(format!("get_my_sell_orders: get_queue_state(d={d})")) + })?; + queue_states.insert(d, qs); + } + + // 4. Filter candidates by kit's own rules: drop already-claimed, drop invalid + // order_id, drop order_id outside the live queue range. + let mut candidates: Vec<(u16, u64)> = Vec::new(); + for (denom, order_id) in created_pairs { + if claimed_pairs.contains(&(denom, order_id)) { + continue; + } + let Some(qs) = queue_states.get(&denom) else { + continue; + }; + if order_id == 0 || order_id >= qs.next_id { + continue; + } + candidates.push((denom, order_id)); + } + candidates.sort(); + + // 5. Apply cursor: take items strictly greater than the cursor's pair. + let start = match cursor.as_deref() { + Some(c) => { + let pair = parse_cursor(c).ok_or_else(|| { + AppError::new(format!("get_my_sell_orders: invalid cursor `{c}`")) + })?; + candidates.iter().position(|p| *p > pair).unwrap_or(candidates.len()) + } + None => 0, + }; + let limit = page_size.max(1) as usize; + // Take limit+1 to detect next page. + let take = candidates.len().saturating_sub(start).min(limit + 1); + let page_pairs: Vec<(u16, u64)> = candidates[start..start + take].to_vec(); + + // 6. For each pair on this page: derive lot address + fetch lot.get_details on + // hot. Driven concurrently with a semaphore cap (rate limiter still applies + // via with_retry). Not spawned: wasm32 is single-threaded and gloo_timers + // futures aren't Send, so we drive everything on the current task via + // FuturesUnordered. + let semaphore = Arc::new(tokio::sync::Semaphore::new(GETTER_CONCURRENCY)); + let mut tasks = FuturesUnordered::new(); + for (denom, order_id) in &page_pairs { + let denom = *denom; + let order_id = *order_id; + let hot = hot.clone(); + let rl_clone = rate_limiter.clone(); + let seller_owned = seller.to_string(); + let qs = queue_states + .get(&denom) + .cloned() + .expect("queue state pre-fetched for all valid denoms"); + let sem = semaphore.clone(); + tasks.push(async move { + let _permit = sem.acquire_owned().await.expect("semaphore never closed"); + let acc = ShellAccumulatorRootUsdc::new_default(hot.clone()); + let addr_result = bee_infra::with_retry_policy( + &sell_orders_retry_policy(), + rl_clone.as_ref(), + |_: &ackinacki_kit::contracts::error::KitError| true, + || { + let acc = acc.clone(); + async move { + acc.get_sell_order_address(ParamsOfGetSellOrderAddress { + d: denom, + order_id, + }) + .await + } + }, + ) + .await + .map_err(|e: ackinacki_kit::contracts::error::KitError| { + AppError::from(e).with_context(format!( + "get_my_sell_orders: get_sell_order_address(d={denom}, order_id={order_id})" + )) + })?; + let lot_addr = addr_result.sell_order_addr; + let lot = ShellSellOrderLot::new_default(hot.clone(), &lot_addr); + let details = bee_infra::with_retry_policy( + &sell_orders_retry_policy(), + rl_clone.as_ref(), + |_: &ackinacki_kit::contracts::error::KitError| true, + || { + let lot = lot.clone(); + async move { lot.get_details().await } + }, + ) + .await + .map_err(|e: ackinacki_kit::contracts::error::KitError| { + AppError::from(e).with_context(format!( + "get_my_sell_orders: lot.get_details(addr={lot_addr}, d={denom}, order_id={order_id})" + )) + })?; + // Defensive: if owner doesn't match, this order isn't ours + // (shouldn't happen — event already had us as seller — but kit + // checks this too). + if !addresses_equal(&details.owner, &seller_owned) { + return Ok::, AppError>(None); + } + let sold = order_id <= qs.sold_prefix; + let position_in_queue = if sold { + 0 + } else { + order_id.saturating_sub(qs.sold_prefix) + }; + Ok(Some(SellOrderInfo { + denom, + order_id, + sell_order_address: lot_addr, + claimed: details.claimed, + sold, + position_in_queue, + })) + }); + } + + let mut results: Vec = Vec::with_capacity(tasks.len()); + while let Some(res) = tasks.next().await { + match res { + Ok(Some(info)) => results.push(info), + Ok(None) => {} + Err(e) => return Err(e), + } + } + results.sort_by_key(|o| (o.denom, o.order_id)); + + let has_next_page = page_pairs.len() > limit; + if has_next_page && results.len() > limit { + results.truncate(limit); + } + let next_cursor = if has_next_page { + results.last().map(|o| encode_cursor(o.denom, o.order_id)) + } else { + None + }; + + Ok(GetMySellOrdersResult { orders: results, next_cursor, has_next_page }) +} diff --git a/bee_wallet/src/services/transaction/history.rs b/bee_wallet/src/services/transaction/history.rs new file mode 100644 index 0000000..9ea97d8 --- /dev/null +++ b/bee_wallet/src/services/transaction/history.rs @@ -0,0 +1,1497 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use ackinacki_kit::contracts::mvsystem::multifactor::AccountData; +use ackinacki_kit::contracts::mvsystem::multifactor::Multifactor; +use ackinacki_kit::contracts::mvsystem::root::MobileVerifiersRoot; +use ackinacki_kit::contracts::mvsystem::root::ParamsOfGetPopitgame; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::ClientContext; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde_json::json; + +use crate::services::multifactor::query::get_multifactor_decoded_data; + +/// Address of the MobileVerifiersGameRoot contract that sends mining rewards. +const GAME_ROOT_ADDRESS: &str = + "0:0505050505050505050505050505050505050505050505050505050505050505"; + +/// ECC currency id for NACKL. Mining rewards (`RewardedPopitGame` events +/// from `GAME_ROOT_ADDRESS`) are issued exclusively in NACKL — querying the +/// mining stream for any other currency is wasted round-trips and (worse) +/// surfaces NACKL rewards mis-stamped as the requested currency. +const NACKL_CURRENCY_ID: u32 = 1; + +/// Well-known system contract addresses → human-readable names. +const KNOWN_ADDRESSES: &[(&str, &str)] = &[ + ("0:1111111111111111111111111111111111111111111111111111111111111111", "Giver"), + ("0:1010101010101010101010101010101010101010101010101010101010101010", "DEX"), + ("0:1515151515151515151515151515151515151515151515151515151515151515", "DEX Oracle"), + ("0:1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a", "Exchange"), + ("0:3535353535353535353535353535353535353535353535353535353535353535", "Accumulator"), +]; + +fn known_name(address: &str) -> Option { + KNOWN_ADDRESSES.iter().find(|(addr, _)| *addr == address).map(|(_, name)| name.to_string()) +} + +/// Prefix used to mark cursors that belong to the archive data source. +const ARCHIVE_CURSOR_PREFIX: &str = "a:"; + +/// A decoded cursor that knows which data source it came from. +enum CursorSource { + /// Cursor for the hot (recent) data endpoint. + Hot(Option), + /// Cursor for the archive data endpoint. + Archive(Option), +} + +fn decode_cursor(cursor: Option) -> CursorSource { + match cursor { + None => CursorSource::Hot(None), + Some(c) if c.starts_with(ARCHIVE_CURSOR_PREFIX) => { + let inner = c[ARCHIVE_CURSOR_PREFIX.len()..].to_string(); + CursorSource::Archive(if inner.is_empty() { None } else { Some(inner) }) + } + Some(c) => CursorSource::Hot(Some(c)), + } +} + +fn encode_archive_cursor(cursor: Option) -> Option { + Some(format!("{}{}", ARCHIVE_CURSOR_PREFIX, cursor.unwrap_or_default())) +} + +/// Maximum number of auto-fetch iterations when the current page has no +/// messages matching the requested `currency_id`. +const MAX_AUTO_FETCH_PAGES: u32 = 10; + +/// Archive endpoint is a soft dependency: the user's history must surface +/// even if the archive node is slow, broken, or unreachable. These bounds +/// keep the SDK from blocking the UI on a misbehaving archive. +/// +/// `ARCHIVE_FETCH_TIMEOUT` covers a real archive page fetch (continuation +/// or hot-empty fallback). `ARCHIVE_PROBE_TIMEOUT` is for the cheap +/// "is there anything in archive?" probe done on the final hot page — +/// shorter because we only need a yes/no, not actual data. +const ARCHIVE_FETCH_TIMEOUT: Duration = Duration::from_secs(5); +const ARCHIVE_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const ARCHIVE_PROBE_PAGE_SIZE: u32 = 1; + +fn default_page_size() -> u32 { + 20 +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfGetHistory { + /// Multifactor account address + pub multifactor_address: String, + /// Token identifier: + /// - ECC: "1" or "2" (currency_id) + /// - TIP-3: "0:ffff..." (token_root address) + pub token_id: String, + /// Number of records per page (default 20) + #[serde(default = "default_page_size")] + pub page_size: u32, + /// Cursor for main messages (`None` = first page) + pub cursor: Option, + /// Cursor for popitgame/mining messages (`None` = first page). + /// For TIP-3 history, leave this as `None`. + pub mining_cursor: Option, +} + +#[derive(Debug, Serialize)] +pub enum TxType { + Mining, + Incoming, + Outgoing, +} + +#[derive(Debug, Serialize)] +pub struct TxData { + pub id: String, + pub tx_type: TxType, + pub created_at: u64, + pub value: u128, + pub src_name: Option, +} + +#[derive(Debug, Serialize)] +pub struct ResultOfGetHistory { + /// Transaction list for the current page + pub data: Vec, + /// Cursor for the next multifactor page (`None` = last page) + pub next_cursor: Option, + /// Cursor for the next popitgame/mining page (`None` = last page) + pub next_mining_cursor: Option, + /// Whether there are more pages (from either stream) + pub has_next_page: bool, +} + +#[allow(dead_code)] +enum TokenKind { + Ecc(u32), + Tip3(String), +} + +/// Determines if token_id is an ECC currency_id or a TIP-3 token_root address. +fn parse_token_id(token_id: &str) -> Result { + match token_id.parse::() { + Ok(currency_id) => Ok(TokenKind::Ecc(currency_id)), + Err(_) => Ok(TokenKind::Tip3(token_id.to_string())), + } +} + +fn de_u32_from_f64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let v = f64::deserialize(deserializer)?; + + Ok(v as u32) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ValueOther { + #[serde(deserialize_with = "de_u32_from_f64")] + pub currency: u32, + pub value: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EccMsg { + pub id: String, + pub src: String, + pub dst: String, + pub created_at: u64, + #[serde(default)] + pub value_other: Vec, +} + +// ---------- GraphQL response types ---------- + +#[derive(Debug, Deserialize)] +struct GqlResponse { + data: GqlData, +} + +#[derive(Debug, Deserialize)] +struct GqlData { + blockchain: GqlBlockchain, +} + +#[derive(Debug, Deserialize)] +struct GqlBlockchain { + account: GqlAccount, +} + +#[derive(Debug, Deserialize)] +struct GqlAccount { + messages: GqlMessages, +} + +#[derive(Debug, Deserialize)] +struct GqlMessages { + edges: Vec, + #[serde(rename = "pageInfo")] + page_info: GqlPageInfo, +} + +#[derive(Debug, Deserialize)] +struct GqlEdge { + node: EccMsg, +} + +#[derive(Debug, Deserialize)] +struct GqlPageInfo { + #[serde(rename = "startCursor")] + start_cursor: Option, + #[serde(rename = "hasPreviousPage")] + has_previous_page: bool, +} + +// ---------- GraphQL queries ---------- + +/// ECC: all incoming and outgoing internal messages for multifactor account. +/// Uses `last`/`before` to get newest messages first (reverse cursor +/// pagination). +const GQL_ECC_QUERY: &str = r#" + query($address: String!, $last: Int!, $before: String) { + blockchain { + account(address: $address) { + messages(msg_type: [IntIn, IntOut], last: $last, before: $before) { + edges { + node { + id + src + dst + created_at + value_other { + currency + value(format: DEC) + } + } + } + pageInfo { + startCursor + hasPreviousPage + } + } + } + } + } +"#; + +/// v3 (`>= 1.0.0`) form of [`GQL_ECC_QUERY`] — account by `account_id` + +/// `dapp_id`. +const GQL_ECC_QUERY_V3: &str = r#" + query($account_id: String!, $dapp_id: String!, $last: Int!, $before: String) { + blockchain { + account(account_id: $account_id, dapp_id: $dapp_id) { + messages(msg_type: [IntIn, IntOut], last: $last, before: $before) { + edges { + node { + id + src + dst + created_at + value_other { + currency + value(format: DEC) + } + } + } + pageInfo { + startCursor + hasPreviousPage + } + } + } + } + } +"#; + +/// Mining: RewardedPopitGame events emitted by GameRoot, filtered by dst = +/// popitgame external address. Each event body contains `reward: uint128`. +/// Uses `last`/`before` for reverse cursor pagination (newest first). +const GQL_MINING_EVENTS_QUERY: &str = r#" + query($address: String!, $dst: String!, $last: Int!, $before: String) { + blockchain { + account(address: $address) { + events(dst: $dst, last: $last, before: $before) { + edges { + node { + msg_id + created_at + dst + body + } + } + pageInfo { + startCursor + hasPreviousPage + } + } + } + } + } +"#; + +/// v3 (`>= 1.0.0`) form of [`GQL_MINING_EVENTS_QUERY`] — account by +/// `account_id` + `dapp_id`. +const GQL_MINING_EVENTS_QUERY_V3: &str = r#" + query($account_id: String!, $dapp_id: String!, $dst: String!, $last: Int!, $before: String) { + blockchain { + account(account_id: $account_id, dapp_id: $dapp_id) { + events(dst: $dst, last: $last, before: $before) { + edges { + node { + msg_id + created_at + dst + body + } + } + pageInfo { + startCursor + hasPreviousPage + } + } + } + } + } +"#; + +// ---------- Query execution ---------- + +async fn execute_gql_query( + tvm_client: &Arc, + address: &str, + page_size: u32, + cursor: Option<&str>, +) -> crate::errors::AppResult<(Vec, GqlPageInfo)> { + // Multifactor account lives under the Mobile Verifiers dApp. + let v3 = crate::dapp::server_uses_dapp_id(tvm_client).await?; + let dapp_id = ackinacki_kit::contracts::dapp::SystemDapp::MobileVerifiers.dapp_id(); + let variables = if v3 { + json!({ + "account_id": crate::dapp::account_id(address), + "dapp_id": dapp_id, + "last": page_size, + "before": cursor, + }) + } else { + json!({ + "address": address, + "last": page_size, + "before": cursor, + }) + }; + + let result = ackinacki_kit::tvm_client::net::query( + tvm_client.clone(), + ackinacki_kit::tvm_client::net::ParamsOfQuery { + query: if v3 { GQL_ECC_QUERY_V3 } else { GQL_ECC_QUERY }.to_string(), + variables: Some(variables), + }, + ) + .await + .map_err(|e| crate::errors::AppError::from(e).with_context("GraphQL query failed"))?; + + let resp: GqlResponse = serde_json::from_value(result.result) + .map_err(|e| crate::errors::AppError::from(e).with_context("Parse GQL response"))?; + + let GqlMessages { edges, page_info } = resp.data.blockchain.account.messages; + let msgs: Vec = edges.into_iter().map(|e| e.node).collect(); + + Ok((msgs, page_info)) +} + +// ---------- Mining events response types ---------- + +#[derive(Debug, Deserialize)] +struct GqlEventsResponse { + data: GqlEventsData, +} + +#[derive(Debug, Deserialize)] +struct GqlEventsData { + blockchain: GqlEventsBlockchain, +} + +#[derive(Debug, Deserialize)] +struct GqlEventsBlockchain { + account: GqlEventsAccount, +} + +#[derive(Debug, Deserialize)] +struct GqlEventsAccount { + events: GqlEvents, +} + +#[derive(Debug, Deserialize)] +struct GqlEvents { + edges: Vec, + #[serde(rename = "pageInfo")] + page_info: GqlPageInfo, +} + +#[derive(Debug, Deserialize)] +struct GqlEventEdge { + node: GqlEventNode, +} + +#[derive(Debug, Deserialize)] +struct GqlEventNode { + msg_id: String, + created_at: u64, + #[allow(dead_code)] + dst: String, + body: String, +} + +/// Queries RewardedPopitGame events from GameRoot, converts them to EccMsg +/// for compatibility with the existing history pipeline. +async fn execute_mining_events_query( + tvm_client: &Arc, + popitgame_address: &str, + currency_id: u32, + page_size: u32, + cursor: Option<&str>, +) -> crate::errors::AppResult<(Vec, GqlPageInfo)> { + use ackinacki_kit::contracts::event::Event; + use ackinacki_kit::contracts::mvsystem::game_root::contract::MobileVerifiersGameRoot; + use ackinacki_kit::contracts::mvsystem::game_root::events::RewardedPopitGameData; + + // Convert popitgame address "0:abc..." → ":abc..." for dst filter + let popitgame_ext_dst = popitgame_address.replacen("0:", ":", 1); + + // GameRoot lives under the Mobile Verifiers dApp. + let v3 = crate::dapp::server_uses_dapp_id(tvm_client).await?; + let dapp_id = ackinacki_kit::contracts::dapp::SystemDapp::MobileVerifiers.dapp_id(); + let variables = if v3 { + json!({ + "account_id": crate::dapp::account_id(GAME_ROOT_ADDRESS), + "dapp_id": dapp_id, + "dst": popitgame_ext_dst, + "last": page_size, + "before": cursor, + }) + } else { + json!({ + "address": GAME_ROOT_ADDRESS, + "dst": popitgame_ext_dst, + "last": page_size, + "before": cursor, + }) + }; + + let result = ackinacki_kit::tvm_client::net::query( + tvm_client.clone(), + ackinacki_kit::tvm_client::net::ParamsOfQuery { + query: if v3 { GQL_MINING_EVENTS_QUERY_V3 } else { GQL_MINING_EVENTS_QUERY } + .to_string(), + variables: Some(variables), + }, + ) + .await + .map_err(|e| { + crate::errors::AppError::from(e).with_context("Mining events GraphQL query failed") + })?; + + let resp: GqlEventsResponse = serde_json::from_value(result.result).map_err(|e| { + crate::errors::AppError::from(e).with_context("Parse mining events GQL response") + })?; + + let GqlEvents { edges, page_info } = resp.data.blockchain.account.events; + + let game_root = MobileVerifiersGameRoot::new_default(tvm_client.clone()); + + let msgs: Vec = edges + .into_iter() + .filter_map(|edge| { + let node = edge.node; + let event = Event { + id: node.msg_id.clone(), + dst: node.dst.clone(), + created_at: node.created_at, + body: node.body.clone(), + }; + let decoded = event.decode::(&game_root).ok()??; + Some(EccMsg { + id: node.msg_id, + src: GAME_ROOT_ADDRESS.to_string(), + dst: popitgame_address.to_string(), + created_at: node.created_at, + value_other: vec![ValueOther { + currency: currency_id, + value: decoded.reward.to_string(), + }], + }) + }) + .collect(); + + Ok((msgs, page_info)) +} + +// ---------- Auto-pagination helpers ---------- + +/// Fetches ECC messages with auto-pagination until filtered results appear or +/// pages are exhausted. Uses reverse pagination (`last`/`before`) — newest +/// messages first. +async fn fetch_ecc_with_auto_pagination( + tvm_client: &Arc, + address: &str, + currency_id: u32, + page_size: u32, + initial_cursor: Option, +) -> crate::errors::AppResult<(Vec, Option, bool)> { + let mut all_msgs: Vec = Vec::new(); + let mut current_cursor = initial_cursor; + let mut next_cursor: Option = None; + let mut has_more: bool = false; + + for _ in 0..MAX_AUTO_FETCH_PAGES { + let (msgs, page_info) = + execute_gql_query(tvm_client, address, page_size, current_cursor.as_deref()).await?; + + let filtered: Vec = msgs + .into_iter() + .filter(|m| m.value_other.iter().any(|vo| vo.currency == currency_id)) + .collect(); + + all_msgs.extend(filtered); + next_cursor = page_info.start_cursor; + has_more = page_info.has_previous_page; + + if !all_msgs.is_empty() || !has_more { + break; + } + current_cursor = next_cursor.clone(); + } + + Ok((all_msgs, next_cursor, has_more)) +} + +/// Fetches mining reward events with pagination. Events are already filtered +/// server-side by dst (popitgame address), so no client-side auto-pagination +/// through empty pages is needed. +async fn fetch_mining_with_auto_pagination( + tvm_client: &Arc, + popitgame_address: &str, + currency_id: u32, + page_size: u32, + initial_cursor: Option, +) -> crate::errors::AppResult<(Vec, Option, bool)> { + let (msgs, page_info) = execute_mining_events_query( + tvm_client, + popitgame_address, + currency_id, + page_size, + initial_cursor.as_deref(), + ) + .await?; + + Ok((msgs, page_info.start_cursor, page_info.has_previous_page)) +} + +// ---------- Archive helpers ---------- + +/// Wraps an archive fetch in a timeout. On timeout/error, returns an empty +/// result with no continuation — archive is best-effort, the user's hot +/// history must still surface. +async fn archive_call_or_empty(timeout: Duration, fut: F) -> (Vec, Option, bool) +where + F: std::future::Future, Option, bool)>>, +{ + match tokio::time::timeout(timeout, fut).await { + Ok(Ok(r)) => r, + Ok(Err(_)) | Err(_) => (vec![], None, false), + } +} + +/// Cheap "does archive have *any* matching data?" probe done on the final +/// hot page. Returns `true` only if the archive responded within +/// `ARCHIVE_PROBE_TIMEOUT` AND has at least one message for `currency_id`. +/// Replaces the old behavior of unconditionally claiming `has_next=true` +/// just because an archive client was configured (which forced the frontend +/// to round-trip into a possibly-dead archive). +async fn archive_ecc_has_data( + arc_client: &Arc, + address: &str, + currency_id: u32, +) -> bool { + let probe = fetch_ecc_with_auto_pagination( + arc_client, + address, + currency_id, + ARCHIVE_PROBE_PAGE_SIZE, + None, + ); + let (msgs, _, _) = archive_call_or_empty(ARCHIVE_PROBE_TIMEOUT, probe).await; + !msgs.is_empty() +} + +async fn archive_mining_has_data( + arc_client: &Arc, + popitgame_address: &str, + currency_id: u32, +) -> bool { + let probe = fetch_mining_with_auto_pagination( + arc_client, + popitgame_address, + currency_id, + ARCHIVE_PROBE_PAGE_SIZE, + None, + ); + let (msgs, _, _) = archive_call_or_empty(ARCHIVE_PROBE_TIMEOUT, probe).await; + !msgs.is_empty() +} + +// ---------- Main entry point ---------- + +/// Fetches paginated ECC transaction history (incoming, outgoing, mining). +/// +/// Uses `tvm_client` for hot data and optionally `archive_tvm_client` for +/// older data. Cursors are opaque strings — `a:` prefix means archive source. +/// Deduplicates messages by id when hot/archive overlap. +/// +/// Archive is treated as a soft dependency: every call into it is bounded +/// by `ARCHIVE_FETCH_TIMEOUT` (or `ARCHIVE_PROBE_TIMEOUT` for the existence +/// check). A hung or broken archive node causes the result to surface +/// without older data instead of blocking the UI. +pub async fn get_ecc_txs( + tvm_client: Arc, + archive_tvm_client: Option>, + params: ParamsOfGetHistory, +) -> crate::errors::AppResult { + let currency_id = match parse_token_id(¶ms.token_id) { + Ok(TokenKind::Ecc(id)) => id, + _ => { + return Err(crate::errors::AppError::new(format!( + "ECC history requires numeric token_id, got: {}", + params.token_id + ))) + } + }; + + // --- 1. Resolve popitgame address (may not exist if user has no miner). + // Skip the lookup entirely for non-NACKL currencies — mining rewards + // are NACKL-only, so SHELL/USDC requests have no business polling the + // mining stream and saving the round-trip avoids leaking fake + // "Mining" entries that mis-stamp NACKL rewards as another currency. --- + let popitgame_address: Option = if currency_id == NACKL_CURRENCY_ID { + let root = MobileVerifiersRoot::new_default(tvm_client.clone()); + root.get_popitgame(ParamsOfGetPopitgame { + multifactor_address: params.multifactor_address.clone(), + }) + .await + .ok() + .map(|pg| pg.address().to_string()) + } else { + None + }; + + // --- 2. Decode cursors to determine data source (hot vs archive) --- + let mf_source = decode_cursor(params.cursor.clone()); + let pg_source = decode_cursor(params.mining_cursor.clone()); + + // --- 3. Fetch from the appropriate source, falling back to archive --- + let (mf_msgs, mf_cursor, mf_has_next) = match mf_source { + CursorSource::Hot(cursor) => { + let (msgs, next_cursor, has_next) = fetch_ecc_with_auto_pagination( + &tvm_client, + ¶ms.multifactor_address, + currency_id, + params.page_size, + cursor, + ) + .await?; + + if !has_next && msgs.is_empty() { + // Hot exhausted with nothing — try archive (best-effort). + match archive_tvm_client.as_ref() { + Some(arc_client) => { + let arc_fetch = fetch_ecc_with_auto_pagination( + arc_client, + ¶ms.multifactor_address, + currency_id, + params.page_size, + None, + ); + let (arc_msgs, arc_cursor, arc_has_next) = + archive_call_or_empty(ARCHIVE_FETCH_TIMEOUT, arc_fetch).await; + (arc_msgs, encode_archive_cursor(arc_cursor), arc_has_next) + } + None => (msgs, next_cursor, has_next), + } + } else if !has_next { + // Hot has data on this page but no more pages. Probe archive + // before claiming `has_next=true` — only signal continuation + // if archive actually has older data and is responsive. + match archive_tvm_client.as_ref() { + Some(arc_client) + if archive_ecc_has_data( + arc_client, + ¶ms.multifactor_address, + currency_id, + ) + .await => + { + (msgs, encode_archive_cursor(None), true) + } + _ => (msgs, None, false), + } + } else { + (msgs, next_cursor, has_next) + } + } + CursorSource::Archive(cursor) => match archive_tvm_client.as_ref() { + Some(arc_client) => { + let arc_fetch = fetch_ecc_with_auto_pagination( + arc_client, + ¶ms.multifactor_address, + currency_id, + params.page_size, + cursor, + ); + let (msgs, next_cursor, has_next) = + archive_call_or_empty(ARCHIVE_FETCH_TIMEOUT, arc_fetch).await; + (msgs, encode_archive_cursor(next_cursor), has_next) + } + None => (vec![], None, false), + }, + }; + + let (pg_msgs, pg_cursor, pg_has_next) = match pg_source { + CursorSource::Hot(cursor) => match &popitgame_address { + Some(addr) => { + let (msgs, next_cursor, has_next) = fetch_mining_with_auto_pagination( + &tvm_client, + addr, + currency_id, + params.page_size, + cursor, + ) + .await?; + + if !has_next && msgs.is_empty() { + match archive_tvm_client.as_ref() { + Some(arc_client) => { + let arc_fetch = fetch_mining_with_auto_pagination( + arc_client, + addr, + currency_id, + params.page_size, + None, + ); + let (arc_msgs, arc_cursor, arc_has_next) = + archive_call_or_empty(ARCHIVE_FETCH_TIMEOUT, arc_fetch).await; + (arc_msgs, encode_archive_cursor(arc_cursor), arc_has_next) + } + None => (msgs, next_cursor, has_next), + } + } else if !has_next { + match archive_tvm_client.as_ref() { + Some(arc_client) + if archive_mining_has_data(arc_client, addr, currency_id).await => + { + (msgs, encode_archive_cursor(None), true) + } + _ => (msgs, None, false), + } + } else { + (msgs, next_cursor, has_next) + } + } + None => (vec![], None, false), + }, + CursorSource::Archive(cursor) => match (&popitgame_address, &archive_tvm_client) { + (Some(addr), Some(arc_client)) => { + let arc_fetch = fetch_mining_with_auto_pagination( + arc_client, + addr, + currency_id, + params.page_size, + cursor, + ); + let (msgs, next_cursor, has_next) = + archive_call_or_empty(ARCHIVE_FETCH_TIMEOUT, arc_fetch).await; + (msgs, encode_archive_cursor(next_cursor), has_next) + } + _ => (vec![], None, false), + }, + }; + + // --- 4. Deduplicate messages by id (hot/archive overlap) --- + let mut seen_ids = HashSet::new(); + let mf_msgs: Vec = + mf_msgs.into_iter().filter(|m| seen_ids.insert(m.id.clone())).collect(); + let pg_msgs: Vec = + pg_msgs.into_iter().filter(|m| seen_ids.insert(m.id.clone())).collect(); + + // --- 3. Collect counterparty addresses (only from multifactor stream) --- + let mut seen = HashSet::new(); + + let counterparties: Vec = mf_msgs + .iter() + .filter_map(|e| { + if e.src == params.multifactor_address { + Some(e.dst.clone()) + } else if e.dst == params.multifactor_address { + Some(e.src.clone()) + } else { + None + } + }) + .filter(|a| a != ¶ms.multifactor_address) + .filter(|a| known_name(a).is_none()) // skip well-known contracts + .filter(|a| seen.insert(a.clone())) + .collect(); + + // --- 4. Resolve counterparty names in parallel --- + const PAR: usize = 10; + let mut decoded_map = HashMap::::new(); + + let mut join_set = tokio::task::JoinSet::new(); + let mut iter = counterparties.into_iter(); + + loop { + while join_set.len() < PAR { + if let Some(addr) = iter.next() { + let tvm_client = tvm_client.clone(); + + join_set.spawn(async move { + let contract = Multifactor::new_default(tvm_client.clone(), addr.clone()); + let contract = Arc::new(contract); + let decoded = get_multifactor_decoded_data(&contract).await; + (addr, decoded) + }); + } else { + break; + } + } + + if join_set.is_empty() { + break; + } + + let (addr, res) = join_set + .join_next() + .await + .ok_or_else(|| "JoinSet unexpectedly empty".to_string())? + .map_err(|e| crate::errors::AppError::from(e).with_context("Join error"))?; + + // Graceful degradation: if we can't decode a counterparty, just skip + if let Ok(decoded) = res { + decoded_map.insert(addr, decoded); + } + } + + // --- 5. Build TxData from multifactor stream (Incoming / Outgoing) --- + let mut data: Vec = mf_msgs + .into_iter() + .map(|evt| { + let vo = evt.value_other.into_iter().find(|vo| vo.currency == currency_id).ok_or_else( + || format!("No value_other for currency {} in message {}", currency_id, evt.id), + )?; + + let value = vo.value.parse::().map_err(|e| { + crate::errors::AppError::from(e) + .with_context(format!("Failed to parse value '{}' in msg {}", vo.value, evt.id)) + })?; + + let tx_type = if evt.src == params.multifactor_address { + TxType::Outgoing + } else if evt.dst == params.multifactor_address { + TxType::Incoming + } else { + return Err(crate::errors::AppError::new(format!( + "Event {} is unrelated: src={}, dst={}, expected addr={}", + evt.id, evt.src, evt.dst, params.multifactor_address + ))); + }; + + let counterparty_addr = match tx_type { + TxType::Outgoing => Some(evt.dst), + TxType::Incoming => Some(evt.src), + TxType::Mining => None, + }; + let src_name = match counterparty_addr { + Some(ref addr) => { + known_name(addr).or_else(|| decoded_map.get(addr).map(|acc| acc.name.clone())) + } + None => None, + }; + + Ok(TxData { id: evt.id, tx_type, created_at: evt.created_at, value, src_name }) + }) + .collect::>>()?; + + // --- 6. Build TxData from mining stream (all are TxType::Mining) --- + let mining_data: Vec = pg_msgs + .into_iter() + .map(|evt| { + let vo = evt.value_other.into_iter().find(|vo| vo.currency == currency_id).ok_or_else( + || { + format!( + "No value_other for currency {} in mining message {}", + currency_id, evt.id + ) + }, + )?; + + let value = vo.value.parse::().map_err(|e| { + crate::errors::AppError::from(e).with_context(format!( + "Failed to parse value '{}' in mining msg {}", + vo.value, evt.id + )) + })?; + + Ok(TxData { + id: evt.id, + tx_type: TxType::Mining, + created_at: evt.created_at, + value, + src_name: None, + }) + }) + .collect::>>()?; + + data.extend(mining_data); + + // --- 7. Sort by created_at DESC --- + data.sort_by_key(|b| std::cmp::Reverse(b.created_at)); + + Ok(ResultOfGetHistory { + data, + next_cursor: mf_cursor, + next_mining_cursor: pg_cursor, + has_next_page: mf_has_next || pg_has_next, + }) +} + +/// Token (TIP-3) transfer history — stub, returns empty result. +/// TODO: implement using TokenRoot::get_wallet_address + query token wallet +/// messages +pub async fn get_token_txs( + _tvm_client: Arc, + _params: ParamsOfGetHistory, +) -> crate::errors::AppResult { + Ok(ResultOfGetHistory { + data: vec![], + next_cursor: None, + next_mining_cursor: None, + has_next_page: false, + }) +} + +/// Unified history entry point. Routes to ECC or TIP-3 based on token_id +/// format. +pub async fn get_history( + tvm_client: Arc, + archive_tvm_client: Option>, + params: ParamsOfGetHistory, +) -> crate::errors::AppResult { + match parse_token_id(¶ms.token_id) { + Ok(TokenKind::Ecc(_)) => get_ecc_txs(tvm_client, archive_tvm_client, params).await, + Ok(TokenKind::Tip3(_)) => get_token_txs(tvm_client, params).await, + Err(e) => Err(crate::errors::AppError::new(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const WAPP_T_1: &str = "0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4"; + const WAPP_T_2: &str = "0:b68ea8d8ab65081b535b962af6f4f1d2b10de5a418d0c54041083124b7931c71"; + const THIRD_PARTY: &str = "0:72d06fead593ad34a27a66bda087b9b9d20975b884be0b9fb9e28a812b9cdc0a"; + // ── JSON fixtures ──────────────────────────────────────────────── + + /// 5 ECC messages for wapp_t_1, page 1, hasNextPage=true + const GQL_PAGE1: &str = r#"{"data":{"blockchain":{"account":{"messages":{"edges":[{"node":{"id":"460d0ee64a1b016718235c4a5c185e3967eea36cc893fc38579f8260b36fbb3b","src":"0:72d06fead593ad34a27a66bda087b9b9d20975b884be0b9fb9e28a812b9cdc0a","dst":"0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4","created_at":1770747978,"value_other":[{"currency":1,"value":"1000000000"}]}},{"node":{"id":"6fd9e62b6c66c37ddca1a2864a7d6ed42d4dd55db779cf50680f9edcc6a267b9","src":"0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4","dst":"0:b68ea8d8ab65081b535b962af6f4f1d2b10de5a418d0c54041083124b7931c71","created_at":1770749208,"value_other":[{"currency":1,"value":"100000000"}]}},{"node":{"id":"e6f6c05eef0512a861f7cdbe524c3bc517d72058f79414924f1fb621f9220ebc","src":"0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4","dst":"0:b68ea8d8ab65081b535b962af6f4f1d2b10de5a418d0c54041083124b7931c71","created_at":1770749434,"value_other":[{"currency":1,"value":"100000000"}]}},{"node":{"id":"36aa16a808eb14ebe6620abbeeb2c881d8ef41781a42265cb73f68a537b66339","src":"0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4","dst":"0:b68ea8d8ab65081b535b962af6f4f1d2b10de5a418d0c54041083124b7931c71","created_at":1770749612,"value_other":[{"currency":1,"value":"100000000"}]}},{"node":{"id":"4e148b0c419928727426a8c7137f6a5078789bd1c4082de7a917f93d5b94e27e","src":"0:b68ea8d8ab65081b535b962af6f4f1d2b10de5a418d0c54041083124b7931c71","dst":"0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4","created_at":1770749616,"value_other":[{"currency":1,"value":"100000000"}]}}],"pageInfo":{"startCursor":"76994aab10001620948890900","hasPreviousPage":true}}}}}}"#; + + /// Empty response + const GQL_EMPTY: &str = r#"{"data":{"blockchain":{"account":{"messages":{"edges":[],"pageInfo":{"startCursor":null,"hasPreviousPage":false}}}}}}"#; + + /// Message with currency=99 + const GQL_WRONG_CURRENCY: &str = r#"{"data":{"blockchain":{"account":{"messages":{"edges":[{"node":{"id":"aaa111","src":"0:72d06fead593ad34a27a66bda087b9b9d20975b884be0b9fb9e28a812b9cdc0a","dst":"0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4","created_at":1770740000,"value_other":[{"currency":99,"value":"500000"}]}}],"pageInfo":{"startCursor":"somecursor123","hasPreviousPage":false}}}}}}"#; + + /// All IntIn to popitgame (junk + 1 real mining reward). + /// Simulates what comes back when counterparties filter doesn't work. + const GQL_POPITGAME_ALL: &str = r#"{"data":{"blockchain":{"account":{"messages":{"edges":[{"node":{"id":"544c01e98e238ce26440b34554f6b4c2dd3fc00e50cd3cf5cc5e27bc6d41412e","src":"0:652a99a9544782f4d93229180d0283a65c1af47c322337e172e8bfef22cf1713","dst":"0:74882e22b289107aaef0bae73f4b989856aebb0f9a423a6f04bbd30d26c7cb47","created_at":1771354583,"value_other":[{"currency":1,"value":"0"}]}},{"node":{"id":"af9f6f488bc3910adb52027090ce6aeda019b9ef8623fdf21e6e4e341a2a31f2","src":"0:0b426256ae8a35d636814dbcb8aeb2b677d440c7d1b8852cd9644bd07480ef1c","dst":"0:74882e22b289107aaef0bae73f4b989856aebb0f9a423a6f04bbd30d26c7cb47","created_at":1771354585,"value_other":[]}},{"node":{"id":"98734336130824965b265e37de80dde2b69e0ebfff7d47c47fd9ed3584a59adb","src":"0:0505050505050505050505050505050505050505050505050505050505050505","dst":"0:74882e22b289107aaef0bae73f4b989856aebb0f9a423a6f04bbd30d26c7cb47","created_at":1771355168,"value_other":[{"currency":1,"value":"86799"}]}}],"pageInfo":{"startCursor":"76994bc2000016209b71711a00","hasPreviousPage":false}}}}}}"#; + + /// Only the real mining reward (what counterparties *should* return) + const GQL_MINING_ONLY: &str = r#"{"data":{"blockchain":{"account":{"messages":{"edges":[{"node":{"id":"98734336130824965b265e37de80dde2b69e0ebfff7d47c47fd9ed3584a59adb","src":"0:0505050505050505050505050505050505050505050505050505050505050505","dst":"0:74882e22b289107aaef0bae73f4b989856aebb0f9a423a6f04bbd30d26c7cb47","created_at":1771355168,"value_other":[{"currency":1,"value":"86799"}]}}],"pageInfo":{"startCursor":"76994bc2000016209b71711a00","hasPreviousPage":false}}}}}}"#; + + // ── Helpers ────────────────────────────────────────────────────── + + fn parse_msgs(json: &str) -> (Vec, GqlPageInfo) { + let resp: GqlResponse = serde_json::from_str(json).unwrap(); + let m = resp.data.blockchain.account.messages; + (m.edges.into_iter().map(|e| e.node).collect(), m.page_info) + } + + // ── Tests: parsing ────────────────────────────────────────────── + + #[test] + fn parse_page1() { + let (msgs, pi) = parse_msgs(GQL_PAGE1); + assert_eq!(msgs.len(), 5); + assert!(pi.has_previous_page); + assert_eq!(pi.start_cursor.as_deref(), Some("76994aab10001620948890900")); + assert_eq!(msgs[0].src, THIRD_PARTY); + assert_eq!(msgs[0].dst, WAPP_T_1); + assert_eq!(msgs[0].created_at, 1770747978); + assert_eq!(msgs[0].value_other[0].currency, 1); + assert_eq!(msgs[0].value_other[0].value, "1000000000"); + } + + #[test] + fn parse_empty() { + let (msgs, pi) = parse_msgs(GQL_EMPTY); + assert!(msgs.is_empty()); + assert!(!pi.has_previous_page); + assert!(pi.start_cursor.is_none()); + } + + #[test] + fn parse_popitgame_all() { + let (msgs, pi) = parse_msgs(GQL_POPITGAME_ALL); + assert_eq!(msgs.len(), 3); + assert!(!pi.has_previous_page); + assert_eq!(msgs[2].src, GAME_ROOT_ADDRESS); + assert_eq!(msgs[2].value_other[0].value, "86799"); + } + + #[test] + fn parse_mining_only() { + let (msgs, _) = parse_msgs(GQL_MINING_ONLY); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].src, GAME_ROOT_ADDRESS); + assert_eq!(msgs[0].value_other[0].value.parse::().unwrap(), 86799); + } + + // ── Tests: currency filtering ─────────────────────────────────── + + #[test] + fn filter_currency_1() { + let (msgs, _) = parse_msgs(GQL_PAGE1); + assert_eq!( + msgs.iter().filter(|m| m.value_other.iter().any(|v| v.currency == 1)).count(), + 5 + ); + assert_eq!( + msgs.iter().filter(|m| m.value_other.iter().any(|v| v.currency == 2)).count(), + 0 + ); + } + + #[test] + fn filter_wrong_currency() { + let (msgs, _) = parse_msgs(GQL_WRONG_CURRENCY); + assert_eq!( + msgs.iter().filter(|m| m.value_other.iter().any(|v| v.currency == 1)).count(), + 0 + ); + assert_eq!( + msgs.iter().filter(|m| m.value_other.iter().any(|v| v.currency == 99)).count(), + 1 + ); + } + + // ── Tests: client-side mining guard ────────────────────────────── + + #[test] + fn client_guard_filters_junk_from_popitgame() { + let (msgs, _) = parse_msgs(GQL_POPITGAME_ALL); + // Without guard: 3 messages (junk included) + assert_eq!(msgs.len(), 3); + // With guard: only from GAME_ROOT_ADDRESS + has currency=1 + let mining: Vec<&EccMsg> = msgs + .iter() + .filter(|m| m.src == GAME_ROOT_ADDRESS) + .filter(|m| m.value_other.iter().any(|v| v.currency == 1)) + .collect(); + assert_eq!(mining.len(), 1); + assert_eq!( + mining[0].id, + "98734336130824965b265e37de80dde2b69e0ebfff7d47c47fd9ed3584a59adb" + ); + } + + // ── Tests: TxType determination ───────────────────────────────── + + #[test] + fn determine_tx_type() { + let (msgs, _) = parse_msgs(GQL_PAGE1); + let mf = WAPP_T_1; + // [0] src=THIRD_PARTY dst=WAPP_T_1 → Incoming + assert_ne!(msgs[0].src, mf); + assert_eq!(msgs[0].dst, mf); + // [1] src=WAPP_T_1 dst=WAPP_T_2 → Outgoing + assert_eq!(msgs[1].src, mf); + // [4] src=WAPP_T_2 dst=WAPP_T_1 → Incoming + assert_eq!(msgs[4].dst, mf); + assert_ne!(msgs[4].src, mf); + } + + // ── Tests: value parsing ──────────────────────────────────────── + + #[test] + fn parse_value() { + let (msgs, _) = parse_msgs(GQL_PAGE1); + let v0 = msgs[0].value_other.iter().find(|v| v.currency == 1).unwrap(); + assert_eq!(v0.value.parse::().unwrap(), 1_000_000_000); + let v1 = msgs[1].value_other.iter().find(|v| v.currency == 1).unwrap(); + assert_eq!(v1.value.parse::().unwrap(), 100_000_000); + } + + // ── Tests: TxData building ────────────────────────────────────── + + #[test] + fn build_ecc_tx_data() { + let (msgs, _) = parse_msgs(GQL_PAGE1); + let mf = WAPP_T_1; + let data: Vec = msgs + .into_iter() + .map(|e| { + let vo = e.value_other.into_iter().find(|v| v.currency == 1).unwrap(); + let val: u128 = vo.value.parse().unwrap(); + let tt = if e.src == mf { TxType::Outgoing } else { TxType::Incoming }; + TxData { + id: e.id, + tx_type: tt, + created_at: e.created_at, + value: val, + src_name: None, + } + }) + .collect(); + assert_eq!(data.len(), 5); + assert!(matches!(data[0].tx_type, TxType::Incoming)); + assert_eq!(data[0].value, 1_000_000_000); + assert!(matches!(data[1].tx_type, TxType::Outgoing)); + assert!(matches!(data[4].tx_type, TxType::Incoming)); + } + + #[test] + fn build_mining_tx_data() { + let (msgs, _) = parse_msgs(GQL_MINING_ONLY); + let data: Vec = msgs + .into_iter() + .filter_map(|e| { + let vo = e.value_other.into_iter().find(|v| v.currency == 1)?; + let val: u128 = vo.value.parse().ok()?; + Some(TxData { + id: e.id, + tx_type: TxType::Mining, + created_at: e.created_at, + value: val, + src_name: None, + }) + }) + .collect(); + assert_eq!(data.len(), 1); + assert!(matches!(data[0].tx_type, TxType::Mining)); + assert_eq!(data[0].value, 86799); + assert_eq!(data[0].created_at, 1771355168); + } + + // ── Tests: sorting ────────────────────────────────────────────── + + #[test] + fn sort_by_created_at_desc() { + let (msgs, _) = parse_msgs(GQL_PAGE1); + let mf = WAPP_T_1; + let mut data: Vec = msgs + .into_iter() + .map(|e| { + let vo = e.value_other.into_iter().find(|v| v.currency == 1).unwrap(); + let val: u128 = vo.value.parse().unwrap(); + let tt = if e.src == mf { TxType::Outgoing } else { TxType::Incoming }; + TxData { + id: e.id, + tx_type: tt, + created_at: e.created_at, + value: val, + src_name: None, + } + }) + .collect(); + data.sort_by_key(|b| std::cmp::Reverse(b.created_at)); + for w in data.windows(2) { + assert!(w[0].created_at >= w[1].created_at); + } + assert_eq!(data.first().unwrap().created_at, 1770749616); + assert_eq!(data.last().unwrap().created_at, 1770747978); + } + + #[test] + fn merge_ecc_and_mining_sorted() { + let ecc = vec![ + TxData { + id: "ecc1".into(), + tx_type: TxType::Outgoing, + created_at: 1770749208, + value: 100_000_000, + src_name: None, + }, + TxData { + id: "ecc2".into(), + tx_type: TxType::Incoming, + created_at: 1770749616, + value: 100_000_000, + src_name: None, + }, + ]; + let mining = vec![ + TxData { + id: "mine1".into(), + tx_type: TxType::Mining, + created_at: 1770749400, + value: 500_000_000, + src_name: None, + }, + TxData { + id: "mine2".into(), + tx_type: TxType::Mining, + created_at: 1770740000, + value: 250_000_000, + src_name: None, + }, + ]; + let mut merged = Vec::new(); + merged.extend(ecc); + merged.extend(mining); + merged.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + assert_eq!(merged.len(), 4); + assert_eq!(merged[0].id, "ecc2"); + assert_eq!(merged[1].id, "mine1"); + assert_eq!(merged[2].id, "ecc1"); + assert_eq!(merged[3].id, "mine2"); + } + + #[test] + fn merge_real_ecc_and_mining() { + let (ecc_msgs, _) = parse_msgs(GQL_PAGE1); + let mf = WAPP_T_1; + let ecc: Vec = ecc_msgs + .into_iter() + .map(|e| { + let vo = e.value_other.into_iter().find(|v| v.currency == 1).unwrap(); + let val: u128 = vo.value.parse().unwrap(); + let tt = if e.src == mf { TxType::Outgoing } else { TxType::Incoming }; + TxData { + id: e.id, + tx_type: tt, + created_at: e.created_at, + value: val, + src_name: None, + } + }) + .collect(); + let (pg_msgs, _) = parse_msgs(GQL_MINING_ONLY); + let mining: Vec = pg_msgs + .into_iter() + .filter_map(|e| { + let vo = e.value_other.into_iter().find(|v| v.currency == 1)?; + let val: u128 = vo.value.parse().ok()?; + Some(TxData { + id: e.id, + tx_type: TxType::Mining, + created_at: e.created_at, + value: val, + src_name: None, + }) + }) + .collect(); + let mut merged = Vec::new(); + merged.extend(ecc); + merged.extend(mining); + merged.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + assert_eq!(merged.len(), 6); // 5 ecc + 1 mining + // Mining (1771355168) is newer than all ECC → first + assert!(matches!(merged[0].tx_type, TxType::Mining)); + assert_eq!(merged[0].value, 86799); + } + + // ── Tests: counterparty dedup ─────────────────────────────────── + + #[test] + fn counterparty_dedup() { + let (msgs, _) = parse_msgs(GQL_PAGE1); + let mf = WAPP_T_1; + let mut seen = HashSet::new(); + let cps: Vec = msgs + .iter() + .filter_map(|e| { + if e.src == mf { + Some(e.dst.clone()) + } else if e.dst == mf { + Some(e.src.clone()) + } else { + None + } + }) + .filter(|a| a != mf) + .filter(|a| seen.insert(a.clone())) + .collect(); + assert_eq!(cps.len(), 2); + assert!(cps.contains(&WAPP_T_2.to_string())); + assert!(cps.contains(&THIRD_PARTY.to_string())); + } + + // ── Tests: serialization / deserialization ─────────────────────── + + #[test] + fn tx_type_serialization() { + assert_eq!(serde_json::to_value(TxType::Mining).unwrap(), "Mining"); + assert_eq!(serde_json::to_value(TxType::Incoming).unwrap(), "Incoming"); + assert_eq!(serde_json::to_value(TxType::Outgoing).unwrap(), "Outgoing"); + } + + #[test] + fn params_defaults() { + let p: ParamsOfGetHistory = + serde_json::from_str(r#"{"multifactor_address":"0:1","token_id":"1"}"#).unwrap(); + assert_eq!(p.page_size, 20); + assert!(p.cursor.is_none()); + assert!(p.mining_cursor.is_none()); + } + + #[test] + fn params_custom() { + let p: ParamsOfGetHistory = serde_json::from_str(r#"{"multifactor_address":"0:1","token_id":"1","page_size":50,"cursor":"a","mining_cursor":"b"}"#).unwrap(); + assert_eq!(p.page_size, 50); + assert_eq!(p.cursor.as_deref(), Some("a")); + assert_eq!(p.mining_cursor.as_deref(), Some("b")); + } + + #[test] + fn parse_token_id_ecc() { + assert!(matches!(parse_token_id("1"), Ok(TokenKind::Ecc(1)))); + assert!(matches!(parse_token_id("2"), Ok(TokenKind::Ecc(2)))); + } + + #[test] + fn parse_token_id_tip3() { + let addr = "0:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + assert!(matches!(parse_token_id(addr), Ok(TokenKind::Tip3(_)))); + } + + // ── Tests: known_name ─────────────────────────────────────────── + + #[test] + fn known_name_giver() { + assert_eq!( + known_name("0:1111111111111111111111111111111111111111111111111111111111111111"), + Some("Giver".to_string()), + ); + } + + #[test] + fn known_name_exchange() { + assert_eq!( + known_name("0:1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a"), + Some("Exchange".to_string()), + ); + } + + #[test] + fn known_name_accumulator() { + assert_eq!( + known_name("0:3535353535353535353535353535353535353535353535353535353535353535"), + Some("Accumulator".to_string()), + ); + } + + #[test] + fn known_name_unknown_returns_none() { + assert_eq!(known_name(WAPP_T_1), None); + assert_eq!(known_name(THIRD_PARTY), None); + } + + #[test] + fn known_addresses_skipped_in_counterparty_resolution() { + let giver = "0:1111111111111111111111111111111111111111111111111111111111111111"; + let exchange = "0:1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a"; + let mf = WAPP_T_1; + let msgs = vec![ + EccMsg { + id: "m1".into(), + src: giver.to_string(), + dst: mf.to_string(), + created_at: 100, + value_other: vec![ValueOther { currency: 1, value: "100".into() }], + }, + EccMsg { + id: "m2".into(), + src: exchange.to_string(), + dst: mf.to_string(), + created_at: 200, + value_other: vec![ValueOther { currency: 1, value: "200".into() }], + }, + EccMsg { + id: "m3".into(), + src: THIRD_PARTY.to_string(), + dst: mf.to_string(), + created_at: 300, + value_other: vec![ValueOther { currency: 1, value: "300".into() }], + }, + ]; + let mut seen = HashSet::new(); + let counterparties: Vec = msgs + .iter() + .filter_map(|e| if e.src == mf { Some(e.dst.clone()) } else { Some(e.src.clone()) }) + .filter(|a| a != mf) + .filter(|a| known_name(a).is_none()) + .filter(|a| seen.insert(a.clone())) + .collect(); + // Only THIRD_PARTY should be in the list — giver/exchange are known + assert_eq!(counterparties.len(), 1); + assert_eq!(counterparties[0], THIRD_PARTY); + } + + // ── Tests: cursor encoding/decoding ───────────────────────────── + + #[test] + fn decode_cursor_none_is_hot() { + assert!(matches!(decode_cursor(None), CursorSource::Hot(None))); + } + + #[test] + fn decode_cursor_plain_string_is_hot() { + let c = Some("76994aab10001620948890900".to_string()); + match decode_cursor(c) { + CursorSource::Hot(Some(v)) => assert_eq!(v, "76994aab10001620948890900"), + _ => panic!("expected Hot(Some(...))"), + } + } + + #[test] + fn decode_cursor_archive_prefix() { + let c = Some("a:76994aab10001620948890900".to_string()); + match decode_cursor(c) { + CursorSource::Archive(Some(v)) => assert_eq!(v, "76994aab10001620948890900"), + _ => panic!("expected Archive(Some(...))"), + } + } + + #[test] + fn decode_cursor_archive_prefix_empty() { + let c = Some("a:".to_string()); + assert!(matches!(decode_cursor(c), CursorSource::Archive(None))); + } + + #[test] + fn encode_archive_cursor_with_value() { + let r = encode_archive_cursor(Some("abc123".to_string())); + assert_eq!(r, Some("a:abc123".to_string())); + } + + #[test] + fn encode_archive_cursor_none() { + let r = encode_archive_cursor(None); + assert_eq!(r, Some("a:".to_string())); + } + + #[test] + fn cursor_roundtrip() { + let original = "76994aab10001620948890900".to_string(); + let encoded = encode_archive_cursor(Some(original.clone())).unwrap(); + match decode_cursor(Some(encoded)) { + CursorSource::Archive(Some(v)) => assert_eq!(v, original), + _ => panic!("expected Archive(Some(...))"), + } + } + + // ── Tests: dedup by message id ────────────────────────────────── + + #[test] + fn dedup_messages_by_id() { + let msg = |id: &str, ts: u64| EccMsg { + id: id.to_string(), + src: THIRD_PARTY.to_string(), + dst: WAPP_T_1.to_string(), + created_at: ts, + value_other: vec![ValueOther { currency: 1, value: "100".into() }], + }; + let hot_msgs = vec![msg("dup1", 300), msg("unique_hot", 200), msg("dup2", 100)]; + let arc_msgs = vec![msg("dup1", 300), msg("dup2", 100), msg("unique_arc", 50)]; + + let mut seen_ids = HashSet::new(); + let deduped_hot: Vec = + hot_msgs.into_iter().filter(|m| seen_ids.insert(m.id.clone())).collect(); + let deduped_arc: Vec = + arc_msgs.into_iter().filter(|m| seen_ids.insert(m.id.clone())).collect(); + + assert_eq!(deduped_hot.len(), 3); // all unique within hot + assert_eq!(deduped_arc.len(), 1); // only unique_arc survives + assert_eq!(deduped_arc[0].id, "unique_arc"); + } +} diff --git a/bee_wallet/src/services/transaction/mod.rs b/bee_wallet/src/services/transaction/mod.rs new file mode 100644 index 0000000..2b44c34 --- /dev/null +++ b/bee_wallet/src/services/transaction/mod.rs @@ -0,0 +1 @@ +pub mod history; diff --git a/bee_wallet/src/services/validate_name.rs b/bee_wallet/src/services/validate_name.rs new file mode 100644 index 0000000..c5674c4 --- /dev/null +++ b/bee_wallet/src/services/validate_name.rs @@ -0,0 +1,50 @@ +use regex::Regex; + +#[derive(Debug, Clone)] +pub enum WalletNameError { + InvalidCharacters, + ConsecutiveHyphens, + ConsecutiveUnderscores, + StartsWithSymbol, + TooLong, + TooShort, +} + +pub fn verify_wallet_name(wallet_name: impl AsRef) -> Result<(), WalletNameError> { + let wallet_name = wallet_name.as_ref().trim().to_lowercase(); + + // Allowed symbols + let re_symbols = Regex::new(r"^[a-z0-9_-]+$").unwrap(); + if !re_symbols.is_match(&wallet_name) { + return Err(WalletNameError::InvalidCharacters); + } + + // No "--" + let re_hyphens = Regex::new(r"-{2,}").unwrap(); + if re_hyphens.is_match(&wallet_name) { + return Err(WalletNameError::ConsecutiveHyphens); + } + + // No "__" + let re_underscores = Regex::new(r"_{2,}").unwrap(); + if re_underscores.is_match(&wallet_name) { + return Err(WalletNameError::ConsecutiveUnderscores); + } + + // Cannot start with "-" or "_" + if wallet_name.starts_with('-') || wallet_name.starts_with('_') { + return Err(WalletNameError::StartsWithSymbol); + } + + // Max length + if wallet_name.len() > 39 { + return Err(WalletNameError::TooLong); + } + + // Min length + if wallet_name.len() < 4 { + return Err(WalletNameError::TooShort); + } + + Ok(()) +} diff --git a/bee_wallet/src/services/zkp/auth.rs b/bee_wallet/src/services/zkp/auth.rs new file mode 100644 index 0000000..1229cef --- /dev/null +++ b/bee_wallet/src/services/zkp/auth.rs @@ -0,0 +1,36 @@ +use crate::services::zkp::utils::provider::OIDCProvider; +use crate::services::zkp::utils::zk_login::decode_base64_url; +use crate::services::zkp::utils::zk_login::verify_extended_claim; + +const ISS: &str = "iss"; + +pub struct Claim { + pub value: String, + pub index_mod_4: u8, +} + +pub fn get_auth_provider(claim: Claim) -> crate::errors::AppResult { + let ext_claim = decode_base64_url(&claim.value, &claim.index_mod_4).map_err(|e| { + crate::errors::AppError::from(e) + .with_context("[get_auth_provider]: failed to decode claims") + })?; + let iss = verify_extended_claim(&ext_claim, ISS).map_err(|e| { + crate::errors::AppError::from(e) + .with_context("[get_auth_provider]: failed to verify_extended_claim") + })?; + let provider = OIDCProvider::from_iss(&iss).map_err(|e| { + crate::errors::AppError::from(e) + .with_context("[get_auth_provider]: failed to create oidc provider") + })?; + + Ok(provider) +} + +pub fn get_domain_by_auth_provider(provider: &OIDCProvider) -> crate::errors::AppResult { + match provider { + OIDCProvider::Google => Ok("www.googleapis.com".to_string()), + OIDCProvider::Gosh => Ok("oauth.gosh.sh".to_string()), + OIDCProvider::Facebook => Ok("www.facebook.com".to_string()), + other => Err(crate::errors::AppError::new(format!("bad auth provider: {other:?}"))), + } +} diff --git a/bee_wallet/src/services/zkp/client_login_complete.rs b/bee_wallet/src/services/zkp/client_login_complete.rs new file mode 100644 index 0000000..1f132e5 --- /dev/null +++ b/bee_wallet/src/services/zkp/client_login_complete.rs @@ -0,0 +1,726 @@ +use std::sync::OnceLock; +use std::time::Duration; + +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use bech32::FromBase32; +use ed25519_dalek::SigningKey; +use num_bigint::BigUint; +use serde::Deserialize; +use serde::Serialize; + +use crate::errors::AppError; +use crate::errors::AppResult; +use crate::progress::ProgressEvent; +use crate::progress::ProgressSink; + +/// Bound for the prover round-trip on every target. Native sets it on the +/// client builder too; `wasm32` reqwest has no client-level timeout, so it +/// must be applied per request (otherwise a hung prover spins forever). +const PROVER_TIMEOUT: Duration = Duration::from_secs(30); + +const SUI_PRIVATE_KEY_PREFIX: &str = "suiprivkey"; +const SUI_ED25519_FLAG: u8 = 0x00; +const BN254_FIELD_MODULUS_DECIMAL: &str = + "21888242871839275222246405745257275088548364400416034343698204186575808495617"; +const ZKP_CURVE_P_DECIMAL: &str = + "21888242871839275222246405745257275088696311157297823662689037894645226208583"; +const MAX_KEY_CLAIM_NAME_LENGTH: usize = 32; +const MAX_KEY_CLAIM_VALUE_LENGTH: usize = 115; +const MAX_AUD_VALUE_LENGTH: usize = 145; +const PACK_WIDTH: usize = 248; +const N_ROUNDS_F: usize = 8; +const N_ROUNDS_P: [usize; 16] = [56, 57, 56, 60, 60, 63, 64, 63, 60, 66, 60, 65, 70, 60, 64, 68]; + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginTempData { + pub max_epoch: u64, + pub randomness: String, + pub ephemeral_private_key: String, +} + +impl std::fmt::Debug for ZkLoginTempData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZkLoginTempData") + .field("max_epoch", &self.max_epoch) + .field("randomness", &self.randomness) + .field("ephemeral_private_key", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginCompleteLocalPrepareParams { + pub saved_data: ZkLoginTempData, + pub jwt: String, + pub jwt_sub: String, + pub jwt_aud: String, + pub user_password: String, +} + +impl std::fmt::Debug for ZkLoginCompleteLocalPrepareParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZkLoginCompleteLocalPrepareParams") + .field("saved_data", &self.saved_data) + .field("jwt", &self.jwt) + .field("jwt_sub", &self.jwt_sub) + .field("jwt_aud", &self.jwt_aud) + .field("user_password", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginCompleteLocalPrepareResult { + pub zkid: String, + pub payload: String, + pub max_epoch: u64, + pub ephemeral_private_key: String, + pub extended_ephemeral_public_key: String, + pub ephemeral_public_key_in_hex: String, + pub ephemeral_secret_key_in_hex: String, +} + +impl std::fmt::Debug for ZkLoginCompleteLocalPrepareResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZkLoginCompleteLocalPrepareResult") + .field("zkid", &self.zkid) + .field("payload", &self.payload) + .field("max_epoch", &self.max_epoch) + .field("ephemeral_private_key", &"[REDACTED]") + .field("extended_ephemeral_public_key", &self.extended_ephemeral_public_key) + .field("ephemeral_public_key_in_hex", &self.ephemeral_public_key_in_hex) + .field("ephemeral_secret_key_in_hex", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProofPoints { + pub a: [String; 3], + pub b: [[String; 2]; 3], + pub c: [String; 3], +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IssBase64Details { + pub value: String, + pub index_mod4: u8, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZkProof { + pub proof_points: ProofPoints, + pub iss_base64_details: IssBase64Details, + pub header_base64: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginCompleteLocalFinalizeParams { + pub zk_proofs: ZkProof, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginCompleteLocalFinalizeResult { + pub zk_proof_compressed: String, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginCompleteWithProverParams { + pub saved_data: ZkLoginTempData, + pub jwt: String, + pub jwt_sub: String, + pub jwt_aud: String, + pub user_password: String, + pub prover_url: String, +} + +impl std::fmt::Debug for ZkLoginCompleteWithProverParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZkLoginCompleteWithProverParams") + .field("saved_data", &self.saved_data) + .field("jwt", &self.jwt) + .field("jwt_sub", &self.jwt_sub) + .field("jwt_aud", &self.jwt_aud) + .field("user_password", &"[REDACTED]") + .field("prover_url", &self.prover_url) + .finish() + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginCompleteWithProverResult { + pub zkid: String, + pub max_epoch: u64, + pub ephemeral_private_key: String, + pub zk_proof_compressed: String, + pub iss_base64_details: IssBase64Details, + pub header_base64: String, + pub ephemeral_public_key_in_hex: String, + pub ephemeral_secret_key_in_hex: String, +} + +impl std::fmt::Debug for ZkLoginCompleteWithProverResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZkLoginCompleteWithProverResult") + .field("zkid", &self.zkid) + .field("max_epoch", &self.max_epoch) + .field("ephemeral_private_key", &"[REDACTED]") + .field("zk_proof_compressed", &self.zk_proof_compressed) + .field("iss_base64_details", &self.iss_base64_details) + .field("header_base64", &self.header_base64) + .field("ephemeral_public_key_in_hex", &self.ephemeral_public_key_in_hex) + .field("ephemeral_secret_key_in_hex", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProverPayload<'a> { + jwt: &'a str, + max_epoch: u64, + jwt_randomness: &'a str, + extended_ephemeral_public_key: &'a str, + salt: &'a str, + key_claim_name: &'a str, +} + +pub fn zk_login_complete_local_prepare( + params: ZkLoginCompleteLocalPrepareParams, +) -> AppResult { + let user_salt_dec = format_pwd_to_int(¶ms.user_password); + let user_salt = parse_biguint_decimal(&user_salt_dec)?; + + let zkid = + gen_address_seed(&user_salt, "sub", ¶ms.jwt_sub, ¶ms.jwt_aud)?.to_str_radix(10); + + let seed = decode_sui_private_key_ed25519(¶ms.saved_data.ephemeral_private_key)?; + let signing_key = SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + let public_key_raw = verifying_key.to_bytes(); + + let mut sui_public_key_bytes = Vec::with_capacity(33); + sui_public_key_bytes.push(SUI_ED25519_FLAG); + sui_public_key_bytes.extend_from_slice(&public_key_raw); + let extended_ephemeral_public_key = BASE64_STANDARD.encode(&sui_public_key_bytes); + + let payload = serde_json::to_string(&ProverPayload { + jwt: ¶ms.jwt, + max_epoch: params.saved_data.max_epoch, + jwt_randomness: ¶ms.saved_data.randomness, + extended_ephemeral_public_key: &extended_ephemeral_public_key, + salt: &user_salt_dec, + key_claim_name: "sub", + }) + .map_err(|e| { + AppError::new(format!("Serialize prover payload ({e})")).with_kind("zk_login_complete") + })?; + + Ok(ZkLoginCompleteLocalPrepareResult { + zkid, + payload, + max_epoch: params.saved_data.max_epoch, + ephemeral_private_key: params.saved_data.ephemeral_private_key, + extended_ephemeral_public_key, + ephemeral_public_key_in_hex: hex::encode(public_key_raw), + ephemeral_secret_key_in_hex: hex::encode(seed), + }) +} + +pub fn zk_login_complete_local_finalize( + params: ZkLoginCompleteLocalFinalizeParams, +) -> AppResult { + let zk_proof_compressed = prepare_compressed_proof(¶ms.zk_proofs)?; + Ok(ZkLoginCompleteLocalFinalizeResult { zk_proof_compressed }) +} + +pub async fn zk_login_complete_with_prover( + params: ZkLoginCompleteWithProverParams, + progress: Option, +) -> AppResult { + complete_with_prover_emitting(params, progress.as_ref(), |payload, prover_url| async move { + fetch_zk_proofs(&payload, &prover_url).await + }) + .await +} + +/// Runs the prove pipeline emitting `prove` milestones, and a terminal +/// `prove:failed` (with the error message) on any error. `fetch` is injected +/// so the milestone/terminal logic is unit-testable without a live prover. +async fn complete_with_prover_emitting( + params: ZkLoginCompleteWithProverParams, + progress: Option<&ProgressSink>, + fetch: F, +) -> AppResult +where + F: FnOnce(String, String) -> Fut, + Fut: std::future::Future>, +{ + let result = complete_with_prover_steps(params, progress, fetch).await; + if let Err(ref e) = result { + crate::progress::emit( + progress, + ProgressEvent::new("prove", "failed").with_detail(e.message.clone()), + ); + } + result +} + +async fn complete_with_prover_steps( + params: ZkLoginCompleteWithProverParams, + progress: Option<&ProgressSink>, + fetch: F, +) -> AppResult +where + F: FnOnce(String, String) -> Fut, + Fut: std::future::Future>, +{ + let prover_url = params.prover_url.clone(); + let local_prepared = zk_login_complete_local_prepare(ZkLoginCompleteLocalPrepareParams { + saved_data: params.saved_data, + jwt: params.jwt, + jwt_sub: params.jwt_sub, + jwt_aud: params.jwt_aud, + user_password: params.user_password, + })?; + // Local witness inputs are ready. The heavy work (witness + halo2) then + // happens inside the remote prover, which returns only a final JSON — so we + // bracket the round-trip but can't report progress within it. + crate::progress::emit(progress, ProgressEvent::new("prove", "prepared")); + + crate::progress::emit(progress, ProgressEvent::new("prove", "proving")); + let zk_proofs = fetch(local_prepared.payload.clone(), prover_url).await?; + crate::progress::emit(progress, ProgressEvent::new("prove", "proof_received")); + + let finalized = zk_login_complete_local_finalize(ZkLoginCompleteLocalFinalizeParams { + zk_proofs: zk_proofs.clone(), + })?; + crate::progress::emit(progress, ProgressEvent::new("prove", "finished")); + + Ok(ZkLoginCompleteWithProverResult { + zkid: local_prepared.zkid, + max_epoch: local_prepared.max_epoch, + ephemeral_private_key: local_prepared.ephemeral_private_key, + zk_proof_compressed: finalized.zk_proof_compressed, + iss_base64_details: zk_proofs.iss_base64_details, + header_base64: zk_proofs.header_base64, + ephemeral_public_key_in_hex: local_prepared.ephemeral_public_key_in_hex, + ephemeral_secret_key_in_hex: local_prepared.ephemeral_secret_key_in_hex, + }) +} + +async fn fetch_zk_proofs(payload: &str, prover_url: &str) -> AppResult { + #[cfg(target_arch = "wasm32")] + let client = reqwest::Client::new(); + + #[cfg(not(target_arch = "wasm32"))] + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(PROVER_TIMEOUT) + .build() + .map_err(|e| { + AppError::new(format!("Build prover client ({e})")).with_kind("zk_login_complete") + })?; + + let request = client + .post(prover_url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(payload.to_owned()); + + // wasm reqwest ignores client-level timeouts; bound this request explicitly + // so a hung prover surfaces as an error instead of a permanently stuck UI. + #[cfg(target_arch = "wasm32")] + let request = request.timeout(PROVER_TIMEOUT); + + let response = request.send().await.map_err(|e| { + AppError::new(format!("Fetch zk proofs ({e})")).with_kind("zk_login_complete") + })?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_else(|_| "".to_string()); + return Err(AppError::new(format!("HTTP error {}: {}", status.as_u16(), body)) + .with_kind("zk_login_complete")); + } + + response.json::().await.map_err(|e| { + AppError::new(format!("Parse zk proofs response ({e})")).with_kind("zk_login_complete") + }) +} + +fn format_pwd_to_int(password: &str) -> String { + let mut out = String::new(); + for ch in password.chars() { + out.push_str(&(ch as u32).to_string()); + } + out +} + +fn gen_address_seed(salt: &BigUint, name: &str, value: &str, aud: &str) -> AppResult { + poseidon_lite_hash(&[ + hash_ascii_str_to_field(name, MAX_KEY_CLAIM_NAME_LENGTH)?, + hash_ascii_str_to_field(value, MAX_KEY_CLAIM_VALUE_LENGTH)?, + hash_ascii_str_to_field(aud, MAX_AUD_VALUE_LENGTH)?, + poseidon_lite_hash(std::slice::from_ref(salt))?, + ]) +} + +fn hash_ascii_str_to_field(value: &str, max_size: usize) -> AppResult { + if value.len() > max_size { + return Err(AppError::new(format!("String is longer than {max_size} chars")) + .with_kind("zk_login_complete")); + } + + let mut bytes = value.as_bytes().to_vec(); + bytes.resize(max_size, 0u8); + let chunk_size = PACK_WIDTH / 8; + let packed = chunk_array_first_shorter(&bytes, chunk_size) + .into_iter() + .map(|chunk| BigUint::from_bytes_be(&chunk)) + .collect::>(); + poseidon_lite_hash(&packed) +} + +fn chunk_array_first_shorter(bytes: &[u8], chunk_size: usize) -> Vec> { + if bytes.is_empty() { + return Vec::new(); + } + let rem = bytes.len() % chunk_size; + let first = if rem == 0 { chunk_size } else { rem }; + let mut out = Vec::new(); + let mut idx = 0usize; + out.push(bytes[idx..idx + first].to_vec()); + idx += first; + while idx < bytes.len() { + out.push(bytes[idx..idx + chunk_size].to_vec()); + idx += chunk_size; + } + out +} + +fn poseidon_lite_hash(inputs: &[BigUint]) -> AppResult { + if inputs.is_empty() || inputs.len() > 16 { + return Err( + AppError::new("poseidon-lite: invalid input length").with_kind("zk_login_complete") + ); + } + + let modulus = bn254_field_modulus(); + let constants = poseidon_constants(inputs.len())?; + let width = inputs.len() + 1; + let n_rounds_p = N_ROUNDS_P[inputs.len() - 1]; + + let mut state = Vec::with_capacity(width); + state.push(BigUint::from(0u8)); + state.extend(inputs.iter().cloned()); + + for round in 0..(N_ROUNDS_F + n_rounds_p) { + #[allow(clippy::needless_range_loop)] + for y in 0..width { + state[y] = (&state[y] + &constants.c[round * width + y]) % modulus; + let is_full_round = round < (N_ROUNDS_F / 2) || round >= (N_ROUNDS_F / 2 + n_rounds_p); + if is_full_round || y == 0 { + state[y] = mod_pow5(&state[y], modulus); + } + } + state = mix_poseidon_state(&state, &constants.m, modulus); + } + + Ok(state[0].clone()) +} + +fn mix_poseidon_state( + state: &[BigUint], + matrix: &[Vec], + modulus: &BigUint, +) -> Vec { + let mut out = Vec::with_capacity(state.len()); + for row in matrix { + let mut acc = BigUint::from(0u8); + for (m, s) in row.iter().zip(state.iter()) { + acc = (acc + ((m * s) % modulus)) % modulus; + } + out.push(acc); + } + out +} + +fn mod_pow5(v: &BigUint, modulus: &BigUint) -> BigUint { + let v2 = (v * v) % modulus; + let v4 = (&v2 * &v2) % modulus; + (v * v4) % modulus +} + +fn bn254_field_modulus() -> &'static BigUint { + static V: OnceLock = OnceLock::new(); + V.get_or_init(|| parse_biguint_decimal(BN254_FIELD_MODULUS_DECIMAL).expect("valid modulus")) +} + +fn zkp_curve_p() -> &'static BigUint { + static V: OnceLock = OnceLock::new(); + V.get_or_init(|| parse_biguint_decimal(ZKP_CURVE_P_DECIMAL).expect("valid p")) +} + +#[derive(Debug, Deserialize)] +struct PoseidonConstantsEncoded { + #[serde(rename = "C")] + c: Vec, + #[serde(rename = "M")] + m: Vec>, +} + +#[derive(Debug)] +struct PoseidonConstantsDecoded { + c: Vec, + m: Vec>, +} + +fn poseidon_constants(arity: usize) -> AppResult<&'static PoseidonConstantsDecoded> { + static C1: OnceLock = OnceLock::new(); + static C2: OnceLock = OnceLock::new(); + static C4: OnceLock = OnceLock::new(); + static C5: OnceLock = OnceLock::new(); + + let load = |raw: &str| -> PoseidonConstantsDecoded { + let enc: PoseidonConstantsEncoded = + serde_json::from_str(raw).expect("valid poseidon-lite constants json"); + PoseidonConstantsDecoded { + c: enc.c.into_iter().map(|v| decode_poseidon_const(&v)).collect(), + m: enc + .m + .into_iter() + .map(|row| row.into_iter().map(|v| decode_poseidon_const(&v)).collect()) + .collect(), + } + }; + + match arity { + 1 => Ok(C1.get_or_init(|| load(include_str!("poseidon_lite_constants_1.json")))), + 2 => Ok(C2.get_or_init(|| load(include_str!("poseidon_lite_constants_2.json")))), + 4 => Ok(C4.get_or_init(|| load(include_str!("poseidon_lite_constants_4.json")))), + 5 => Ok(C5.get_or_init(|| load(include_str!("poseidon_lite_constants_5.json")))), + _ => Err(AppError::new(format!("Unsupported poseidon-lite arity: {arity}")) + .with_kind("zk_login_complete")), + } +} + +fn decode_poseidon_const(value: &str) -> BigUint { + let bytes = BASE64_STANDARD.decode(value).expect("valid base64 poseidon constant"); + BigUint::from_bytes_be(&bytes) +} + +fn parse_biguint_decimal(value: &str) -> AppResult { + BigUint::parse_bytes(value.as_bytes(), 10).ok_or_else(|| { + AppError::new(format!("Invalid decimal bigint: {value}")).with_kind("zk_login_complete") + }) +} + +fn decode_sui_private_key_ed25519(value: &str) -> AppResult<[u8; 32]> { + let (prefix, words, _variant) = bech32::decode(value) + .map_err(|e| AppError::new(format!("Decode suiprivkey bech32 ({e})")))?; + if prefix != SUI_PRIVATE_KEY_PREFIX { + return Err(AppError::new(format!("Invalid suiprivkey prefix: {prefix}"))); + } + + let bytes = Vec::::from_base32(&words) + .map_err(|e| AppError::new(format!("Decode suiprivkey words ({e})")))?; + if bytes.len() != 33 || bytes[0] != SUI_ED25519_FLAG { + return Err(AppError::new("Invalid suiprivkey payload")); + } + + let mut seed = [0u8; 32]; + seed.copy_from_slice(&bytes[1..]); + Ok(seed) +} + +fn prepare_compressed_proof(proof: &ZkProof) -> AppResult { + let a_x = &proof.proof_points.a[0]; + let a_y = parse_biguint_decimal(&proof.proof_points.a[1])?; + let b0_x = &proof.proof_points.b[0][0]; + let b1_x = &proof.proof_points.b[0][1]; + let b1_y = parse_biguint_decimal(&proof.proof_points.b[1][1])?; + let c_x = &proof.proof_points.c[0]; + let c_y = parse_biguint_decimal(&proof.proof_points.c[1])?; + + let hex_ax = prepare_hex_representation(a_x, &a_y)?; + let hex_b0x = prepare_hex_representation(b0_x, &BigUint::from(0u8))?; + let hex_b1x = prepare_hex_representation(b1_x, &b1_y)?; + let hex_cx = prepare_hex_representation(c_x, &c_y)?; + + Ok(format!("{hex_ax}{hex_b0x}{hex_b1x}{hex_cx}")) +} + +fn prepare_hex_representation(init_x: &str, y: &BigUint) -> AppResult { + let x = parse_biguint_decimal(init_x)?; + let mut bytes = to_big_endian_fixed_len(&x, 32); + + if y > &(zkp_curve_p() - y) { + bytes[0] |= 0b1000_0000; + } else { + bytes[0] &= 0b0111_1111; + } + + bytes.reverse(); + Ok(hex::encode(bytes)) +} + +fn to_big_endian_fixed_len(value: &BigUint, len: usize) -> Vec { + let mut bytes = value.to_bytes_be(); + if bytes.len() > len { + bytes = bytes.split_off(bytes.len() - len); + return bytes; + } + if bytes.len() < len { + let mut padded = vec![0u8; len - bytes.len()]; + padded.extend_from_slice(&bytes); + return padded; + } + bytes +} + +#[cfg(test)] +mod tests { + use futures::StreamExt; + + use super::*; + + fn known_prover_params(prover_url: &str) -> ZkLoginCompleteWithProverParams { + ZkLoginCompleteWithProverParams { + saved_data: ZkLoginTempData { + max_epoch: 1787347228, + randomness: "251226222062428742376536481969159709164".to_string(), + ephemeral_private_key: + "suiprivkey1qz70vq46cqnnkynx32su2awahlumavkz4nxc2xv8kkgwnxtln4pc7fpzyc8" + .to_string(), + }, + jwt: "dummy.jwt.token".to_string(), + jwt_sub: "104824947798240050903".to_string(), + jwt_aud: "222414061721-4tu2gsfms6rvagqvt4mp0mjmbom6flbl.apps.googleusercontent.com" + .to_string(), + user_password: "Hello!23".to_string(), + prover_url: prover_url.to_string(), + } + } + + fn canned_proof() -> ZkProof { + ZkProof { + proof_points: ProofPoints { + a: ["123".to_string(), "456".to_string(), "1".to_string()], + b: [ + ["789".to_string(), "321".to_string()], + ["654".to_string(), "987".to_string()], + ["1".to_string(), "1".to_string()], + ], + c: ["111".to_string(), "222".to_string(), "1".to_string()], + }, + iss_base64_details: IssBase64Details { value: "x".to_string(), index_mod4: 0 }, + header_base64: "y".to_string(), + } + } + + #[tokio::test] + async fn prove_emits_all_milestones_on_success() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + let res = complete_with_prover_emitting( + known_prover_params("http://unused"), + Some(&tx), + |_payload, _url| async move { Ok::(canned_proof()) }, + ) + .await; + drop(tx); + + assert!(res.is_ok()); + let events: Vec = rx.collect().await; + let stages: Vec<&str> = events.iter().map(|e| e.stage.as_str()).collect(); + assert_eq!(stages, ["prepared", "proving", "proof_received", "finished"]); + assert!(events.iter().all(|e| e.op == "prove")); + } + + #[tokio::test] + async fn prove_emits_failed_on_prover_error() { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + let res = complete_with_prover_emitting( + known_prover_params("http://unused"), + Some(&tx), + |_payload, _url| async move { + Err::(AppError::new("boom").with_kind("zk_login_complete")) + }, + ) + .await; + drop(tx); + + assert!(res.is_err()); + let events: Vec = rx.collect().await; + let stages: Vec<&str> = events.iter().map(|e| e.stage.as_str()).collect(); + assert_eq!(stages, ["prepared", "proving", "failed"]); + assert_eq!(events.last().unwrap().detail.as_deref(), Some("boom")); + } + + #[test] + fn local_prepare_matches_known_vector() { + let result = zk_login_complete_local_prepare(ZkLoginCompleteLocalPrepareParams { + saved_data: ZkLoginTempData { + max_epoch: 1787347228, + randomness: "251226222062428742376536481969159709164".to_string(), + ephemeral_private_key: + "suiprivkey1qz70vq46cqnnkynx32su2awahlumavkz4nxc2xv8kkgwnxtln4pc7fpzyc8" + .to_string(), + }, + jwt: "dummy.jwt.token".to_string(), + jwt_sub: "104824947798240050903".to_string(), + jwt_aud: "222414061721-4tu2gsfms6rvagqvt4mp0mjmbom6flbl.apps.googleusercontent.com" + .to_string(), + user_password: "Hello!23".to_string(), + }) + .expect("local prepare"); + + assert_eq!( + result.zkid, + "3232339675244503454337597888874283153045927740736964457203808240868199427174" + ); + assert_eq!( + result.extended_ephemeral_public_key, + "ADYQ0ezspH3N2GD8HlSIiJ5uMwYvKM57qL4hAOIF+GXr" + ); + assert!(result.payload.contains("\"salt\":\"72101108108111335051\"")); + assert!(result.payload.contains("\"keyClaimName\":\"sub\"")); + assert!(result.payload.contains("\"maxEpoch\":1787347228")); + assert!(result + .payload + .contains("\"jwtRandomness\":\"251226222062428742376536481969159709164\"")); + } + + #[test] + fn local_finalize_returns_hex_string() { + let result = zk_login_complete_local_finalize(ZkLoginCompleteLocalFinalizeParams { + zk_proofs: ZkProof { + proof_points: ProofPoints { + a: ["123".to_string(), "456".to_string(), "1".to_string()], + b: [ + ["789".to_string(), "321".to_string()], + ["654".to_string(), "987".to_string()], + ["1".to_string(), "1".to_string()], + ], + c: ["111".to_string(), "222".to_string(), "1".to_string()], + }, + iss_base64_details: IssBase64Details { value: "x".to_string(), index_mod4: 0 }, + header_base64: "y".to_string(), + }, + }) + .expect("finalize"); + + assert_eq!(result.zk_proof_compressed.len(), 256); + assert!(result.zk_proof_compressed.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/bee_wallet/src/services/zkp/client_login_prepare.rs b/bee_wallet/src/services/zkp/client_login_prepare.rs new file mode 100644 index 0000000..89a9c1c --- /dev/null +++ b/bee_wallet/src/services/zkp/client_login_prepare.rs @@ -0,0 +1,331 @@ +use std::sync::OnceLock; + +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use bech32::FromBase32; +use bech32::ToBase32; +use bech32::Variant; +use ed25519_dalek::SigningKey; +use num_bigint::BigUint; +use rand::random; +use serde::Deserialize; +use serde::Serialize; + +use crate::errors::AppError; +use crate::errors::AppResult; + +const SUI_PRIVATE_KEY_PREFIX: &str = "suiprivkey"; +const SUI_ED25519_FLAG: u8 = 0x00; +const ZK_LOGIN_TEMP_TTL_DAYS: u64 = 179; +const MILLIS_IN_SECOND: u64 = 1000; +const MILLIS_IN_DAY: u64 = 24 * 60 * 60 * MILLIS_IN_SECOND; +const NONCE_BYTES_LEN: usize = 20; +const NONCE_LEN_B64URL_NO_PAD: usize = 27; +const TWO_POW_128_BITS: usize = 128; +const BN254_FIELD_MODULUS_DECIMAL: &str = + "21888242871839275222246405745257275088548364400416034343698204186575808495617"; +const POSEIDON_LITE_ARITY4_WIDTH: usize = 5; +const POSEIDON_LITE_FULL_ROUNDS: usize = 8; +const POSEIDON_LITE_PARTIAL_ROUNDS_ARITY4: usize = 60; +const POSEIDON_LITE_ROUNDS_ARITY4: usize = + POSEIDON_LITE_FULL_ROUNDS + POSEIDON_LITE_PARTIAL_ROUNDS_ARITY4; + +#[derive(Debug, Deserialize)] +struct PoseidonLite4ConstantsEncoded { + #[serde(rename = "C")] + c: Vec, + #[serde(rename = "M")] + m: Vec>, +} + +#[derive(Debug)] +struct PoseidonLite4Constants { + c: Vec, + m: Vec>, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ZkLoginPrepareResult { + pub nonce: String, + pub max_epoch: u64, + pub randomness: String, + pub ephemeral_private_key: String, +} + +impl std::fmt::Debug for ZkLoginPrepareResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZkLoginPrepareResult") + .field("nonce", &self.nonce) + .field("max_epoch", &self.max_epoch) + .field("randomness", &self.randomness) + .field("ephemeral_private_key", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone)] +pub struct ZkLoginPrepareDeterministicInput { + pub max_epoch: u64, + pub randomness: String, + pub private_key_sui_bech32: String, +} + +impl std::fmt::Debug for ZkLoginPrepareDeterministicInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ZkLoginPrepareDeterministicInput") + .field("max_epoch", &self.max_epoch) + .field("randomness", &self.randomness) + .field("private_key_sui_bech32", &"[REDACTED]") + .finish() + } +} + +pub fn prepare_zk_login_with_now_ms(now_ms: u64) -> AppResult { + let seed: [u8; 32] = random(); + let randomness_bytes: [u8; 16] = random(); + let randomness = BigUint::from_bytes_be(&randomness_bytes).to_str_radix(10); + let max_epoch = (now_ms + ZK_LOGIN_TEMP_TTL_DAYS * MILLIS_IN_DAY) / MILLIS_IN_SECOND; + + prepare_zk_login_with_inputs(ZkLoginPrepareDeterministicInput { + max_epoch, + randomness, + private_key_sui_bech32: encode_sui_private_key_ed25519(&seed)?, + }) +} + +pub fn prepare_zk_login_with_inputs( + input: ZkLoginPrepareDeterministicInput, +) -> AppResult { + let seed = decode_sui_private_key_ed25519(&input.private_key_sui_bech32)?; + let signing_key = SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + + let mut sui_public_key_bytes = vec![SUI_ED25519_FLAG]; + sui_public_key_bytes.extend_from_slice(&verifying_key.to_bytes()); + + let nonce = build_zk_login_nonce(&sui_public_key_bytes, input.max_epoch, &input.randomness)?; + + // Defensive parity guard: Mysten generateNonce() returns base64url without + // padding. + if URL_SAFE_NO_PAD.decode(&nonce).is_err() { + return Err(AppError::new("Generated nonce is not valid base64url-no-pad") + .with_kind("zk_login_prepare")); + } + + Ok(ZkLoginPrepareResult { + nonce, + max_epoch: input.max_epoch, + randomness: input.randomness, + ephemeral_private_key: input.private_key_sui_bech32, + }) +} + +fn encode_sui_private_key_ed25519(seed: &[u8; 32]) -> AppResult { + let mut bytes = Vec::with_capacity(33); + bytes.push(SUI_ED25519_FLAG); + bytes.extend_from_slice(seed); + + bech32::encode(SUI_PRIVATE_KEY_PREFIX, bytes.to_base32(), Variant::Bech32) + .map_err(|e| AppError::new(format!("Encode suiprivkey bech32 ({e})"))) +} + +fn decode_sui_private_key_ed25519(value: &str) -> AppResult<[u8; 32]> { + let (prefix, words, _variant) = bech32::decode(value) + .map_err(|e| AppError::new(format!("Decode suiprivkey bech32 ({e})")))?; + if prefix != SUI_PRIVATE_KEY_PREFIX { + return Err(AppError::new(format!("Invalid suiprivkey prefix: {prefix}"))); + } + + let bytes = Vec::::from_base32(&words) + .map_err(|e| AppError::new(format!("Decode suiprivkey words ({e})")))?; + + if bytes.len() != 33 { + return Err(AppError::new(format!( + "Invalid suiprivkey length: expected 33 bytes, got {}", + bytes.len() + ))); + } + if bytes[0] != SUI_ED25519_FLAG { + return Err(AppError::new(format!( + "Invalid suiprivkey scheme flag: expected {SUI_ED25519_FLAG}, got {}", + bytes[0] + ))); + } + + let mut seed = [0u8; 32]; + seed.copy_from_slice(&bytes[1..]); + Ok(seed) +} + +fn build_zk_login_nonce( + sui_public_key_bytes: &[u8], + max_epoch: u64, + randomness: &str, +) -> AppResult { + let public_key_as_bigint = BigUint::from_bytes_be(sui_public_key_bytes); + let split_base = BigUint::from(1u8) << TWO_POW_128_BITS; + let eph_public_key_0 = &public_key_as_bigint / &split_base; + let eph_public_key_1 = &public_key_as_bigint % &split_base; + + let hash = poseidon_lite_hash_4([ + eph_public_key_0, + eph_public_key_1, + BigUint::from(max_epoch), + parse_biguint_decimal(randomness)?, + ])?; + + let nonce_bytes = to_big_endian_fixed_len(&hash, NONCE_BYTES_LEN); + let nonce = URL_SAFE_NO_PAD.encode(nonce_bytes); + if nonce.len() != NONCE_LEN_B64URL_NO_PAD { + return Err(AppError::new(format!( + "Invalid nonce length {} (expected {})", + nonce.len(), + NONCE_LEN_B64URL_NO_PAD + )) + .with_kind("zk_login_prepare")); + } + + Ok(nonce) +} + +fn poseidon_lite_hash_4(inputs: [BigUint; 4]) -> AppResult { + let modulus = bn254_field_modulus(); + let constants = poseidon_lite4_constants(); + + let mut state = Vec::with_capacity(POSEIDON_LITE_ARITY4_WIDTH); + state.push(BigUint::from(0u8)); + state.extend(inputs); + + for round in 0..POSEIDON_LITE_ROUNDS_ARITY4 { + #[allow(clippy::needless_range_loop)] + for y in 0..POSEIDON_LITE_ARITY4_WIDTH { + let c_idx = round * POSEIDON_LITE_ARITY4_WIDTH + y; + state[y] = mod_add(&state[y], &constants.c[c_idx], modulus); + #[allow(clippy::manual_range_contains)] + let is_full_round = round < (POSEIDON_LITE_FULL_ROUNDS / 2) + || round >= (POSEIDON_LITE_FULL_ROUNDS / 2 + POSEIDON_LITE_PARTIAL_ROUNDS_ARITY4); + if is_full_round || y == 0 { + state[y] = mod_pow5(&state[y], modulus); + } + } + state = poseidon_lite_mix(&state, &constants.m, modulus); + } + + state + .into_iter() + .next() + .ok_or_else(|| AppError::new("Poseidon-lite state is empty").with_kind("zk_login_prepare")) +} + +fn poseidon_lite_mix( + state: &[BigUint], + matrix: &[Vec], + modulus: &BigUint, +) -> Vec { + let mut out = Vec::with_capacity(state.len()); + for row in matrix { + let mut acc = BigUint::from(0u8); + for (col, state_item) in row.iter().zip(state.iter()) { + let mul = (col * state_item) % modulus; + acc = mod_add(&acc, &mul, modulus); + } + out.push(acc); + } + out +} + +fn mod_add(left: &BigUint, right: &BigUint, modulus: &BigUint) -> BigUint { + (left + right) % modulus +} + +fn mod_pow5(value: &BigUint, modulus: &BigUint) -> BigUint { + let v2 = (value * value) % modulus; + let v4 = (&v2 * &v2) % modulus; + (value * v4) % modulus +} + +fn parse_biguint_decimal(value: &str) -> AppResult { + BigUint::parse_bytes(value.as_bytes(), 10).ok_or_else(|| { + AppError::new(format!("Invalid decimal bigint: {value}")).with_kind("zk_login_prepare") + }) +} + +fn bn254_field_modulus() -> &'static BigUint { + static MODULUS: OnceLock = OnceLock::new(); + MODULUS.get_or_init(|| { + BigUint::parse_bytes(BN254_FIELD_MODULUS_DECIMAL.as_bytes(), 10) + .expect("valid BN254 modulus constant") + }) +} + +fn poseidon_lite4_constants() -> &'static PoseidonLite4Constants { + static CONSTANTS: OnceLock = OnceLock::new(); + CONSTANTS.get_or_init(|| { + let encoded: PoseidonLite4ConstantsEncoded = + serde_json::from_str(include_str!("poseidon_lite_constants_4.json")) + .expect("valid poseidon-lite arity-4 constants json"); + + let c = encoded.c.into_iter().map(|v| decode_poseidon_lite_bigint(&v)).collect(); + let m = encoded + .m + .into_iter() + .map(|row| row.into_iter().map(|v| decode_poseidon_lite_bigint(&v)).collect()) + .collect(); + + PoseidonLite4Constants { c, m } + }) +} + +fn decode_poseidon_lite_bigint(value: &str) -> BigUint { + let bytes = BASE64_STANDARD.decode(value).expect("poseidon-lite constant is valid base64"); + BigUint::from_bytes_be(&bytes) +} + +fn to_big_endian_fixed_len(value: &BigUint, len: usize) -> Vec { + let mut bytes = value.to_bytes_be(); + if bytes.len() > len { + bytes = bytes.split_off(bytes.len() - len); + return bytes; + } + if bytes.len() < len { + let mut padded = vec![0u8; len - bytes.len()]; + padded.extend_from_slice(&bytes); + return padded; + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prepares_zk_login_from_known_vector() { + let result = + prepare_zk_login_with_inputs(ZkLoginPrepareDeterministicInput { + private_key_sui_bech32: + "suiprivkey1qqg83wnuw4f0h3w0wcz8lmyt7q82l0qwgwrwqd76kvv98uvcreh72l8k3uv" + .to_string(), + max_epoch: 1787345514, + randomness: "272376485534551742589579395044831478249".to_string(), + }) + .expect("prepare zk login"); + + assert_eq!( + result.ephemeral_private_key, + "suiprivkey1qqg83wnuw4f0h3w0wcz8lmyt7q82l0qwgwrwqd76kvv98uvcreh72l8k3uv" + ); + assert_eq!(result.max_epoch, 1787345514); + assert_eq!(result.randomness, "272376485534551742589579395044831478249".to_string()); + assert_eq!(result.nonce, "gVrxAdtrow48OUYB-nGNle9P6go".to_string()); + } + + #[test] + fn nonce_output_has_expected_shape() { + let nonce = build_zk_login_nonce(&[0u8; 33], 1, "1").expect("nonce"); + assert_eq!(nonce.len(), NONCE_LEN_B64URL_NO_PAD); + assert!(URL_SAFE_NO_PAD.decode(nonce).is_ok()); + } +} diff --git a/bee_wallet/src/services/zkp/mod.rs b/bee_wallet/src/services/zkp/mod.rs new file mode 100644 index 0000000..4f39392 --- /dev/null +++ b/bee_wallet/src/services/zkp/mod.rs @@ -0,0 +1,38 @@ +use ackinacki_kit::tvm_client::crypto::KeyPair; +use serde::Deserialize; +use serde::Serialize; + +pub(crate) mod auth; +pub(crate) mod client_login_complete; +pub(crate) mod client_login_prepare; +pub(crate) mod utils; + +#[derive(Debug, Deserialize, Clone)] +pub struct ParamsOfAddZKPFactor { + pub wallet_name: String, + pub proof: String, + pub epk: String, + pub esk: String, + pub header_base_64: String, + pub epk_expire_at: u64, + pub jwk_expires_at: u64, + pub kid: String, + pub sub: String, + pub password: String, + pub zkid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultOfAddZKPFactor { + pub name: String, + pub address: String, + pub message_id: Option, + pub message_ids: Vec, + pub pubkey: String, + pub password_hash: String, + pub signing_keys: KeyPair, +} + +pub use client_login_complete::ZkLoginCompleteWithProverParams; +pub use client_login_complete::ZkLoginCompleteWithProverResult; +pub use client_login_prepare::ZkLoginPrepareResult; diff --git a/bee_wallet/src/services/zkp/poseidon_lite_constants_1.json b/bee_wallet/src/services/zkp/poseidon_lite_constants_1.json new file mode 100644 index 0000000..2b18935 --- /dev/null +++ b/bee_wallet/src/services/zkp/poseidon_lite_constants_1.json @@ -0,0 +1 @@ +{"C":["CcRunsaOm9T+H6q6KUy6OKcaoXdTTN0bbH3A29Cr16c=","DANWUwiW7sQql+2TfzE1z8UUKzrkBbg0PB2D/6YEy4E=","Hiih2TVpitEULlEYK7VM9KAOpaq9Ymi9MX6pd8wVSjA=","J68tgxqdJ0gICWXbMOKY5A5XV8PgCNuWTPnisSuRJR8=","Hm8RzmD8j1E6ajz+Fq4XWkEpFGLyFM0Iear0NUW3TgM=","Kmc4TTu9XkOFQYGctoHwvgRGLtFMNhPY9xkgYmjRQtM=","C2b981YJOmEWCfjhL7/s8LmF44HwJRiJNkCPXVyfRdA=","AS7j7B541HCDDGEJPCreNwsmyDzFzr7t2qaFLb2wniE=","AlK6X2dgv739iPZ/gXXj/WzRxDGwmba7LRCOe0Rbsbk=","F5R0zOyl/2dsa+w871QpY1Q5Gok1/3HW71rqrXypMvE=","LCQmE3mlG/qSKP9KUD/U7Zwfl0omSWmzfholibvtK5E=","HMHXtiaS5j6sLyiL0GlbQ8L2P1AB/A/FU+ZsBVGAGwU=","JVBZMBqtqYuy7VX4UpeelgB4Tb8X+6zQXZ7/X9nJG1Y=","KEN746wcsuR54fXA7M0ys66iQjSXCoGTsRwpzn5Z79k=","KCFqRC8uH3EcpPprU3ZusRhUjaj7T3jUM4diw39fIEM=","LB9HzRf6Wt8fOfTnBW3QP+7h784DCUWBEx8jdzI0gsk=","B6utArel68SGMrzJNWzrfdna/KJ2Y4pjZGuFZqYhr8k=","AjAmRgH/3yknWzP/qrUd/pQp+QiAppzRN9oMTRX5bDw=","G8lzBU5R2QWg8WhlZJfKQKhkQUVX7iiecX5dZomaoKk=","Lhwi+WRDUAggbDFX6GNB7dJJr/XC2EIfKmsiKI8KZ/w=","EiTzjfZ8U3gSHB1fRhu8UJ6OoVmORsn3pwRSvCu6hrg=","AuTmnYulnlGSgLS9ntAGj9e/6M2d/toZadKYkYbN4g4=","Hx7Mw0qroBN/XfgfwE/z7k8Z7jZOZT8HbUfpc12YAY4=","FnKtPXCaNTl0JmwwOamnMRQkRIAyzRgZ6suKTUKE9YI=","KD4/3CxuQgxW9Er1GStK6c2mlh8oTSSZHS7WAt+Mj8c=","HCo9EgxVDs/Q2wlXFw+gE2g3Ufj9/1nWYU+9af85S8w=","IW+Eh3qsYXL3iXpzI0Vu/hQ6mkN3PqbylstrgXdlP70=","LA0nK+zyp1dkun6OPijRK86qR+phylmkEaH1FVL5R4g=","FuNCmYZcDihITuenTEVOnxcKVICr4FCPy0psPYlUb0M=","F1zrpZnpb1s3WiMqb7nMcXcgR3ZYAikPSM2Tl1VIj8U=","DHWURA3EjBb+rZ4XWLAoBmqkEL+8NU9U2MX/u0Sh7jI=","GjwpvDnyG7XEZtt9frb9j3YOIAE8z5EskkeYgtkZ/Y0=","DM/dkG80JuXAmG6gSbJTQAhV00kHT1pmlcjuq80i5o8=","FPa8gdnxhvYr20dc5slBGGanqKP9Bls84OaZtn3Z55Y=","CWK4J4n7PRKXAspwsvbFqswJmBDJxJXIiO3rc4a5cFI=","GogK9wdNGLO/IMed4lEnvBMoSrAe8CV1r+8Mj2oxqG0=","EMuhhBmmozLNXnfwIRwVSyCvKST8IP8/TDASu3rpMRs=","BX5iqaj4mz69x2umOp6sqPontzGcrjQGdWooSfMC8Q0=","KHyXHekdwKvUSt9ThLSYjLlhMDu/Zc/1r6BBO0QoDO4=","Id8ziK8Wh7uzvKnaDMqQjx5WK8RtSrpOb395YOMGiR0=","G+XIh9JbznA+JcyXTQk0zXid+PcLSY/YPv+LVg4WgrM=","Jo2jb3blaPtoEXF1zqLNDdLLXUL9pazqSNWcJwag1cE=","DherCR9urlDGCb6vVRDs7MXYu3QTXr0FvQZGDMJqXtY=","BNcn5yj/oKZ67lNasHSkMJHvYtjPg9JwBA9cqh9ir0A=","DdvXv5wpNBWBtUl2K8Ai7TNwKsEPG/2GKxVBfX45ym4=","J5DrM1FiF1J2gWLoKYnGwjT1sNHTr5tYiinEnIeJZUs=","HkV8YBpjtz5EcZUBk9ilcDlfPZq4sv0JhLdkIGFC+ek=","Ia5kMB3KliVjjWqyu+cTX/qQ7NDEP/kfxMaG/EbgkbA=","A3n2PIzjRo1NopMWb0lJKIVL6eNDLglVWFhTTu2NNQs=","AC1WQgNZ0CZqdEoICAngVMoOSSGkZoasjJ9YoyTDUEk=","EjFY5ZZbXZsdaLPNMuELvtqNYkWeIfQJD8LFr5Y1FaY=","C+KfxAhHqUFmHRS79svgQg+7K29Sg21OYMgOtJytnsE=","Gslpkd7CuwVXcWFCAVpFPDbbnYWcrV+aIzgC8k/fTBo=","FZZEP3Y9vMJfSWT8YdI7Pl4SyfqX8YqSUcozVbywYn4=","EuC802VL36drKGHU7Drq4PGFfZ8X5xWu1tBJ6uO6MhI=","D8krTxu+qCuepz1K+a8qUM6rrH83FUsZBObHbHz5ZLo=","H5wLFhBEZELW8uWSqAE/QLFPfHciI29PnH6WUjOHJ2I=","Dr10JErnJnX4zeBhV6eC9AUNkU2ji0wFjRWfZD279NM=","LLfw7Tnhbp9pqfr9SrlRwDsGcelzRu45eoOYOdzPxtE=","Gp1uLs/wIsxWBUQ+5BurIM52HQUUzlJmkMcrynNS2b8=","KhFUOWB/M1peqDw7xEqTMdDBMyapp7owh9oYLWSOxy8=","I/m2UptdBA0VuPp67j40EOc4tWMFzUTylTXBFcWkwGA=","BYcsFtsPcqIkmsa6SEu5w6POl8FtWLaLJg65OfDm6Kc=","EwC97gi7eCTKIPuAEYB19AIZthUdVbXFK2JKfN7d9qc=","Gbm2PS8QjhfmOBeGOo9sKI160pkW2YyxBy5Oe31Ss3Y=","AVvuE1fjwBW1vaI3ZoUi9hPRyIcmtexCJKIBKEgbT38=","KVNzbpS7a58blwek8WFeTv4eHOS6shjL6pLHhbEo/9E=","CwaTU7oJFhiGL4BhgMA4X4UbmNNytF9UTOcmbtZgjfw=","ME901GHMwTEV5OC8+5OBflWut+uTBrZOT1iKyX2B9Ck=","FbvxRs6bygnooz9ed9/k9arSoWSkYXpMuO5UFc3pE/w=","CrTf4MJ0LN5EkBAxSHlk7ZuPS4UEBcEMqf8jhZVyyMY=","DjLbMgoETjGX9F92SaGWde9e7f6lRt6pJR3jn5Y5d5o=","ChdWqh83jKSydjWni2iI5meXczqCd0iWoweO+lFtoBY=","BExKM7EPaTRH/RcXf5Uu+JXmHTKPhe+pQlTWoqJdk+8=","LtNhG3JbinC+ZVtTf2b3AP4IedeaSWiR03sHtUZsS4s=","H5uk6Lq3zkLI7MPXIqouDq3965z900e12DOepxIIWKo=","GyMwQwUujCiPfukHqE5RiqOOgqxFAgZtt0BW+GXF09o=","JDHhzBZLuNB0Axq3K9VbTJAgU7/A8U2wyi+XsCCHWVQ=","CC+TTJH1qsMwzWlToKfbRaE+MiCXWDMZp5Hyc5ZYAf0=","K5oKIj51OLCjS+B0MVVCo8dyReKufL6Zmta7kwxImXw=","DhzZHt0s+izOuFSDuIepvoFkFj51qKAOsLWJzHAhTn0=","Lh6sDyv9/WPJUfYUd+NpiZl3TxmFTQD1iNMkYBzr4vk=","DL+pXzf7dAYMdhWOdp1tFXNFeE2O/bM8I9dIEVtQC4M=","CPBbO+kj7UTWWtSdimHppnbZkeOndRPZmAwjLfpKT4Q=","InGeKgcLzQhSv44hmE0EQ+coSSXcB1ijJaLdUQwEfvY=","BB9Zap7hyyvAYPf8w6GrTHvb8DYRmYLA9B9isvJoMMA=","Iz/TXeG+UgqHYo6wb2sdTAIb4cLQ3EZKGfzdCYaxD4k=","BSS0bRqoel5DJeCkI+vIENMeB4qhtHB+78tFPGHJwmc=","LDT0JMgeVxbOR/ysiUuFgkInu5VLDzGZzESGI3xRUhE=","C18qS2M4eBkgfv/CtVQfty3SAltUV8yX8zAQMn3kkV4=","IiB4VggszFTFty/kOdLP1sF0NdL1evbOrvrEH+BcZZ8=","JNV6i/XaY/5OJBWbf4lQtc37IQGUyvefJ4VASM4sgXE=","Cvqxgf3V4Fg7Nx11vWk/mDdK1wl7sBqFc5Gbsjt5OW4=","LbqbEI8gh3KZilLvrHy9VnbABXGUwWwL8WKQ1isRKO4=","JjSbZu24sW9W+IHHiPU/g8u4PeC9WSslWv8T5rzkILM=","Ja984OXhA1doXpX5Izl1OtgaVtKOzBk7I1KIo+bxN9s=","JbTOe9IpQ5DAlNalXt1ouXDu16roiyv/H3wBh/41AR8=","IsVD8Q9siew4flPxkIqI5d6c7yjr3zCxjLnVTB4CtjE=","Ajb5PneJxHJPx5CKnxkeHkJekGqRnXo032aOdIgvh6k=","KTULQBFmygEOfSfjfQXamWUr2uEU6wFlnLSXr5gMS1I=","Du14fWWCDT9r0xu6tUf3WmXtt12ETruJ7hJgkWZSNj8=","B8wRcPE7RvIDanU/Ugsykf3NDpm9lCl9GQb2VvTeb60=","Irk5IzsdcgX0m89hOj0wsZCHhtf59dEMIFlDVonorOo=","AUUXYqCquByKrR3IvDPocHQPCDpaqFQ4rdZQrOYK5aY=","I1BrtdhyfURh+r8QJdRtH+MuqmHex9pX5wT+wIkvzok=","LkhMROg4rqC6wGrj9xvdCSo3CVMeHv6pf4vWiQc1VSI=","D0vH0H66/WQ3nnjFC9LkK69KWUVFztwlRUGNomg1tUw=","H008j2WD6eX6dmN4Yvqu6FFYI4hyXfRg5iCZbVDY504=","CTUU4McHEfgmYNB74OSpiPrgKrx7aB2RU+uby0j+c4k=","GtqwyOKzutNGaZorXzvANkPug+zkcijySljgo0fhU9g=","FnKxcmBX2Z3RRwnrtHRkGjeMG5S4ByusGiLb756A2tI=","Hf1T1Fdq8uOPRPU/3KtGjMXY4vrgrMTuMNR7I5tHnBQ=","DGiIoQt1sPOnCjYmOjfhf+bXfWQPb8PevH8gd1MgXGA=","Gt25M6Zb53CSs0p+d9Ev6GEaYeAO5oSLhQkezKnR5Qg=","ANdUDc0mioRcEK4Y0d6TPPY4/1Ql8K//eTVijimdF5E=","FAwOQmh+nq0BsoJ6VmTKnCb+3eSs2Z2x0xaTnSC4LA4=","Lww6EV1DF9GRuom40T0YBsIKD5sk+MXtwJHirlZWWYQ=","DE7neP98FFUwBu0iDPnIEAigz/ZwsiuC2MU4odyVjGE=","FwTydm1G+Cw2k/AEQMzDYJQk7SbArMZiJ8PXSF3nTGk=","Ly0ZzD6l146noCwbUdJEq/B2nJ+FROQCObZv6QCcPPo=","GuA4U7dfyrpQU/ES4qjo3N1+5suc/tnH1sdmqAb8Zik=","CXGqv3lSQd9R0THQ+mGqXzVWkhstbwFOTkGobdrwVtU=","FAjDFuYBThqR1M9rbg3nPtpiT4OA3xyHX1wp97/i9kY=","Fmfz/i7b6FAkir5CtUMJO2yJ8fdz7yhTQWkfOYIu9b0=","E798XQ0sQ3akiwoDVXzfkVuBcYQJ5cEzQkxpV2UA/jc=","B2IKbfsLbOwwFq3z01M8JAJLlTR4VreXGbwLp0OmLCw=","FXTH7wxDVF82qMoIvb3YsHXSlZ4vMitzFnXePhmCtNA=","Jp5LW3oushr9VnlwpxfO7FvUGEVxwlT9wG4Dp/+DePA="],"M":[["Bm9vhdb2ioXsEDRTUaI6Oq8H84r4yVKnvOynC9KvetU=","K51LQRDJrpl3guFQmx0P2yCnwCu9i+pzBUYrn4Elseg="],["DMV827CFB9Yr9npEk8wmL7bAnVVwE//x9XP0MSIfj/k=","EnTmSaMu01WjGm7WlyThra3oV+hutcOhIbzRR5QyA8g="]]} \ No newline at end of file diff --git a/bee_wallet/src/services/zkp/poseidon_lite_constants_2.json b/bee_wallet/src/services/zkp/poseidon_lite_constants_2.json new file mode 100644 index 0000000..f55b041 --- /dev/null +++ b/bee_wallet/src/services/zkp/poseidon_lite_constants_2.json @@ -0,0 +1 @@ +{"C":["DumlkrqalRjQWYbWVvQMIRTEmTwRuymTjSHUcwTNjm4=","APFEUjXyFIxZhlhxafwbzYh7CNTQCGjfVpb/9AlW6GQ=","CN/zSH6KyZ4fKaBY0PqAuTDHKHMLerNs6HnziQ7Pc/U=","Lye+aQ/a7kbDzij3UysTyFbDU0LIS9puIJZjEPrcAdA=","KyrhrPaLe40kFr6/PU9iNLdj/gS4BD7ki4MnvryhbPI=","AxnQYgcr737MperAb5fU1VlSwXWrawPq5ktEx9vxHPo=","KIE9yuuuqoKKN234evSmO8i3vyetScYpjvezh78oUm0=","JydnOyzLyQPxgb844cHUDSAzhlIAw1K8FQkord35y3g=","I07EXKJ3J8LnSr0rKhSUzW771D40BYfWuPueMeZcxjI=","FbUlNAMa4Y9/hiyyz3z3YKsQqBUKM3sczZn/boeX1Cg=","Dcj61tnks19e2aPRhrec444Oio0bWLEy1wHU7s9o0fY=","G82V/8IR+8pgD3BfrT+1Z+pOs3j2Lh/sl4BVGKR+TZw=","EFILCrchyt/p7/gbAW/DTcdto2wleJN4F8uXjQad5Vk=","H21IFJuOf32bJX2O1fu69CkySYB1/tCs6IqeuB9WJ/Y=","HZZV9lIwkBTSngDvNaIIm//43ByBbw3JyjS9tUYMhwU=","BN9aVv+VvK+wUfexzUOpm6cx/2fkcDIFj+PUGFaXzH0=","BnLZlfj/9kAVGz0pDO2vFIaQoQqMhCSn9uwoK25L6Cg=","CZlStBSIRFSyEgDX/6/dXwyancwG8nCOn8HYIJtcdbk=","BSy6IlXf0Ax8SDFDuo1GlEjkNYaptM2Rg/0OhDprn6Y=","C4ut7mkK246wvXRxK3mZr4LeVXByUa13Fgd8uTxGTdw=","EZsVkPEzB69aHuZRAgwHx0nBXWBoOoBQuWPQqOSyvdE=","AxULfNbV0XslKdNr4PZ7gyxKz8iE707lzhW+C/tKjQk=","LMYYLF4UVG488ZUfFzkSNVN077g9gImKvmnLMXyepWU=","AFAyVR5jeMRQz+EppASzdkIYyt7awU4rktLNcxEb8Pk=","IzI34yibqjS7FH6XLry5UWRpw5n8wGn7iPnaLMKCdrU=","Bcj09OvUpuPJgNMWdL++YyMDfyGzSuWk6AwtTCTWAoA=","CnsdsTBC05a6BdgYoxnyUlK8817zru2R7h8JslkPxls=","KnO3H5shDPWxQpZXLJ0y2/FW4rCG/0fcXfVCNlpATsA=","GsmwQXq8yaGTUQfp/8kdw+wY8sTb5/Ipdqdgu1xQxGA=","EsAzmuCDdII/q7B2cH70eSafPk1ssQQ0kBXuBG3JP8A=","C3R1sQKhZa1/WxjbTh5wT1KQCqMlO6rGgkZoLlbpoo4=","A3woSeGRyj7bHF5J9ui4kXyEPjeTZvLqMqs6qI1/hEg=","BaaBH4VW8BTpJnRmHiF+m9UgbFyToH3BRf2xdqcWNG8=","KaeV59mAKJRulHt11U6fBEB26Hp7KIO0e2de9fOL1m4=","IEOaDISzIutFo4V6/Bj1gm6Mc4LIoVhcUHvhmZgf0i8=","Lguo2U2ez0qU7CBQxzcf8btQ8neZqEttSipvKgmCyIc=","FD/RFc4I+yfKOOt8zoIrRReCLNIQkEjS5tDdzKF9ccg=","DGTL7LHHNLhXlo273PgTzfhhFlkyPby/yEMjYjvpyvE=","AoowWEfGg/ZG/KklwWP/WudPNI1iwrZw8UJs75QD2lM=","Lk71EP8Lb9pfqUCrTEOA8mpry2TYlCe4JNZ1W1254ww=","AIHJW8QzhOZj15JwyVbOO4kltPbQM7B4uWOE9QV5QA4=","LtXwyRy9l0kYfi+t5ofgXuJJGzScA5oLuoqfQCOguzg=","MFCZkfiNo1BLvzdO1ari8DRIoix2I0yMmQ8B8zpzUgY=","HD8g/VVAmlMiG3xNSaNWufChEZ+yBntBp1KQlEJOxq0=","ELTn86td8AMElRRFm24Y7sRrsiE+jhMeFwiHtH3cuWw=","KhmCl5w/9/Q93VQ9iRwqvd2A+ATAd9d1A5qjUC5Dre8=","HHTuZPFeHbb+3b6tVtbVXbpDHrw5bJr5XK0PExW9XJE=","B1M+yFC6f5jquTA8rOAbS55PLouCcIz6nC/kWgrhRqA=","IVdrQ45QBEmhUeTurxexVChcaPQtQsGAihGr83ZMB1A=","LxfAVZuP55YIrVyhk9YvELzoOEyBXwkGdD1pMINtSp4=","LUd+OGLQdwinnoqulGFwvJd1pCATGEdK5mWwsbficw4=","Fi9SQ5ZwZMOQ4JVXeYTyka+6ImbDj1q82Jvg9bJ0fqs=","K0yyM+3pukgmTs0siuUNGteoWWqH8p+Kd3enAJI5MxE=","LI+8st2Fc9wduvj0YihUd22y7s5thcTPQlTnw14DsHo=","HW80dyXkgWry/0U/DNVrGZ4bYen2Aemt5eiNuHCUnak=","IEsMOX9OvnHrwtiz31uRPfnmrAK2jTEyTNSa9cRWVSk=","DEy53DxP2BdPEUmzxjw8L57LgnzX3CVTT/j7dbx5xQI=","F0rWGhRIyJmiVBZHT0kwMB5cSUdSeeBjmmFt3EW8e1Q=","GpYXe89NjYn3Wd9OwvPN4uqqKMF3zA+hOpgW1Jo40u8=","Bm0EskMx1xzQ74BUvGDE/wUgLBJqIzwagkKs42C4owo=","KkxPxuwLDPUhlXgoccbdOzgcxl9y4CrVJwN6Yqob2AQ=","E6stE2zPN9RH6fLhSnztyV5yf4RG9tnX5Vr8ASGf1kk=","ESFVL8omBhYZ0k2EPcgnacGwT87Cb1UZTC4+hprMapo=","AO9lMyKxPWyIm8gXFcN9d6bNJn1ZXEqJCaVUbHyXz/E=","DiVIPkWmZSCLJh2Lp0BR5kAMd21lJZXZhFrKNdijl9M=","KfU23LnddoIkUmRlnhXYjjlaw9Td6S2MRkSNuXnuuok=","KlbvnyxT/rrf2jNXXb29iFoSTieAu+oXDkVrqs4Ppb4=","HINhx461z13s+3otF7XECfKuKZmkZ2Lo7kFiQKjLmvE=","FRr/XziyCg/ARzCJqvAga4Po5op2RQe/09CrS+dDGcU=","BMYYfkHtiB3BsjnIj3+dQ6n1L8jIts3R525HYVtR8QA=","E7N72A9NJ/sQ2EMx9vttU0uBxh7RV3ZEnoAbfdycKWc=","AaXFNic8LZ31eL+9MsF7eizjZkwqUgMskyHOscToqOQ=","KrNWGDTKc4Na0F9desuVC0qaLGZrlybagyI5Blt8OwI=","HU2OwpHnINsgD+bWhsDWE6yvavTpXTv2n37VFqWXtkY=","BBKU0sxITSKPV4T+eRn9K7klNRJAoEtxFRTJyAtlrx0=","FUrJjgFwjGEcT6cVmR8ASJj1eTnRJuOSBClx3ZDoH8Y=","CzOdisyn1Pg+7dhAk671EFCzaEyI+LCwRSRWO8bqTaQ=","CVXknmYQyUJUpPhM+6s0RZjw5x6v9Kfdge2VtQg5yC4=","BnRqYVbrpUQmueIiBvFavKmm9B5vU1xvNSVAHqBlRiY=","Dxj1oOzRQjxJbzggxUnCeDjleQ4r0KGWrJF8f/Mgd/s=","BPbuyhdR9zCKxZ7/W+smHku1Y1g+3nvJKnOCI9b3bhM=","K1aXM2TExPXBo+xNo83OA4gR6xFvs+RbwXaNJvwLN1g=","Ejdp3UnVsFTc12uJgEsby44TkrOFcWpdg/62XUN/Ke8=","IUe0JPxIyAqI7lK5EWmqzqmJ9kRkcRUJlCV7L7AcY+k=","D9wfWFSLhXAabFUF6jMqKWR+bzStQkPC6lStiXzr5U0=","Ejc6glH+oATfaKvPD3eG1Lzv8oxdu+DDlE9oXMCgsfI=","IeT06l81+FutfqUv90LJ6KZCdWtq9EID3YofNcGpADU=","FiQ5FtadLKPftHIiJNTEYrVzZkkvRekNioGTTxvDsUc=","HvvkbdeleLT2b5rbyItDeKvCFWbhoEU8oTpBWcrASsI=","B+pehTfPXdCIhgIOI6fzh9Ro1VJb5m+FO2csyWqIlpo=","BajE+ZaLiqO3tHijD5pbY2UPGadefOEcqf4WwLdsALw=","IPBXcSzCFlT7/lm9NF6NrD94GMcBuceILZ1Xtyoy6D8=","BKEu3tqd/WiWcvjGf+4xY23NjojQHUkBm9kLM+sz22k=","J+iNjBXzfc7kTx5UJaUd7L0TbOUJGmdn5J7JVEzNEBo=","L+7Re4QoXtm4pcjF6VpB9m4JZhmncDIjF2xB7kM95NE=","HtfMdu30XHxAQkFCD3Kc85TllCkRMSoNaXK4vVOv8rg=","FXQumbm/oyMVf/jFhvVmDqxng0dhRM3K3yh0vkVGaxo=","GqwoU4f2XoLIlfxoh930BXcQdFTG7AMXKE8DPyfQx4U=","JYUcPIRdR5D53a29tgVzV4MuLnpJd19x7HWpZVTWfHc=","FaWCFWXMLsLOeEV9sZft81O367osVSM3DdzMPZ8Uamc=","JBHVekgTuZgO+n4xodtZZtz2TzYEQndQLxVIXyjHFyc=","AC5vjWUgzUcT4zW4wLbS5kfpqY4S9M0lWIKLXvbLTJs=","L/e8j0OAzemX2gC2FrD80a+PDpHi/h7XOYg0YJ4DFdI=","ALmDG5SFJVle4CckRxvNGC6VIfa3u2jx6Tvk/rsNPL4=","Ci9TdouOv2qGkTsOV8BOARykCGSKR0OofXetvwycNRI=","ACSBVhQv0Dc6R5+R/yOelg9Zn/fpS+abfyopAwXhGY0=","Fx1WILh7+xMoz4wCqz8MmjlxlqpqVCwjUOtRKisrzak=","FwpPVVNvfclwCHx8ENb612DJUhct1U3ZnRBF5Ow0qAg=","KaujP3mf5mwu8xNK6gQzbsw344wc0hG6SC7KF+Lb+uE=","HpvBeaT911j90bsZRQiNR+cNEUoD9qDotbplA2nmSXM=","HdJpeZtmD61Y9/SJLfsLWv6q2GmpxLRPnJ4cQ72vjwk=","Is28i3ARetFAEYHQLhVFnnzNQm/oacfJXR3Syw8krzg=","DvBC5FR3HFM6n1elXFA/zv0xUPUu2Up81bqTucfazv0=","EWCeBq1sj+Lyh/MDYDfohRMY6LCKA1mgOzBP/KYugoQ=","EWbZ5VRhbbqedT7qQnwXt/7NWMB23+QnCLCPW3g6qa8=","LeUpiUMahZWTQTAmNUQT2xd/v0zSrAtW+FWoiDV+5GY=","MAbrT/x6hYGabaSS86isHfUa7lsXuOiddL8Bz19x6a0=","KvQfu2G6ioD9z2//nj9vQimT/o8KRjn5YjRMgiUUUIY=","EZ5oTeR2FV/lprQajryF24cYqyeInoXngbIUus5IJ8M=","GDW3huLokl4Yi+pZrjY1N7USSMI4KPBHz/eEuXs/2AA=","KCAaNMWU36NNeUmWxkM6INFSusKnkFySbEDihasy7rY=","CD79eifRdRCU6A/vr3iwAIZMgutXEYdySnYfiMIsxOc=","C2+Io1dxmVJhWOYc7qJ76BHBbfd3TdhRngeVZPYf0Ts=","Dsho5tFeUdlkT2bh1kcalFiVEcoA0p4QFDkObuQlT1s=","KvM+P4ZncScawMmz7S4RQuzT50uTnNQNANk3q4TJhZE=","C1ICEfkEtefQm12WHGrOdzRWjFR91oWLNkzl5HlR8Xg=","Cy1yLQkZoarY21jxAGKpLqDFasQnDoIsyiKGIBiKHUA=","H3kNTX+M8JTZgM6zfCRT6Ve1SpmRyji74AYdHtblYtQ=","AXHrld+/fR6uqXzThfeAFQiFwWI1oqao2pLOsB5QQjM=","DC0OO1/VdUkym/aIXaZrm3kLQN79LIZQdiMFOBsWiHM=","EWL7KGicJxVOWoIotOcrN3y8r6WJ4oPDXTgDBUQHoY0=","LxRZtl3uRBtkrThqkegxDygsWpKonhmSFiPvgklxG8A=","Hm/zIWtojD2ZbXQ2fVzUwbxInUZ1TrcSwkP3DRtTz7s=","AcqL5zgyuNBoFIfSfRV4AtdBpvNs3CoFdogfkyZHiHU=","H3c1cG/+n8WG+XbVvfIj3GgChggLEM6gC5td4xX5ZQ4=","JSK2D06jMHZAoMLc4EH7qSGsEKPV8JbvR0XKg4KF8Bk=","I/C+4AGxAp1SVQdd3JV/gzQYytT1K2w/jOFsI1VyV1s=","K8Gui43buB/KrC1EVV7VaF0UJjPp35BfZtlAEJMILVk=","D5QGuCllZKNzBFB7jbo+0WI3EnOgex/JgBH81q1yIF8=","I2Co6wzH3vpntymY3pBxThfnWxdKUu5KyxJsjNmV8Kg=","FYcaXN3q2XaATIA8uu8lXrSBWl6W34sAbcu8J2f4iUg=","GTpWdmmY7p4KhlLdLzsdoDYvT1T3I3lUT5V8ze77Qg8=","KjlKQ5NPhpgvm+Vv9PqxcDsuY8itM0g05DCYBed3rg8=","GFmVTP64aV8+i2NdyzRRkoks0RIjRDuntBZuiHbA0UI=","BOEYF2MFDlgBNETby5nxkCsRvCXZC73KQI04GfT+0ys=","D9slPe6Dhp1AwzXqZN6MW7EOuC2wi16LH15VUr/QXyM=","BYy+ippQJ72qTvtiOt6tYnXwhobxwImEqdfFuum08cA=","E4Ltzplx4YZJfq2xrrH1KyO0uDvvAjqw0VIotMzspZo=","A0ZJkPBFxu4IGcpR/RGwvn9huOuZ8Ut34eZjRgHZ6LU=","I/e/yHINwpb/8ztB+Y/4PG/KtGBdsutaqlvBN663Clg=","ClmhWOPuwhF+bpTn8OnezxjD/9XhUxqSGWNhWLuvYvI=","BuxUyAOBwFK1i/I7MS/9POLE66BlQgr49MI+0Adf0Hs=","EYhy3IMuDrVHa1ZkjoZ+yLCTQPenvLG0li8P+e0fnQE=","E9afoSfYNBZa1cfLp61Z7VLgsPDkLX/qleGQa1IJIbE=","FpoXf2PqaBJwscaHenPSG94UOUL7cdxV/YpJ8Z8Qx3s=","BO9RWRxurZfvQvKHrc5A2Tq+sDK5IvZv+36aWnRQVE0=","JW4XWh3AeTkOzXynA/suOxnsYYBdTwPO1fRe5t0Paew=","MBAtKGNqvV/l8q9BL/YAT3XMNg0yBd0toAKBPT4s7rI=","EJmOQt/NO78cBxS8c+sb9ARDo/qZvvSjH9Mb4YL8x5I=","GT7djp/PPXYl+n0ktZih2J8zYur01YLv7K12+HnjaGA=","GBaK/TTy2RXQNozoC3szR9HHpWHOYRQl8mZNeqUfC10=","KTg8AevTtqsMAXZW6+ZYtqMo7He8M2JuKeLpWzPqYRE=","EGRtLyYD3jmh9K5ed3GmSnAttuhvt2q2AL9XP5AQxxE=","C+teB9GycUX1dfE5WlW/Ey+QwltA2ns4ZNAkLcsRF/s=","FtaFJSB4wTPcDT7K1itciDD5W7LlS1mr3/vwGNlvozY=","Cmq9HYM5OPM8dBVOBAS0tApVW7vsId36/Wct1iBH8Bo=","GmefXTbre1yOoSpMLe3I/rEt/+7EUDFycKbxmzTPGGA=","CYD7IzvUVsI5dNUODr/eRyakI+raTo9v+8dZLj8bk9Y=","FhtCIy5huEy/GBCvk6OPwM7OPVYoySggA+ustcMSxys=","CtoQqQx/BSCVD31Hpg1eakk/CXh/FWTl0JID20feGgs=","GnMNNyMQuoIyA0WimsQjjtPweoorThIbtQ3bmvQH9FE=","LIEg8mjvBU+BcGTDad2n6pCDd/6rpcTf+9oQ71joxVY=","HHyIJPdYdT+lfAB4nGhCF7kw6VMTvLc+bnuGSaSWj3A=","LNntMfX4aRyOOeQHenT6oPQArYtJHrP3tHsn+j/Rz3c=","I/9PnUaBNFfPYNkvV2GDmaXgIqwyHKVQhUriORiiLuo=","CZRaXRR6T2bO7OZAXd3Z0K9aLFEDUpQH3/HqWPGAQm0=","GI2cUoAl1MK2dmDGt3G5D3x9puqinT8mim3SI+xvxjA=","MFDjeZZZa3+B9oMRQx2HNNun2SbTYzWV4MDY3fTw9H8=","Fa8RaTloMKkWAMqBAsNcQmzq5UYeP5XYnYKVGNMK/Xg=","HabQmIVDLqmgbZ83+HPZhdrpM+NRRmspBChNozINisw=","J5bqkNJpryn1+KzzOSESTk5PrT2+ZYlF5UbuQR3aqcs=","IC190doPa0sDJcizMHdC8B4VYS7I6TBKfLAxngHTLWA=","CW1nkNBbt1kVapUromPWcqLX+ceI9Mgxop2s5MD4vl8=","BU76H2Ww/OKDgIllJ12He0ONojzlsT4ZY3mMsUR9JaQ=","GxYvg9kX6T7bMwjCmALeudiqaQETsuFIZMz24Y5BZfE=","IeUkHhJWTdb9nxzdKg3jnu3+/BRmzFaOxc63RaBQbtw=","HPtWYujPWskiaoDuF7Nqvstzq1+H4WGSe0NJ4Q5L3wg=","DyEXfjAqdxu65tjR7LNztiyZrzRiIKwBKcU/Zm6yQQA=","FnFSI3RgaZKv+w3X9xsSvsQjau3mKQVGvO9+H1FcIyA=","D6PsW5SIJZwutM8kUBv62b4uyeQsXMjM1BnSppLK2HA=","GTwOBOC9KYNXyyZsFQYIDtNu3OhcZIzAhejFexq1S7o=","ECrfjvdHNaJ+kSgwbcvDyZ9vcpHNQGV4zhTqKtq6aPg=","D+CveFjkmFnipU1vGtlFsTFqokv73SOuQKbQy3DD6rE=","IW9nF7vH3tsIU2oiIIQ/Ti2l8dqp69796KXqc0R5jSI=","HaVcyQDw0h9KPmlDkZGKGzwjsqx3PGs++I4uQigyUWE="],"M":[["EJt/QRug5MmytwyvXDansZS+fBGtJDeL/ttoWSuoEYs=","Fu1B4Tu5wMZq4RlCT928vJMU3J/b3upV1sZFQ9xJA+A=","K5C7oA/KBYn2F+fcv+guDfcGq2QM6yR7eRqTt042c20="],["KWnyfu0xpIC5w2x2Q3nbyizI/dFBXD3e1ilAvN4L13E=","LiQZ+ewC7DlMmHHIMpY9wbiddDyMe5ZAKbIxFoex/iM=","EBBx8AMjebaXMVh2aQ8FPRSNThCfX7BlyKrMVaD4m/o="],["FDAh7GhqPzMNX55lRjgGXObNeeKMWzdTMmJE7mWhsac=","F2zAKWla0CWCpw7/CKb9mdBX4S5Y59e2sWzfq8juKRE=","GaP8ClZwK/QXun/uOAJZP6ZERwMHBD93cyec1x0l1eA="]]} \ No newline at end of file diff --git a/bee_wallet/src/services/zkp/poseidon_lite_constants_4.json b/bee_wallet/src/services/zkp/poseidon_lite_constants_4.json new file mode 100644 index 0000000..4cd7f72 --- /dev/null +++ b/bee_wallet/src/services/zkp/poseidon_lite_constants_4.json @@ -0,0 +1 @@ +{"C":["DrVE/uKBXdp/U+KcysmO19iJu069R8OGTzwr2BptqJE=","BVTXNjFbhmLwL9un3XN/vKGXrrEupkcTunM/KEdRKMs=","L4O53yWbK2i810gFYwfDd1SQffDA+wA19Qh8WNXowtQ=","LKcOLo1/OaEkR6yDBSRRtGHxX4tBp17zGRUgj1q6loM=","HLX5MZvmpF6RsE1yIicclJlBlvEu0ixdTscZy4Ps/qk=","LrT5nGn5Zuv4pCGS3n/2FiHHu0e5N1DCueoI0YRGwSI=","Ikoo5aNThafFGYFp5AXZ6g/H2ouT7hO21ffQmeKZUg4=","D3QRtGXmAO7Yr91q/KScMDbzPsvZoPl4I3lrmTu9gvc=","D50NWq0slVWivnFQOS2NmBmyCK4zcPmaBib5/12Q5OM=","HpqW3IKSu1lvUqWVONMpIpcyslJZz3RLahLTBwLW+6A=","CHgFFMzZA4CIfVeMRVVeWTz+Uuq0uUXGws1NUo+z/jw=","JySY/O1obHrIFJ+j9z74ws7WRxfjVW1aWfEZ1inMtfw=","Ae+PndfJOqxLfLgJML0G60W9NQr/WF8Q49Dvingu998=","BFufWbZZXmFNwI8iK0abE46IbmS/PECql+oK51STTTA=","CsHpHFfZ2pGf1vWdKkD/jqPkHiTiR6OHrfJYQpXWHGY=","AooWIalAVLDH+aQhNTzYnQ/WcGGu6Zl50S5o8E5i0TQ=","JrQYAsBx6kyWMmR+0FkjblDBnD+zyW0J0CquKg3Nnbw=","L7XdqAcrtyy6rC9j5GghXgXJ3gZ1jbapSvNDhK7bRis=","IhLToPX8yvJE/zVH/YIySa2KuLoqGNOD3QXFbuiU2FA=","GwQa1bLwaEJY5N+u6gm+VqMnb9sZ9EwBXNDH7tRl4uM=","CgF3a7IvS2uOzP8z52/e0xRPt+OsFOhGqR5kr7FQDv8=","K3tWdKrsw8vzTT8nUGbVSaTzOujBXPgn95NkQIEKzkM=","KdKZuAzUSJ5M91d57VS0jGCwQiV7ePwATBuAM4Gjvf0=","HEaDHZp0UpNXZBwhnXIadKQnEQAyteHdGd3jBCS+QB4=","BtdibJU8y3LzcUHcNNV44DYpbAZXZ0+Ac5rh2IPpEmk=","KP/dyG8YwTbFQAJ0jgxBDtxcRAowIs2WDxCMcc2ikww=","Lmf37l5KopX4Xe7QnkALF75n8bftKratuOwGGfb7xek=","Js44+mNskGMOl/JRFKeaLcpWhZ73WeU856vyLCToDyc=","Lm4Hw8lb98NN16AdAKf/7ELLPRah9ychr6y0xM/TXbE=","KqdPdZfwyfRfkdeWHDpU+4iQ0nZhLhJGOEsUcNok2Mw=","KH1oGkai+q4sfAkPZoq0W4pxMTwVCRg+LsDKY5t/c/4=","ISvRnfgS6q70pAYAUo89faXTEG/1Zao7EeKfMwXnPAQ=","EVT3z1GRhr8ar7FLNQ64YPl/2XQJJtq5OAnChARxNQQ=","Hf9jhcsx8cJGN4EKS9Gxb79RUpBb42WD2nR+eWYfwgc=","DkRFgtIrTnbAgdNMRMGOQkARo01UdiUoY+o8YGtVHlw=","AyPJ5DO6ZsSrq2Y4Mo8C8YFXc+nChGMj/3LTqrfk7/g=","EnRrvXF5EFkZO7p5zexEjyW4zwAnQBEttw8saHapwp0=","EXO30RLCp5j9m503UYQsddRmyDfPUNc+/QSetEOKIkA=","E9UcEJChrUh20eVV1/7RPajlcTslAm6+X9tICHAyQ9o=","AIdME0SkrVH/jct8vS2XQ8tydD8DlO/n9KWOvrlWuqE=","It8iExqquFhlziNrB/JE+g7qSNNUbpfWoypWIHT+8I8=","C/lk0tvSW5CHCLQ3pEX8PphFJKWRAebBi/XrBakZ8VU=","CbGNm5F6VbyjAr4ffxgeDmQLnXOpqymMabQ1tfxQLzI=","CU9VNERPrjakv8HVvz3AW/u7xwpjZTZt1nRaUGconkM=","KZm6saXyUhBRn6ZiKvU6FaPiQMDaVwHLeE/dwNwj8B8=","L2iYwHWB9jccqU23NxDogIQwG86Kk9E2aVdaEbA6PSM=","ByaOqroIvBnsFtfhMYpHQFZd6x6OV0L4YhdLGmhm/Ms=","GGJ5sANFTbATOf93ETvJ62JgPgeOHGaJpslYLEGgUp8=","GKP3NlCRl9bkkVvdBNPl3bZ+LMXemiJ1B2jlUkc3Fyw=","CiH6GYjPONh3zB4u0kyAjHJeLUvLLToAe1mHuHCFZx0=","FbKFy+JsRn8fr172pkYlIoMowYSixDvACzahNeeF+6I=","FktwYsRnHPCMCLjD+YBtVgt3dbfJAvV4jNKN4+d58WE=","CJC6CBmsCm+G2YZf5+UO82HGHT1DtuZdeiT2USSbqnA=","L76k1l1+1CWkJxLlpyHk6qYnrFyw64eMzC7grtVD6SI=","BJK/ODw2+lVUAwOjtTb4XntwpY6FSrm5ED1/Xzeauqo=","Bekf6UTpRBBOICUcVlFC1h1hhanOhWdfapadViktwk4=","Ev5cICnksziT1GPLBBrK0JlbliHm5Jw7fjgKduNubBw=","AkFUrfAlXUeVj3cjkhR0Ex8mKfrciUlpBs0B3G+geE4=","GIJKCeavr0o27SRiqGvQuteYgVZE8rveiBPBNFekVVA=","DItILboK1Rvp8lXeDD293d+EpjCvaNULuwaYPj1dWKU=","FzJf0KtjWHE2PgoWZ9O2fFpPpn/Naq+GRBOSh4/bBeY=","BQrpX20vFRkSL1r2e2kPMeVQdz+o0Yv3HMbQ6RH6QC4=","Dw0Tmg6B6UMDjLKI1iY2dku7YpXwdWmIV3HshO3FDEA=","HA+Gl3lWic33D9LywPk9Gnmznrx6GxxUnbvKe450fNY=","K9D5QK2Ta3ltK8LgSLyXnkm+I6SxNZj5/lNqFtwdgeY=","J+sb4nycTpNHeMCaAFMzf6BuuydeCW0WfOVNHpbuYss=","LkiJ2DCmflqPlr3TFVp8oyhPvTB9H3Gw8VG+YlSOKuo=","GT/j2wq0fTxdLsXpxb2Zg8mJHyytwWXbYGS75vzB4wU=","K/MIbpbDbHvOQVkHrQxA7W6WYcAJZ55ON8sTAnyD5SU=","EvFuLebUrUapjNtpfGytXdXn5BP3Qczyn/LqSG5Zuyg=","KnIUfSMBGfOgJi42U93RnzPz1dbsbEvwrZGbA0O5LS8=","Ib4OLEv9ZOVtxH+VeAbcXwotm8wmQS4pd995rMELqXQ=","Di1+HclG1wsnSaO1Q2eyWnG4T7kRqleuE3/UtsIbREo=","Jmf3+1pPoSRhcKdF2KQYjMMa2w6uMyXcnz8H1LkrPi4=","LMxvQx+3QAcwp4O2YGRpehVQwSsI3+tygw4QfaeONAU=","CIiKlPxaLKNPAgFGJCAAH65tvunoygwkLsUGIeOObl0=","Apd7NO6qPLatQN1Cybb916DS++dTr4izas/NPMvFPyo=","EgzM4T0ot1z9b7bJ6hOmSL/P4Nfm/46WELXp+XHha5o=","CfrSJpxKjpPIHhuXcOoJjJJ4ekV1sr1zoL8q8y+G/zw=","AmCR/T1MRNUKSzEOSsbw+g3r23B3XuuK9jDP+2AJLW8=","KUBKorpWW3e7f7qd+2/DISVDzFavrWr8uQT9K8qJOZQ=","J0lHXDmarznU6HwlSGlbTvH/2GWQ4IJ95yATUbfIg/k=","CYyEIyJHn3I5kStQQkaFy6Lr4twuTacKx1V9q2X/oiI=","GM71gSIrZH4xI45X/q19XHWKzhTJPE2kAZHQwFO1GTY=","Exd4OcaKUIDU50Z0XkNxHTy8DKShCPmNY7KqaBaY3mA=","AgymlvUx5D7AiPVvS3QyVibMTfcSwOXwqQfYjl8N7/0=","JyMO7enMz8n6gFow/FSNtpPRNwjGRoQdFuAoOHx6wCI=","AWRZEcEZiwHWT940o0KheGSXwFlpoBVDkFfS/nW7KBw=","LDI/4WSBv0luQ5yINBziXxmJceFEhwVs/cpKRRpdhkM=","D8CC3+cHKOhFC9IHTD4i4bAiwSTTv/6LWviK5ttQhcg=","IFLBdIANsgnYzcpWjcwls76WQhFqxMd+/opIi0I1Ie4=","KOQg4Q3y+7WvltYh1VQjGQvjUc6BKQZajdn9BbPs6cA=","JWmMpeJKG3mfeDxEYqJNtlXWrhvazRy1SdbgvDrlBpo=","FgqZgaXImlfPj/v6V9UQSaKXthB0QirBNNm4V9aYTTU=","IckaOeFFw7w02baUuEPzv4t86/Wd27CgZGQrBpmX89Q=","GsjYDc1e6HbSsJNF7xEjRdbqoCnZPwO20Ql1Rh5Bc0w=","CrPmrQ7Pi458FmKkF0xSIl2CKJXidVVEuNvOpWV84Cw=","HGdRglEmIK4n47C5F7OiHKUu8+9ZCbThxbIjfL2rM3c=","LNvJmN/Xr/09lI0MhbrS4uN6Sj4Hp9ddDIqQkqwr7UU=","I7WEpW4hF7B3S/Z8wN7jMyQzc1Awnf+DPkkaEzu2Oy4=","Hp4rMQ9gup+MtzAwo8nSoQ0TO8a6TsEVLz0g3hRl6aU=","DgHjZbpbMDGrw+cgFArnRsmrXauYdSDEYLzU8fpbIts=","BAiEzc/GS/x7cSc0BJjVxEM4IBG2HJpLE4fYW8EmTmg=","GQse4SBeuVAMdKOZjyvqNjU/FyTWBn7QoKF94xHvlmg=","FkfHKuxsQ4jQT1L8I82cCMHfz2XOYeFl/CjR+DK9Oyw=","JDAAY0agFF95mIDMTIc2Jp9UlNiftIsChC5ZW3HkVB0=","F3uaCDQ5F+E2UQej2jrn9p2FOQK7FrrLMiGFAlK3V68=","BKQg5kKxGulOWIYqaPXjJgnNU9CuKUI0ObEdBGZt9Pg=","JdDg9zn7OfwQWoj6sK/YEN4kYYWOlWzMzfq+3baiXI8=","BEdtkbfv8v2FkFy/WGUe3DIMsVYQ6u1FLE1P+gx0Cic=","EJDAtos9fXuLycokGeuN6hwo9tXhJQy16XgP2coob64=","JTk847klbVBEinJcXHzVrTdvLUNYVcEOvyiZy1xmF74=","JZMcDHNx9PH8hi8wbm5YMO2CQ4jWuTQml9FE8Pq0ZjA=","I5bLUBcAu+bIKq1RsPt5z4pNNTGF1YCCA/c/Iq+/YvY=","JqNjSDNItYlU6nSKcSmnsKPckGjDzKe1s/DOA7hySIQ=","J8oQfKIE8qGNbxU1uSxUeMmbiTM0IV9rp6DltF/NaJc=","Jtoo/Al+13zkZiveMmsszqwV9zAReFgdjS0Cs7LZEFY=","BWqzUWkdi7NwPjBVBwrJzGVXdMG7NdV1cpcbpW7gy4k=","Jji1fyO3VK7HbRCaL0gao8IlR6Ef/FAVLXKa9jI3apA=","MEdUu4xX1gcy9JLCYFGE/cM+RqUyveyA6nvFUZ7efO8=","ANFyf4RX7gNRTxVbWAbL90jsaFf8VUAQdSrJOpt2Gaw=","AO4fPGb7wFxDuilaMDxy+rW8qGgF7JQZxYjlCUd2H6M=","Cvr63PW03UpKdrWh2CQV/RChn7z8WQeMYfkpfrZ12XI=","CyRJ85dGCF6GzkXo7tEI7mWiNINaCmpeqJltEk3QTQo=","IGsM4vGyxbfJ83sARSJwlfbG8HHsO92nan3fSCPdXdY=","D+uk+4eDTHy2luZ0M2KM1sr/w6TvIP6oUsfhApRZQJw=","JU2/rHTEmwuJJnUuCE4CUTsG8TFebXDhgXPpcjNuVdM=","Ct2xNyzuThZGVRaMNnVZ4ZYGxb0XkQrrN3Ge36DKh2I=","JrJbfiV/PpfHmQJPsBn2XGyk2NgbGuFiIaWJ1ogx11k=","CQmVt5rOwkBBO41MZYeH5aRle5qwC9tbGWCxBZ4RO6M=","CNvcLiHvEfLFcploeEPOo+sNjkDpkTH0KXQXjUT3O3s=","CeirpnFIEZdnn691Kg9440L+nEkVlqtnWPFwk5eFF58=","HesFGA6DPkVlkFKn66+BbH79Eqf57slLe8fGg/E2PVw=","GacOxr38kJipJu+8wEqp7iSJl+iywkrzNf1lI+UlCHk=","IddzZgra+4qHmYb5qrSJBWY1Ojd32KPx65Or4Qu/H2Q=","CfGJD3Lp3HE+ILpje4nV05emsB/NZnNH9vRmF4QcOQE=","Ba9Fk2HrRU0qMAxh5EaZjUj6H4l78hnWCMIUXDOxEcM=","D6Gh1oKfA0VmSmbcdaZXM18zbxXzQHVs+hL8hQzItRM=","AuR6NbzAw6C9oLHAMHrVQ/QoD8+H9jb4U2Vc+Xpii7A=","FPdz6YNMa964+Q54v0wktyA0EUYBEkkQNmIYlSBNDxI=","EC2Yz1Au2EMlXPGdKbx9jmQqvnz9Y5mS/7CRli/I98w=","BD3V9Kpadt1MR/bGXafKIyDUxzrTKUc4y6aGp+kTc8I=","IYM4GcMzcZSmwNKaSNTyZ28OfHl0OjBvTP2ysmvRHvo=","DygZJc9e5km0dKaBnRFso+tOyiRsMR7K3FMmKjz/K1M=","DT4kd6exC+tEcJx3RtaCTt9iXdYFBNXck85mLxXCONY=","LNf2Qb7b9mlW/4oBvpzeNdgPgKtR5ztJrL/D7/Wu/EQ=","KelbSSvy+V9NCTgPmLdOOJFJ0kBFgR16ht2GExBGPPg=","ItpmvGLo8BEmbvyoamyBD5rkxRr2/+tX+LPFDfg8wT4=","D+bTDeeoLRYwI0kXlPSsoyINt56BKd82QwcthBklVUo=","AFDoQqEpmQkSPEbv8YXCOtMS0D/vGt/sx+B+yymP1n8=","ITCjp7MiEiK+NMxTpC13M2Zvnd9xTtfFiFy722MQjCE=","LfnuKU7fmePY1Yg/4FZsJKpmcx80qTKA4dMo5nszyfo=","G/fW5ImtjAzybraMwh/1QVgTI5bcJQrrpLb8X8M3J2I=","DGAvoVW+lYdh6vc5YXqxNs97gHcov3/jXUd40xF4DlQ=","LlDixbNqogUyQH2GuNItfVFUCAokly+utj+vASHtfyE=","F8JRCYKntYJXENYpDsT3gvZ0mV7oQJtCtFkSOxgDMuE=","Cw1S8DyK9ydoA+zyRluIWyEze1OOq9L2sqslXzdrQqg=","D1Yz3xlyuUVZU9iKY/gGR6msd8bA+F1FYZct2Pq4vRQ=","Dr960pyhOAThQi6TloEVUSR4D/Q+dukpA1SYEwp/FXI=","Gv8TyBvaR+gLApYhc7ujQ+GPlL7ifIpXZhsRA6cg/+I=","IQRJ2/XPMGHaJGW+hVBYYtPzHeGjtY/zVxO+V++sbAc=","CIIwwnlOUMV9dc1tPHudvhnR4vHTABBEuTrRw+5imBc=","HECMJWSQsKHaCNxGQTjfx4zOmp4Wx3BWF6TW27IOfjo=","B0UX4IHrTB8i0XcSAPsHZY98d2VNWEQEkN1vVX6eOQM=","AtBOnCHfHb2IUkvbIDaRtM7lUwVZ1s8PoFrfYeEv3L8=","LregEbi86RCC4T69dd47WOubRlDa6fEaqB2zLPG2exM=","Lv2nftNfSvApn3XW6KhJtU0qxr+VNoME5gMMGPDPF7U=","CRmdyv1QzmQu3b7aZSBtT2GnPRCFK4EUxRskQBkq4GQ=","Joxc/ERtOZxN0xnbZmp1tctlXYwXl+n6dhgctCFuFWI=","IwOmUslJBxgmsOmjbIBXhpe0TpEszmaHAShU7aEaGNw=","J8U1Y7EqbuLD8EHzHcRZIrxTU+sRCGjSNwc/Tvs1+98=","EgGofq9K5hjwK9gtClEJBJlptSSM/pD0LCePImFdKw4=","LEMWlDn81p6tghSZe7Bpvsr8sbosUeVwbLS0PasqRD0=","BoNZcxU1kEDqA8RdaYTGiU9Gy7NtcC48T7mEfmME2UQ=","A1RXBnBuqzavuTsSj+vRb7BCXhWDFBl7d3la06eY0YM=","GjPCVOwRdhnTXx/AUbMXKHQL7SOmo3hw7bOTtxoMDms=","H/5paKRHDNVnsMACKByvmW6I9x51m4fm8zjlF/FpDHg=","D9ZuA7qICP/ssFnImf2A9BQN3V0qXESDEH9OAuNVs5M=","Jjq2nxO5ZvgZc5RVKQaxfmyGF6e91ddKe+M5a3/gE6s=","FqQl5H0REGJQVNWhZd5BPjvYfVqjlY/dbrfgPjm6QEY=","LcUQpHGewQytdS8DxnPw4lPMMdE+OekJ/MX3OvkTjZo=","JN+OjYVsW14b0crSPQfdo0I8UXkym3qCy0qnCalFduU=","K8yU/0/Dx2881caJFaBC6HYoJJoBsJVhvfJKbNzlYg8=","B2weiNxUDI2N5U40PffEKdMpX1LDjP/mtIvoaFLal98=","CbXyCaRRrEMcBR+xLZpeT+QO4WARIJR9qZD7jhLLRuE=","IF8XsNhyni6qiNakQTWmq2TpQk9VsPHqBoOvdetnfAc=","KBxcaIg29s+RJjjDi+BGzQkWgfCkF2FyDN0e358jcCk=","GgU+aHjpAPRfTWdEjEcc8wCaROegLqUOSvpE8lkmIfU=","EA3H1CbevjAH+3zqyE5PVGjvy4l+e77pgXQoOdWeBkw=","FwImcqAWqVe7h+LPrci3X7KJBb22LILICxyzG0EeScg=","EIbbfidg/ItxBTqH6+FRI5+4tUcYKxcN4MJyA/lU9NI=","FThP451ztjMCRgrkwpQvrCtB+2WhhVNvuF3ST9dYQGQ=","LrtZn+kTbUJL9KvFNCxsdEexqFMgX8+1UZ5VE1dwkAg=","G0teh8+5Jiz+w8DwVC5MWkzyeCkrTOPu2Zb6xvTTcog=","JGUFOuULaIWAHz+C4wLK+7tKdYG7T7pgtjf+vmWeUFc=","EU8y7c3qCc0JXFu1048bl9qfBeGLNwi/bgq509VIWe8=","K8cN/rK6qy9rOHzXe+d5rC5eVRnz0YEj7ijYwlQ8cUg=","Acm/eiA84it3XjphrX53tqeDSLn27GikEuSb/jLAVBU=","BRSw/lkJ6oh77bApX7vOw1XPtXX/apfNn0rQDMtX7ps=","Jnx27IGTTMgaEyqLBYkQoSCSUgsSogGvA+MgLXtsG34=","KRcOMyKz2NXHjIS6u7RwrfFiJJPOg+lc+xUc91e95dY=","AZ9qgSSxnjOvM+XThz+cM1xvCaRUhsq1Nt1ZbKQdlRk=","GQSqTWkIVEqLNI6dsZgcJwCe2OoXFRiuVAXQNiQrYOk=","JvF4c5Sbxnn38EOVZpTkIrPO4d6d1vZHO5MqR2RV/xo=","GsZo9hK4JDwZOzNyC4qlQEDEdgMRlxMevcrJsYvEj3U=","CZbZYadcDQcZba5Fv2JHZsz7+FVb6XltpS+BVo7wZj0=","AwyX4bjK0dT9UNG0OD++ZnTRcfmcY/67VCWzlcJPyBk=","BuOtakaQDi05UzcCVbaPibPlI/H+UCZC7iJvLYvQhI8=","HWs3VTMc0CFraIDkL5iA9WXLlLDgRVFToymJBYjMkW4=","KOTcukuW8SpZsEFTXnMKyMNRidwLhawDPdOMCLrlMfI=","CLYIYEaoNVCMz0hPKXS2prBxKkdiYDdsejs+S8SkehQ=","FizSyn/jtfFES87JeBIBm7b9hfumoFNqiWQ+Fbm7O1I=","KPHgO6rqm7wFr1sRk35PXLXJqcEZIGPRmYwBxk1IOnY=","G9sGJ3jXwV2jla8nNMJfqgEn0qq0qnE2YDGgu2eRzhA=","I3WDlQLgmJDLKRToKWJ+Dg/JiHCyMkqLUDKevdJHScs=","H6hmL7y2H7OtfFVmjclCOjMtyHz7LfRW6S0zYR7Xu1A=","Hk+tLdawpvH4cH9yFxbIpEbi+yxHpROPP3+XNgeddpQ=","IRJW0Wxyaf1t9vX83R+niLo70FAFn1PSYbD18Tcx/+c=","LkkISzNuzqpPjiouavCDGPQgYOV03aNB9KEHmxK8xaU=","DOGfVM3Dn38781GSrGgIIRrs6gjf4UyrdY0liR+wC7k=","ABHF1Ww5Dok8w5QiEmHYdI3GBFHkrk4chKhGi6ssFMs=","F9ef8GtjrCqKngXuavPbt8pg4Xv6ObR1FKjNgFFXm0w=","GafTpEbLU5PcdFYAk1krBrGos1zWQWouyrABc2OQFfo=","AwwAoJM9zboqgIsuG5KC8zHwRZbYko2nqmw8lyNwN6Y=","Fry0R84tUPOuJa0IBpU4LpNdLQAYTErMk3C+iqtkE5w=","EjQbRrAVCqJepOyHFTEpl+YhJPN8q3ttOSVbfNZv6x0=","DobRORf0QFC3Kpeyv2EMhAAvwo4pbRBE3IkhLbakn/Q=","CObrQInTfWbTV+ALU9fzDRBSoYH48usU0FkCWxEMcmI=","LqEjhWJF9shHONFd0UgaDAQVzLNRoeDO4QxIzpfKexg=","LcpysuvKuMI0RuADMLFjEEGVeJAlQTq/Zk2w+chN+m8=","Bv+e1Q0yfoRjMp9YXskks/L2tCNfA2+kxkomy9Qrams=","JGoQt+PgCJlH98m9o9VN+OKmDgzKhOoqxjCkU1r79zA=","IqY1AcXwS5AYcZ7ZnXAO5S+EanFa5nrXXJaznWiLZpE=","L0xQR39/2cZxeZrF0uIkzbkWT1g1HYqhQOwH5RT66Tc=","EP+3qtH1HH0TsX9Nh22aHjjwuopKI9S1DNoyythRVn4=","Dpzv3cPC076k05ciUy1UIHhAJzUhh+evGgVpNcNYA64=","B6+EpNMUHnrCM1Lm3G6kr6Flb5ajPIl4o+g73UumK0E=","LZ4xoQrrx2H43gDRSx5WbRo5Mj1uibY46UDz7Ioiw8U=","J/GaZTLma1Mz2xr9WS9m8dNgNLMU2thEdlZ0e+J+ZMc=","AFj6PIRU1jNUsgJMO0pXehgO2Z+PMVXNfk1hfUfQf/0=","BBYntnFbeAlnlXwIBpk0PrBBSiBdOhddcIlklWgWpdU=","AGrEndklPtx/Yy5XuVjM7NmCAUcc8fZliYiPErcnxS0=","ATGt/9i9clSx2MNha74zhuwMnA1tJamk7EamvxgwE5g=","HEpvUsn8z3pBOOQT72Kig3eXetfiXkmjzwMOHNj59bY=","A/KmvlHsZ3+UZVGzhg6kef7gSK4geK630feVjSwmRfY=","LadwqtLC6wk5Ggy3jvOpZIoTcthUMRlWTXN2OWuN3GI=","FSeEY2ZfdM3cGAL+v6sCzsnUX+hmw1nHOAYq+3XWSgM=","Ev4niqNlROrJcxAnCQUY1DTjjqlmoIpvjVgGOKxUx3M=","FJucgCGCVYpMRdEZ0/TMf9hYdgTKTw1uIbBv8wtqI7Y=","CBLntNhHvIUX0ZMZdy88mFXgRP1g26yaCtxJWbaR3+Q=","Au2Njd6v49nY338ooL+qf1VYE8fnUDrqKmaXNwOgxhs=","Dr0HO6BTe1FN62Ap+SECnlXl5NmgPWtroTBAOGYtTbg=","FcdU1bFLLEIFxrqNLM0CglWz55LGr6CLRO51ti7/n1k=","FpUVyJrFR52w7Y+m+jEbORzBI1Jw9MvFwp58vDDocyo=","JUefv7Omj5gjiPJiEAEQFgi9wp9v8DdpbZFh9c2aT+8=","FEdcS9UgRR88hSywMRpXjKf45ulyGCGWzglIbpS+YHE=","BFppEGbMZr7JuvJ5iDOh39OoR1Aq7I1fXE5zNj0Jd5k=","JgKcDCZ8eZ+4M6yKEeOj8BR6jKA3IhuQATuLyzfrpoM=","Fj+ss0/1cvv3yUaWnBwmCHPOEqapSj5FuBAdW5SNFkE=","LHFOluGROzUdlpMgzGnV7BPgamJ15YaIr47gDEJA7ig=","HBZh4qfOdLdauoRmXs0r+d3WJo8G3r/i1SuATv8dX6Y=","Bqaa55Xum/5eWvPmYZpH0mY1s0wqCIn+qMPAaLfcLHE=","ET1YU12JIRXF0otMGaNgk3Tb2631QZXHMUFshdcx1Go=","KriRAuK41eY4/5fXYdpgQuU08f9H95F6LKGnQGO0YQE=","A8Ecp55B/f6WJzDEXmmVRjSQMYk9orT9OYBP1qFa0bM=","JwlsZyYhQDiIAU3bu/ydoff2e01M/oRsat8ED6ryZpw=","LeMq0VSXrvTVBNTe61OxPGbbeQzkhhMMqp3CtX71vg0=","DcEI8rCigNL9XTQTEHIqLSjHON3a7J89JVdURI7v0AE=","GGnzt2P+gWTJaFihu5761bzcPuvECb58fTTKUDZdgy8=","Ai7Totn/Mcv4JVn+apEYQ7YWlF4WpWjUjG0zdnEpaC0=","IVXWAFIQFp45RO0TZb0OcpL8ofJ8GcJmEMauwHfQJrw=","DeG6elYqj3rK6TJj9fG0u+wMBVbJGvPbPqWSjIyuroU=","Bdu0QGAkvqvPzlv0bsfaOBJvdAvOjWN7Y1Hfp9qQJWM=","BdQUm6rEE77U2NyK13jTLADnieP81y3MyX5UJ6No/V4=","Ac34tFLZfCub5QRuc5fnb/C2gC+pQceHkhLiIXLCey4=","H8anGGcCf1avgIX/ga3OM8TXxQFeztjHGwoiJ51GwHw=","EEC+9MZC0DRdTVmlp6OkK6nhhbdTBtnDVo4P2paqr8I=","FrecOmvzFuD/LJGyiTNKTSsh6VZ2QxkYqAgUdauPrQ0=","IN/xvDD222tDSzoTh+PIxqNAcOUrYB/BPL4c3NWfR04=","AhKsKrem6q7CVJVQMKlw+AYt1BcacmqL37f9hRKuBg0=","Lyk3dJFHREKGmhCckhVjfLAtwDE08ARCE8gRn2mWrgk=","CYTKal+RhdUl7JPDP+pgMnO+nzhmqihMWDfZ8y2BS/o=","DQgKa2s7YHANKZvW+oEiDeSRNhyKa9Gc6w7pKUsk8Cg=","DmXNmehLBS9niVMGOMsK2CGsyFtkACZNzpKe18haRUQ=","LiCIdbx6wSJICPcscWzQXuMOPSA4D/amVZddoSc2kgs=","KYnzrkd8L9N2oLD/PX36wa4uO4lK/Sn2SmDRqoWSutU=","ETYc5UTpQTeSItEB5vrAzpGBBqRjKQo+OnTDzqcYlFk=","Ho0BS4bLWn2lOeEMFz9qddEiqCK4+zZsNMi9BaIGFDg=","Fz9lreyN7uJ7qBKtKVWOI6DCMkFn72yRIS7iwo7phzM=","AcNtqvnwHxuv7ovQx3msPl2l33rUVJnQmRvWlTEO3dk=","E1OssIwFrbSqmrHEhbuF//J30aPy/ImUSm9XQfOB5WI=","Llq9JTcgfK0YYOceoRiO5ACdM960+TrrIPHIejsGTTQ=","GR1cXtrvQtPQLu27erhWJRPetOs0kToTQhcmuo9pRVw=","Edf40fJpJkKComP+ptdZnYKgTHTBJ96d7nk53S3NCJ4=","BCGP3jZoKe2Q95rV5nmXlzRFy0zWvG+VG60IUobKyXE=","AHB3L3z1JFMEg5fKX0eiAgJ7c7SJMBwyJ7cccw121t0=","A4o4m6712afIZbBlaHodm2doGpjNBRY0wdwE2+PSuGE=","CaXu+rizaoDNpEaytLWczQ850AlmpQvq8ZhgeJAVpuU=","AbWIhIuLR8i5acFFEJtLWD2eyZ7frLdInRYhLHWEzYw=","C4RuSjkOVg9uGvbfwzQUGVReWr+jI9gX/tkeMNQpVKY=","I6ZnnH2a22YNQ6At25AAQOsVE7w5T8T5hcq/6FznL+M=","LgN0ppkZfjQ+XKo18TUen0w0Avt8hezM9y8x1v4IklQ=","B1LNiZ5S3E1/egivTN4/9kuMwLEXa7nsN9QZE6eie0g=","Bo+IExJymdrDSaK21XOXpQJ1FCtmS4AsmeKHPdeuVac=","K6cKECNV1UlndXQWdDSz+YaHLQSilbW4s3QzDy2iArU=","LEZ6+IdIq/ajNNHfA7VSEwn5CZuCXdKJuGCecKC1CCg=","BcXyC+8b2CcBAJorRIrogeOlLC0aMZVyltKeV2Po9Jc=","DcY4X9xWe+WEKjgfYAbixgzQg6LGSdnyOsjJ/mG3OHE=","FC05g/Pcf34Z1JkRuGcPpwN41bhBUNJe0lW6qBFLNpw=","KaAe+y9qqJT9fm2YyWoPoPNvhqepmqNcAPoYwbLfZ78=","BSX/7nN9YFE4xKUGZkTsYwq56K/GRVW30qGvBOthOnY=","HoB9yoHXlYHwdmd8oOgidn4WT2FJECZO8XfPQjgwHcg=","A4X7P4nHTcmTUQgWRyR000wCI+D3M6Uv26VggtvYdXw=","A3ZA3Br8AUPhpimOU8rln8+r1wFv1u8a9VjzN7qw6gE=","E0GZmh7YaRnxKmxSYIKe7l/VbPAx2oBQt+TA3olgdLQ=","Bp6wdYZrCvNWkG1Lr7EK13Ov1kLv3MVleyRPZb7Y7Oc=","FxwLgeYhNuOVs46OCLPmRtJyYQHTr6oC6hkJphkDNpY=","LIGBTJRT9Ry261XDEXU+hMu9yzm/5pb5VXUQdQKsztg=","KdhDwEFdNdnjsz+tzydLKrBLOQMq3Kks45uKhqfDpgQ=","CF1qEHDzUT2ENrzNq7eHUNjhXqWUfyzap2ac8/rncos=","EYIDY+1UHaoQpEumZb8wLNvx3U5nBrAsnipc2kEvw5Q=","IBk1pY9cV/wCtg1hqDeFvd/TFQ4F8d9dEFhAt1GhYxc=","CowoIMVpcariepUqvTOgPUZ5Tu3Whs2Oz+1hDofALpo=","GAY4/zAaZMoEq9bQvXUAtmULZf8z5r4f1Q28FjooGHc=","CVxxYmbx3lkET5cRSkFYo/hcqKk3z77GPpsyGoEt02s=","F8MeoC+8N4Mg2G/+1sfKFYO2GMXBpoeBjUCHpJfXNJA=","BbhsS7jvMYtqcifkGS0UnTwXqXZMzWYN5NUKd/GSqRs=","JlvJXfSkxIdv9w1+ov3ix6sV9KauDSN81s50uphsens=","JHUrR7xsa8jZu+SPX+8vaQhwFznF9bSz1siG1HFceSk=","FIFKHg9JKk6g2G5SepZIIXjWJLmNqW7l5YO5Mk2XTv4=","EN75MQc7ZHm9YFdzePKTgZl8jgQdPPs9x1I7ypBvAL0=","FPeudwv36V9/cGwNirTtA/oLiA0oxp0DG0WSyYYQF18=","Gu9QoM7nUbWfkmr0DoA10Z3sydQo6+TndcXMnc4c5Yk=","BBk1YHFy9o66ZcpgBo3+OwhsKi1X0JYClRIUtX5zz1o=","JoY+ndJCVdFXO9CDlZuFbAST++/oPIGYN6FR079FLLg=","IDbvtvmDCWXrPXoGi9CHyfWt8lG6YgUsZSc45j/4s68=","DHEql1t03J12a2OaAplpyjC+T3WnU/hUsA+k8bT07ps=","CAFNqzzRZn4nr8mb+sHmgHr9/2RWSSyjN1cx04dTlpk=","GY0HGS20+sKoKkp5g51qK5fE3U03tOjztTAJ95s05qQ=","Kesd5Co604GyO0ExQmiXoycJsp1Tu5Rt/RV4TR9j5XI="],"M":[["JR5/35lZEIAICwrxM7nkNp8i5XrOPNf2T8b9vPONfaE=","JftQtlrPT7BHy9OxwX2Xx/4m6pyiONbjSFUEhukcd2U=","KT1hfX2nIQI1Xznr9i+RsG3rUyXzZ6RVbqHjHtV2eDM=","EE0ClasAyF6WARGsJdpHQ2ZZnldam37fYUXxS6bTwcQ=","Cqo14shLrxF96j4zbNlqOXkrOBOVT+m/PtW5Dy9pyXc="],["KnC58dS7zNvAPhfB0dzbAgUpA9xmCeppafZhsut0yDk=","KBFUZRySHnRjFamTTxuKG7qfkq2O9Ll5EVuOLpkczXo=","KMK+L4Jk+V8LU8cyE076M4zNj9ue4rRfuGqJT32zbDc=","IYiAQeb+vVRtQnyJCxiDu5tibYy03BjcxOyPp15TChM=","FN21+toBcduAGVuVktjPK+gQkw4+pFdKNQ1l4sv/SUE="],["L2mnGY4fvMfepDJlMGo37VW5G/9lKtaapPqEeJcNQB0=","ABwe3WJkW3Otkxq4Dje7sme6MSs0FA5xbWo3R1lNMFI=","FbmM6T5HvGTOLyyWxpZjxDnEDGAwSUZvp/mksii/wys=","EsfirfpSTllY9lvi+6yAn8uoRYso5E2SZQUd4zFjz5w=","LvwrkNaIE0hJAYIi57iSLq9nznmBbvRoUx7C3lO70Wc="],["DD8FCmv1rxUZgeVePhopoTw/+kVQvSUU8a/Wxfch+DA=","DexU5tv3UgX6dbp5kr008Isu/i7NQkpz7ad4QyCho24=","HEgqJacp9d8gIlgVA0sZYJg2ShH02Yj7fMdc8y2BNvo=","JiXOSKezmkJScyYk5KuUNggSrC/JoUpfuLYHrp/YUUo=","B/AXp+vVbdCG981P1xDFCe1++OMAuai7n7nyivcQJR8="],["KiDjpKDlfZL5fJ1hhsbD6nxeVcIBRiWb4veMLMwuNZU=","EEn4IQVmtR+q+x6aXWPA7nAWc67YINnEQDsB/rcnpUk=","AuysaH71tLVoACvZ0blrS+81emnj6GtVYbkpm4LWnI4=","LToa6i5tREZoCPiMm6kD073La1i6QEQe1OvPEbvh43s=","FAdLsUyYLIHJrRceTzX+SbOcSnpy27bZyY2AO/7WXmQ="]]} \ No newline at end of file diff --git a/bee_wallet/src/services/zkp/poseidon_lite_constants_5.json b/bee_wallet/src/services/zkp/poseidon_lite_constants_5.json new file mode 100644 index 0000000..6964009 --- /dev/null +++ b/bee_wallet/src/services/zkp/poseidon_lite_constants_5.json @@ -0,0 +1 @@ +{"C":["FEhhRZjgD5jnrn3qRfvYO9loZT74OQzeLoa3Bq1AxlE=","CreykTiOXJ5DwNwfWR+4Ps22UCLhtwr0O4p7QMHf98M=","K3y7IXiW9SyajAiOZUryHoTN51SjzvWxXE1UZmEtat8=","K8aw3b4dcBtlcEKL3Byhvw2ln/O7u5X8K8ccDG5nplw=","EjpVoxmAOE89ILLOy8RO1gw4wR99IOknHvq5qQXu/Tw=","A3UBzIydyBkwmnafTfCY5YiwGFi8jrfieeKIO+n7jFM=","HCEW5H4DqGuxFpWwpfbatrmkYLHrlRqwHCWeyj/UfVE=","LBghNIkDLoWpyMuOmmWDm/rtE+V7wPrknb2uv1T1b5M=","Luj+09TSxxoEKer9jl2xcY8p4iJ5hf3yrYcDyDW54DE=","KMZNj17XqsAEySAp2em/kbqUNtHM6UuTFtERxwoMFxQ=","GKAdn/t0euDePoPHB/iyT2gshPFav1cbNCVKA0eGZeA=","HCHZK+8ZfnOyNOR3e2DbFOZCpWzucVFdVOGscc3nK9M=","CtQEzLyx4ZWJfLYMgJgeu51mpmd9u+2ti2RV/mLYB7E=","Cptt6DMGT5O2rbma9sAFlFy2VMt70UyLl6+LYMwfs4c=","ExKeP5MK7W1HaQMx/wncUWDvpY3c4sPmGA1FvsOqOm8=","DXphTImRUIqxzkiVgTuxyC8Yv3v8nigMzKGAeYOTh/E=","BTL37DbjAEGwSGmGh1yROkm93y9a9f6+jDHy9AlP/qU=","BrvLjo4YAgEpPnEvSVDxsLvugIydZCY8hNnYrhVcuJI=","D1WKTbGjrAf2Hi5r7pR/c1hr9A8hHOtPaHylZ4qdyzM=","K+FApgtbXy+O3XioGKlpsgxkPkGbzwtXfCSg0Oes/pg=","HEnEuanwn3ua1fdOusxxBRK46Ge6zifLDeoG6JuW9jE=","FwwacychsSzefzPkdqOaGqd6gcBuLqxQOEewDVl2Uts=","GcJ9DlL2XKNPTjGgaOSTMca/w52SQfnUwwIEFhXPJ/E=","LxvcUlT5Igwacx/FJ2lk2rJrOF+kC2sEvtmWDiVDugg=","BbQtL7zL9NOdK+kznKvp0Nxtkh6FXNkRVLY50o1KHPA=","EiAEBxWkGtWfT0EODAWkLF/TKsUv6dBviBiNcfYeCTU=","JflSZSYVW4OUZgn3u5UH3Ukl74cd7pFtkUTrtOzhNDw=","AXv+QoQpmud0C20OIElR4xSopdBFJBkUeXipWzR0JEQ=","Kl1HZAIcpx14qWdMtnCPFYjSzq81eMQRHPizWe7wic8=","F/Atq3RfvjwIEyH+XO+EXnuNBwslFNKbKnt9icwIFdo=","GdpiYm23GZtl9K3PV/pKPbqhdkp70VVwjubzeMie8BM=","D4jilfou2BtCbJH6aTZqc+33Xzm/GGNM0mbsQDiCngU=","H+McVUhUbHlI/k7hvXQS4ygO/30gywmqhfSfJ2YUgBc=","EP3BYTvb9n84vd5WGy+R5MxItZ+Y1kNjj9wK+tv+Em4=","HyYYwuvpV0UIucUvAADjPr/drRoD/da8pu9/AJMSe+8=","Ep/n/D76xqirI9um2IbzlNoR9ZU8+Y4oJpoNuip0XdM=","Fa/UzfHk+CDBYx1KuFykujuvz+5yvq3p+uYFIxAkSOM=","Hyx0ulw2fjcNco5x4VsmiFGnu4tFUoy3NJVgeayZsBI=","ETDhhy128vk2nPWble35zhnwH6icnDaybgne9nhtrTw=","E1I9Fz9+a623O2P8HJu9vuJCxhvGhlZJMydTOlwbHco=","FNpA0K9CemXxhBta3JZThlM2j3JUy1Zn3a27rXpXTNQ=","AJH5ZADkKX6oW7GGwXswToJjjlf9Yx/2MVl24aXdi4Y=","MDMpv5AxxVFbmjTUmmS7agJnvHtUoN7KXEUCd6ACzcs=","FO1H5VwdocLwXTwaGy5sGFCfyDNuz+nbc3kW4oP6ghs=","EWHxCzV3ddgQrVO8xKINWt0rAyUcdH3rBO6UxWXljWs=","F6ilCucs5wfyK8Bw65koUcqRTrlMxo6vu4qWpxTrgiE=","Gmxh15Xbr2L5klCzfsXfiGRaHBU3kdtjErky3CUOT2I=","H4vSq4qoQGZMTu4ZjEaE3EsFdyuyoIadpnIrFfRHoTM=","H/y4UqTwAnqXmfExzXS5jM+4y8BjSdj+/MYvEMj7Pi8=","A150LsUvGbNtSJxyD0Z/+td81TvC213dskayMCH3nxg=","Hfqu5BvflNeDqin8Yrfse1VnOqgY0wX9QtF1oF8uPYY=","KCE3hHegLplQBaVjUIhUCUW9Mz8tFFXwOKIZuMR5azo=","HbSk0PI4pXCxBhxu7IHALzH/3Up8GedjF08jjQSJdCE=","FL94iUV7ILehNns0o6U4IX1pO1JCav9ApLtyiTsXhMo=","LO1Swr8pb4fldBDD7JqUg6eW0WT2BJEnEJ/w06nAhGU=","Hd6sWAWn9K2k0EQe0QjjFJ1M5lhPSa5b39RtZ2buozQ=","Lja05enJe0YjBOjitfnciOHJ8hYboEBnP5ERI/BCrnA=","DGhA0csGZtxZ6JsYZSddihZLRHxe1kNHyu5jUCwjjV4=","E34uPonnHUYfTJvD6PEhgyYqTR21XFibLK6qwBI49Yw=","JQky57CtzyyE7Uv7YKNra4LlWqlHURV7HUV5Swgciq0=","FwpykvVjTAbdO/CatcnE7NSwDVzi81+XK0VVOR8WtC0=","DWjLvnconnjVy/UdcPG3W6IV30570BSdELLFDypPO4E=","DK90VjuQUl9kWm0gNuzRMG+h3GgLSdnOTtJMl0mXMXg=","IKfRwKJ/zOeP/jcvTFgwaxZvlFbtRs3rJV45W30w1Co=","BiPzImtUcLJ4m4pTBA5ERDOF6Wuc+gvk01AVFYpGhGU=","FjIwhojCXnkPV9aKU1AkEkKlYwU0feSlAJzka4zcuR8=","LeR5Om+ZzRTj9mQiEfTQt7z6NhWXxUT/y1pWfpB29H8=","HU0G0Z6hsJyteQhtUb3hFyWlVPqZVZyi8J87tz1yjGY=","BIDnR5pmp82ephyLKJdDiZCDUKvEqvwYzXXjPdEwwUQ=","MEMLAzaOvKqRJGlgSQvPkX14aBRj4ufXRL+0QzXawk0=","C1ezcyASfUxQ8mkSSw29yysfE1IkGl0SEDKD4InAx0I=","LPSJBlDSckDhlfYKT2mO2iSbjdYUsjN2tQF40t9tK48=","HiIcVSaJi/0S3oaFGg2XA3UaLyOQCKtfm307aRHGQYQ=","KOB0ha19mS7RpY8ynKEq3OTsaT6927KVLlTTOfLuvaU=","L0TWT4TeFtxnvV6tUe+x3IOByEUgwShU3V7zoHms1OA=","BQp2vDLr0d/ivjMPME7ces5xZ6t7oVFvQCHGLPDU+sI=","L1jEXl1lmmfXgTZyQfbDXYy0Y2HZeyiUfSlCHCcFlKk=","JejamuDkLoQOBLIwNw54K9tnU0hEMlujb8fl4WDGanQ=","L+xzTaIP4yAD6gTxJ/hEck84o2i6EMKVRCUr55YED38=","KIpnePOoOYio7Rcn8V6TtMsU9OOju7kd1tH6yv/9Xu8=","INzGx1/Yklm+f0BnULPbZ5olqM0nFdJFuRdTkKySLIQ=","F/QroQlC3yXLilQXgqGLb9Mc+WXREXjHsErEW03qXdM=","Ao7rhdEVqQQCDgxhSO7GYD6c7avGZKvudkqv1FWYa6U=","Cx187POnmyrT+imPbOp66V2AwCmezJGOn4ycPTjVnUA=","BEAznJdkzsecFu/bg0omJh244/Es4c9yLSPA4R/0zwc=","BspkfClyfBlioAIXfaLVBPSwel9+tXx5uI5reru9rVw=","LqEgqGT1xAk90ali6PATx7jvd4sE0rpb/DyrKGGbqeM=","K7c3VGxK7nwMwrqHwRV+KnfEeev7Xcdq27Oc+Gl2M/0=","DjDaZJBiXTPnnNUBdvVo+aLCjC9EmivVGiXRVoaAOpM=","DffKcnihNlC5GdhUl7LrsPcQNafCBDDUEx2QOrf1dSE=","J8xYn1v1hXlKus5Yn7inSi94TAmQuA/KppRAl/hw4tU=","IlXDajjIc13kXO30Uq+oQjMtMwQveOYMQ8dFVCGzJb8=","Ez2WAr0zeNafaBwnsFvf/Ji32GzKY9c6YMrtSFeE0Ic=","DhVI6UKunT4mhgaZuTcnyBeplIYWyT70rM2YGx3D14o=","DyDw5V2TaJ/gnsMS9q9HYnSC5L3goWAqjiyNboTopq4=","LlIyhIPLW3/y605FsS5RsmIyybwXtykpVMCp9r+lG7k=","ArIWLVM+BZpu2iq7dHEu2zp4YL7qld2KSr/JV2YIBPQ=","GeCSdxXRzG04lCmUf7Nzfa1zOXTGsuE+Wz1DJRlRbHQ=","DTqABFfXd4VjYwO4uU8X3P/LRgSIcqyfdO9/J+5XNwU=","LJdNGVJVehqsX3uuSZZhbaYZtz9EHE5QTcj+nPtVnjI=","B2a/7u3izPNwjhtP8wcUwiwdQ0zb6PVVFLq8LdXZe+8=","I9rI6lQIL8Ex4XOuVeRjDNTKfIcbKgpHnB505/GR5iw=","F9X7bCyzcBDj41irLVdTdocO0zGGuOrkmtO0fjQKjX8=","F13Kx22KgSYTm1g644hTKQJG5D54P6aQPsgAfxeMACM=","DE/Qj+3l0iGtt6v1SYmMkeW+foW/H9KmEb8YLMLnFlU=","J3k0uQnnLTo0dbsex2arejitWbEoMD/FAC8Cplvf5yk=","Dog0mZjf5wPxsYRST5w5TWAEzKz5y5UolujP2wsHi2g=","HxsgeLYLD84Hgk4qK8jK6O5nNRSwBwqLRXEMx4y7mUI=","LrFVlWbFNt28MW9kgtUfo0BVdldwD1uKhG6BKg7TNNE=","HE29wzXPZ2Q1Ugi0ydJD00VB1iPGad7Cw7oGa76vZ3M=","I3SmstpvjKuOXP6NgF3Tot/KHot+ul3IV0Ah/RJB47Q=","Gd00JTPMxgOplzjj+1pWm5TvcbPkn5D7h09hYXMwcvQ=","IX1m22x/s+/6UIgAWH0us8bQPYOFEy8vzOfzXycFzM8=","CBX7hZH+AQOM06OziyNvnvynfGGNO/xsKn+okpbH5k8=","K7lDtAwr1FamwXhTscqI6w/zb1l0sv+aX1CT6b9joW8=","EaUVP85llRPufLmXSubLpYHjtM0UVwxXCf7D2NP8guk=","G3K/0HY12FAbLv+HhaJJW650x2U8+Q5tXJ8URCaDbfQ=","FJAsBwDuyJeuF4uoyvhQ15Px2HUSvqDs6jnPax/uIz0=","CcE4xuCmFqSf+Q1DprBD87dFt4hlhW3EwaReL9hMs/Q=","BbWKPc5XsoGicdaYlQUtiHRYpxV4PoMX4CSmGjXsELw=","K+jSlSXAz91eazEl473jv1WOVfvoZ/AkRXqWdlR00Dc=","Bh1y948bqdxrTX93hCJdaoG9/Bta1sJDafnAVgUj2a0=","C/GK78rP+r30ES7drcphRXOLSAOzYUW7lRbbUBoGkuk=","LnPdEF+osuyTHYzfKexnnjqYAakwcafV6jBlklXwO8Y=","D4RA72Z8mugTN7pdjJJ6U0fecpaGCyEcrR7L+101mO8=","AE0wOy3qYnsnMb6D+TrDTn0U0XihOABVjKc5Y5XrEY8=","I0VBrXIECnDaKZajUmkjDJRpnu8xOk1IBQgAjLw9N8E=","DRI/HnLSa5K92P1z0UKGwxKtTCOstGsuCMFXEEQJ4XQ=","L7Ngd28N551wmO56pBI8Be5rBai+Rgp3TzoEjhOFRbs=","A2hcB5Q04WcnbFfTzHlwO339xBwVbqHot/mbaValUyY=","Jgrw4P/8yXcsFjGxeTRFZrR6qto2geuQNMb3XDcFwcc=","KGK0E3T4m2lSdLM7dz8lVJFuK/+f9nJUX8L0lWP2J2c=","AqmRL+FwMQInGJ6h5pHQNi8Ys4tACw7/GSyllRPrqNU=","COUTreaUoNisHz6/GpZEDTLHE9UFjhIk4HA0jCgfSm8=","FApKQx4u55QA7XRll42EdzITxigmT/gPIax6a2c9Cas=","KWr00BnLXffZWbKdVJw/BxICtOuotT3F7pee0UM3eSc=","AYMuKEp/TIFhSIK2k5/A8YVXO9ICPj5QV2VHC7gSs0k=","GoTVame/3T2WWr3NMpqnjU/pNDRJby0QOGH9GdZtcmA=","BAy4KEd3OSfSrv3AdIkDep0fdjHsp1yfsN2gy5294UM=","AQ3PCEzCnLfK7PJqpjO85O0rAZ8oh87nsaePidP6vi8=","B+3CKgkR6iFEJe9UK3dtsjsP5YF4ENQMcsqYqr2a+oM=","LupKsIrsd18hSEeeo2+7lpNtpYuki9HS06zUgXOqq+c=","HkDA6CV/5KYQBc3PrRSM9/R9G1z936oIJzhpVRgkXxk=","I6J4CVg70epR9DbeVEPhCPadRM31HcHwPiGUi0mAuHY=","LkZSsETb/kDmO2sjL81fPzmr+9IFHuaK3HVAgNSSUKk=","Eeer227Lr8Ln2M3v6ce5xQR160dds8LK9/fWf0hXdfI=","GZ1SNQzDDoxzgh+AIJbw5UehNVGye/a4mTlvY6xc+Oc=","D1ddbuZ8vs2YNFYk4DKjfIWafL7zCz/dyUnNCXhIQQE=","HEtvmiritBjmJlrLqclrBhhNBwKOX7eE80da53cv8Fc=","Lctc+Ilt458ijhV8DFWT9GJvubwiUgY4PbIDYKvwySU=","E0CrufThExhr3CbL30vMpQtTGhB/hjylRFdePPhw+OE=","I2jmkrcnh8uIcOqIjnFOAG9Z0rRGDPt0xIqMxzsdGls=","H6ua3ZuqSk9W8jFld1xvLZIqdjKpT5Y3S33IUnVvVLY=","DH97gjANPGzj+JV7oeSt1UxMAV4g2XZdIgVxwWq4aA8=","FdY+hr6s2Txgg2iOXZyPPGlHkp+fH5mrV4pMOpIu/wM=","C+hDrl+bB+UlcheK99ro7QXTaxLAYHhikpNV6nQCPZ4=","EzJ0nFI2lMtpNeCWOgfoGwWWfOHZUMC3MQWOySp6DJo=","JUOUCIEOB0wL3UWYuYFf7okruVylECns8Am/+lubloI=","BX6NGd2ZmpGNopsJQLODup/RXbCw9kmW3/Z/61X5p0I=","HgFON+mxF887SHDZmfK1XTU00Ka+mOnjV/pD8B5wop0=","Gk7STm4DrrzWvbEAUz3JZll6/hXIUbS4Y/boiQhMZHk=","JTQgAHCD8aqGOtR2CQXBA57UERyfBT8ncQRS+DzjapA=","InahRBlxcJr/5tKpkyAAHsRexyFVxXXd7srA4ydZqwY=","KJV90SGOp5n9NBHrGTJYU633rorhKB91MwL+fTHfp7A=","L9klcmq3lMiL11eWqj5/HmaS8pFM+AImfd8B43kCoAg=","HPilycdqhLFHyCONklPNVbR8DEPYKWbEY2ooZ0cF/Zo=","A3PLvDBuG6uecHc2hxXmIwtLLi5KHbnGdLjDWaQekQg=","BgKD0v5/I9/1E9kRCz3GJEi8SPUxzgweq1kgvyMpCkA=","DatGXW2RB0DzPvbMDq3HG/gRm9/Vo1J9yLv636pAJjw=","DLp7y8giSyqOSroXl3IwpobNZCHcDKU0bzRGtiQ5xMM=","HkNl2weQycT0RbBlPEZv8h25bDi0B2uovWi8tN6mkR0=","G7LbohmamrO8hu9fnef2xcoT1g6rQs7WjemPxkOACo0=","CtPBhwxtbvQO661SEjzRopE9nWLoC/usroEuCCAh+co=","AbCYyR57DLtcNFiAd8Dd+VMA3fYUk1YwwM46JickUwg=","Gf1cDqwU+udZi9TO6jseKZiwwWhJO21yrkG1duVbnD8=","DUdJ15zBY/FxEKQEpG/kJ8ZDTz/me357TM+mq5W9fhg=","Hrv+gRSkG7gJ4LMzmSQSMuuUCthyjIpRbUCtpEDb/c8=","JwTlthM9l2TW0/F9SdgzIj45N/gOufrqu/upuvS0wbg=","IWXhyAJzBbGuDjI1cWNeXVQNE9cQw/mjkLaRPxTQNeM=","LjSX5NNf2llsBq+mO8Og8uVdTuukrOtg5lCBrWOqi4o=","Ax2kNF7s1ttsD3sHx4Fdet0f4FRtc49NeatcV6qEHt8=","CJ7OVOR6pckI5D5fCHN8FDaWcIkAasqxyc0Z6sSiCHY=","L1PBXire0zxH9VoHBIPmzH84Ifv4qkBnfQVS7Z0Q2Ec=","FCqjT0suitDfeiGz45wAyLCqKFcJSAHqr9cr7+0Hf5M=","F66k2kx7zw11iLAU64tAl53Scl7aTmrOMxmCRnx/8r8=","DpcMGdGXSNjEZRBNjwIgA2P5pBeG8C8YJ3QrINwNFyc=","BLytnlU3lWQvWbr3FKa9tDL8RaCgt38aujqYI0dt+bk=","JCwL+82qdvcV29S6glxx/P7WccGxkB+khMh/gQMV0M4=","JdsTQ8JBBAcQI/tu002ZCQeDEeHv6FrwoRsZEU+p55A=","L/5NnEIKWenNx8Masr81GHyhR8uJijlC3rNnd4YDaoA=","EluwOvPizxi75vW1kOs7+NDRumO+aWSD6Y8oO8fNB6M=","CBa+QnRbfbtM7/5bjiTqYP2LcZ3rpQA3rHt1lIdFxrw=","ERFg+az27DYNG2pxIxOg28viPmRCAFVHHS7kxd7bNdQ=","E3eXjhsfaokl+o57eUG9+PtZq5VCNCQZKD2CA0Nck5E=","De/B2IghZu88zeU6TyNvuoPThGIZN87lfkIaUT0NM5c=","L4+lx4xwbjpdSgPyp6OVMEbX6Uy4in7zUOZ7W6Dw3r8=","GiqVfsCnI9phwhNLqwvxe+sA5tzYRpDCMNy55Y2pSCc=","HN+HEJlfXgNBK0p/aZUy+f0B8OoWeo38Hd834oBa3e8=","Jv0xRxgow2rjbCe3SAVLDAxP5SObMBaZ43Ze6+zBiUY=","B3XZlswsRFbzA6LB+QB2R+Eakh2f6j97kmFDuZ0voL4=","AW+5M3cIymOM39qRvQ2uprlyJO97IGJnKt3RvRi7iQA=","LDkvvn0/3kL8pPlHi7Q5MxJYJVNW8YSvb3bxGQVBF9c=","GHoqO/eaafo+UInvnx/Vb9tHxV7s53qiKKo94bSGvLE=","AnGoY6KAoyZB/6M1ELLt0njJhjA1lTLz5Qaydf1dIM4=","FVdFnJx0yUqgDlr2mh4xEvtpU3zol+wMcYlY2WUW8qs=","Ko4myo1kfZpjiFFuqdz/iQg9U55YFowqUMba4w8QnyE=","Ict1IZTPQ/O1GULrAEDrqd4rz7HCo/rpeSS3EPJoMs0=","LCba+Za+JHrNbdSsrWDTi1pHHmMiGI0CwTfny0hDd+w=","AkAXbuDnmC7r6Spo0+OjjCaCGswPXQWM+ME3vKLSbxs=","JjbglzyGXBvZdN142qqNCoTNr2vhrUfs8qDRjxFzGPI=","GehPTyWnmUlgQWYdxdl1toH24GdEzuibe+XZ/eF0SsA=","Dr+JBko68kfKHzb281cBiOJx4LMmxPsmZk6J4UVMoRA=","Jcfpe0db4A6LVZo4xFI2T0ycUx/suKxpj3/XPOIucew=","BETJnlkjU+WuyqMCrdkBwU2MVScKFgr+1EKe9VmK108=","E424iHgwVl8mk9Dg8C5OeeFEln8LpTsDUZq6dktcmUo=","JNQPRiEU/p7gKq/PdLT8ok4a42XcdcO1K7E8u7LyHt0=","IeZdbY7kN2C8pA5zC130xM86inMtsUj0spUbTGHWjow=","JI3XlmnsCdvwNQoV1sdcapvarO/KFNUTAJePE9GrbRw=","K4I4wVSPnL4p/TXPkee0jw69p+Y57faf6NWrp5JNU2I=","JDn9I5JX84GBx7489RPxv3I166lPa4lCqUy93s9vYvc=","IAlYI1KBphuixL4KoygqGMdLbSYvXefC4z0rs+iT3+w=","Dh7KXfiO5fYM+n4f5b77txn62CEfqbLQL8wjMZDBfxI=","JrU0J/mz6ix2nZxmD8YIgaFpwScy0AG3FY7ksbhCyiQ=","IPOz9Kyv6fivPgZmGzqPd4+igSUiudcKZ0As/42ysbQ=","IR5dKznWJSCnpifs6MrLrJ+XUG3vTsKGkoumwn1GOxc=","C7dD7jSAISnFVnMa7Z0wLc0IUxPOVy9iQtE4MuU2tLQ=","I8smYbSI7nHkx1P/I65L0l2KRAlPZrZTKXfiIUDrpcs=","A6NaoxI5Ec20U1uu0zWfX2pSBbnJPvMdNTI6R4B7i8k=","J4A4SKCu2WqT+pQ7ZjXkUCF+E39K3nSmLXkXMicUtpc=","DLN4OcLJp/95iEy+x19B6b5eR8dtYVOCMb2BYpltb2c=","HwAm0L8fjh3VQjzC/sH7XNqh7NxMPLIY287vd8ANL5M=","AqfXu5cLim7S7mb6u7qVa22jsQD1tfuSju9C+XCCc8k=","DP1/QhXkNMjaF+wyWLC8YFrRqy6QqklDUeTuQLvEkfo=","GAsRtyBiKhVoSdxvf25/VxZZvmloIjDF7ZrDOXAKfN4=","BOlqllvOPToKJKSkV8lRWCyHE0nOfu4aq/5XipTGUBE=","FZMfeCtF9/tlbyzb0fdwXDU6I/4dMKWkahUi7RYN860=","LiluV8l6Uwms0m/r9VrJY6VETBxfcDrYig17l7ndOLE=","JhV7zreOhGu7Ji+aHgbUJxveWlvOjwQZlS+X/9E+rKg=","IZTriYR9aw8Yl/Z18ZwMVrYbEySO/zyjbjT7nRx57kM=","I1C/NUd2VomRUa193pbqeFfhVQFEcAjatrPSfI/6J08=","GkhvCuWRys2vCcWKScTReVQFQ1NAgZ4APwRp0RC3dSs=","G1bc92+yPMSoNNRVpAZeEzVxQCt98wnVm8MQXUKowwE=","GnSdeWSvC3ICkT7yBMZT8rS/tlzqt7aFIzq1nOO7aSU=","GK5ZAHP5aWlq92L/pOjw67+X+Mx4fjfN3R8yG+O+rbs=","IcR7J12C3eZGDV52mplCEUSxxanaWSlK3py7MXED8kk=","BHPdvVLnN+UnNk6OtjIHl1w41f1swysnIQKwgs0VGPs=","CxL6yVttOogdiSZXyEJOZFrE5rAFFfkC1ZRXQwKybgI=","CK52FqJgz2ZX+Pc6woRYjSxfB/9CXYN6p83O9j4+IQM=","A52vaHYoC4Doc78qMv0oNKg8aXV7rdWKiI74Gekmzig=","Jeex10cKPHXxPwtWVGyOCfLY7+/wbvdm+ceDyoadEw0=","Ho/TY0w/92QYTQNDX5hYSxG1sVrrnHUmLaPx6iwqnno=","JB3MUaw3gIpBXdHjwoHwWv8ReJ3Ayv3XejVITgmT+aQ=","H/wxU8Vu+XVZMs6ivgVzdJva/hxPoHgaS4tAeM6ddUc=","F2MNYtmj5RDIik1Dw2D5K8D6ALZgMa3sKb2VQ/06F+4=","KYBADt0ddOPWnbVFjSzNX6vbI27BaoKkMBoKtZ6kpuk=","MDT7JDZhI+xtyvytNXJtv7FhlMA23NZI+mlDm/zQDNQ=","Gqfo9Bicqd/z2yq3ZIvgojkplc5GBB4EaA3KitcjLfA=","H6GV+DSmnmI3L2DrSX2hZ2RurhQVPYA7OdxdEfXXgAs=","DyPxx01fv2GVrVpq7l5WmTxUd+hFP1uToNe6/TMwNtM=","AWVW+sk0inNatQqgiclxUbPKrwogo0+52TcFBaFRVyk=","I9kreTZIEQ/Fru8GM/DHfKyw27yhh5uKb25d9EXl9ws=","LkwQ7F5l4vI5u8Q8EwMd8mhqtA/XmjBLBdYRuCPyO3M=","EkGLv9d7Y61eFoZK2cMv+/xaPdm3jsK3kyn+XgqNKVM=","HkqKrOFavB1bdqnoSEMdLAanj3K2vrsSk+bFjlGFaW0=","Dz6WEH3s29aHLCDqCaz5LN8Xo+4dEzFIgJLZYXbet1U=","ASw3gCB/OVzCHesKvZUWge6jJJjdumzol6j58MI1cGc=","E+qxtOZyuhscG7kBdpMB8eVlnQPqEMYd4kd/8KwiFCE=","INxmSrsgt0VsBmKc43oeyxonpOiyTjG0i5xGNaowMj4=","LGseLP6njiw2eF52qM+xsFfpRx8k9bORF1w97LAeAA8=","GIySYlX1t689qWNVcpwqhnCrTCxwQASBsqyQN0Dgxas=","L5kTII4J49bp5vumOE/QdquJ8mYpduPjDghwuzDrVPI=","KzOAPZCIlwbnFPcgtWKNJvtgtUWh8+nOSaaukSsCQIY=","JsyrwQ6wQyfLXMPd4quzbwlwhsl+c4wTPJ9XB350iwk=","GxauDXxUQIy3X9kx8kZ1HysMPcINeegqJTG3bCK01d8=","EdC7RhvYryhE9J8PhAyU75UYslETRHQtH1Q4/j1BWuQ=","IzAxhHtHa+rQEY09szjokTPsQg1nPlBK1kclnfZVVx4=","H4TpeJW+5DjrPJLcmxhGya0pwWQ4ewautu0YQe2MTco=","J39/m1QvDCu19FvtBU8JYkU2AQw8+UUtInMZMyf4AdY=","HvyckGnlBouqwT0uZkVkG30n6A/CMHcWFTXERoLuV6k=","DW7Ed3YeLvusTxSzvz1SV6meZMPyX+EE+vmIsg/l/0Q=","Dg59fFUBmZt9Fhc7WbfK4fIDvvIa6/ACUYgUOcz5MBM=","IXvvL08SxtzJHCBYojORy3feU8puRNzcbqPTb+oybqY=","BXgMiK3wFTG1D4F+P+RER9KbNaqKOJxx6M8SJqzvaLo=","GHM4h6ays7TJDY5JkBluI0ReR9fqWTnr+4mj7j1ntL0=","ILrOY6z8rgscnyvuJLjp2oW6WX03sJBXIMTxXbIxsHo=","Fm6llTdaZ4asUn7p7O1z7Wv1UIdqvK86yStCyAiwDY8=","MEJiqe/0BArPQ+Mi1vUmdq4vhT7C56gNsAxIjPkXx04=","ImuscFAWbl9tt4zQsS028wW26MmgVRFK13Ceb1ckW2s=","JrL1OcVzgp9qypG6qVRQW8XD604d8dY4WCcX+98jiMw=","BqD79M1S6Tul5MbEr2XbAu6WKX+K0gDy8c/yUudptVE=","LLnCQRLTU0Gs6siDYPtSiSTli27KwyG5+ynmqjNo/yM=","IOiKTWB1Jt0H/gijVSpEZpEp64f8wLE6rI/or9kwFSE=","FURkmivXPjunLzlt+R3WVAHdj69R3jJfuu251TatlPw=","GYAHdFeZVxLETafhdxMljj+Os1S/2A7Z6vPsuvaWAQU=","JdHSL/E+dwXTwIX5f8Tk9pFLgv+qXSCR7GTaxCN2Xvc=","L+yZDvVW7+EDWkZP9VgedAZ0Rc1Uq8r2uMA5n+DSTPw=","G9lWNQbZVE7z5IMOE1RQEsV5N5wtzBMwQWxK5JvE7GE=","AK/80XumADxW36hVcfwpc3siWoDUgOfdft7AHxTyMBA=","I2cNuu+WaIHwf5GaLYgxKMeyPPdnpHeysuB2K8DbwYs=","H5OlMpFzlMfiL9F6vupjicZv164t2fAvhg9tlpR/Dt0=","LeQun1N7fWGwITdxwOdPVVUSvge2pQk0c04sW+tAvjc=","JcVX9FuZeBzTfTuyKTFmKmf3izd4LIhbRWu5bVXohAQ=","IHTItwlwXJiIU4p/ijxK/2R3Mb0W+OJU+nTqnyvnZiw=","Jzg1WVYpgTiUnkQhcdak5LdO8gZXQNt8/DoLYP1XOss=","E9Nq0KTr64GWl3hkllnGXLfQxBzFGYcf23Gp6moMqlY=","CKLBi6QTgTSMGs+/lhdxaAa0YqFpG8LjQ7ebgIXjdrA=","BZCS/Dla7ShYB7v1V62aEEH1nAeYIrEIhFeIL+57YSw=","FhkkFRtaWtLYysEZUiqZGpBvFehTHccFZ/ayg3HMJOM=","HGjKj3qhdlkHVAXvY0G45popi5pNcvO7hUswnkuoehs=","J/XQO8ocggf3I5pLLPc65VmhWqN+e93fOqsF7sXOVZI=","Dsv/SEaWKpddNH6pqPxGX7RoYVV2IvLCVkp+Y5gzwWk=","J3xN4jY9i1tFbPxaf/jkb/LsjapZhV9a1kvAUh86xWc=","GxGGLFKs01G3pGR5P0+7V/7Jn4MrYyJvldF1yNL8CLI=","BqcZxYTHT/vdchjrVly0yL2GyS49+zxz4VJyAapRI04=","Iw5K3uy3mYd/fOmljINrmdUzWEoZXB13oxOr4cfRJr0=","ELEJuGSAnEdnoTPM5sutbIhigXO46lHozKhYMMp95SI=","DiEReXDc+9SxUmslNjbzd1ONO0+q61qLJL9iANFMxZE=","Jmc0mXhAE2L2sXk57rDmT/VWB+vbNccHHbRrs+e6R3g=","BQAPpf2lBeApoTv+MEwmew2GxywDm6v20/8C7iRr4C4=","Jk2eCUrtX0GmAkIiCjSihAiQh7JDapv86BdMyb6MLiA=","CAdvnEdD3mEw/2Is9AHt0skvJL/hFPPF5ySJF0YxXEc=","EyNwq927Cx3VfypSDCUza9fO3pS5W79cIVHW2I5kG2Q=","CP8RFreiJ7/f1EZaZ4kIgrYVyMTBfyjY0klY7fYC3cs=","K8sLDbi54+ArfpwclGD92cbNmFYjMuZI2KPgq5RZdSA=","EupozmiBvsrX+KaxF7A6uXb3q9WX+QOwvyMNINIalDo=","J0OcmKdmiAZ6CXsZtv3X141fiOJ04Nj+peprdAb92n8=","AvQNCtBfVlLjHvlECtcevIQZ45NJOTfwXwBJnQKpnjY=","L78EKEMn7k9oDwa9OQ4wnQ0TrMdLnFsUtjBZuMx6v/U=","G+aG1T4qitV6gosGUUJc/Gl4xwJ+2/JH9rZyPCHfhuc=","JoO0JehaUI+WhS8UtCIPz+n3rYsXv+/A40jEfKeLtX8=","FtrOmy6AEuMdscfr5nLYa75hoao+FpPg7d/A3gqd2VE=","J6Mh+MfTyQIuli9/7y48hItFOdu3WqE58wQw/lRbzts=","BszXIQ3uHWsOIreeEtGQgtgHi3iNcQB7leendO2GplE=","CkHdQiIWU3Ur7zUPbXSpF7bLsf12o6EhZvTQvpeOQCY=","IgoCiB5NR6yU2VDN+DhidNF4LifL0NhFl43uyRKY8WU=","DiFVpUX+Xzy7Y5dgZYnqwZzZJjkznGsBcpikrTQItLk=","Dw8ZxikeUVRqJnxgzHdOX7nQiLrFMHgtiR7Br0uEcHM=","DpJbzRxt20o6HGfsje771AxTwNM+eu7xtGeVrtWUPJ0=","KtAAsXSKu4Es1uVBEoa5/z7wpb09JZo25F7wW561vus=","CmWqIy0y7W6N5j0c3/68Lz+mFkZcJ6r5fozT3P9khlI=","AmPYRwq0scYddNjoliQvTyYdyxZ6OgaSOJPXyyyT1qE=","KQHZRq3clLBA/VgATZpfjNGSZUDHqGEs7BxYy2DCs6U=","GInPqCCfSVLfkCLbncWDtXF6BpbaQc7mSTfQzWMh5pM=","I2Bk1xy2xkyEdHrCX8+NiBUC5fA7/4dWG4WhFrHzmso=","L/ehdP/Owphi4E9dvcc+vzZhVwAzV2KQwMH2zYztJ64=","GeckoddCyrEDRV8AQO33RaJpanEITJPjInFUUN1Nb1s=","A+7TiStvDmxdoQWcXzeTmFg1qig1AKgSmQSpTIfxYb8=","COK4Jzv6MMGshQMG2R5Gip6NBQkq7ky8gMaHJIRjujA=","B63Mp22DN3KIOaG2rDs+1Cr7h9cq+Y9S9Bby7FiyjOw=","Fx7zeJa64rECCgpYOb1ReEzhG7QjfVSMFxFp0y+hm0A=","IP/fy4b00AUGTtvClpGMMy0y++/xcp3lBWomq7w6Nfo=","COzXpvFzXu2GuqCU5gj0iPONuzmPz+1LmUODoMqORkc=","HD9dhuWSH96YkBifHYxhh1QohgDmkovBgqxNXkyfDMs=","KcYRhO2dRg8zdVihr2Oap+PAl15AFO2OvK1KJdUeq/M=","De/UWyiVhygiituy29rval6bGmSQKnNPQCuM77irO1Y=","CnTqItigkzYGBhAXmsHYL/+pSS33be7U6mDgEzsIEag=","A6N78S2vFADSl6xKwTuiTBfcJi2xbIUj3u5ODM3ppoA=","Ef4XkNWrv1k1/yIxjk9//mmWatovkTa1T4MOrLCmU2g=","AYFlhC9AY3XyNGaGkVr7FL8f4FZMiFjuO94Kuj3l9o8=","Jh2yXnz/Wp+3LydrH5JgtmcwD7fTYbUP1cDotplbBfk=","KjrDMUsrZueW++Nt93jF5GlyMgzEPsgHBIgmtnBLp8Q=","I8qkuA7PqZ6dP+orvB2782nRv8iTfQPQdAYcMP2M12s=","J9smAIXiJImN8UXyP2NfIGbY5OEk5YHoxiYZKbHf4Qc=","J09sX9NKeE1rkV7wXUJO5sC6u/Np55qxOLgWe1YY7H8=","LDop4TqE0moJEckona8apM9YQKraBwHVfiPfx5babaE=","HqIQ8gAaM00+gB9OUycNQtp6rzF6VTtCgqp46qIoLm0=","JU2+tSiEtpnBun+g1ugNYQkDsYo+UJw2NRzMOwJJRuM=","BZ54HWWJbr4OS6JtwvKZB/R7ze2kososcT2FBeox/V0=","C1sc7GPULV5hXcJpuIWiTO8wPseMly3RfNuz6RXMT/s=","KnwBXpw7LFfKi30m05obzIXW/6y32fvWbSqPHWTtDJI=","Kbc2uRHXGnnPY9im94bxG9Wr7iQWHcVnp8hR6uHkO1E=","KFdFqQp/49Ca9agIcEvGnG8XAeVzkS31zB4mXVlsQUE=","LZAbgZXDyWyMNuuZ/sATTsK4MEroEL0w2lVOMICCZxU=","GQXTUYNV6rp4WbWR7XuMnCU5gPBFDb31TXp3groFg5I=","I+gTAm/AuABk0ZtcVCiUL99+/qgL+o7ECVJyv9t7TJ8=","I8ChmiUsh+axwcIbGnmAAgDD+/8+MwDn5VaAcd6e+4E=","EcSuYHuuSSQTv2LNqiwoaO0f7G3AYxsGfKYPqxJbnio=","LNBV67fuRoY2XepFDwRv9iQF+uGxr8n7AXB8+B2g47k=","BTyf7y4CH6miD62iL96hUFtYoxWbu0czfb95GyFbFFI=","CjW9dOh8urqr6JrRMZ0snoY7TGMcIZOMmlOVv5eHKp8=","HBFQVlOc4gzVoE0aXEPisA++g7JZAb429d3EZm/Dg/4=","JClUBH5Xcv073tWQ7IvrTFQvLiZMjD4oTNxHNQXFGpA=","Diq9MVtHwNyThJwM3yZ+gRy9vbIApufCtn7ffLAXQhQ=","KCs3AgwIkNdRw/12lQ2AaGaOHf6uYh3VUtLeiH2i6nU=","KJM4UiZrUtnqa1u5I9nZTy5aW+XHeOdeB5QsI0tkO9k=","CZq2dlUFuhGY7xQOd7eVTU++eaBWznK6zjnASMANo88=","KvIR2OCsLY/af4SbjyKaIlxhhrVXYsensq4tHdhcV8s=","DNBw8jQBSigJq5DHHB2mHpipYyL+3Zm2qq4coQTz+s8=","Jnk+KryNPDDGBib7qhWPJjWH1r0Vgz1EixFiZLkwJWo=","IlvjbtDuheH4Ra2oTldIpWaZFSET/2G1BWtti95gwZ0=","AhdPSe2wLVFU0r7KLckrnMWVOD2h/ejwnkte4+paBl4=","D2SJHCyLAg5Gw1lMt1jwvdzb0JvQMIgW+0FzSoaYcsM=","GSqEyi+Z02mR4tKx3v85idHBVsI54Q6fVhQOGFRXYGc=","Kd/Ne2PwWr8nU6jDQda3pgxiQ7BMmhuLMyC7oEpNR4c=","HuJ61rm1qGdzOvxhorPnalK6PkvV5let6R/AOIGduls=","CrR3PxUMP4rTvJU49DzsOVp+NzGulz/v62I6CSF+ZMc=","E8NSoC9ZUYYgLLC5n6WMVUKrZ/m206Cv0QPe7/bYD0E=","KpfPLBDEv7/SmfZ8UqFp+SwFt9rFakHE3U/ofIJGzhQ=","AL7LtHBCvX+Mn2u0IhYtGu0ImihIL3/RarBqEyhf5wI=","AI5E2iHXOGkbiBdX7zftKcW9n3pEUPz1MpCpLMLKIXY=","KyBai21LcGPZMfO7XTRkBThD/n++S4PBeIP4ZSeIKhg=","LZ4yp8kFVv4QjSVawB513zOPzWOyv4TBkoDUJymIY/w=","KaMiqEwlvS3fbi5CACKNlavWNJoCJmrB27pSBzjOypc=","BnjJv8by3wEvT+VeM7torBTO0d8NAhUnkgidBG2CjEM=","D6/zpedCV5T+IKfg62FbixdgOUt/IwQoajrkAJEk2yM=","H49bYRr5/rnOqGwIQFgSBVPkBBA67iE/WkHR0CVBwNM=","Fgh12EeWAvlvQKzC0ELuUsFYi2op3kKEllptxskw6gc=","Fth6UYOjFqHXCvyVHv4s1mfHcyj8/aRYy/X+MEX0bZ4="],"M":[["EkZm+AVh7VkW8vBwsb0kjG1T9E0nPZVqDIe5F2kqTRg=","EZJPAv0ZsJJVqqHPRuoFGOPXv+70dCFglJEBHbC9CwI=","JH+n8CIwShmU/1BUVsIgHvm3FzaUmNP/zkRmAe2d+EU=","A/17Ge8shh8i93/4EPVOJ3vJTrdsAtedmGvj3N8FHD8=","GL1BI5w+cVeaZ3RD7P+9VVqB7u6mk1Kmi2fIVjwMKgY=","LXjDpdKN6f81vwoldjUZblcwyn9ASTJ3B4zXXai069w="],["ClFKXCJ/TOyV36Ap6N0STDSJWqRrsnwJEfN4DVAVVAo=","GS4W0X2VayV7haZS7v3y7glYnqxb6AkVd1cj0ssdoG0=","KYzgweMRO7k1xwWOd3K1M7GqnbDAkmvciRflYFyjrBA=","CUy06DYhr9Jx5BvHFyfwFY69YSI5rJ1pixf+S+Bbf8g=","A9iAOVvpPCfWSa9f0ULnazORjLiEHVooFzvVz30yh5E=","KO6ua1hmrWjkQ7uvkWgNt9fiswN+OP72G0LLzP/OyoE="],["J4u0mntORK6kbrD4gstpKAGm5g/dW1wjxjzWXMzk/go=","Bj7ewb7YMfUGr422SNb96hRTRYh+i9z/EJA1odm2dNc=","G67xy1UJtSakIGH7U2V/mbMjJQDoVRksvoyUDgaMR18=","EyRWSse9+eIhZOmFjX+o42ixZerqPa9Otn7lnA3y5dQ=","AFdhuMauyxqMpOpN/CyDdgZKSoAEzu2iEKVSQFYt3BM=","EMnigxWdWMtMsuNf3oOjuh/cKAAu2ZY9KpnxhheKFI0="],["DDmen2eqQHB6ID/u+wuVi72tzsXKNJAdJT0CaiQZ9qI=","CD8N8/GgNR0DMOw/9gLKjMNTt/bnYscQcYTNe0I0SfY=","Gmdk1ZQ/xKcgtMChn9uMcRmEMHKHpYubX59dWCEssmM=","ARpjom/qv4f6Zr3mbMJakiyWOC12xqf/SPFTe+rtaDo=","CMp7ZGV8NUjzK+9bY60kKIpBwLJRCZrSf5Q0MH4+ZNQ=","AZmCcEcek2GVVEawzbi+qRXsBnXxzWSN3LBDA1B6RIk="],["HWs9X26jacJvgl0jYpM+qjHqNewKd8H72eAcoVI+RDI=","EZ7xiLs90NMjBpdsGZQehmS+aH56aWkton2iFabwbUA=","LZ4KtcBok9/f0DSBOBuoa25ikt9WCdcfLGSy2aefgJ4=","JfFmMb93Bg9+o0CHwCW/E1eEMZ7wjNouMUGe4KUp5lg=","FEx6EdpafF2rrj8z+9A8rYbRi8WUx5pJfsuYlO21VPE=","D5cRYmJ3I/P+rayyiwwQTLj3TeUIdS+o18DbKvE96O4="],["JL5RAJVDYgbdCr0LDLuVyIOrMEqlJZixppMG7JgaaI0=","IRYQ4q1KN3Qm+t9waLDBpsKZoWTBwaYD6u2USHDQubk=","FaZ9mBBBsfbwnz+evv2GTnedOvCBV3hqwHdQXlDsefw=","BJMn+nnSjBKiyCQGlH938Gd1sCh0aLMTaHdwHb58lZg=","IwlA3MUjJlj/nClpej/UFtFw6MmY8aqF3qDELXn5Uao=","GxIcBJzRFZ4okAfgydqZlcxLq0wm+4iOw5cqii5laWQ="]]} \ No newline at end of file diff --git a/bee_wallet/src/services/zkp/utils/error.rs b/bee_wallet/src/services/zkp/utils/error.rs new file mode 100644 index 0000000..582b896 --- /dev/null +++ b/bee_wallet/src/services/zkp/utils/error.rs @@ -0,0 +1,53 @@ +// copied from https://github.com/tvmlabs/tvm-sdk/blob/main/tvm_vm/src/executor/zk_stuff/error.rs +use thiserror::Error; + +pub type ZkCryptoResult = Result; + +/// Collection of errors to be used in fastcrypto. +#[derive(Clone, Debug, Error, Eq, PartialEq)] +#[allow(dead_code)] +pub enum ZkCryptoError { + /// Invalid value was given to the function + #[error("Invalid value was given to the function")] + InvalidInput, + + /// Input is to short. + #[error("Expected input of length at least {0}")] + InputTooShort(usize), + + /// Input is to long. + #[error("Expected input of length at most {0}")] + InputTooLong(usize), + + /// Input length is wrong. + #[error("Expected input of length exactly {0}")] + InputLengthWrong(usize), + + /// Invalid signature was given to the function + #[error("Invalid signature was given to the function")] + InvalidSignature, + + /// Invalid proof was given to the function + #[error("Invalid proof was given to the function")] + InvalidProof, + + /// Not enough inputs were given to the function, retry with more + #[error("Not enough inputs were given to the function, retry with more")] + NotEnoughInputs, + + /// Invalid message was given to the function + #[error("Invalid message was given to the function")] + InvalidMessage, + + /// Message should be ignored + #[error("Message should be ignored")] + IgnoredMessage, + + /// General cryptographic error. + #[error("General cryptographic error: {0}")] + GeneralError(String), + + /// General opaque cryptographic error. + #[error("General cryptographic error")] + GeneralOpaqueError, +} diff --git a/bee_wallet/src/services/zkp/utils/mod.rs b/bee_wallet/src/services/zkp/utils/mod.rs new file mode 100644 index 0000000..bb87a64 --- /dev/null +++ b/bee_wallet/src/services/zkp/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod error; +pub mod provider; +pub mod zk_login; diff --git a/bee_wallet/src/services/zkp/utils/provider.rs b/bee_wallet/src/services/zkp/utils/provider.rs new file mode 100644 index 0000000..c13b70b --- /dev/null +++ b/bee_wallet/src/services/zkp/utils/provider.rs @@ -0,0 +1,131 @@ +// copied from https://github.com/tvmlabs/tvm-sdk/blob/main/tvm_vm/src/executor/zk_stuff/zk_login.rs +use std::fmt; +use std::fmt::Formatter; +use std::str::FromStr; +use std::sync::LazyLock; + +use regex::Regex; + +use super::error::ZkCryptoError; + +static RE_AWS_TENANT: LazyLock = LazyLock::new(|| { + Regex::new(r"AwsTenant-region:(?P[^.]+)-tenant_id:(?P[^/]+)") + .expect("AWS tenant regex is invalid — this is a compile-time constant") +}); + +static RE_AWS_ISS: LazyLock = LazyLock::new(|| { + Regex::new(r"https://cognito-idp\.(?P[^.]+)\.amazonaws\.com/(?P[^/]+)") + .expect("AWS ISS regex is invalid — this is a compile-time constant") +}); + +/// Supported OIDC providers. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum OIDCProvider { + /// See https://accounts.google.com/.well-known/openid-configuration + Google, + /// See https://id.twitch.tv/oauth2/.well-known/openid-configuration + Twitch, + /// See https://www.facebook.com/.well-known/openid-configuration/ + Facebook, + /// See https://kauth.kakao.com/.well-known/openid-configuration + Kakao, + /// See https://appleid.apple.com/.well-known/openid-configuration + Apple, + /// See https://slack.com/.well-known/openid-configuration + Slack, + /// See https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration + Microsoft, + /// Example: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_LPSLCkC3A/.well-known/jwks.json + AwsTenant((String, String)), + /// https://accounts.karrier.one/.well-known/openid-configuration + KarrierOne, + /// https://accounts.credenza3.com/openid-configuration + Credenza3, + Gosh, +} + +impl FromStr for OIDCProvider { + type Err = ZkCryptoError; + + fn from_str(s: &str) -> Result { + match s { + "google" => Ok(Self::Google), + "gosh" => Ok(Self::Gosh), + "twitch" => Ok(Self::Twitch), + "facebook" => Ok(Self::Facebook), + "kakao" => Ok(Self::Kakao), + "apple" => Ok(Self::Apple), + "slack" => Ok(Self::Slack), + "microsoft" => Ok(Self::Microsoft), + "karrierOne" => Ok(Self::KarrierOne), + "credenza3" => Ok(Self::Credenza3), + _ => { + if let Some(captures) = RE_AWS_TENANT.captures(s) { + let region = captures.name("region").unwrap().as_str(); + let tenant_id = captures.name("tenant_id").unwrap().as_str(); + Ok(Self::AwsTenant((region.to_owned(), tenant_id.to_owned()))) + } else { + Err(ZkCryptoError::InvalidInput) + } + } + } + } +} + +impl fmt::Display for OIDCProvider { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Google => write!(f, "google"), + Self::Gosh => write!(f, "gosh"), + Self::Twitch => write!(f, "twitch"), + Self::Facebook => write!(f, "facebook"), + Self::Kakao => write!(f, "kakao"), + Self::Apple => write!(f, "apple"), + Self::Slack => write!(f, "slack"), + Self::Microsoft => write!(f, "microsoft"), + Self::KarrierOne => write!(f, "karrierOne"), + Self::Credenza3 => write!(f, "credenza3"), + Self::AwsTenant((region, tenant_id)) => { + write!(f, "AwsTenant-region:{}-tenant_id:{}", region, tenant_id) + } + } + } +} + +impl OIDCProvider { + /// Returns the OIDCProvider for the given iss string. + pub fn from_iss(iss: &str) -> Result { + match iss { + "https://accounts.google.com" => Ok(Self::Google), + "https://oauth.gosh.sh" => Ok(Self::Gosh), + "https://id.twitch.tv/oauth2" => Ok(Self::Twitch), + "https://www.facebook.com" => Ok(Self::Facebook), + "https://kauth.kakao.com" => Ok(Self::Kakao), + "https://appleid.apple.com" => Ok(Self::Apple), + "https://slack.com" => Ok(Self::Slack), + "https://accounts.karrier.one/" => Ok(Self::KarrierOne), + "https://accounts.credenza3.com" => Ok(Self::Credenza3), + iss if match_micrsoft_iss_substring(iss) => Ok(Self::Microsoft), + _ => match parse_aws_iss_substring(iss) { + Ok((region, tenant_id)) => Ok(Self::AwsTenant((region, tenant_id))), + Err(_) => Err(ZkCryptoError::InvalidInput), + }, + } + } +} + +/// Check if the iss string is formatted as Microsoft's pattern. +fn match_micrsoft_iss_substring(iss: &str) -> bool { + iss.starts_with("https://login.microsoftonline.com/") && iss.ends_with("/v2.0") +} + +/// Parse the region and tenant_id from the iss string for AWS. +fn parse_aws_iss_substring(url: &str) -> Result<(String, String), ZkCryptoError> { + if let Some(captures) = RE_AWS_ISS.captures(url) { + let region = captures.name("region").unwrap().as_str().to_owned(); + let tenant_id = captures.name("tenant_id").unwrap().as_str().to_owned(); + Ok((region, tenant_id)) + } else { + Err(ZkCryptoError::InvalidInput) + } +} diff --git a/bee_wallet/src/services/zkp/utils/zk_login.rs b/bee_wallet/src/services/zkp/utils/zk_login.rs new file mode 100644 index 0000000..e6f1e2f --- /dev/null +++ b/bee_wallet/src/services/zkp/utils/zk_login.rs @@ -0,0 +1,125 @@ +// copid from https://github.com/tvmlabs/tvm-sdk/blob/main/tvm_vm/src/executor/zk_stuff/zk_login.rs +use itertools::Itertools; +use serde_json::Value; + +use super::error::ZkCryptoError; +use super::error::ZkCryptoResult; + +pub const BASE64_URL_CHARSET: &str = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +fn bitarray_to_bytearray(bits: &[u8]) -> ZkCryptoResult> { + if bits.len() % 8 != 0 { + return Err(ZkCryptoError::InvalidInput); + } + Ok(bits + .chunks(8) + .map(|chunk| { + let mut byte = 0u8; + for (i, bit) in chunk.iter().rev().enumerate() { + byte |= bit << i; + } + byte + }) + .collect()) +} +fn base64_to_bitarray(input: &str) -> ZkCryptoResult> { + input + .chars() + .map(|c| { + BASE64_URL_CHARSET + .find(c) + .map(|index| index as u8) + .map(|index| (0..6).rev().map(move |i| index >> i & 1)) + .ok_or(ZkCryptoError::InvalidInput) + }) + .flatten_ok() + .collect() +} + +pub fn decode_base64_url(s: &str, i: &u8) -> Result { + if s.len() < 2 { + return Err(ZkCryptoError::GeneralError("Base64 string smaller than 2".to_string())); + } + let mut bits = base64_to_bitarray(s)?; + match i { + 0 => {} + 1 => { + bits.drain(..2); + } + 2 => { + bits.drain(..4); + } + _ => { + return Err(ZkCryptoError::GeneralError("Invalid first_char_offset".to_string())); + } + } + + // Use usize arithmetic to avoid u8 truncation (s.len() may exceed 255) + // and the resulting underflow when (s.len() as u8) wraps to 0. + let last_char_offset = ((*i as usize + s.len() - 1) % 4) as u8; + match last_char_offset { + 3 => {} + 2 => { + bits.drain(bits.len() - 2..); + } + 1 => { + bits.drain(bits.len() - 4..); + } + _ => { + return Err(ZkCryptoError::GeneralError("Invalid last_char_offset".to_string())); + } + } + + if bits.len() % 8 != 0 { + return Err(ZkCryptoError::GeneralError("Invalid bits length".to_string())); + } + + Ok(std::str::from_utf8(&bitarray_to_bytearray(&bits)?) + .map_err(|_| ZkCryptoError::GeneralError("Invalid UTF8 string".to_string()))? + .to_owned()) +} + +pub fn verify_extended_claim( + extended_claim: &str, + expected_key: &str, +) -> Result { + // Last character of each extracted_claim must be '}' or ',' + if !(extended_claim.ends_with('}') || extended_claim.ends_with(',')) { + return Err(ZkCryptoError::GeneralError("Invalid extended claim".to_string())); + } + + let json_str = format!("{{{}}}", &extended_claim[..extended_claim.len() - 1]); + let json: Value = serde_json::from_str(&json_str).map_err(|_| ZkCryptoError::InvalidInput)?; + let value = json + .as_object() + .ok_or(ZkCryptoError::InvalidInput)? + .get(expected_key) + .ok_or(ZkCryptoError::InvalidInput)? + .as_str() + .ok_or(ZkCryptoError::InvalidInput)?; + Ok(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_base64_url_does_not_panic_on_long_input() { + // A 256-char base64 string used to trigger `s.len() as u8` truncation + // to 0, then `0 + 0 - 1` panicked with subtract overflow. + let s: String = "A".repeat(256); + let result = decode_base64_url(&s, &0); + // It either decodes successfully or returns a clean error — never panics. + assert!(result.is_ok() || result.is_err()); + + // Also exercise other lengths around the u8 boundary. + for len in [255usize, 256, 257, 1024] { + for i in [0u8, 1, 2] { + let s: String = "A".repeat(len); + let _ = decode_base64_url(&s, &i); + } + } + } +} diff --git a/bee_wallet/src/types.rs b/bee_wallet/src/types.rs new file mode 100644 index 0000000..4123e52 --- /dev/null +++ b/bee_wallet/src/types.rs @@ -0,0 +1,342 @@ +use ackinacki_kit::contracts::mvsystem::multifactor::AccountData as MultifactorData; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use num_bigint::BigInt; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde_with::serde_as; +use serde_with::DisplayFromStr; + +pub fn deserialize_u64_from_string_or_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum NumberOrString { + Number(u64), + String(String), + } + + match NumberOrString::deserialize(deserializer)? { + NumberOrString::Number(v) => Ok(v), + NumberOrString::String(s) => s.parse().map_err(serde::de::Error::custom), + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ParamsOfGetMultifactorAddress { + pub pubkey: String, +} + +#[derive(Serialize, Deserialize)] +pub struct UpdateMultifactorZkIdReq { + pub address: String, + pub zkid: String, + pub password: String, + pub proof: String, + pub epk: String, + pub esk: String, + pub jwk_modulus: String, + pub jwk_modulus_expire_at: i64, + pub index_mod_4: i64, + pub iss_base_64: String, + pub header_base_64: String, + pub epk_expire_at: i64, + pub pubkey: String, + pub secretkey: String, + pub kid: String, + pub sub: String, +} + +impl std::fmt::Debug for UpdateMultifactorZkIdReq { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UpdateMultifactorZkIdReq") + .field("address", &self.address) + .field("zkid", &self.zkid) + .field("password", &"[REDACTED]") + .field("proof", &"[REDACTED]") + .field("epk", &self.epk) + .field("esk", &"[REDACTED]") + .field("jwk_modulus", &self.jwk_modulus) + .field("jwk_modulus_expire_at", &self.jwk_modulus_expire_at) + .field("index_mod_4", &self.index_mod_4) + .field("iss_base_64", &self.iss_base_64) + .field("header_base_64", &self.header_base_64) + .field("epk_expire_at", &self.epk_expire_at) + .field("pubkey", &self.pubkey) + .field("secretkey", &"[REDACTED]") + .field("kid", &self.kid) + .field("sub", &self.sub) + .finish() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ParamsGetMirrorAddress { + pub pubkey: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResultGetMirrorAddress { + pub address: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ParamsOfGetMultifactorInfo { + pub address: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResultOfGetMultifactorInfo { + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetBalanceByTokenRootReq { + pub token_root: String, + /// Bare 64-hex dApp ID of `token_root` (TIP-3 only; ignored for ECC "1"/"2" + /// and on gql-server < 1.0.0). + pub token_dapp: String, + pub multifactor_address: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResultOfGetBalanceByTokenRoot { + pub own_balance: ResultOfGetBalance, + pub locked_balance: Option, +} + +#[serde_as] +#[derive(Debug, Serialize, Deserialize)] +pub struct ResultOfGetBalance { + #[serde_as(as = "DisplayFromStr")] + pub value: BigInt, + #[serde_as(as = "DisplayFromStr")] + pub decimals: BigInt, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WithdrawPopitgameRewardsReq { + pub multifactor_address: String, + pub signer_keys: KeyPair, + #[serde(default)] + pub bounce: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SendTokensReq { + pub multifactor_address: String, + pub destination_address: String, + pub token_root: String, + /// Bare 64-hex dApp ID of `token_root` (TIP-3 only; ignored for ECC and on + /// gql-server < 1.0.0). + pub token_dapp: String, + #[serde(deserialize_with = "deserialize_u64_from_string_or_number")] + pub amount_raw: u64, + pub signer_keys: KeyPair, + #[serde(default)] + pub bounce: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SendTokensDirectReq { + pub multifactor_address: String, + pub destination_address: String, + pub token_root: String, + #[serde(deserialize_with = "deserialize_u64_from_string_or_number")] + pub amount_raw: u64, + pub flags: u8, + pub signer_keys: KeyPair, + #[serde(default)] + pub bounce: Option, + /// Native value (gas) attached to the message, in nanotons. + /// Default: 1_000_000_000 (1 VMShell). + #[serde(default)] + pub value: Option, + /// ABI-encoded message body (base64). When set, the message carries + /// a function call to the destination contract. + #[serde(default)] + pub payload: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetEPKExpireReq { + pub epk: String, + pub multifactor_address: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetEPKExpireRes { + pub epk_expire_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ResultOfBlockchainWrite { + pub message_ids: Vec, + pub pending_stage: Option, + pub pending_reason: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BuyShellsReq { + /// Multifactor wallet address of the sender. + pub multifactor_address: String, + /// Amount in whole USDC (no decimals). SDK multiplies by + /// USDC_DECIMALS_FACTOR internally. + pub usdc_amount: u64, + /// Signer keys (EPK factor). + pub signer_keys: KeyPair, + #[serde(default)] + pub bounce: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SellShellsReq { + /// Multifactor wallet address of the sender. + pub multifactor_address: String, + /// Denomination: 1, 10, 100, or 1000 (whole USDC). + /// SDK computes Shell amount = D * SHELL_PER_USDC. + pub denom: u16, + /// Signer keys (EPK factor). + pub signer_keys: KeyPair, + #[serde(default)] + pub bounce: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SellShellsResult { + /// Hash of the sent message. + pub message_hash: Option, + /// Assigned order_id. + pub order_id: u64, + /// Order denomination. + pub denom: u16, + /// SellOrderLot contract address. + pub sell_order_address: String, + /// Order already sold (bought by a buyer). + pub sold: bool, + /// Position in queue (0 if sold). + /// If not sold: how many orders ahead (including this one). + pub position_in_queue: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ClaimUsdcReq { + /// Order denomination (1, 10, 100, 1000). + pub denom: u16, + /// Order ID (from SellShellsResult.order_id). + pub order_id: u64, + /// Signer keys. + pub signer_keys: KeyPair, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ClaimUsdcResult { + /// Hash of the sent message. + pub message_hash: Option, + /// Payout in micro-USDC (D * USDC_DECIMALS_FACTOR). + pub usdc_payout: u64, +} + +fn default_sell_orders_page_size() -> u32 { + 20 +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetMySellOrdersReq { + /// Multifactor wallet address (seller). + pub multifactor_address: String, + /// Number of records per page (default 20). + #[serde(default = "default_sell_orders_page_size")] + pub page_size: u32, + /// Cursor for the next page (`None` = first page). + pub cursor: Option, +} + +/// Paginated result of `get_my_sell_orders`. +#[derive(Debug, Serialize, Deserialize)] +pub struct GetMySellOrdersResult { + pub orders: Vec, + /// Opaque cursor for the next page (`None` = last page). + pub next_cursor: Option, + pub has_next_page: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SellOrderInfo { + pub denom: u16, + pub order_id: u64, + pub sell_order_address: String, + pub claimed: bool, + pub sold: bool, + /// Position in queue (1..N for unsold, 0 for sold). + pub position_in_queue: u64, +} + +impl From + for SellOrderInfo +{ + fn from( + info: ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::SellerOrderInfo, + ) -> Self { + Self { + denom: info.denom, + order_id: info.order_id, + sell_order_address: info.sell_order_address, + claimed: info.claimed, + sold: info.sold, + position_in_queue: info.position_in_queue, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NacklRedeemRateResult { + /// USDC available for redemption (micro-USDC). + pub redeemable_usdc: u128, + /// Current NACKL supply in circulation (nanoNACKL): supply - burned. + pub current_nackl_supply: u128, + /// Estimated USDC payout for 1 whole NACKL, scaled by 1e18. + /// To get micro-USDC per 1 whole NACKL: `usdc_per_nackl / 1e9`. + /// To get micro-USDC for N nanoNACKL: `N * usdc_per_nackl / 1e18`. + /// 0 if currentSupply == 0 or redeemable == 0. + pub usdc_per_nackl: u128, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RedeemNacklReq { + pub multifactor_address: String, + pub nackl_amount: u64, + pub signer_keys: KeyPair, + #[serde(default)] + pub bounce: Option, +} + +/// Migrate TIP-3 USDC to ECC[3] USDC via Exchange contract. +#[derive(Debug, Serialize, Deserialize)] +pub struct MigrateTip3UsdcReq { + pub multifactor_address: String, + /// TIP-3 USDC token root address. + pub token_root: String, + /// Bare 64-hex dApp ID of `token_root` (ignored on gql-server < 1.0.0). + pub token_dapp: String, + /// Amount in raw micro-USDC (decimals=6). + #[serde(deserialize_with = "deserialize_u64_from_string_or_number")] + pub amount_raw: u64, + pub signer_keys: KeyPair, + #[serde(default)] + pub bounce: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ParamsOfUpdateContract { + pub multifactor_address: String, + pub keys: KeyPair, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeleteZkpFactorByItselfReq { + pub multifactor_address: String, + pub signer_keys: KeyPair, +} diff --git a/bee_wallet/tests/dex_flows/common/context.rs b/bee_wallet/tests/dex_flows/common/context.rs new file mode 100644 index 0000000..ada4bf6 --- /dev/null +++ b/bee_wallet/tests/dex_flows/common/context.rs @@ -0,0 +1,34 @@ +//! Network constants, TVM client context, Dex client construction. + +use std::sync::Arc; + +use ackinacki_kit::tvm_client::ClientConfig; +use ackinacki_kit::tvm_client::ClientContext; +use dodex_sdk::Dex; + +pub const ENDPOINT: &str = "shellnet.ackinacki.org"; +pub const TOKEN_TYPE_NACKL: u32 = dodex_sdk::proof::TokenType::Nackl as u32; +pub const VAULT_DEPOSIT: u64 = 100_000_000_000; // 100 NACKL (Nominal::N100) +pub const ECC_SHELL_DEPOSIT: u64 = 100_000_000_000; // 100 ECC shell (Nominal::N100) +pub const PMP_DEPOSIT: u64 = 1_000_000_000_000; // 1000 NACKL (Nominal::N1000) — enough for initial stakes + regular stake +pub const DEPLOYER_SEED_AMOUNT: u128 = 100_000_000_000; // 100 NACKL per outcome +pub const STAKE_AMOUNT: u128 = 200_000_000; +pub const STAKE_OUTCOME: u32 = 0; +pub const ORACLE_FEE: u128 = 100; +pub const STAKE_PERIOD: u64 = 60; +pub const STAKE_PERIOD_LONG: u64 = 300; // 5 min — stake window = 30 sec for multi-step tests +pub const CURRENCY_ID_SHELL: u32 = 2; +pub const CURRENCY_ID_NACKL: u32 = 1; +pub const GIVER_ADDRESS: &str = + "0:1111111111111111111111111111111111111111111111111111111111111111"; + +pub fn create_context() -> Arc { + let mut config = ClientConfig::default(); + config.network.endpoints = Some(vec![ENDPOINT.to_string()]); + Arc::new(ClientContext::new(config).expect("create context")) +} + +pub fn create_dex() -> Dex { + Dex::new(dodex_sdk::DexConfig { endpoints: vec![ENDPOINT.to_string()], ..Default::default() }) + .expect("create Dex") +} diff --git a/bee_wallet/tests/dex_flows/common/keys.rs b/bee_wallet/tests/dex_flows/common/keys.rs new file mode 100644 index 0000000..329c1e1 --- /dev/null +++ b/bee_wallet/tests/dex_flows/common/keys.rs @@ -0,0 +1,28 @@ +//! Random keypair generation via tvm_client mnemonic. + +use std::sync::Arc; + +use ackinacki_kit::tvm_client::crypto; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicDeriveSignKeys; +use ackinacki_kit::tvm_client::crypto::ParamsOfMnemonicFromRandom; +use ackinacki_kit::tvm_client::ClientContext; + +pub fn gen_keys(context: Arc) -> KeyPair { + let phrase = crypto::mnemonic_from_random( + context.clone(), + ParamsOfMnemonicFromRandom { dictionary: None, word_count: Some(24) }, + ) + .expect("mnemonic") + .phrase; + crypto::mnemonic_derive_sign_keys( + context, + ParamsOfMnemonicDeriveSignKeys { + phrase, + path: None, + dictionary: None, + word_count: Some(24), + }, + ) + .expect("derive keys") +} diff --git a/bee_wallet/tests/dex_flows/common/misc.rs b/bee_wallet/tests/dex_flows/common/misc.rs new file mode 100644 index 0000000..397d671 --- /dev/null +++ b/bee_wallet/tests/dex_flows/common/misc.rs @@ -0,0 +1,36 @@ +//! Misc small helpers: time, account-active wait, NACKL balance read, +//! GraphQL event-entry destructuring. + +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use ackinacki_kit::contracts::account::AccountStatus; +use ackinacki_kit::contracts::account::ParamsOfWaitAccount; +use ackinacki_kit::contracts::traits::AccountAccessor; + +use crate::common::context::TOKEN_TYPE_NACKL; + +pub fn now_unix() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_secs() +} + +pub async fn wait_active(contract: &T, label: &str) { + contract + .wait_account(ParamsOfWaitAccount { + status: AccountStatus::Active, + attempts: Some(30), + attempts_timeout: Some(2_000), + }) + .await + .unwrap_or_else(|e| panic!("wait {label} active: {e:?}")); +} + +pub fn pn_nackl(details: &dodex_sdk::PrivateNoteDetails) -> u128 { + details.balance.get(&TOKEN_TYPE_NACKL.to_string()).copied().unwrap_or_default() +} + +/// Pull `eventName` from an `OracleEventList._events` map entry. The getter +/// returns ABI-camelCase fields, so we look up `eventName` (not snake_case). +pub fn event_entry_name(entry: &serde_json::Value) -> Option<&str> { + entry.get("eventName").and_then(|v| v.as_str()) +} diff --git a/bee_wallet/tests/dex_flows/common/mod.rs b/bee_wallet/tests/dex_flows/common/mod.rs new file mode 100644 index 0000000..b9a80b4 --- /dev/null +++ b/bee_wallet/tests/dex_flows/common/mod.rs @@ -0,0 +1,20 @@ +//! Shared helpers for the wallet-coupled DEX flow tests. Each sub-module +//! owns one responsibility: +//! +//! - `context` — endpoint constants + `ClientContext` / `Dex` builders. +//! - `keys` — random keypair generation. +//! - `misc` — small utilities (time, account-active wait, balance read). +//! - `voucher` — `mint_voucher_via_multifactor` driving the live halo2 pipeline +//! off a multifactor-wallet-emitted voucher event. +//! - `pn` — PrivateNote deploy / gas-funding helpers + `ensure_root_pn_funded`. +//! - `wallet` — multifactor-wallet helpers. +//! +//! Each test module imports the items it actually uses via +//! `use crate::common::::;`. + +pub mod context; +pub mod keys; +pub mod misc; +pub mod pn; +pub mod voucher; +pub mod wallet; diff --git a/bee_wallet/tests/dex_flows/common/pn.rs b/bee_wallet/tests/dex_flows/common/pn.rs new file mode 100644 index 0000000..36a0930 --- /dev/null +++ b/bee_wallet/tests/dex_flows/common/pn.rs @@ -0,0 +1,211 @@ +//! PrivateNote deploy / gas-funding helpers. + +use std::collections::HashMap; +use std::sync::Arc; + +use ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver; +use ackinacki_kit::contracts::giver::v3::top_up_native_with_giver_if_below; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::ClientContext; +use dodex_contracts::dex::private_note::PrivateNote; +use dodex_contracts::dex::root_pn::ParamsOfDeployPrivateNote; +use dodex_contracts::dex::root_pn::ParamsOfGetPrivateNoteAddress; +use dodex_contracts::dex::root_pn::ParamsOfSendEccShellToPrivateNote; +use dodex_contracts::dex::root_pn::RootPn; +use dodex_sdk::dex_contract_params; +use dodex_sdk::proof; +use dodex_sdk::Dex; + +use crate::common::context::CURRENCY_ID_NACKL; +use crate::common::context::CURRENCY_ID_SHELL; +use crate::common::context::ECC_SHELL_DEPOSIT; +use crate::common::context::PMP_DEPOSIT; +use crate::common::context::TOKEN_TYPE_NACKL; +use crate::common::keys::gen_keys; +use crate::common::misc::wait_active; +use crate::common::voucher::make_voucher_proof; + +/// Deploy a PN via Dex (deposit only, no gas). Returns (address, dih_dec, +/// keys). +pub async fn deploy_pn( + context: &Arc, + dex: &Dex, + deposit: u64, +) -> (String, String, KeyPair) { + let keys = gen_keys(context.clone()); + let signer = Signer::Keys { keys: keys.clone() }; + + let zk = + make_voucher_proof(context.clone(), &keys.public, TOKEN_TYPE_NACKL, deposit, false).await; + let dih_dec = proof::hex_u256_to_dec(&zk.deposit_identifier_hash_hex); + let epk_dec = proof::pubkey_to_dec(&keys.public); + + dex.deploy_private_note( + ParamsOfDeployPrivateNote { + zkproof: zk.proof, + deposit_identifier_hash: dih_dec.clone(), + final_layer_historical_hash_root: proof::hex_u256_to_dec( + &zk.final_layer_historical_hash_root_hex, + ), + voucher_nominal_fr: proof::hex_u256_to_dec(&zk.voucher_nominal_fr_hex), + token_type_fr: proof::hex_u256_to_dec(&zk.token_type_fr_hex), + ephemeral_pubkey: epk_dec, + value: zk.voucher_value, + token_type: zk.voucher_token_type, + layer_number: zk.layer_number, + }, + signer, + ) + .await + .expect("deploy_private_note"); + + let pn_address = dex + .get_private_note_address(ParamsOfGetPrivateNoteAddress { + deposit_identifier_hash: dih_dec.clone(), + }) + .await + .expect("get_private_note_address"); + + let pn = PrivateNote::new(context.clone(), dex_contract_params(&pn_address)); + wait_active(&pn, "PrivateNote").await; + + (pn_address, dih_dec, keys) +} + +/// Deploy a PN with a specific keypair (not random). Returns (address, +/// dih_dec). +pub async fn deploy_pn_with_keys( + context: &Arc, + dex: &Dex, + keys: &KeyPair, + deposit: u64, +) -> (String, String) { + let signer = Signer::Keys { keys: keys.clone() }; + + let zk = + make_voucher_proof(context.clone(), &keys.public, TOKEN_TYPE_NACKL, deposit, false).await; + let dih_dec = proof::hex_u256_to_dec(&zk.deposit_identifier_hash_hex); + let epk_dec = proof::pubkey_to_dec(&keys.public); + + dex.deploy_private_note( + ParamsOfDeployPrivateNote { + zkproof: zk.proof, + deposit_identifier_hash: dih_dec.clone(), + final_layer_historical_hash_root: proof::hex_u256_to_dec( + &zk.final_layer_historical_hash_root_hex, + ), + voucher_nominal_fr: proof::hex_u256_to_dec(&zk.voucher_nominal_fr_hex), + token_type_fr: proof::hex_u256_to_dec(&zk.token_type_fr_hex), + ephemeral_pubkey: epk_dec, + value: zk.voucher_value, + token_type: zk.voucher_token_type, + layer_number: zk.layer_number, + }, + signer, + ) + .await + .expect("deploy_pn_with_keys"); + + let pn_address = dex + .get_private_note_address(ParamsOfGetPrivateNoteAddress { + deposit_identifier_hash: dih_dec.clone(), + }) + .await + .expect("get_pn_address"); + + let pn = PrivateNote::new(context.clone(), dex_contract_params(&pn_address)); + wait_active(&pn, "PN").await; + + (pn_address, dih_dec) +} + +/// Fund PN with Shell gas (ECC shell) + native gas. Call before write +/// operations. +pub async fn fund_pn_gas( + context: &Arc, + dex: &Dex, + pn_address: &str, + dih_dec: &str, + keys: &KeyPair, + shell_amount: u64, +) { + let ecc_zk = + make_voucher_proof(context.clone(), &keys.public, CURRENCY_ID_SHELL, shell_amount, true) + .await; + dex.send_ecc_shell( + ParamsOfSendEccShellToPrivateNote { + proof: ecc_zk.proof, + nullifier_hash: proof::hex_u256_to_dec(&ecc_zk.deposit_identifier_hash_hex), + deposit_identifier_hash: dih_dec.to_string(), + final_layer_historical_hash_root: proof::hex_u256_to_dec( + &ecc_zk.final_layer_historical_hash_root_hex, + ), + voucher_nominal_fr: proof::hex_u256_to_dec(&ecc_zk.voucher_nominal_fr_hex), + token_type_fr: proof::hex_u256_to_dec(&ecc_zk.token_type_fr_hex), + value: ecc_zk.voucher_value, + layer_number: ecc_zk.layer_number, + recipient_ephemeral_pubkey: proof::pubkey_to_dec(&keys.public), + }, + Signer::Keys { keys: keys.clone() }, + ) + .await + .expect("send_ecc_shell"); + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + send_currency_with_flag_from_default_giver( + context.clone(), + pn_address, + 20_000_000_000, + HashMap::new(), + 1, + ) + .await + .expect("giver fund PN native gas"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; +} + +/// Deploy a PN + fund gas. **Halo2 voucher proofs MUST be minted sequentially** +/// — the proof commits to `final_layer_historical_hash_root` at generation +/// time, and the contract validates it against the *current* root at submit. +/// If we mint two vouchers in parallel, the second one's root drifts during +/// the first one's submit + wait_active (~10-15 sec, 2-3 blocks) and the +/// contract rejects with `ERR_INVALID_ZKPROOF` (137). +pub async fn deploy_funded_pn( + context: &Arc, + dex: &Dex, + deposit: u64, +) -> (String, String, KeyPair) { + let (pn_address, dih_dec, keys) = deploy_pn(context, dex, deposit).await; + fund_pn_gas(context, dex, &pn_address, &dih_dec, &keys, ECC_SHELL_DEPOSIT).await; + (pn_address, dih_dec, keys) +} + +/// Top up RootPN with NACKL + Shell ECC for test operations. +pub async fn ensure_root_pn_funded(context: &Arc) { + let root_pn = RootPn::new(context.clone(), dex_contract_params(RootPn::DEFAULT_ADDRESS)); + wait_active(&root_pn, "RootPN").await; + top_up_native_with_giver_if_below( + context.clone(), + &root_pn, + 120_000_000_000, + 50_000_000_000, + "RootPN", + ) + .await + .expect("top up RootPN native gas"); + let mut ecc = HashMap::new(); + ecc.insert(CURRENCY_ID_NACKL, PMP_DEPOSIT * 2); + ecc.insert(CURRENCY_ID_SHELL, ECC_SHELL_DEPOSIT * 2); + send_currency_with_flag_from_default_giver( + context.clone(), + RootPn::DEFAULT_ADDRESS, + 50_000_000_000, + ecc, + 1, + ) + .await + .expect("giver top up RootPN ECC"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; +} diff --git a/bee_wallet/tests/dex_flows/common/voucher.rs b/bee_wallet/tests/dex_flows/common/voucher.rs new file mode 100644 index 0000000..8cf93ba --- /dev/null +++ b/bee_wallet/tests/dex_flows/common/voucher.rs @@ -0,0 +1,231 @@ +//! Test-only halo2 voucher pipeline driver. Mints ECC via the default +//! Giver (a shellnet/dev shortcut — Giver doesn't exist on production) +//! and proves the resulting `RootPN.VoucherGenerated` event via the +//! production `dodex_sdk::halo2::live::prove_voucher_for_event` Stage B. +//! +//! Production code emits the voucher through a multifactor wallet and +//! drives `prove_voucher_for_event` directly (see +//! `flows::test_production_flow_voucher_deploy_pn_and_stake`). This +//! helper only exists so the rest of the integration tests don't have to +//! stand up a full multifactor wallet for every voucher they need. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use ackinacki_kit::contracts::giver::GiverV3; +use ackinacki_kit::contracts::giver::ParamsOfSendCurrencyWithBody; +use ackinacki_kit::contracts::traits::AbiAccessor; +use ackinacki_kit::contracts::traits::AddressAccessor; +use ackinacki_kit::tvm_client::abi::CallSet; +use ackinacki_kit::tvm_client::abi::ParamsOfEncodeMessageBody; +use ackinacki_kit::tvm_client::abi::Signer; +use ackinacki_kit::tvm_client::crypto::KeyPair; +use ackinacki_kit::tvm_client::ClientContext; +use dodex_contracts::dex::root_pn::ParamsOfGenerateVoucher; +use dodex_contracts::dex::root_pn::RootPn; +use dodex_sdk::dex_contract_params; +use dodex_sdk::halo2::live::prove_voucher_for_event; +use dodex_sdk::halo2::sk_commit::compute_sk_u_commit_hex; +use dodex_sdk::halo2::voucher_event; +use dodex_sdk::halo2::Halo2Paths; +use dodex_sdk::proof; +use serde_json::json; + +use crate::common::context::ENDPOINT; + +/// Production-grade voucher cycle: multifactor wallet emits the voucher, +/// halo2 proves against the wallet-emitted event. Picks a fresh `sk_u` +/// per call so each voucher binds to its own commitment. +#[allow(clippy::too_many_arguments)] +pub async fn mint_voucher_via_multifactor( + wallet: &bee_wallet::Wallet, + mf_address: &str, + mf_keys: &KeyPair, + context: Arc, + root_pn: RootPn, + token_type: u32, + amount: u64, + is_fee: bool, +) -> proof::Halo2Proof { + let sk_u_hex = proof::random_secret_key(); + let sk_u_commit_hex = + compute_sk_u_commit_hex(&sk_u_hex).expect("compute skUCommit (poseidon([sk_u, 0]))"); + + wallet + .generate_voucher(bee_wallet::ParamsOfGenerateVoucher { + multifactor_address: mf_address.to_string(), + token_type, + amount, + is_fee, + sk_u_commit: format!("0x{sk_u_commit_hex}"), + signer_keys: mf_keys.clone(), + }) + .await + .expect("wallet.generate_voucher"); + + // Locate OUR voucher event by `skUCommit` match — bulletproof under + // parallel test runs because each call generates a fresh random + // commitment (collision-free vs. timestamp-window heuristics). + let event = voucher_event::wait_for_voucher_event_by_sk_u_commit( + context.clone(), + root_pn.address(), + &format!("0x{sk_u_commit_hex}"), + Duration::from_secs(180), + ) + .await + .expect("wait_for_voucher_event_by_sk_u_commit (multifactor flow)"); + + let paths = Halo2Paths::from_env(); + prove_voucher_for_event( + context, + format!("https://{ENDPOINT}"), + event, + sk_u_hex, + sk_u_commit_hex, + amount, + token_type, + mf_keys.public.clone(), + None, + &paths, + ) + .await + .expect("prove_voucher_for_event with env-default Halo2Paths") +} + +const EVENT_WAIT_TIMEOUT_S: u64 = 300; +const BLOCK_ID_WAIT_TIMEOUT_S: u64 = 180; + +/// Drive a full live halo2 voucher proof against shellnet using the +/// default Giver as ECC source. `ephemeral_pubkey_hex` is the raw 32-byte +/// hex (no `0x`) committed in the proof; for PN deploy / sendEcc this is +/// the PN's own ephemeral key. +pub async fn make_voucher_proof( + context: Arc, + ephemeral_pubkey_hex: &str, + voucher_token_type: u32, + voucher_value: u64, + is_fee: bool, +) -> proof::Halo2Proof { + let t_total = std::time::Instant::now(); + let root_pn = RootPn::new(context.clone(), dex_contract_params(RootPn::DEFAULT_ADDRESS)); + let network_url = format!("https://{ENDPOINT}"); + let ephemeral_pubkey_hex = proof::strip_0x(ephemeral_pubkey_hex).to_string(); + + // 1. Random sk_u; skUCommit = poseidon([sk_u, 0]). + let t_sk = std::time::Instant::now(); + let sk_u_hex = proof::random_secret_key(); + let sk_u_commit_hex = + compute_sk_u_commit_hex(&sk_u_hex).expect("compute skUCommit (poseidon([sk_u, 0]))"); + eprintln!("[halo2-time] skUCommit poseidon: {:.3}s", t_sk.elapsed().as_secs_f64()); + eprintln!( + "[halo2-live] sk_u={sk_u_hex}, skUCommit=0x{sk_u_commit_hex}, \ + tokenType={voucher_token_type}, value={voucher_value}" + ); + + // 2. Encode `RootPN.generateVoucher` body. + let t_encode = std::time::Instant::now(); + let voucher_body = encode_generate_voucher_body( + context.clone(), + &root_pn, + &format!("0x{sk_u_commit_hex}"), + is_fee, + ) + .await; + eprintln!("[halo2-time] encode generateVoucher body: {:.3}s", t_encode.elapsed().as_secs_f64()); + + // 3. Fire the Giver-funded `generateVoucher` message, then locate OUR + // `VoucherGenerated` ext-out by matching `skUCommit` (a 32-byte Poseidon + // commitment freshly generated above). The naive "snapshot count → wait for + // one more event" approach raced badly under parallel test runs — we'd grab + // another concurrent test's voucher event by accident, then fail later at + // submit with `ERR_INVALID_ZKPROOF (137)` because the proof was built + // against that other voucher's commitment. + let t_send = std::time::Instant::now(); + let mut ecc = HashMap::new(); + ecc.insert(voucher_token_type, voucher_value); + let giver = GiverV3::new_default(context.clone()); + giver + .send_currency_with_body( + ParamsOfSendCurrencyWithBody { + dest: root_pn.address().to_string(), + value: 2_000_000_000, + ecc, + flag: 1, + body: voucher_body.body, + }, + Signer::None, + ) + .await + .expect("Giver.send_currency_with_body → RootPN.generateVoucher"); + eprintln!( + "[halo2-time] Giver.sendCurrencyWithBody (RootPN.generateVoucher): {:.2}s", + t_send.elapsed().as_secs_f64() + ); + + let t_wait_event = std::time::Instant::now(); + let event = voucher_event::wait_for_voucher_event_by_sk_u_commit( + context.clone(), + root_pn.address(), + &format!("0x{sk_u_commit_hex}"), + Duration::from_secs(EVENT_WAIT_TIMEOUT_S + BLOCK_ID_WAIT_TIMEOUT_S), + ) + .await + .expect("wait_for_voucher_event_by_sk_u_commit"); + eprintln!( + "[halo2-time] indexer surfaced event + block_id (sk_u_commit match): {:.2}s", + t_wait_event.elapsed().as_secs_f64() + ); + + // 4. Hand off to the production Stage B prover. Tests pull paths from env + // (matching the historical `PARAMS_DIR` / `HALO2_PK_CACHE` / + // `HALO2_FIXTURE_DIR` defaults); host integrations should construct + // `Halo2Paths` explicitly from app-managed dirs instead. + let paths = Halo2Paths::from_env(); + let proof_out = prove_voucher_for_event( + context, + network_url, + event, + sk_u_hex, + sk_u_commit_hex, + voucher_value, + voucher_token_type, + ephemeral_pubkey_hex, + None, + &paths, + ) + .await + .expect("prove_voucher_for_event with env-default Halo2Paths"); + eprintln!("[halo2-time] make_voucher_proof TOTAL: {:.2}s", t_total.elapsed().as_secs_f64()); + proof_out +} + +async fn encode_generate_voucher_body( + context: Arc, + root_pn: &RootPn, + sk_u_commit_hex_0x: &str, + is_fee: bool, +) -> ackinacki_kit::tvm_client::abi::ResultOfEncodeMessageBody { + let abi = root_pn.abi().clone(); + ackinacki_kit::tvm_client::abi::encode_message_body( + context, + ParamsOfEncodeMessageBody { + abi, + address: Some(root_pn.address().to_string()), + call_set: CallSet { + function_name: "generateVoucher".to_string(), + header: None, + input: Some(json!(ParamsOfGenerateVoucher { + sk_u_commit: sk_u_commit_hex_0x.to_string(), + is_fee, + })), + }, + is_internal: true, + signer: Signer::None, + processing_try_index: None, + signature_id: None, + }, + ) + .await + .expect("encode RootPN.generateVoucher body") +} diff --git a/bee_wallet/tests/dex_flows/common/wallet.rs b/bee_wallet/tests/dex_flows/common/wallet.rs new file mode 100644 index 0000000..c644b11 --- /dev/null +++ b/bee_wallet/tests/dex_flows/common/wallet.rs @@ -0,0 +1,72 @@ +//! Multifactor-wallet helpers used by production-flow tests. + +use crate::common::context::create_context; +use crate::common::context::ENDPOINT; +use crate::common::misc::now_unix; + +pub fn create_wallet() -> bee_wallet::Wallet { + bee_wallet::Wallet::new(bee_wallet::WalletConfig { + endpoints: vec![ENDPOINT.to_string()], + api_url: "https://app-backend.ackinacki.org/api".to_string(), + app_id: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), + ..Default::default() + }) + .expect("create Wallet") +} + +pub fn deploy_wallet_params(name: String) -> bee_wallet::ParamsOfDeployMultifactor { + bee_wallet::ParamsOfDeployMultifactor { + epk: "6d26db3f0d23f66f358ca7d8f4e340ecc784f899002946b4eb04b1f7cb3325d6".to_string(), + epk_expire_at: 1784029474, + esk: "15910e12c0bc445cda49ad240a9533546a8c26b8a8d0313cd59533af1b463bc7".to_string(), + header_base_64: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImZmOGVlZDA0MjgyZjFkYmQ4OWY1YTc5Yjc4N2Q2N2JjODc2MjA1OTcifQ".to_string(), + index_mod_4: 1, + iss_base_64: "yJpc3MiOiJodHRwczovL29hdXRoLmdvc2guc2giLC".to_string(), + jwk_modulus: "c5b6adf2b02c0731bcd01071786afc797f34ef21d61f3cb5d1ce8c82486427db1eaca9a0ce7f9f9687790a2cc80e87aaff3b1ccd2c4c5a89aafc2885e6a6ce1a0ef569a6608263bda6aec4b369114210139d28346f010ed15cd876bf932cf43d6c7682d97e6c12e940ce05b30c00009177a7692372f281c6ec2fa51f271b0d9e2a38d983d7436682b2b7b9892829448f1834042ddcf9d02eade650658dd41668138df8cf1f79ec03323e80e7eb2814e28918ced0c16cddd891379120152174d170f1acabe5cb937213ccf844371630062bc4a923e406f7d1a92bf4aa5f611cf5848fcc482978ac9d55d2239e8e5670deab82417d3a8c044e187e83bfd79b9fa5".to_string(), + jwk_modulus_expire_at: now_unix() + 3600, + kid: "ff8eed04282f1dbd89f5a79b787d67bc87620597".to_string(), + password: "Hello!23".to_string(), + proof: "355528cf17afdde0a63f28050ad475407b6b515e7ba4cd171b77a6f0449874107e7f584f650117d7dbc4440cd62c5922f5f67a045b6364f107665e5d987bf12ceb191f246463920decbf50cf43567de0a885c53771440764cabd578f84c3581bcafd999764284c4f49b9b5ebc2f4508a931b62984970af006000b86c3effc08a".to_string(), + sub: "272114864".to_string(), + wallet_name: name, + zkid: "11122679641859749640320083403412561847128433970247905841202114460910422214869".to_string(), + } +} + +/// Deploy a fresh multifactor wallet funded for DEX operations. +pub async fn deploy_dex_wallet() -> (String, ackinacki_kit::tvm_client::crypto::KeyPair) { + use std::sync::atomic::AtomicU32; + static CTR: AtomicU32 = AtomicU32::new(0); + + let wallet = create_wallet(); + let idx = CTR.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let name = format!("dex_prod_{}_{}", now_unix(), idx); + let params = deploy_wallet_params(name); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let result = wallet.deploy_wallet(params).await.expect("deploy_wallet"); + let address = result.address; + + let ctx = create_context(); + let ecc = std::collections::HashMap::from([ + // 1500 NACKL — must cover at least one Nominal::N1000 deposit + // voucher (1000G, used by user_flow PMP_DEPOSIT) plus headroom for + // production_flow's Nominal::N100 (100G) and any per-message fees. + (1u32, 1_500_000_000_000u64), + (2u32, 200_000_000_000u64), /* 200 SHELL — covers Nominal::N100 SHELL + * voucher (100G) + per-message fees. */ + ]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + ctx, + &address, + 50_000_000_000, + ecc, + 1, + ) + .await + .expect("giver fund DEX wallet"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let keys = ackinacki_kit::tvm_client::crypto::KeyPair { public: epk, secret: esk }; + (address, keys) +} diff --git a/bee_wallet/tests/dex_flows/flows.rs b/bee_wallet/tests/dex_flows/flows.rs new file mode 100644 index 0000000..4e0c75c --- /dev/null +++ b/bee_wallet/tests/dex_flows/flows.rs @@ -0,0 +1,449 @@ +//! Wallet-coupled multi-step user flows: a real multifactor wallet drives +//! the voucher cycle end-to-end, halo2 proves against the wallet-emitted +//! event, and the DEX façade consumes the proof. +//! +//! - production flow: voucher → deploy PN → SHELL gas voucher. +//! - user flow: voucher → deploy PN → browse oracle events → deploy PMP. + +use std::collections::HashMap; + +use ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver; +use ackinacki_kit::contracts::giver::v3::top_up_native_with_giver_if_below; +use ackinacki_kit::tvm_client::abi::Signer; +use dodex_contracts::dex::oracle::Oracle; +use dodex_contracts::dex::oracle_event_list::OracleEventList; +use dodex_contracts::dex::oracle_event_list::ParamsOfAddEvent; +use dodex_contracts::dex::pmp::ParamsOfSubmitSetTimings; +use dodex_contracts::dex::pmp::Pmp; +use dodex_contracts::dex::private_note::ParamsOfDeployPmp; +use dodex_contracts::dex::private_note::PrivateNote; +use dodex_contracts::dex::root_oracle::ParamsOfDeployOracle; +use dodex_contracts::dex::root_oracle::RootOracle; +use dodex_contracts::dex::root_pn::ParamsOfDeployPrivateNote; +use dodex_contracts::dex::root_pn::ParamsOfGetPmpAddress; +use dodex_contracts::dex::root_pn::ParamsOfGetPrivateNoteAddress; +use dodex_contracts::dex::root_pn::ParamsOfSendEccShellToPrivateNote; +use dodex_contracts::dex::root_pn::RootPn; +use dodex_sdk::dex_contract_params; +use dodex_sdk::proof; + +use crate::common::context::create_context; +use crate::common::context::create_dex; +use crate::common::context::CURRENCY_ID_NACKL; +use crate::common::context::CURRENCY_ID_SHELL; +use crate::common::context::DEPLOYER_SEED_AMOUNT; +use crate::common::context::ECC_SHELL_DEPOSIT; +use crate::common::context::PMP_DEPOSIT; +use crate::common::context::STAKE_PERIOD_LONG; +use crate::common::context::TOKEN_TYPE_NACKL; +use crate::common::keys::gen_keys; +use crate::common::misc::now_unix; +use crate::common::misc::pn_nackl; +use crate::common::misc::wait_active; +use crate::common::pn::ensure_root_pn_funded; +use crate::common::voucher::mint_voucher_via_multifactor; +use crate::common::wallet::create_wallet; +use crate::common::wallet::deploy_dex_wallet; + +/// Production flow end-to-end: multifactor wallet emits a voucher, +/// halo2 proves *against that exact event* (no Giver shortcut anywhere in +/// the halo2 pipeline), `RootPN.deployPrivateNote` consumes the proof, +/// then a SHELL gas voucher follows the same path through the wallet +/// to fund the new PN. +#[tokio::test] +async fn test_production_flow_voucher_deploy_pn_and_stake() { + let wallet = create_wallet(); + let dex = create_dex(); + let context = create_context(); + let root_pn = RootPn::new(context.clone(), dex_contract_params(RootPn::DEFAULT_ADDRESS)); + + // 1. Deploy funded multifactor wallet. + let (mf_address, mf_keys) = deploy_dex_wallet().await; + println!("wallet: {mf_address}"); + + // 2. Multifactor → RootPN.generateVoucher → halo2 prove for the deposit voucher + // (100 NACKL). + // + // NOTE: deposit + gas voucher proofs MUST be minted sequentially. + // Each commits to `final_layer_historical_hash_root` at proof + // generation time; the contract validates it against the *current* + // root at submit. Parallel mint via `tokio::join!` worked CPU-wise + // but the second proof's root drifted during the first one's + // submit + wait_active (~10-15 sec, 2-3 blocks) → contract rejected + // the second proof with `ERR_INVALID_ZKPROOF (137)`. + let zk = mint_voucher_via_multifactor( + &wallet, + &mf_address, + &mf_keys, + context.clone(), + root_pn.clone(), + CURRENCY_ID_NACKL, + proof::Nominal::N100.raw_value(proof::TokenType::Nackl), + false, + ) + .await; + println!("deposit voucher proven"); + + // 3. Deploy PN against the wallet-bound proof. + let dih_dec = proof::hex_u256_to_dec(&zk.deposit_identifier_hash_hex); + let epk_dec = proof::pubkey_to_dec(&mf_keys.public); + dex.deploy_private_note( + ParamsOfDeployPrivateNote { + zkproof: zk.proof, + deposit_identifier_hash: dih_dec.clone(), + final_layer_historical_hash_root: proof::hex_u256_to_dec( + &zk.final_layer_historical_hash_root_hex, + ), + voucher_nominal_fr: proof::hex_u256_to_dec(&zk.voucher_nominal_fr_hex), + token_type_fr: proof::hex_u256_to_dec(&zk.token_type_fr_hex), + ephemeral_pubkey: epk_dec, + value: zk.voucher_value, + token_type: zk.voucher_token_type, + layer_number: zk.layer_number, + }, + Signer::Keys { keys: mf_keys.clone() }, + ) + .await + .expect("deploy_pn"); + + let pn_address = dex + .get_private_note_address(ParamsOfGetPrivateNoteAddress { + deposit_identifier_hash: dih_dec.clone(), + }) + .await + .expect("pn_address"); + println!("PN: {pn_address}"); + + let pn = PrivateNote::new(context.clone(), dex_contract_params(&pn_address)); + wait_active(&pn, "PN").await; + + // 4. SHELL gas voucher: same path again, multifactor-emitted + wallet-bound + // halo2 proof (must be minted *now*, not earlier — see note in step 2). + let ecc_zk = mint_voucher_via_multifactor( + &wallet, + &mf_address, + &mf_keys, + context.clone(), + root_pn.clone(), + CURRENCY_ID_SHELL, + ECC_SHELL_DEPOSIT, + true, + ) + .await; + println!("gas voucher proven"); + + dex.send_ecc_shell( + ParamsOfSendEccShellToPrivateNote { + proof: ecc_zk.proof, + nullifier_hash: proof::hex_u256_to_dec(&ecc_zk.deposit_identifier_hash_hex), + deposit_identifier_hash: dih_dec, + final_layer_historical_hash_root: proof::hex_u256_to_dec( + &ecc_zk.final_layer_historical_hash_root_hex, + ), + voucher_nominal_fr: proof::hex_u256_to_dec(&ecc_zk.voucher_nominal_fr_hex), + token_type_fr: proof::hex_u256_to_dec(&ecc_zk.token_type_fr_hex), + value: ecc_zk.voucher_value, + layer_number: ecc_zk.layer_number, + recipient_ephemeral_pubkey: proof::pubkey_to_dec(&mf_keys.public), + }, + Signer::Keys { keys: mf_keys.clone() }, + ) + .await + .expect("send_ecc_shell"); + tokio::time::sleep(std::time::Duration::from_secs(8)).await; + println!("PN funded"); + + // 5. Verify deposit landed. + let details = dex.get_private_note_details(&pn_address).await.expect("details"); + let nackl = details.balance.get("1").copied().unwrap_or_default(); + assert_eq!(nackl, 100_000_000_000u128); + assert!(details.busy_address.is_none()); + println!("balance: {} NACKL — production flow complete", nackl); +} + +/// Full user flow through the DEX façade: a real multifactor wallet +/// drives the voucher cycle (PN deploy + SHELL gas), then the user +/// browses oracle events and deploys their own PMP. No Giver-driven +/// voucher mint anywhere — only the production multifactor → halo2 path. +#[tokio::test] +async fn test_user_flow_deploy_pn_then_pmp_via_dex() { + let wallet = create_wallet(); + let context = create_context(); + let dex = create_dex(); + let root_pn = RootPn::new(context.clone(), dex_contract_params(RootPn::DEFAULT_ADDRESS)); + ensure_root_pn_funded(&context).await; + + // --- Step 0: Deploy multifactor wallet (the user's signer for everything + // that goes through the DEX). All voucher mints below come from this + // wallet, mirroring how an end user actually drives the flow. --- + let (mf_address, mf_keys) = deploy_dex_wallet().await; + eprintln!("multifactor: {mf_address}"); + + // --- Step 1: User mints a deposit voucher and deploys their PN against + // the wallet-bound halo2 proof. --- + let zk = mint_voucher_via_multifactor( + &wallet, + &mf_address, + &mf_keys, + context.clone(), + root_pn.clone(), + CURRENCY_ID_NACKL, + PMP_DEPOSIT, + false, + ) + .await; + let dih_dec = proof::hex_u256_to_dec(&zk.deposit_identifier_hash_hex); + let epk_dec = proof::pubkey_to_dec(&mf_keys.public); + dex.deploy_private_note( + ParamsOfDeployPrivateNote { + zkproof: zk.proof, + deposit_identifier_hash: dih_dec.clone(), + final_layer_historical_hash_root: proof::hex_u256_to_dec( + &zk.final_layer_historical_hash_root_hex, + ), + voucher_nominal_fr: proof::hex_u256_to_dec(&zk.voucher_nominal_fr_hex), + token_type_fr: proof::hex_u256_to_dec(&zk.token_type_fr_hex), + ephemeral_pubkey: epk_dec, + value: zk.voucher_value, + token_type: zk.voucher_token_type, + layer_number: zk.layer_number, + }, + Signer::Keys { keys: mf_keys.clone() }, + ) + .await + .expect("deploy_pn"); + let pn_address = dex + .get_private_note_address(ParamsOfGetPrivateNoteAddress { + deposit_identifier_hash: dih_dec.clone(), + }) + .await + .expect("pn_address"); + let pn = PrivateNote::new(context.clone(), dex_contract_params(&pn_address)); + wait_active(&pn, "PN").await; + + let details = dex.get_private_note_details(&pn_address).await.expect("pn details"); + assert_eq!(pn_nackl(&details), PMP_DEPOSIT as u128); + assert!(details.busy_address.is_none()); + eprintln!("step 1: PN deployed at {pn_address} with {} NACKL", pn_nackl(&details)); + + // --- Step 2: Deploy two oracles with different fees --- + let root_oracle = + RootOracle::new(context.clone(), dex_contract_params(RootOracle::DEFAULT_ADDRESS)); + wait_active(&root_oracle, "RootOracle").await; + top_up_native_with_giver_if_below( + context.clone(), + &root_oracle, + 120_000_000_000, + 50_000_000_000, + "RootOracle", + ) + .await + .expect("top up RootOracle native gas (deploy-then-PMP flow)"); + + // Oracle A: fee = 100 + let oracle_a_keys = gen_keys(context.clone()); + let run_id = now_unix(); + let oracle_a_name = format!("OracleA{run_id:x}"); + dex.deploy_oracle( + ParamsOfDeployOracle { + oracle_pubkey: proof::pubkey_to_dec(&oracle_a_keys.public), + oracle_name: oracle_a_name.clone(), + }, + Signer::Keys { keys: gen_keys(context.clone()) }, + ) + .await + .expect("deploy oracle A"); + let oracle_a_addr = dex.get_oracle_address(oracle_a_name.clone()).await.expect("oracle A addr"); + let oracle_a = Oracle::new(context.clone(), dex_contract_params(&oracle_a_addr)); + wait_active(&oracle_a, "Oracle A").await; + let el_a_addr = dex + .get_event_list_address( + &oracle_a_addr, + dodex_contracts::dex::oracle::ParamsOfGetEventListAddress { index: 0 }, + ) + .await + .expect("el A addr"); + let el_a = OracleEventList::new(context.clone(), dex_contract_params(&el_a_addr)); + wait_active(&el_a, "EventList A").await; + + let mut outcomes = HashMap::new(); + outcomes.insert(1u32, "Yes".to_string()); + outcomes.insert(2u32, "No".to_string()); + + dex.add_event( + &el_a_addr, + ParamsOfAddEvent { + event_name: format!("Event{run_id:x}"), + oracle_fee: 150, // fee = 150 Shell + deadline: 2_000_000_000, + describe: "Test event".to_string(), + outcome_names: outcomes.clone(), + trust_addr: None, + }, + Signer::Keys { keys: oracle_a_keys.clone() }, + ) + .await + .expect("add event A"); + + // --- Step 3: Browse events, read oracle_fee --- + let mut event_id = String::new(); + let mut oracle_fee: u128 = 0; + for _ in 0..15 { + let events = dex.get_parsed_events(&el_a_addr).await.expect("events"); + if let Some(evt) = events.first() { + event_id = evt.event_id.clone(); + oracle_fee = evt.oracle_fee; + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert!(!event_id.is_empty()); + assert_eq!(oracle_fee, 150); + eprintln!("step 3: found event {event_id} with oracle_fee={oracle_fee}"); + + // --- Step 4: Fund PN with gas --- + // oracle_fee goes to oracle on PMP approval. PN also needs Shell ECC for + // internal messages. Use ECC_SHELL_DEPOSIT which covers oracle_fee + gas. + let mut shell_ecc = HashMap::new(); + shell_ecc.insert(CURRENCY_ID_SHELL, ECC_SHELL_DEPOSIT * 2); + send_currency_with_flag_from_default_giver( + context.clone(), + RootPn::DEFAULT_ADDRESS, + 2_000_000_000, + shell_ecc, + 1, + ) + .await + .expect("giver top up RootPN SHELL (deploy-then-PMP flow)"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + // Mint a SHELL gas voucher through the multifactor wallet and route it + // into the freshly-deployed PN via `dex.send_ecc_shell`. Mirrors what the + // production flow does — no Giver-driven SHELL voucher anywhere. + let ecc_zk = mint_voucher_via_multifactor( + &wallet, + &mf_address, + &mf_keys, + context.clone(), + root_pn.clone(), + CURRENCY_ID_SHELL, + ECC_SHELL_DEPOSIT, + true, + ) + .await; + dex.send_ecc_shell( + ParamsOfSendEccShellToPrivateNote { + proof: ecc_zk.proof, + nullifier_hash: proof::hex_u256_to_dec(&ecc_zk.deposit_identifier_hash_hex), + deposit_identifier_hash: dih_dec.clone(), + final_layer_historical_hash_root: proof::hex_u256_to_dec( + &ecc_zk.final_layer_historical_hash_root_hex, + ), + voucher_nominal_fr: proof::hex_u256_to_dec(&ecc_zk.voucher_nominal_fr_hex), + token_type_fr: proof::hex_u256_to_dec(&ecc_zk.token_type_fr_hex), + value: ecc_zk.voucher_value, + layer_number: ecc_zk.layer_number, + recipient_ephemeral_pubkey: proof::pubkey_to_dec(&mf_keys.public), + }, + Signer::Keys { keys: mf_keys.clone() }, + ) + .await + .expect("send_ecc_shell"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + // Native vmshell on the PN is compute fuel for its internal messages — + // not part of the user's voucher cycle, top it up directly. + send_currency_with_flag_from_default_giver( + context.clone(), + &pn_address, + 20_000_000_000, + HashMap::new(), + 1, + ) + .await + .expect("giver fund PN native gas (deploy-then-PMP flow)"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + eprintln!("step 4: funded PN with {} Shell gas (oracle_fee={})", ECC_SHELL_DEPOSIT, oracle_fee); + + // --- Step 5: Deploy PMP --- + dex.deploy_pmp( + &pn_address, + ParamsOfDeployPmp { + event_id: event_id.clone(), + oracle_fee: vec![oracle_fee], + token_type: TOKEN_TYPE_NACKL, + names: vec![oracle_a_name.clone()], + index: vec![0], + initial_stakes: vec![DEPLOYER_SEED_AMOUNT, DEPLOYER_SEED_AMOUNT], + }, + Signer::Keys { keys: mf_keys.clone() }, + ) + .await + .expect("deploy_pmp"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let pmp_address = root_pn + .get_pmp_address(ParamsOfGetPmpAddress { + event_id: event_id.clone(), + names: vec![oracle_a_name], + token_type: TOKEN_TYPE_NACKL, + }) + .await + .expect("pmp addr") + .pmp_address; + + let pmp = Pmp::new(context.clone(), dex_contract_params(&pmp_address)); + wait_active(&pmp, "PMP").await; + + // Wait for approval + for _ in 0..30 { + let d = dex.get_pmp_details(&pmp_address).await.expect("pmp"); + if d.approved_oracle_events >= d.number_of_oracle_events && d.number_of_oracle_events > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + + // Wait for oracle confirmation + for _ in 0..30 { + let d = dex.get_pmp_details(&pmp_address).await.expect("pmp"); + if d.approved_oracle_events >= d.number_of_oracle_events && d.number_of_oracle_events > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // Oracle sets timings → _approved = true + let result_start = now_unix() + STAKE_PERIOD_LONG; + dex.submit_set_timings( + &pmp_address, + ParamsOfSubmitSetTimings { result_start }, + Signer::Keys { keys: oracle_a_keys }, + ) + .await + .expect("submit_set_timings"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let pmp_details = dex.get_pmp_details(&pmp_address).await.expect("pmp details"); + assert!(pmp_details.approved, "PMP should be approved after set_timings"); + assert_eq!(pmp_details.num_outcomes, 2); + assert_eq!(pmp_details.total_pool, 2 * DEPLOYER_SEED_AMOUNT); + eprintln!("step 5: PMP deployed, oracle confirmed, timings set at {pmp_address}"); + + // Verify PN balance decreased by initial stakes + let after = dex.get_private_note_details(&pn_address).await.expect("pn after"); + assert!(pn_nackl(&after) < PMP_DEPOSIT as u128); + eprintln!( + "PN balance after PMP: {} NACKL (initial deposit was {})", + pn_nackl(&after), + PMP_DEPOSIT + ); + + // Check history — should have PmpDeployed + let history = dex.get_notes_history(&[pn_address.clone()], 50, None).await.expect("history"); + assert!( + history.events.iter().any(|e| e.event_type == "PmpDeployed"), + "history should contain PmpDeployed" + ); + eprintln!("user flow complete: deploy PN → browse events (fee={oracle_fee}) → fund gas → deploy PMP → verified"); +} diff --git a/bee_wallet/tests/dex_flows/main.rs b/bee_wallet/tests/dex_flows/main.rs new file mode 100644 index 0000000..92fd11b --- /dev/null +++ b/bee_wallet/tests/dex_flows/main.rs @@ -0,0 +1,24 @@ +//! Wallet-coupled full-flow DEX integration tests against shellnet. +//! +//! These are the two multifactor flows that exercise **bee-wallet together +//! with the DEX façade** — a real multifactor wallet drives the voucher +//! cycle, halo2 proves against the wallet-emitted event, and the DEX +//! (`dodex-sdk`, imported as a git dev-dependency) consumes the proof. They +//! live here because they straddle the wallet↔DEX seam and so can't move out +//! to `dodex-backend` (which has no bee-wallet). The rest of the DEX +//! integration suite lives in `dodex-backend/sdk/tests`. +//! +//! - `common/` — shared helpers (network context, key generation, voucher +//! pipeline, PN deploy/gas, multifactor wallet). +//! - `flows` — the two multifactor end-to-end flows. +//! +//! Run (needs SSH access to the halo2 kit + local halo2 params; see +//! `PARAMS_DIR` / `HALO2_PK_CACHE` defaults in `Halo2Paths::from_env`): +//! cargo test -p bee-wallet --test dex_flows -- --nocapture --test-threads=1 + +// `common/` is a shared helper module: each test uses a subset, so some +// helpers are unused from any single flow's point of view. +#![allow(dead_code)] + +mod common; +mod flows; diff --git a/bee_wallet/tests/hello.abi.json b/bee_wallet/tests/hello.abi.json new file mode 100644 index 0000000..a06dbc1 --- /dev/null +++ b/bee_wallet/tests/hello.abi.json @@ -0,0 +1,91 @@ +{ + "ABI version": 2, + "version": "2.4", + "header": ["time", "expire"], + "functions": [ + { + "name": "constructor", + "inputs": [ + {"name":"value","type":"uint64"} + ], + "outputs": [ + ] + }, + { + "name": "exchangeToken", + "inputs": [ + {"name":"value","type":"uint64"} + ], + "outputs": [ + ] + }, + { + "name": "renderHelloWorld", + "inputs": [ + ], + "outputs": [ + {"name":"value0","type":"string"} + ] + }, + { + "name": "touch", + "inputs": [ + ], + "outputs": [ + ] + }, + { + "name": "callExtTouch", + "inputs": [ + {"name":"addr","type":"address"} + ], + "outputs": [ + ] + }, + { + "name": "sendVMShell", + "inputs": [ + {"name":"dest","type":"address"}, + {"name":"amount","type":"uint128"}, + {"name":"bounce","type":"bool"} + ], + "outputs": [ + ] + }, + { + "name": "sendShell", + "inputs": [ + {"name":"dest","type":"address"}, + {"name":"value","type":"uint128"} + ], + "outputs": [ + ] + }, + { + "name": "deployNewContract", + "inputs": [ + {"name":"stateInit","type":"cell"}, + {"name":"initialBalance","type":"uint128"}, + {"name":"payload","type":"cell"} + ], + "outputs": [ + ] + }, + { + "name": "timestamp", + "inputs": [ + ], + "outputs": [ + {"name":"timestamp","type":"uint32"} + ] + } + ], + "events": [ + ], + "fields": [ + {"init":true,"name":"_pubkey","type":"uint256"}, + {"init":false,"name":"_timestamp","type":"uint64"}, + {"init":false,"name":"_constructorFlag","type":"bool"}, + {"init":false,"name":"timestamp","type":"uint32"} + ] +} diff --git a/bee_wallet/tests/hello.sol b/bee_wallet/tests/hello.sol new file mode 100644 index 0000000..5c4a437 --- /dev/null +++ b/bee_wallet/tests/hello.sol @@ -0,0 +1,131 @@ +pragma tvm-solidity >=0.76.1; +pragma AbiHeader expire; + +interface IHelloWorld { + function touch() external; +} + + +// This is class that describes you smart contract. +contract helloWorld { + // Contract can have an instance variables. + // In this example instance variable `timestamp` is used to store the time of `constructor` or `touch` + // function call + uint32 public timestamp; + + // The contract can have a `constructor` – a function that is called when the contract is deployed to the blockchain. + // Parameter `value` represents the number of SHELL tokens to be converted to VMSHELL to pay the transaction fee. + // In this example, the constructor stores the current timestamp in an instance variable. + // All contracts need to call `tvm.accept()` for a successful deployment. + constructor(uint64 value) { + // Call the VM command to convert SHELL tokens to VMSHELL tokens to pay the transaction fee. + gosh.cnvrtshellq(value); + + // Ensure that the contract's public key is set. + require(tvm.pubkey() != 0, 101); + + // The current smart contract agrees to buy some gas to complete the + // current transaction. This action is required to process external + // messages, which carry no value (and therefore no gas). + tvm.accept(); + + // Set the instance variable to the current block timestamp. + timestamp = block.timestamp; + } + + // Converts SHELL to VMSHELL for payment of transaction fees + // Parameter `value`- the amount of SHELL tokens that will be exchanged 1-to-1 into VMSHELL tokens. + function exchangeToken(uint64 value) public pure { + tvm.accept(); + getTokens(); + gosh.cnvrtshellq(value); + } + + // Returns a static message, "helloWorld". + // This function serves as a basic example of returning a fixed string in Solidity. + function renderHelloWorld () public pure returns (string) { + return 'helloWorld'; + } + + // Updates the `timestamp` variable with the current blockchain time. + // We will use this function to modify the data in the contract. + // Сalled by an external message. + function touch() external { + // Informs the TVM that we accept this message. + tvm.accept(); + getTokens(); + // Update the timestamp variable with the current block timestamp. + timestamp = block.timestamp; + } + + // Used to call the touch method of a contract via an internal message. + // Parameter 'addr' - the address of the contract where the 'touch' will be invoked. + function callExtTouch(address addr) public view { + // Each function that accepts an external message must check that + // the message is correctly signed. + require(msg.pubkey() == tvm.pubkey(), 102); + tvm.accept(); + getTokens(); + IHelloWorld(addr).touch(); + } + + // Sends VMSHELL to another contract with the same Dapp ID. + // Parameter `dest` - the target address within the same Dapp ID to receive the transfer. + // Parameter `value`- the amount of VMSHELL tokens to transfer. + // Parameter `bounce` - Bounce flag. Set true if need to transfer funds to existing account; + // set false to create new account. + function sendVMShell(address dest, uint128 amount, bool bounce) public view { + require(msg.pubkey() == tvm.pubkey(), 102); + tvm.accept(); + getTokens(); + // Enables a transfer with arbitrary settings + dest.transfer(varuint16(amount), bounce, 0); + } + + // Allows transferring SHELL tokens within the same Dapp ID and to other Dapp IDs. + // Parameter `dest` - the target address to receive the transfer. + // Parameter `value`- the amount of SHELL tokens to transfer. + function sendShell(address dest, uint128 value) public view { + require(msg.pubkey() == tvm.pubkey(), 102); + tvm.accept(); + getTokens(); + + TvmCell payload; + mapping(uint32 => varuint32) cc; + cc[2] = varuint32(value); + // Executes transfer to target address + dest.transfer(0, true, 1, payload, cc); + } + + // Deploys a new contract within its Dapp. + // The address of the new contract is calculated as a hash of its initial state. + // The owner's public key is part of the initial state. + // Parameter `stateInit` - the contract code plus data. + // Parameter `initialBalance` - the amount of funds to transfer. + // Parameter `payload` - a tree of cells used as the body of the outbound internal message. + function deployNewContract( + TvmCell stateInit, + uint128 initialBalance, + TvmCell payload + ) + public pure + { + // Runtime function to deploy contract with prepared msg body for constructor call. + tvm.accept(); + getTokens(); + address addr = address.makeAddrStd(0, tvm.hash(stateInit)); + addr.transfer({stateInit: stateInit, body: payload, value: varuint16(initialBalance)}); + } + + // Checks the contract balance + // and if it is below the specified limit, mints VMSHELL. + // The amounts are specified in nanotokens. + // Used to enable automatic balance replenishment. + function getTokens() private pure { + if (address(this).balance > 100000000000) { // 100 VMSHELL + return; + } + gosh.mintshell(100000000000); // 100 VMSHELL + } + +} diff --git a/bee_wallet/tests/hello.tvc b/bee_wallet/tests/hello.tvc new file mode 100644 index 0000000000000000000000000000000000000000..4db0c575ff50cacb8e3220dd73342a4a86afac5d GIT binary patch literal 994 zcmb7@K}-`t6o%i-wty6L)e7hqItwIlAf!YLXqwQpLXapyMWu#B140M_g_3{=BTz6P z2_%Ro!@+|GyvqT^gj-uN-O?rYP|^?^6E1kz^f1N5I=c%A1~2X&=HK0!`M>?&o0XqS zUW7#i+&93e8Y6JSe_Rej>FoYplHkZjI)TZ??*vv%MFx)GH)6~#1DemD%!(EeS{?|6 zf+=XDPC~mkxH`tI9`0OD4FzWnY#s>UhVVX{T&Ov}9dbWn;{)*eyox&i?iA z1ad^hXg1L9;&9Z&eW-;U(GLkMMsuyox|ra!$%TbRLkscoWwq|EPYlV&KfM8NU5!K* zme#a)&)+_K!4#jJm4SP1>;rM~$ zaGK?mfO=^?8F+0Qj8o^DvfHh$3P>+!LUYFEj)#B8h*n>DE2O;O;4jGI>sB;|bMOP{+Q>SxHoc$lp@>m3p+;aqU|D-!w8Fe#)@h>1vIYQ_Fk9#DC=80NtY${FX1({i0(WJ8% zHKuRtN{ybWOZn!rxLDt6TOQj!BtIwMc6FoT6a=JMaBQZ7;kK1gqy@)_hb7ZMD^Fr} z$t+EN)!5HNbvOcOF=D8SYa^4JcJPUht858T5C8SZhC zbm`906iLF6Wa^)h%)ud6Bmxz*%)|Q%@*79?V14IM_+7YNH)IatyigLB(SgHO6_BP$ z$j48!JS5YE#=T(suJ%)bfbh&kF!yxz^?4q6ynRnFE3Vp6tnY?d+xg3=Q`zmJpk}Lu zEkG%4qAuzTY^FEsq)Aqr{Z=Svhd@rtbu!4-E!s=FX(#P~Y^{4SpL|b}pOuYfVg>WC Or;pewYpzya2KWOFubJ8a literal 0 HcmV?d00001 diff --git a/bee_wallet/tests/integration.rs b/bee_wallet/tests/integration.rs new file mode 100644 index 0000000..409689f --- /dev/null +++ b/bee_wallet/tests/integration.rs @@ -0,0 +1,4299 @@ +// Integration tests hit shellnet and share on-chain state (accumulator queues). +// Run sequentially to avoid flaky failures from queue contention: +// +// cargo test -p bee-wallet -- --test-threads=1 + +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering; + +use ackinacki_kit::tvm_client::crypto::KeyPair; +use bee_connect::ConnectClient; +use bee_connect::ParamsOfCreateSharedKeySession; +use bee_crypto::Crypto as BeeCrypto; +use bee_wallet::now_secs; +use bee_wallet::ConnectPayload; +use bee_wallet::GetBalanceByTokenRootReq; +use bee_wallet::GetEPKExpireReq; +use bee_wallet::ParamsGetMirrorAddress; +use bee_wallet::ParamsOfAcceptSharedKeyConnect; +use bee_wallet::ParamsOfDeployMultifactor; +use bee_wallet::ParamsOfDestroyConnectProfile; +use bee_wallet::ParamsOfGetHistory; +use bee_wallet::ParamsOfGetMultifactorAddress; +use bee_wallet::ParamsOfGetMultifactorInfo; +use bee_wallet::ParamsOfGetNativeBalances; +use bee_wallet::ParamsOfGetTokensBalances; +use bee_wallet::ParamsOfPrepareDeploy; +use bee_wallet::ParamsOfQuerySessionMessages; +use bee_wallet::ResultOfSendMessage; +use bee_wallet::Wallet; +use bee_wallet::WalletNameErrorCode; + +// walet wapp_t_1 = +// 0:2f50197761a36c34b43e696624b4738664d7f51a6915a61207589fd3e9d259c4 +// +/// Set mbiCur on a Boost contract. +/// Run: cargo test -p bee-wallet -- debug_set_boost_mbi --nocapture --ignored +/// Deploy two test wallets on shellnet, fund them, deploy miner on first. +/// Run once, then paste the printed constants into this file. +/// Run: cargo test -p bee-wallet -- setup_shellnet_test_wallets --nocapture +/// --ignored +#[tokio::test] +#[ignore] +async fn setup_shellnet_test_wallets() { + let wallet = create_shellnet_wallet(); + + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + for label in ["t1", "t2"] { + let name = format!("test_{label}_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let epk_expire_at = params.epk_expire_at; + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let addr = deploy_result.address.clone(); + + // Fund: gas + NACKL (ECC[1]) + SHELL (ECC[2]) + USDC (ECC[3]) + let ecc = std::collections::HashMap::from([ + (1u32, 5_000_000_000u64), // 5 NACKL + (2u32, 5_000_000_000u64), // 5 SHELL + (3u32, 5_000_000u64), // 5 USDC (decimals=6) + ]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &addr, + 10_000_000_000, // 10 vmshell gas + ecc, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // Deploy miner on first wallet + if label == "t1" { + let signer_keys = KeyPair { public: epk.clone(), secret: esk.clone() }; + wallet + .deploy_miner(bee_wallet::ParamsOfDeployMiner { + multifactor_address: addr.clone(), + signer_keys: signer_keys.clone(), + }) + .await + .expect("deploy_miner failed"); + + let mining_keys = create_crypto().gen_mining_keys().expect("gen_mining_keys"); + wallet + .set_mining_keys(bee_wallet::ParamsOfSetMiningKeys { + multifactor_address: addr.clone(), + signer_keys, + mining_pubkey: mining_keys.public.clone(), + app_id: String::new(), + epk_expire_at: None, + }) + .await + .expect("set_mining_keys failed"); + println!("miner deployed + keys set for {label}"); + } + + println!("\n// --- {label} ---"); + println!("const SHELLNET_{}: &str = \"{addr}\";", label.to_uppercase()); + println!("const SHELLNET_{}_EPK: &str = \"{epk}\";", label.to_uppercase()); + println!("const SHELLNET_{}_ESK: &str = \"{esk}\";", label.to_uppercase()); + println!("const SHELLNET_{}_NAME: &str = \"{name}\";", label.to_uppercase()); + println!("const SHELLNET_{}_EPK_EXPIRE_AT: u64 = {epk_expire_at};", label.to_uppercase()); + } + + // Send NACKL from T1 to T2 to seed history + println!("\nDone. Paste constants above into the test file."); +} + +/// One-off shellnet setup: set `_subscriber` (= Exchange) on the Exchange's +/// USDC TIP-3 wallet. Every shellnet redeploy loses it; without it +/// `migrate_tip3_usdc` deposits land silently (acceptTransfer credits the +/// wallet but `onTransferReceived` is never sent) and ECC[3] is never minted. +/// +/// Flow (token/TokenWallet.sol + Transaction.sol): +/// 1. `deployTransaction(SET_SUBSCRIBER_TYPE, ...)` on the wallet — open +/// access; +/// 2. the Transaction executes only on a plain transfer from the wallet's +/// owner (the Exchange), so `Exchange.triggerTransaction(txAddr)` signed +/// with the exchange owner key finishes the job. +/// +/// Run: cargo test -p bee-wallet --test integration -- +/// setup_exchange_usdc_subscriber --nocapture --ignored +#[tokio::test] +#[ignore] +async fn setup_exchange_usdc_subscriber() { + use ackinacki_kit::contracts::exchange::exchange_contract::Exchange; + use ackinacki_kit::contracts::exchange::exchange_contract::ParamsOfTriggerTransaction; + use ackinacki_kit::contracts::token::wallet::ParamsOfDeployTransaction; + use ackinacki_kit::contracts::token::wallet::TokenWallet; + use ackinacki_kit::contracts::token::wallet::Transaction as TokenTx; + use ackinacki_kit::tvm_client::abi::Signer; + + // Exchange owner keys on shellnet (rotate on every redeploy, same as the + // TIP-3 mint keys above). + let exchange_keys = KeyPair { + public: "ac09134c34f139b3e257727aca9db870410f8dcf17697b5fbcebef0cb5ea6e91".to_string(), + secret: "9038c35fa5499244d10d07b57885f8a6b38e55f21cb559374163de682de09ff0".to_string(), + }; + + let ctx = create_tvm_context(); + let exchange = Exchange::new_default(ctx.clone()); + + // Sanity: the provided key must be the on-chain owner, otherwise + // triggerTransaction is a silent no-op. + let owner = exchange.get_owner_pubkey().await.expect("get_owner_pubkey").owner_pubkey; + let owner_norm = owner.trim_start_matches("0x").to_lowercase(); + println!("on-chain exchange owner pubkey: {owner}"); + assert_eq!(owner_norm, exchange_keys.public, "exchange owner pubkey mismatch"); + + let usdc_wallet_addr = exchange.get_usdc_wallet().await.expect("get_usdc_wallet").usdc_wallet; + println!("exchange USDC wallet: {usdc_wallet_addr}"); + + let wallet = TokenWallet::new( + ctx.clone(), + ackinacki_kit::contracts::account::ParamsOfNewContract::new( + &usdc_wallet_addr, + ackinacki_kit::contracts::dapp::SystemDapp::System, + ), + ); + + let tx_spec = + TokenTx::SetSubscriber { destination_owner: Some(Exchange::DEFAULT_ADDRESS.to_string()) }; + + // 1. Deploy the SET_SUBSCRIBER transaction contract. + let deploy_res = wallet + .deploy_transaction( + ParamsOfDeployTransaction::from(tx_spec.clone()), + Signer::Keys { keys: exchange_keys.clone() }, + ) + .await + .expect("deploy_transaction(SET_SUBSCRIBER)"); + println!("deployTransaction message: {:?}", deploy_res.message_hash); + + let tx_addr = wallet + .get_transaction_address(tx_spec.into()) + .await + .expect("get_transaction_address") + .transaction_address; + println!("transaction address: {tx_addr}"); + + // 2. Wait until the Transaction contract lands on-chain. + let tx_account_id = tx_addr.trim_start_matches("0:").to_string(); + let mut deployed = false; + for _ in 0..30 { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + if ackinacki_kit::tvm_client::account::get_account( + ctx.clone(), + ackinacki_kit::tvm_client::account::ParamsOfGetAccount { + account_id: tx_account_id.clone(), + dapp_id: ackinacki_kit::contracts::dapp::SystemDapp::System.dapp_id().to_string(), + }, + ) + .await + .is_ok() + { + deployed = true; + break; + } + } + assert!(deployed, "SET_SUBSCRIBER transaction never appeared at {tx_addr}"); + + // 3. Owner triggers execution. + let trig = exchange + .trigger_transaction( + ParamsOfTriggerTransaction { tx_addr: tx_addr.clone() }, + Signer::Keys { keys: exchange_keys }, + ) + .await + .expect("trigger_transaction"); + println!("triggerTransaction message: {:?}", trig.message_hash); + println!("Done. Re-run test_migrate_tip3_usdc to verify the subscriber works."); +} + +fn create_shellnet_wallet() -> Wallet { + Wallet::new(bee_wallet::WalletConfig { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + api_url: "https://app-backend.ackinacki.org/api".to_string(), + app_id: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), + ..Default::default() + }) + .expect("Failed to create shellnet Wallet") +} + +fn create_crypto() -> BeeCrypto { + let endpoints = vec!["shellnet.ackinacki.org".to_string()]; + BeeCrypto::new(endpoints).expect("Failed to create BeeCrypto") +} + +fn create_tvm_context() -> std::sync::Arc { + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + std::sync::Arc::new(ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context")) +} + +struct DeployedWallet { + address: String, + epk: String, + esk: String, + name: String, +} + +/// Deploy a fresh wallet on shellnet, fund it with gas + ECC tokens via giver. +async fn deploy_fresh_wallet(wallet: &Wallet) -> DeployedWallet { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let idx = COUNTER.fetch_add(1, Ordering::SeqCst); + let name = format!("test_dyn_{}_{}", now_secs(), idx); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy_wallet failed"); + let address = deploy_result.address.clone(); + + let tvm_ctx = create_tvm_context(); + let ecc = std::collections::HashMap::from([ + (1u32, 5_000_000_000u64), // 5 NACKL + (2u32, 5_000_000_000u64), // 5 SHELL + (3u32, 5_000_000u64), // 5 USDC + ]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx, + &address, + 10_000_000_000, + ecc, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + DeployedWallet { address, epk, esk, name } +} + +/// Deploy a fresh wallet with enough ECC for DEX operations (voucher nominal = +/// 100+). +async fn deploy_fresh_wallet_for_dex(wallet: &Wallet) -> DeployedWallet { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let idx = COUNTER.fetch_add(1, Ordering::SeqCst); + let name = format!("test_dex_{}_{}", now_secs(), idx); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy_wallet failed"); + let address = deploy_result.address.clone(); + + let tvm_ctx = create_tvm_context(); + let ecc = std::collections::HashMap::from([ + (1u32, 200_000_000_000u64), // 200 NACKL (enough for N100 voucher + gas) + (2u32, 50_000_000_000u64), // 50 SHELL (for gas voucher) + (3u32, 5_000_000u64), // 5 USDC + ]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx, + &address, + 50_000_000_000, + ecc, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + DeployedWallet { address, epk, esk, name } +} + +fn create_deploy_wallet_params(wallet_name: String) -> ParamsOfDeployMultifactor { + ParamsOfDeployMultifactor { + epk: "6d26db3f0d23f66f358ca7d8f4e340ecc784f899002946b4eb04b1f7cb3325d6" + .to_string(), + epk_expire_at: 1784029474, + esk: "15910e12c0bc445cda49ad240a9533546a8c26b8a8d0313cd59533af1b463bc7" + .to_string(), + header_base_64: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImZmOGVlZDA0MjgyZjFkYmQ4OWY1YTc5Yjc4N2Q2N2JjODc2MjA1OTcifQ".to_string(), + index_mod_4: 1, + iss_base_64: "yJpc3MiOiJodHRwczovL29hdXRoLmdvc2guc2giLC".to_string(), + jwk_modulus: "c5b6adf2b02c0731bcd01071786afc797f34ef21d61f3cb5d1ce8c82486427db1eaca9a0ce7f9f9687790a2cc80e87aaff3b1ccd2c4c5a89aafc2885e6a6ce1a0ef569a6608263bda6aec4b369114210139d28346f010ed15cd876bf932cf43d6c7682d97e6c12e940ce05b30c00009177a7692372f281c6ec2fa51f271b0d9e2a38d983d7436682b2b7b9892829448f1834042ddcf9d02eade650658dd41668138df8cf1f79ec03323e80e7eb2814e28918ced0c16cddd891379120152174d170f1acabe5cb937213ccf844371630062bc4a923e406f7d1a92bf4aa5f611cf5848fcc482978ac9d55d2239e8e5670deab82417d3a8c044e187e83bfd79b9fa5".to_string(), + jwk_modulus_expire_at: now_secs() + 3600, + kid: "ff8eed04282f1dbd89f5a79b787d67bc87620597".to_string(), + password: "Hello!23".to_string(), + proof: "355528cf17afdde0a63f28050ad475407b6b515e7ba4cd171b77a6f0449874107e7f584f650117d7dbc4440cd62c5922f5f67a045b6364f107665e5d987bf12ceb191f246463920decbf50cf43567de0a885c53771440764cabd578f84c3581bcafd999764284c4f49b9b5ebc2f4508a931b62984970af006000b86c3effc08a".to_string(), + sub: "272114864".to_string(), + wallet_name, + zkid: "11122679641859749640320083403412561847128433970247905841202114460910422214869".to_string(), + } +} + +// Shellnet pre-deployed test wallets (created by setup_shellnet_test_wallets) +const SHELLNET_T1: &str = "0:cfa74aad2b19d5f8721dc5ec0bd05c6ca7d03d6666493293a1310d8289d6fb81"; +const SHELLNET_T1_EPK: &str = "6d26db3f0d23f66f358ca7d8f4e340ecc784f899002946b4eb04b1f7cb3325d6"; +const SHELLNET_T1_ESK: &str = "15910e12c0bc445cda49ad240a9533546a8c26b8a8d0313cd59533af1b463bc7"; +const SHELLNET_T1_NAME: &str = "test_t1_1780589811"; +const SHELLNET_T1_EPK_EXPIRE_AT: u64 = 1784029474; + +const SHELLNET_T2: &str = "0:9f6b5c10aa720c2b6b842f7689cbcd9220e364ba452e59ee2f7c3561647bff07"; +const SHELLNET_T2_EPK: &str = "6d26db3f0d23f66f358ca7d8f4e340ecc784f899002946b4eb04b1f7cb3325d6"; +const SHELLNET_T2_ESK: &str = "15910e12c0bc445cda49ad240a9533546a8c26b8a8d0313cd59533af1b463bc7"; +const SHELLNET_T2_EPK_EXPIRE_AT: u64 = 1784029474; + +// ============================================================ +// Sync / offline tests +// ============================================================ + +// --- validate_name --- + +#[test] +fn test_validate_name_valid() { + let wallet = create_shellnet_wallet(); + let result = wallet.validate_name("valid_wallet_name".to_string()); + assert!(result.is_valid()); + assert!(result.error_code().is_none()); +} + +#[test] +fn test_validate_name_invalid() { + let wallet = create_shellnet_wallet(); + + let r = wallet.validate_name("invalid*name".to_string()); + assert!(!r.is_valid()); + assert_eq!(r.error_code(), Some(WalletNameErrorCode::InvalidCharacters)); + + let r = wallet.validate_name("--name".to_string()); + assert!(!r.is_valid()); + assert_eq!(r.error_code(), Some(WalletNameErrorCode::ConsecutiveHyphens)); + + let r = wallet.validate_name("bad__name".to_string()); + assert!(!r.is_valid()); + assert_eq!(r.error_code(), Some(WalletNameErrorCode::ConsecutiveUnderscores)); + + let r = wallet.validate_name("-name".to_string()); + assert!(!r.is_valid()); + assert_eq!(r.error_code(), Some(WalletNameErrorCode::StartsWithSymbol)); + + let r = wallet.validate_name("a".repeat(40)); + assert!(!r.is_valid()); + assert_eq!(r.error_code(), Some(WalletNameErrorCode::TooLong)); + + let r = wallet.validate_name("abc".to_string()); + assert!(!r.is_valid()); + assert_eq!(r.error_code(), Some(WalletNameErrorCode::TooShort)); +} + +// ============================================================ +// Async / network tests (shellnet) +// ============================================================ + +// --- check_name_availability --- + +#[tokio::test] +async fn test_check_name_availability_nonexistent() { + let wallet = create_shellnet_wallet(); + let name = format!("test_{}", now_secs()); + let result = + wallet.check_name_availability(name).await.expect("check_name_availability failed"); + assert!(result.is_available); + assert!(result.multifactor_address.is_none()); +} + +// --- get_multifactor_data_by_name --- + +#[tokio::test] +async fn test_get_multifactor_data_by_name_nonexistent() { + let wallet = create_shellnet_wallet(); + let name = format!("test_{}", now_secs()); + let res = wallet.get_multifactor_data_by_name(name).await; + assert!(res.is_err()); +} + +// --- buy_shells / sell_shells --- + +/// Validates that sell_shells rejects invalid denomination. +/// Uses a real shellnet multifactor so EPK resolves, then service-level +/// validation catches invalid denom. +#[tokio::test] +async fn test_sell_shells_invalid_denom_rejected() { + let wallet = create_shellnet_wallet(); + + let result = wallet + .sell_shells(bee_wallet::SellShellsReq { + multifactor_address: SHELLNET_T1.to_string(), + denom: 7, + signer_keys: KeyPair { + public: SHELLNET_T1_EPK.to_string(), + secret: SHELLNET_T1_ESK.to_string(), + }, + bounce: None, + }) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("invalid denom"), + "expected invalid denom error, got: {}", + err.message + ); +} + +/// Validates that buy_shells rejects zero USDC amount. +/// Uses a real shellnet multifactor so EPK resolves, then service-level +/// validation catches `usdc_amount == 0`. +#[tokio::test] +async fn test_buy_shells_zero_amount_rejected() { + let wallet = create_shellnet_wallet(); + + let result = wallet + .buy_shells(bee_wallet::BuyShellsReq { + multifactor_address: SHELLNET_T1.to_string(), + usdc_amount: 0, + signer_keys: KeyPair { + public: SHELLNET_T1_EPK.to_string(), + secret: SHELLNET_T1_ESK.to_string(), + }, + bounce: None, + }) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("usdc_amount must be > 0"), + "expected zero-amount error, got: {}", + err.message + ); +} + +/// Query ECC balance for a given currency id via sdk `get_account` (REST) + +/// boc parse — independent of the wallet's own balance code. +/// +/// Account state is NOT served over GraphQL on `>= 1.0.0` servers (the old +/// `account(address:)` form is gone and the `info` sub-resolver hangs; the +/// supported path is `get_account`). `messages` queries still live on GraphQL. +async fn get_ecc_balance_by_id(address: &str, ecc_id: u32, _endpoint: &str) -> u64 { + let ctx = create_tvm_context(); + let account_id = address.strip_prefix("0:").unwrap_or(address).to_string(); + // Multifactor wallets live under the MobileVerifiers system dApp. + let dapp_id = "0000000000000000000000000000000000000000000000000000000000000001".to_string(); + let got = ackinacki_kit::tvm_client::account::get_account( + ctx.clone(), + ackinacki_kit::tvm_client::account::ParamsOfGetAccount { account_id, dapp_id }, + ) + .await + .expect("get_account"); + let parsed = ackinacki_kit::tvm_client::boc::parse_account( + ctx, + ackinacki_kit::tvm_client::boc::ParamsOfParse { boc: got.boc }, + ) + .expect("parse_account") + .parsed; + parsed["balance_other"] + .as_array() + .and_then(|arr| arr.iter().find(|b| b["currency"].as_u64() == Some(ecc_id as u64))) + .and_then(|b| b["value"].as_str()) + .and_then(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok()) + .unwrap_or(0) +} + +/// E2E: deploy wallet → fund USDC via giver → buy_shells → verify Shell +/// balance. +#[tokio::test] +async fn test_buy_shells_e2e() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy a fresh wallet + let name = format!("buyshell_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + println!("deployed: {} ({})", name, mf_address); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. Create TVM context for shellnet giver operations + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // 3. Fund: gas (vmshell) + USDC (ECC[3]) + let usdc_to_buy: u64 = 5; + + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 5_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let usdc_micro = usdc_to_buy * 1_000_000; + let ecc_usdc = std::collections::HashMap::from([(3u32, usdc_micro)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 1_000_000_000, + ecc_usdc, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // 4. Check USDC arrived + let usdc_before = get_ecc_balance_by_id(&mf_address, 3, "shellnet.ackinacki.org").await; + assert!(usdc_before >= usdc_micro, "multifactor should have USDC: got {usdc_before}"); + + // Remember Shell balance before purchase + let shell_before = get_ecc_balance_by_id(&mf_address, 2, "shellnet.ackinacki.org").await; + + // 5. Buy Shell + let result = wallet + .buy_shells(bee_wallet::BuyShellsReq { + multifactor_address: mf_address.clone(), + usdc_amount: usdc_to_buy, + signer_keys, + bounce: None, + }) + .await + .expect("buy_shells failed"); + println!("buy_shells tx: {:?}", result.message_hash); + + // Diagnostic: check USDC balance after buy + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + let usdc_after = get_ecc_balance_by_id(&mf_address, 3, "shellnet.ackinacki.org").await; + println!( + "USDC before={usdc_before}, after={usdc_after}, diff={}", + usdc_before.saturating_sub(usdc_after) + ); + + // 6. Poll for Shell balance increase (up to 120s) + // 5 USDC * 100 Shell/USDC * 1e9 nanoShell/Shell = 500_000_000_000 + let expected_shell = usdc_to_buy * 100 * 1_000_000_000; + let mut shell_after = shell_before; + + for attempt in 1..=24 { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + shell_after = get_ecc_balance_by_id(&mf_address, 2, "shellnet.ackinacki.org").await; + let delta = shell_after.saturating_sub(shell_before); + println!("attempt {attempt}/24: Shell after={shell_after}, delta={delta}, expected={expected_shell}"); + if delta >= expected_shell { + break; + } + } + + let shell_delta = shell_after.saturating_sub(shell_before); + assert!( + shell_delta >= expected_shell, + "Expected at least {expected_shell} nanoShell, got {shell_delta}" + ); +} + +/// E2E: deploy wallet → fund Shell via giver → sell_shells → verify order +/// created. +#[tokio::test] +async fn test_sell_shells_e2e() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy a fresh wallet + let name = format!("sellshell_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + println!("deployed: {} ({})", name, mf_address); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. TVM context for giver + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // 3. Fund: gas (vmshell) + Shell (ECC[2]) + let denom: u16 = 1; + // D=1 → 1 * 100 * 1e9 = 100_000_000_000 nanoShell + let shell_amount: u64 = (denom as u64) * 100 * 1_000_000_000; + + // Gas + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 5_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // Shell (ECC[2]) + let ecc_shell = std::collections::HashMap::from([(2u32, shell_amount)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 1_000_000_000, + ecc_shell, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // 4. Verify Shell arrived + let shell_before = get_ecc_balance_by_id(&mf_address, 2, "shellnet.ackinacki.org").await; + assert!(shell_before >= shell_amount, "multifactor should have Shell: got {shell_before}"); + + // 5. Sell Shell + let result = wallet + .sell_shells(bee_wallet::SellShellsReq { + multifactor_address: mf_address.clone(), + denom, + signer_keys, + bounce: None, + }) + .await + .expect("sell_shells failed"); + + println!( + "sell_shells tx: {:?}, order_id: {}, denom: {}, sell_order_address: {}, sold: {}, position_in_queue: {}", + result.message_hash, result.order_id, result.denom, + result.sell_order_address, result.sold, result.position_in_queue + ); + + assert_eq!(result.denom, denom); + assert!(!result.sell_order_address.is_empty(), "sell_order_address should be non-empty"); + + // 6. Wait for accumulator processing + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + + // 7. Verify Shell left the wallet + let shell_after = get_ecc_balance_by_id(&mf_address, 2, "shellnet.ackinacki.org").await; + assert!( + shell_after < shell_before, + "Shell balance should decrease after sell: before={shell_before}, after={shell_after}" + ); +} + +// --- get_my_sell_orders --- + +#[tokio::test] +async fn test_get_my_sell_orders_empty_for_new_wallet() { + let wallet = create_shellnet_wallet(); + + let name = format!("orders_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + + let orders = wallet + .get_my_sell_orders(bee_wallet::GetMySellOrdersReq { + multifactor_address: mf_address, + page_size: 20, + cursor: None, + }) + .await + .expect("get_my_sell_orders failed"); + + assert!(orders.orders.is_empty(), "fresh wallet should have no sell orders"); +} + +/// E2E: deploy → fund Shell → sell_shells → get_my_sell_orders → verify order +/// in list. +#[tokio::test] +async fn test_sell_then_get_my_orders() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy + let name = format!("sellord_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. TVM context for giver + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // 3. Fund: gas + Shell (ECC[2]) for D=1 + let denom: u16 = 1; + let shell_amount: u64 = (denom as u64) * 100 * 1_000_000_000; + + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 5_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let ecc_shell = std::collections::HashMap::from([(2u32, shell_amount)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 1_000_000_000, + ecc_shell, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // 4. Sell Shell + let sell_result = wallet + .sell_shells(bee_wallet::SellShellsReq { + multifactor_address: mf_address.clone(), + denom, + signer_keys, + bounce: None, + }) + .await + .expect("sell_shells failed"); + println!("sell order_id: {}, denom: {}", sell_result.order_id, sell_result.denom); + + // 5. Get my sell orders + let orders = wallet + .get_my_sell_orders(bee_wallet::GetMySellOrdersReq { + multifactor_address: mf_address.clone(), + page_size: 20, + cursor: None, + }) + .await + .expect("get_my_sell_orders failed"); + + println!("orders: {orders:?}"); + assert!(!orders.orders.is_empty(), "should have at least 1 sell order"); + + let my_order = orders + .orders + .iter() + .find(|o| o.order_id == sell_result.order_id && o.denom == denom) + .expect("our sell order should be in the list"); + + assert_eq!(my_order.denom, denom); + assert!(!my_order.claimed); + assert!(!my_order.sold); + assert!(my_order.position_in_queue > 0, "unsold order should have position > 0"); + assert!(!my_order.sell_order_address.is_empty()); +} + +/// Two wallets, each places 2 sell orders (D=1 and D=10). +/// Verify get_my_sell_orders returns correct orders per wallet — no +/// cross-contamination. +#[tokio::test] +async fn test_get_my_sell_orders_two_wallets_multiple_denoms() { + let wallet = create_shellnet_wallet(); + + // TVM context for giver + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // Deploy two wallets + let mut wallets = Vec::new(); + for i in 0..2 { + let name = format!("multi_sell_{}_{}", i, now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + let signer_keys = KeyPair { public: epk, secret: esk }; + println!("wallet {i}: {} ({})", name, mf_address); + wallets.push((mf_address, signer_keys)); + } + + // Fund both wallets: gas + Shell for D=1 (100 Shell) + D=10 (1000 Shell) = 1100 + // Shell + let shell_d1: u64 = 1 * 100 * 1_000_000_000; // D=1 + let shell_d10: u64 = 10 * 100 * 1_000_000_000; // D=10 + let total_shell = shell_d1 + shell_d10; + + for (mf_address, _) in &wallets { + // Gas + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + mf_address, + 10_000_000_000, // 10 vmshell (need gas for 2 sells) + std::collections::HashMap::new(), + 1, + ) + .await; + + // Shell + let ecc_shell = std::collections::HashMap::from([(2u32, total_shell)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + mf_address, + 1_000_000_000, + ecc_shell, + 1, + ) + .await; + } + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // Each wallet places 2 sell orders: D=1 and D=10 + let denoms: Vec = vec![1, 10]; + let mut expected_orders: Vec> = vec![Vec::new(), Vec::new()]; + + for (i, (mf_address, signer_keys)) in wallets.iter().enumerate() { + for &denom in &denoms { + let result = wallet + .sell_shells(bee_wallet::SellShellsReq { + multifactor_address: mf_address.clone(), + denom, + signer_keys: signer_keys.clone(), + bounce: None, + }) + .await + .expect(&format!("sell_shells failed: wallet {i}, denom {denom}")); + println!( + "wallet {i} sell: denom={}, order_id={}, addr={}", + result.denom, result.order_id, result.sell_order_address + ); + expected_orders[i].push((denom, result.order_id)); + } + } + + // Verify each wallet sees only its own orders + for (i, (mf_address, _)) in wallets.iter().enumerate() { + let orders = wallet + .get_my_sell_orders(bee_wallet::GetMySellOrdersReq { + multifactor_address: mf_address.clone(), + page_size: 20, + cursor: None, + }) + .await + .expect(&format!("get_my_sell_orders failed for wallet {i}")); + + println!("wallet {i} orders: {orders:?}"); + + // Should have at least 2 orders (D=1 and D=10) + assert!( + orders.orders.len() >= 2, + "wallet {i} should have at least 2 orders, got {}", + orders.orders.len() + ); + + // All expected orders must be present + for &(denom, order_id) in &expected_orders[i] { + let found = orders.orders.iter().find(|o| o.order_id == order_id && o.denom == denom); + assert!( + found.is_some(), + "wallet {i}: order (denom={denom}, order_id={order_id}) not found in {orders:?}" + ); + let order = found.unwrap(); + assert!(!order.claimed); + assert!(!order.sell_order_address.is_empty()); + } + + // No orders from the other wallet + let other = 1 - i; + for &(denom, order_id) in &expected_orders[other] { + let leaked = orders.orders.iter().any(|o| o.order_id == order_id && o.denom == denom); + assert!( + !leaked, + "wallet {i} should NOT see wallet {other}'s order (denom={denom}, order_id={order_id})" + ); + } + } +} + +// --- redeem_nackl --- + +#[tokio::test] +async fn test_redeem_nackl_zero_amount_rejected() { + let wallet = create_shellnet_wallet(); + + let result = wallet + .redeem_nackl(bee_wallet::RedeemNacklReq { + multifactor_address: + "0:0000000000000000000000000000000000000000000000000000000000000000".to_string(), + nackl_amount: 0, + signer_keys: KeyPair { public: "aaaa".to_string(), secret: "bbbb".to_string() }, + bounce: None, + }) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.message.contains("nackl_amount must be > 0"), + "expected zero-amount error, got: {}", + err.message + ); +} + +/// E2E: deploy → fund NACKL → get_nackl_redeem_rate → redeem_nackl → verify +/// USDC received. +#[tokio::test] +async fn test_redeem_nackl_e2e() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy + let name = format!("redeem_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + println!("deployed: {} ({})", name, mf_address); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. TVM context for giver + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // 3. Fund: gas + NACKL (ECC[1]) + let nackl_amount: u64 = 1_000_000_000; // 1 NACKL + + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 5_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let ecc_nackl = std::collections::HashMap::from([(1u32, nackl_amount)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 1_000_000_000, + ecc_nackl, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // 4. Check rate before redeem + let rate = wallet.get_nackl_redeem_rate().await.expect("get_nackl_redeem_rate failed"); + println!( + "rate: redeemable={}, supply={}, usdc_per_nackl={}", + rate.redeemable_usdc, rate.current_nackl_supply, rate.usdc_per_nackl + ); + + // Skip if no redeemable USDC (nothing to redeem against) + if rate.redeemable_usdc == 0 { + println!("No redeemable USDC on accumulator, skipping redeem test"); + return; + } + + // 5. Remember USDC balance before + let usdc_before = get_ecc_balance_by_id(&mf_address, 3, "shellnet.ackinacki.org").await; + + // 6. Redeem NACKL + let result = wallet + .redeem_nackl(bee_wallet::RedeemNacklReq { + multifactor_address: mf_address.clone(), + nackl_amount, + signer_keys, + bounce: None, + }) + .await + .expect("redeem_nackl failed"); + println!("redeem tx: {:?}", result.message_hash); + + // 7. Wait and verify USDC received + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + + let usdc_after = get_ecc_balance_by_id(&mf_address, 3, "shellnet.ackinacki.org").await; + let usdc_delta = usdc_after - usdc_before; + println!("USDC before={usdc_before}, after={usdc_after}, delta={usdc_delta}"); + assert!(usdc_delta > 0, "Should have received some USDC from redeem"); +} + +// --- get_nackl_redeem_rate --- + +#[tokio::test] +async fn test_get_nackl_redeem_rate() { + let wallet = create_shellnet_wallet(); + + let rate = wallet.get_nackl_redeem_rate().await.expect("get_nackl_redeem_rate failed"); + + println!( + "redeemable_usdc={}, current_nackl_supply={}, usdc_per_nackl={}", + rate.redeemable_usdc, rate.current_nackl_supply, rate.usdc_per_nackl + ); + + // Supply should be > 0 on shellnet (emission started) + assert!(rate.current_nackl_supply > 0, "NACKL supply should be > 0"); + + // If there's redeemable USDC, rate should be > 0 + if rate.redeemable_usdc > 0 { + assert!(rate.usdc_per_nackl > 0, "rate should be > 0 when redeemable > 0"); + } +} + +// --- claim_usdc --- + +/// E2E full cycle: deploy → sell → giver buys queue → claim → verify USDC +/// received. +#[tokio::test] +async fn test_claim_usdc_full_cycle() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy wallet + let name = format!("claim_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + println!("deployed: {} ({})", name, mf_address); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. TVM context for giver + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // 3. Fund wallet: gas + Shell for D=1 + let denom: u16 = 1; + let shell_amount: u64 = (denom as u64) * 100 * 1_000_000_000; + + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 10_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let ecc_shell = std::collections::HashMap::from([(2u32, shell_amount)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 1_000_000_000, + ecc_shell, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // 4. Sell Shell + let sell_result = wallet + .sell_shells(bee_wallet::SellShellsReq { + multifactor_address: mf_address.clone(), + denom, + signer_keys: signer_keys.clone(), + bounce: None, + }) + .await + .expect("sell_shells failed"); + let order_id = sell_result.order_id; + println!("sell order_id: {order_id}, address: {}", sell_result.sell_order_address); + + // 5. Giver buys entire D=1 queue (clears FIFO including our order) + use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ParamsOfGetQueueState as QSP; + use ackinacki_kit::contracts::accumulator::shell_accumulator_root_usdc::ShellAccumulatorRootUsdc as AccRoot; + + let accumulator = AccRoot::new_default(tvm_ctx.clone()); + let queue = accumulator.get_queue_state(QSP { d: denom }).await.expect("getQueueState"); + let available = queue.available; + println!("queue D={denom}: available={available}, soldPrefix={}", queue.sold_prefix); + + // Buy in a loop until our order is sold (accumulator may process in batches) + for attempt in 0..10 { + let q = accumulator.get_queue_state(QSP { d: denom }).await.expect("getQueueState"); + if order_id <= q.sold_prefix { + println!( + "order {order_id} sold after {attempt} buy round(s), soldPrefix={}", + q.sold_prefix + ); + break; + } + let unsold = q.next_id.saturating_sub(q.sold_prefix); + if unsold == 0 { + break; + } + let usdc_to_buy = unsold * (denom as u64) * 1_000_000; + let ecc_usdc = std::collections::HashMap::from([(3u32, usdc_to_buy)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + AccRoot::DEFAULT_ADDRESS, + 1_000_000_000, + ecc_usdc, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + + // 6. Verify our order is now sold + let queue_after = + accumulator.get_queue_state(QSP { d: denom }).await.expect("getQueueState after buy"); + assert!( + order_id <= queue_after.sold_prefix, + "order should be sold: order_id={order_id}, soldPrefix={}", + queue_after.sold_prefix + ); + + // 7. Fund accumulator + SellOrderLot with gas for claim chain + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + AccRoot::DEFAULT_ADDRESS, + 5_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &sell_result.sell_order_address, + 5_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // 8. Remember USDC balance before claim + let usdc_before = get_ecc_balance_by_id(&mf_address, 3, "shellnet.ackinacki.org").await; + + // 10. Claim USDC + let claim_result = wallet + .claim_usdc(bee_wallet::ClaimUsdcReq { denom, order_id, signer_keys: signer_keys.clone() }) + .await + .expect("claim_usdc failed"); + println!("claim tx: {:?}, payout: {}", claim_result.message_hash, claim_result.usdc_payout); + + // 10. Wait and verify USDC received + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + + let usdc_after = get_ecc_balance_by_id(&mf_address, 3, "shellnet.ackinacki.org").await; + let usdc_delta = usdc_after - usdc_before; + let expected_payout = (denom as u64) * 1_000_000; + println!( + "USDC before={usdc_before}, after={usdc_after}, delta={usdc_delta}, expected={expected_payout}" + ); + assert!( + usdc_delta >= expected_payout, + "Expected at least {expected_payout} micro-USDC, got {usdc_delta}" + ); + + // 11. Verify order disappeared from get_my_sell_orders (best-effort: + // accumulator may become inactive after claim self-destruct chain) + if let Ok(orders) = wallet + .get_my_sell_orders(bee_wallet::GetMySellOrdersReq { + multifactor_address: mf_address.clone(), + page_size: 20, + cursor: None, + }) + .await + { + let found = orders.orders.iter().any(|o| o.order_id == order_id && o.denom == denom); + assert!(!found, "claimed order should not appear in get_my_sell_orders"); + } +} + +/// Pagination + random claim test: +/// 1. Create 5 sell orders (denom=1, small page_size=2 to force pagination) +/// 2. Claim orders [1] and [3] (random middle positions) +/// 3. Paginate through all pages — collect all orders +/// 4. Verify: total count correct, claimed orders absent, no infinite loop +#[tokio::test] +async fn test_sell_orders_pagination_with_random_claims() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy wallet + let name = format!("pagclaim_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + println!("deployed: {mf_address}"); + + let signer_keys = + ackinacki_kit::tvm_client::crypto::KeyPair { public: epk.clone(), secret: esk.clone() }; + + // 2. Fund: gas + Shell for 5 orders of denom=1 (each needs 100 Shell = 100 * + // 10^9 nano) + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // Gas + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 10_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + // Shell: 5 orders × 100 Shell × 10^9 = 500_000_000_000 + let shell_ecc = std::collections::HashMap::from([(2u32, 500_000_000_000u64)]); + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 1_000_000_000, + shell_ecc, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // 3. Create 5 sell orders + let mut order_ids = Vec::new(); + for i in 0..5 { + let result = wallet + .sell_shells(bee_wallet::SellShellsReq { + multifactor_address: mf_address.clone(), + denom: 1, + signer_keys: signer_keys.clone(), + bounce: None, + }) + .await + .expect(&format!("sell_shells #{i} failed")); + println!("order #{i}: id={}, sold={}", result.order_id, result.sold); + order_ids.push(result.order_id); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert_eq!(order_ids.len(), 5); + + // 4. Claim orders at index 1 and 3 (random middle positions) + let claim_indices = [1, 3]; + for &idx in &claim_indices { + // Wait for order to be sold (poll) + let order_id = order_ids[idx]; + println!("claiming order #{idx} (id={order_id})..."); + + // Fund the queue: send USDC to accumulator to buy the orders + let usdc_ecc = std::collections::HashMap::from([(3u32, 1_000_000u64)]); // 1 USDC + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 1_000_000_000, + usdc_ecc, + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + match wallet + .claim_usdc(bee_wallet::ClaimUsdcReq { + denom: 1, + order_id, + signer_keys: signer_keys.clone(), + }) + .await + { + Ok(r) => println!(" claimed: payout={}", r.usdc_payout), + Err(e) => println!(" claim failed (may not be sold yet): {e}"), + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + + // 5. Paginate with page_size=2 — collect all orders + let mut all_orders = Vec::new(); + let mut cursor: Option = None; + let mut pages = 0; + let max_pages = 20; // safety limit + + loop { + let result = wallet + .get_my_sell_orders(bee_wallet::GetMySellOrdersReq { + multifactor_address: mf_address.clone(), + page_size: 2, + cursor: cursor.clone(), + }) + .await + .expect("get_my_sell_orders failed"); + + pages += 1; + println!( + "page {pages}: {} orders, has_next_page={}, cursor={:?}", + result.orders.len(), + result.has_next_page, + result.next_cursor, + ); + for o in &result.orders { + println!( + " denom={} order_id={} sold={} claimed={} pos={}", + o.denom, o.order_id, o.sold, o.claimed, o.position_in_queue + ); + } + + all_orders.extend(result.orders); + + if !result.has_next_page || result.next_cursor.is_none() { + break; + } + + // Safety: no infinite loop + assert!( + pages < max_pages, + "pagination exceeded {max_pages} pages — possible infinite loop" + ); + cursor = result.next_cursor; + } + + println!("\ntotal: {} orders across {pages} pages", all_orders.len()); + + // 6. Verify results + // All 5 order_ids should be accounted for (either in list or claimed/skipped) + for (i, &oid) in order_ids.iter().enumerate() { + let in_list = all_orders.iter().any(|o| o.order_id == oid); + let was_claimed = claim_indices.contains(&i); + if was_claimed { + // Claimed orders may or may not appear (depends on kit behavior with + // self-destructed lots) + if in_list { + let o = all_orders.iter().find(|o| o.order_id == oid).unwrap(); + println!( + "order #{i} (id={oid}): claimed and still in list (claimed={})", + o.claimed + ); + } else { + println!("order #{i} (id={oid}): claimed and removed from list (expected)"); + } + } else { + // Unclaimed orders MUST be in the list + assert!(in_list, "unclaimed order #{i} (id={oid}) missing from paginated results"); + } + } + + // No duplicates + let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); + for o in &all_orders { + assert!( + seen_ids.insert(o.order_id), + "duplicate order_id={} in paginated results", + o.order_id + ); + } + + println!( + "pagination test passed: {pages} pages, {} unique orders, no infinite loop", + all_orders.len() + ); +} + +// --- deploy_wallet + check_name_availability --- + +#[tokio::test] +async fn test_check_name_availability_and_deploy_wallet() { + let wallet = create_shellnet_wallet(); + let name = format!("test_{}", now_secs()); + let res = wallet.check_name_availability(name.clone()).await; + match res { + Ok(r) => { + assert!(r.is_available); + assert!(r.multifactor_address.is_none()); + } + Err(e) => { + assert!(!e.message.is_empty()); + } + } + let params = create_deploy_wallet_params(name); + let result = wallet.deploy_wallet(params).await; + println!("result {result:#?}"); + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_deploy_wallet_invalid_name_fails_fast() { + let wallet = create_shellnet_wallet(); + let params = create_deploy_wallet_params("invalid*name".to_string()); + let err = wallet.deploy_wallet(params).await.expect_err("deploy should fail on invalid name"); + + assert_eq!(err.error_code.as_deref(), Some("INVALID_WALLET_NAME")); + assert!(err.message.contains("Invalid wallet name")); +} + +// --- get_miner_details --- + +#[tokio::test] +async fn test_get_miner_details_by_multifactor_address() { + let wallet = create_shellnet_wallet(); + let result = wallet + .get_miner_details_by_multifactor_address(SHELLNET_T1.to_string()) + .await + .expect("get_miner_details_by_multifactor_address failed"); + assert!(!result.address.is_empty()); + assert!(!result.owner_address.is_empty()); +} + +// --- get_miner_address --- + +#[tokio::test] +async fn test_get_miner_address() { + let wallet = create_shellnet_wallet(); + let address = wallet.get_miner_address(SHELLNET_T1).await.expect("get_miner_address failed"); + assert!(!address.is_empty()); +} + +// --- get_multifactor_balances --- + +#[tokio::test] +async fn test_get_multifactor_balances() { + let wallet = create_shellnet_wallet(); + let result = wallet + .get_multifactor_balances(ParamsOfGetNativeBalances { + multifactor_address: SHELLNET_T1.to_string(), + }) + .await + .expect("get_multifactor_balances failed"); + assert!(!result.ecc.is_empty() || !result.popitgame.is_empty()); +} + +// ============================================================ +// Network tests using existing shellnet wallets +// ============================================================ + +// --- get_multifactor_address --- + +#[tokio::test] +async fn test_get_multifactor_address() { + let wallet = create_shellnet_wallet(); + let data = wallet + .get_multifactor_data_by_name(SHELLNET_T1_NAME.to_string()) + .await + .expect("get_multifactor_data_by_name failed"); + let mf = data.expect("shellnet_t1 should exist"); + + let result = wallet + .get_multifactor_address(ParamsOfGetMultifactorAddress { pubkey: mf.owner_pubkey.clone() }) + .await + .expect("get_multifactor_address failed"); + assert!(!result.address.is_empty()); +} + +// --- get_mirror_address --- + +#[tokio::test] +async fn test_get_mirror_address() { + let wallet = create_shellnet_wallet(); + let data = wallet + .get_multifactor_data_by_name(SHELLNET_T1_NAME.to_string()) + .await + .expect("get_multifactor_data_by_name failed"); + let mf = data.expect("shellnet_t1 should exist"); + + let result = wallet + .get_mirror_address(ParamsGetMirrorAddress { pubkey: mf.owner_pubkey.clone() }) + .expect("get_mirror_address failed"); + assert!(!result.address.is_empty()); +} + +// --- get_multifactor_info --- + +#[tokio::test] +async fn test_get_multifactor_info() { + let wallet = create_shellnet_wallet(); + let address = SHELLNET_T1.to_string(); + let result = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address }) + .await + .expect("get_multifactor_info failed"); + assert!(result.data.is_some()); +} + +// --- get_balance_by_token_root --- + +#[tokio::test] +async fn test_get_balance_by_token_root() { + let wallet = create_shellnet_wallet(); + let address = SHELLNET_T1.to_string(); + let result = wallet + .get_balance_by_token_root(GetBalanceByTokenRootReq { + token_root: "1".to_string(), + // ECC (NACKL): dapp_id unused; shellnet is 0.9.0 so ignored anyway. + token_dapp: String::new(), + multifactor_address: address, + }) + .await + .expect("get_balance_by_token_root failed"); + // own_balance is always present, value can be zero + let _ = result.own_balance; +} + +// --- get_epk_expire_at --- + +#[tokio::test] +async fn test_get_epk_expire_at() { + let wallet = create_shellnet_wallet(); + + let result = wallet + .get_epk_expire_at(GetEPKExpireReq { + epk: SHELLNET_T1_EPK.to_string(), + multifactor_address: SHELLNET_T1.to_string(), + }) + .await + .expect("get_epk_expire_at failed"); + assert!(result.epk_expire_at > 0); +} + +// --- get_tokens_balances --- + +#[tokio::test] +async fn test_get_tokens_balances() { + let wallet = create_shellnet_wallet(); + let address = SHELLNET_T1.to_string(); + let result = wallet + .get_tokens_balances(ParamsOfGetTokensBalances { + multifactor_address: address, + token_roots: vec![bee_wallet::TokenRef { + token_root: "0:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + .to_string(), + // shellnet: this root lives under the System dApp (verified); + // ignored on 0.9.0 regardless. + token_dapp: "0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + }], + }) + .await + .expect("get_tokens_balances failed"); + let _ = result; +} + +// ============================================================ +// ECC transfer + history integration tests +// ============================================================ + +async fn send_ecc( + wallet: &Wallet, + from: &str, + to: &str, + amount: u64, + signer_keys: KeyPair, +) -> ResultOfSendMessage { + wallet + .send_tokens(bee_wallet::SendTokensReq { + multifactor_address: from.to_string(), + destination_address: to.to_string(), + token_root: "1".to_string(), + // ECC (NACKL): dapp_id unused; shellnet 0.9.0 ignores it anyway. + token_dapp: String::new(), + amount_raw: amount, + signer_keys, + bounce: None, + }) + .await + .expect(&format!("send_tokens failed {} → {}", from, to)) +} + +/// Paginates through ALL history pages searching for a matching tx. +async fn wait_for_tx( + wallet: &Wallet, + address: &str, + min_ts: u64, + expected_value: u128, + expected_type: &str, +) { + let val_str = expected_value.to_string(); + + for attempt in 0..20 { + let mut cursor: Option = None; + let mut mining_cursor: Option = None; + let mut total = 0; + + loop { + let history = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: address.to_string(), + token_id: "1".to_string(), + page_size: 50, + cursor: cursor.clone(), + mining_cursor: mining_cursor.clone(), + }) + .await + .expect("get_ecc_history failed"); + + total += history.data.len(); + + let found = history.data.iter().any(|tx| { + tx.tx_type == expected_type + && tx.created_at.parse::().unwrap_or(0) >= min_ts + && tx.value == val_str + }); + + if found { + println!("found after {} attempt(s), {} total entries scanned", attempt + 1, total); + return; + } + + if !history.has_next_page || history.data.is_empty() { + break; + } + + cursor = history.next_cursor; + mining_cursor = history.next_mining_cursor; + } + + if attempt % 5 == 0 { + println!( + "attempt {}/20, {} total entries, looking for type={} value={} min_ts={}", + attempt, total, expected_type, expected_value, min_ts + ); + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + + panic!( + "tx not found: addr={}, type={}, min_ts={}, value={}", + address, expected_type, min_ts, expected_value + ); +} + +/// Sends 0.05 NACKL from SHELLNET_T1 to SHELLNET_T2. +/// Checks that the tx appears in history of both wallets. +#[tokio::test] +async fn transfer_ecc_visible_in_history() { + let wallet = create_shellnet_wallet(); + + assert!(SHELLNET_T1_EPK_EXPIRE_AT > bee_wallet::now_secs(), "SHELLNET_T1 EPK expired!"); + assert!(SHELLNET_T2_EPK_EXPIRE_AT > bee_wallet::now_secs(), "SHELLNET_T2 EPK expired!"); + + println!("direction: {} → {}", SHELLNET_T1, SHELLNET_T2); + + let ts = bee_wallet::now_secs().saturating_sub(5); + let amount: u64 = 50_000_000; + + let signer_keys = + KeyPair { public: SHELLNET_T1_EPK.to_string(), secret: SHELLNET_T1_ESK.to_string() }; + let _res = send_ecc(&wallet, SHELLNET_T1, SHELLNET_T2, amount, signer_keys).await; + + // Wait for Outgoing in sender history + wait_for_tx(&wallet, SHELLNET_T1, ts, amount as u128, "Outgoing").await; + + // Wait for Incoming in receiver history + wait_for_tx(&wallet, SHELLNET_T2, ts, amount as u128, "Incoming").await; +} + +#[tokio::test] +async fn ecc_history_pagination() { + let wallet = create_shellnet_wallet(); + + let page1 = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: SHELLNET_T1.to_string(), + token_id: "1".to_string(), + page_size: 2, + cursor: None, + mining_cursor: None, + }) + .await + .expect("page1"); + + if page1.has_next_page { + let page2 = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: SHELLNET_T1.to_string(), + token_id: "1".to_string(), + page_size: 2, + cursor: page1.next_cursor.clone(), + mining_cursor: page1.next_mining_cursor.clone(), + }) + .await + .expect("page2"); + + let ids1: std::collections::HashSet<&str> = + page1.data.iter().map(|t| t.id.as_str()).collect(); + for tx in &page2.data { + assert!(!ids1.contains(tx.id.as_str()), "duplicate: {}", tx.id); + } + } +} + +#[tokio::test] +async fn token_history_stub_returns_empty() { + let wallet = create_shellnet_wallet(); + + let result = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: SHELLNET_T1.to_string(), + token_id: "0:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + .to_string(), + page_size: 20, + cursor: None, + mining_cursor: None, + }) + .await + .expect("get_history for TIP-3"); + + assert!(result.data.is_empty()); + assert!(!result.has_next_page); +} + +// ============================================================ +// send_tokens: NACKL (ECC[1]), SHELL (ECC[2]), USDC (ECC[3]) +// ============================================================ + +#[tokio::test] +async fn test_send_all_ecc_tokens() { + let wallet = create_shellnet_wallet(); + + let sender = deploy_fresh_wallet(&wallet).await; + let receiver = deploy_fresh_wallet(&wallet).await; + let signer = KeyPair { public: sender.epk.clone(), secret: sender.esk.clone() }; + + let ts = bee_wallet::now_secs().saturating_sub(5); + + // ECC[1] NACKL, ECC[2] SHELL, ECC[3] USDC + let tokens = [ + ("1", 50_000_000u64, "NACKL"), // 0.05 NACKL (9 decimals) + ("2", 50_000_000u64, "SHELL"), // 0.05 SHELL (9 decimals) + ("3", 50_000u64, "USDC"), // 0.05 USDC (6 decimals) + ]; + + for (token_root, amount, label) in &tokens { + let result = wallet + .send_tokens(bee_wallet::SendTokensReq { + multifactor_address: sender.address.clone(), + destination_address: receiver.address.clone(), + token_root: token_root.to_string(), + // shellnet 0.9.0 ignores dapp_id; System for any TIP-3 root here. + token_dapp: "0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + amount_raw: *amount, + signer_keys: signer.clone(), + bounce: None, + }) + .await; + match &result { + Ok(res) => println!("{label} send OK: {:?}", res.message_hash), + Err(e) => panic!("{label} send failed: {e:?}"), + } + } + + // Verify NACKL appears in sender history (representative check) + wait_for_tx(&wallet, &sender.address, ts, 50_000_000, "Outgoing").await; + // Verify NACKL appears in receiver history + wait_for_tx(&wallet, &receiver.address, ts, 50_000_000, "Incoming").await; +} + +// --- send_tokens_direct (sendTransaction with flags) --- + +#[tokio::test] +async fn test_send_tokens_direct_shell_with_flags() { + let wallet = create_shellnet_wallet(); + + let sender = deploy_fresh_wallet(&wallet).await; + let receiver = deploy_fresh_wallet(&wallet).await; + let signer = KeyPair { public: sender.epk.clone(), secret: sender.esk.clone() }; + + let result = wallet + .send_tokens_direct(bee_wallet::SendTokensDirectReq { + multifactor_address: sender.address.clone(), + destination_address: receiver.address.clone(), + token_root: "2".to_string(), // ECC[2] SHELL + amount_raw: 50_000_000, // 0.05 SHELL (9 decimals) + flags: 16, + signer_keys: signer, + bounce: None, + value: None, + payload: None, + }) + .await; + + let res = result.expect("SHELL send_direct failed"); + assert!(!res.aborted.unwrap_or(false), "tx should not be aborted"); + assert_eq!(res.exit_code.unwrap_or(0), 0, "exit_code should be 0"); + println!("SHELL send_direct OK: {:?}", res.message_hash); + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let sender_history = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: sender.address.clone(), + token_id: "2".to_string(), + page_size: 5, + cursor: None, + mining_cursor: None, + }) + .await + .expect("get_history sender failed"); + + println!("=== Sender SHELL history ==="); + for tx in &sender_history.data { + println!( + " id={} type={} value={} created_at={} src_name={:?}", + tx.id, tx.tx_type, tx.value, tx.created_at, tx.src_name + ); + } + + let receiver_history = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: receiver.address.clone(), + token_id: "2".to_string(), + page_size: 5, + cursor: None, + mining_cursor: None, + }) + .await + .expect("get_history receiver failed"); + + println!("=== Receiver SHELL history ==="); + for tx in &receiver_history.data { + println!( + " id={} type={} value={} created_at={} src_name={:?}", + tx.id, tx.tx_type, tx.value, tx.created_at, tx.src_name + ); + } +} + +// --- topup future contract address (send_tokens_direct flag 16) --- + +#[tokio::test] +async fn test_topup_future_contract_address() { + let wallet = create_shellnet_wallet(); + + let sender = deploy_fresh_wallet(&wallet).await; + let signer = KeyPair { public: sender.epk.clone(), secret: sender.esk.clone() }; + + let tvm_ctx = create_tvm_context(); + + // Generate random keypair for the future contract + let contract_keys = + ackinacki_kit::tvm_client::crypto::generate_random_sign_keys(tvm_ctx.clone()) + .expect("generate keys"); + println!("contract keys: public={}, secret={}", contract_keys.public, contract_keys.secret); + + // 3. Load TVC and ABI + let tvc_bytes = std::fs::read("tests/hello.tvc").expect("read hello.tvc"); + let tvc_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &tvc_bytes); + let abi_json = std::fs::read_to_string("tests/hello.abi.json").expect("read hello.abi.json"); + let abi = ackinacki_kit::tvm_client::abi::Abi::Json(abi_json); + + // 4. Compute future contract address via encode_message with DeploySet + let encode_result = ackinacki_kit::tvm_client::abi::encode_message( + tvm_ctx.clone(), + ackinacki_kit::tvm_client::abi::ParamsOfEncodeMessage { + abi: abi.clone(), + address: None, + deploy_set: Some(ackinacki_kit::tvm_client::abi::DeploySet { + tvc: Some(tvc_b64), + code: None, + state_init: None, + workchain_id: Some(0), + initial_data: Some(serde_json::json!({ + "_pubkey": format!("0x{}", contract_keys.public) + })), + initial_pubkey: None, + }), + call_set: Some(ackinacki_kit::tvm_client::abi::CallSet { + function_name: "constructor".to_string(), + header: None, + input: Some(serde_json::json!({ "value": 1000000000u64 })), + }), + signer: ackinacki_kit::tvm_client::abi::Signer::Keys { keys: contract_keys.clone() }, + processing_try_index: None, + signature_id: None, + }, + ) + .await + .expect("encode_message for future address"); + + let future_address = encode_result.address; + println!("future contract address: {future_address}"); + + // 5. Check account state BEFORE top-up — should be NonExist or Uninit. + // A fresh account created by a plain value transfer becomes the root of + // its OWN dApp: dapp_id == its account_id (lookups are dapp-scoped on + // >= 1.0.0 servers, so fetching it under SystemDapp::System finds nothing). + let future_dapp = future_address.trim_start_matches("0:").to_string(); + let mut account = ackinacki_kit::contracts::account::Account::new( + tvm_ctx.clone(), + &future_address, + future_dapp, + ); + account.fetch().await.expect("fetch account before"); + println!( + "account BEFORE: acc_type={:?}, balance={:?}, ecc={:?}", + account.acc_type, account.balance, account.ecc + ); + + // 6. Send SHELL (ECC[2]) with flag 16 to the future address + let shell_amount: u64 = 100_000_000; // 0.1 SHELL + println!( + "sending {shell_amount} SHELL (ECC[2]) with flag=16, bounce=false to {future_address}" + ); + let result = wallet + .send_tokens_direct(bee_wallet::SendTokensDirectReq { + multifactor_address: sender.address.clone(), + destination_address: future_address.clone(), + token_root: "2".to_string(), + amount_raw: shell_amount, + flags: 16, + signer_keys: signer, + bounce: Some(false), // non-deployed address — no bounce + value: None, + payload: None, + }) + .await + .expect("send_tokens_direct failed"); + + assert!(!result.aborted.unwrap_or(false), "tx should not be aborted"); + assert_eq!(result.exit_code.unwrap_or(0), 0, "exit_code should be 0"); + println!("send_tokens_direct OK: {:?}", result.message_hash); + + // 7. Wait a bit then check account state AFTER top-up + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + account.fetch().await.expect("fetch account after"); + println!( + "account AFTER: acc_type={:?}, balance={:?}, ecc={:?}", + account.acc_type, account.balance, account.ecc + ); + + // Account should transition from NonExist to Uninit after top-up + assert_eq!( + account.acc_type, + ackinacki_kit::contracts::account::AccountStatus::Uninit, + "future account should be Uninit after top-up" + ); + + // ECC[2] key should exist (SHELL currency registered on the account) + assert!( + account.ecc.contains_key(&2), + "future account should have ECC[2] (SHELL) key after top-up, ecc={:?}", + account.ecc + ); + println!("SHELL (ECC[2]) balance: {}", account.ecc.get(&2).unwrap()); +} + +// --- update_zk_id --- + +#[tokio::test] +async fn test_update_zk_id() { + let wallet = create_shellnet_wallet(); + let name = format!("zkid_test_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let initial_zkid = params.zkid.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy_wallet failed"); + let addr = deploy_result.address.clone(); + println!("deployed wallet: {} ({})", name, addr); + + let owner_keys = create_crypto() + .get_keys_from_mnemonic(deploy_result.phrase.clone()) + .expect("get_keys_from_mnemonic failed"); + + let info_before = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address: addr.clone() }) + .await + .expect("get_multifactor_info failed before update"); + let data_before = info_before.data.expect("no data before update"); + assert_eq!(data_before.zkid, initial_zkid); + + let new_zkid = "148536752753571014218927771785777007655354657642539776711310815121986360450"; + wallet + .update_zk_id(bee_wallet::UpdateMultifactorZkIdReq { + address: addr.clone(), + zkid: new_zkid.to_string(), + password: "Hello!234".to_string(), + proof: "9a6f8d061d92d5f3b8de80e210cc46dde0b5847ce37a5b7d1fb27a50151a70a16553dba074487ad37a6ec47fca8e6496b7bf39be6cf5a1c7cf2e4f547155a605670b4720a8626a5c6e30aa7575053a8a5f6c06f0849fefcf634acd056aad1398e570f4ea6b211efc53082da3e7dd839d4fa65b13099db4f9d6c597936864911b".to_string(), + epk: "4199685466e5f30239e040cb740a9874be680b950aab45a279a15edf2d2c9751".to_string(), + esk: "cfbcb94227aef19712dcd544b8199d9eac2b3e796a6afd6f970ebc20a9bab84a".to_string(), + jwk_modulus: "c5b6adf2b02c0731bcd01071786afc797f34ef21d61f3cb5d1ce8c82486427db1eaca9a0ce7f9f9687790a2cc80e87aaff3b1ccd2c4c5a89aafc2885e6a6ce1a0ef569a6608263bda6aec4b369114210139d28346f010ed15cd876bf932cf43d6c7682d97e6c12e940ce05b30c00009177a7692372f281c6ec2fa51f271b0d9e2a38d983d7436682b2b7b9892829448f1834042ddcf9d02eade650658dd41668138df8cf1f79ec03323e80e7eb2814e28918ced0c16cddd891379120152174d170f1acabe5cb937213ccf844371630062bc4a923e406f7d1a92bf4aa5f611cf5848fcc482978ac9d55d2239e8e5670deab82417d3a8c044e187e83bfd79b9fa5".to_string(), + jwk_modulus_expire_at: (now_secs() + 3600) as i64, + index_mod_4: 1, + iss_base_64: "yJpc3MiOiJodHRwczovL29hdXRoLmdvc2guc2giLC".to_string(), + header_base_64: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImZmOGVlZDA0MjgyZjFkYmQ4OWY1YTc5Yjc4N2Q2N2JjODc2MjA1OTcifQ".to_string(), + epk_expire_at: 1786976441, + pubkey: owner_keys.public.clone(), + secretkey: owner_keys.secret.clone(), + kid: "ff8eed04282f1dbd89f5a79b787d67bc87620597".to_string(), + sub: "272114864".to_string(), + }) + .await + .expect("update_zk_id failed"); + + let info_after = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address: addr.clone() }) + .await + .expect("get_multifactor_info failed after update"); + let data_after = info_after.data.expect("no data after update"); + assert_eq!(data_after.zkid, new_zkid); +} + +// --- deploy_wallet → delete_zkp_factor_by_itself → verify factors empty --- + +/// Deploys a fresh wallet (which creates 1 ZKP factor), then deletes it, +/// then verifies factors_len == "0" via get_multifactor_info. +/// Covers: deploy_wallet, delete_zkp_factor_by_itself, get_multifactor_info. +#[tokio::test] +async fn test_deploy_wallet_and_delete_zkp_factor() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy a new wallet — creates 1 ZKP factor + let name = format!("zkp_del_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy_wallet failed"); + let addr = deploy_result.address.clone(); + println!("deployed wallet: {} ({})", name, addr); + + // Verify we start with 1 factor + let info = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address: addr.clone() }) + .await + .expect("get_multifactor_info failed"); + let data = info.data.expect("no data after deploy"); + let factors_before: u32 = data.factors_len.parse().unwrap_or(0); + println!("factors_len before: {}", factors_before); + assert!(factors_before >= 1, "expected at least 1 factor after deploy, got {}", factors_before); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. Delete the ZKP factor + let _res = wallet + .delete_zkp_factor_by_itself(bee_wallet::DeleteZkpFactorByItselfReq { + multifactor_address: addr.clone(), + signer_keys, + }) + .await + .expect("delete_zkp_factor_by_itself failed"); + println!("zkp factor deleted"); + + // 3. Verify factors list is empty + let info = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address: addr.clone() }) + .await + .expect("get_multifactor_info failed"); + let data = info.data.expect("no data after delete"); + let factors_after: u32 = data.factors_len.parse().unwrap_or(999); + println!("factors_len after: {}", factors_after); + assert_eq!( + factors_after, + factors_before - 1, + "expected factors_len={}, got {}", + factors_before - 1, + factors_after + ); +} + +// --- add_zkp_factor --- + +#[tokio::test] +async fn test_add_zkp_factor() { + let wallet = create_shellnet_wallet(); + let name = format!("add_zkp_test_{}", now_secs()); + let mut deploy_params = create_deploy_wallet_params(name.clone()); + deploy_params.zkid = + "148536752753571014218927771785777007655354657642539776711310815121986360450".to_string(); + deploy_params.password = "Hello!234".to_string(); + deploy_params.proof = "9a6f8d061d92d5f3b8de80e210cc46dde0b5847ce37a5b7d1fb27a50151a70a16553dba074487ad37a6ec47fca8e6496b7bf39be6cf5a1c7cf2e4f547155a605670b4720a8626a5c6e30aa7575053a8a5f6c06f0849fefcf634acd056aad1398e570f4ea6b211efc53082da3e7dd839d4fa65b13099db4f9d6c597936864911b".to_string(); + deploy_params.epk = + "4199685466e5f30239e040cb740a9874be680b950aab45a279a15edf2d2c9751".to_string(); + deploy_params.esk = + "cfbcb94227aef19712dcd544b8199d9eac2b3e796a6afd6f970ebc20a9bab84a".to_string(); + deploy_params.epk_expire_at = 1786976441; + deploy_params.jwk_modulus_expire_at = now_secs() + 3600; + + let deploy_result = + wallet.deploy_wallet(deploy_params).await.expect("deploy_wallet failed for add_zkp_factor"); + let addr = deploy_result.address.clone(); + println!("deployed wallet: {} ({})", name, addr); + + let info_before = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address: addr.clone() }) + .await + .expect("get_multifactor_info before add_zkp_factor failed"); + let data_before = info_before.data.expect("no multifactor data before add_zkp_factor"); + let factors_before: u32 = data_before.factors_len.parse().unwrap_or(0); + + let result = wallet + .add_zkp_factor(bee_wallet::ParamsOfAddZKPFactor { + wallet_name: name.clone(), + proof: "a9784d77bb52cc893bcc59472d15379f0a7c41bb8e19d1250e70452ee5b5419c2436933af45472c092de9be3d7a5125b97d974b191fb67ee3e5edc8cfaeebd070f11c186a66e2fe11a69d683e88ecc832318fabd056fbd5f4a5b973326a8390b11f928fb423654fd64b9fe55c9a74b69c87cbf362cc3667012d1ae69d8dcb103".to_string(), + epk: "14cdf838c938515f97e8eb41d5888deeb588d16f7b9a880bdbdf6385ee787ced" + .to_string(), + epk_expire_at: 1786985794, + esk: "aa2461113a6ce5ae393118ebcba9d6e1406ff0b8775db105ec3b3fdd05d6763d" + .to_string(), + header_base_64: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImZmOGVlZDA0MjgyZjFkYmQ4OWY1YTc5Yjc4N2Q2N2JjODc2MjA1OTcifQ".to_string(), + jwk_expires_at: 1771541773, + kid: "ff8eed04282f1dbd89f5a79b787d67bc87620597".to_string(), + sub: "272114864".to_string(), + password: "Hello!234".to_string(), + zkid: "148536752753571014218927771785777007655354657642539776711310815121986360450" + .to_string(), + }) + .await + .expect("add_zkp_factor failed"); + + assert_eq!(result.name, name); + assert_eq!(result.address, addr); + + let info_after = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address: addr.clone() }) + .await + .expect("get_multifactor_info after add_zkp_factor failed"); + let data_after = info_after.data.expect("no multifactor data after add_zkp_factor"); + let factors_after: u32 = data_after.factors_len.parse().unwrap_or(0); + assert_eq!(factors_after, factors_before + 1); +} + +// --- deploy_wallet → deploy_miner → set_mining_keys --- + +/// Deploys a fresh wallet, then deploys a miner for it, then sets mining keys. +/// Covers: deploy_wallet, deploy_miner, set_mining_keys. +#[tokio::test] +async fn test_deploy_wallet_then_miner_and_mining_keys() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy a new wallet + let name = format!("miner_test_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy_wallet failed"); + let addr = deploy_result.address.clone(); + println!("deployed wallet: {} ({})", name, addr); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. Deploy miner explicitly (deploy_wallet does not deploy miner) + let first_deploy_miner_result = wallet + .deploy_miner(bee_wallet::ParamsOfDeployMiner { + multifactor_address: addr.clone(), + signer_keys: signer_keys.clone(), + }) + .await + .expect("deploy_miner failed"); + assert!( + !first_deploy_miner_result.message_ids.is_empty(), + "first deploy_miner call should deploy miner for a fresh wallet" + ); + println!("miner deployed for {}", addr); + + // deploy_miner is idempotent — second call should be a no-op + let second_deploy_miner_result = wallet + .deploy_miner(bee_wallet::ParamsOfDeployMiner { + multifactor_address: addr.clone(), + signer_keys: signer_keys.clone(), + }) + .await + .expect("deploy_miner (idempotent) failed"); + assert!( + second_deploy_miner_result.message_ids.is_empty(), + "second deploy_miner call should be a no-op" + ); + + // 3. Generate mining keys and set them + let mining_keys = create_crypto().gen_mining_keys().expect("gen_mining_keys failed"); + println!("mining pubkey: {}", mining_keys.public); + + wallet + .set_mining_keys(bee_wallet::ParamsOfSetMiningKeys { + multifactor_address: addr.clone(), + signer_keys: signer_keys.clone(), + mining_pubkey: mining_keys.public.clone(), + app_id: String::new(), // falls back to ctx.app_id + epk_expire_at: None, // resolved from contract + }) + .await + .expect("set_mining_keys failed"); + println!("mining keys set for {}", addr); +} + +/// Deploys a fresh wallet, deploys miner, sets mining key and verifies it, +/// then removes mining key with wait and verifies it was removed. +/// Covers: deploy_wallet, deploy_miner, set_mining_keys, del_mining_key, +/// get_miner_details. +#[tokio::test] +async fn test_del_mining_key() { + let wallet = create_shellnet_wallet(); + + let name = format!("del_miner_key_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy_wallet failed"); + let addr = deploy_result.address.clone(); + println!("deployed wallet: {} ({})", name, addr); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + wallet + .deploy_miner(bee_wallet::ParamsOfDeployMiner { + multifactor_address: addr.clone(), + signer_keys: signer_keys.clone(), + }) + .await + .expect("deploy_miner failed"); + + let mining_keys = create_crypto().gen_mining_keys().expect("gen_mining_keys failed"); + let expected_owner_public = format!("0x{}", mining_keys.public); + let app_id = "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + + wallet + .set_mining_keys(bee_wallet::ParamsOfSetMiningKeys { + multifactor_address: addr.clone(), + signer_keys: signer_keys.clone(), + mining_pubkey: mining_keys.public.clone(), + app_id: String::new(), + epk_expire_at: None, + }) + .await + .expect("set_mining_keys failed"); + + let details_after_set = wallet + .get_miner_details_by_multifactor_address(addr.clone()) + .await + .expect("get_miner_details_by_multifactor_address after set failed"); + let actual_owner_public = details_after_set.owner_public.get(app_id.as_str()).cloned(); + assert_eq!(actual_owner_public, Some(expected_owner_public)); + + wallet + .del_mining_key(bee_wallet::ParamsOfDelMiningKey { + multifactor_address: addr.clone(), + signer_keys, + app_id: String::new(), + epk_expire_at: None, + wait: true, + }) + .await + .expect("del_mining_key failed"); + + let details_after_del = wallet + .get_miner_details_by_multifactor_address(addr.clone()) + .await + .expect("get_miner_details_by_multifactor_address after del failed"); + let has_owner_public = details_after_del + .owner_public + .get(app_id.as_str()) + .map(|value| !value.is_empty()) + .unwrap_or(false); + assert!(!has_owner_public, "owner_public for app_id should be removed"); +} + +// --- deploy_wallet → change_seed_phrase → verify owner_pubkey --- + +/// Deploys a fresh wallet, changes the seed phrase, then verifies +/// that owner_pubkey in multifactor info matches the new key. +/// Covers: deploy_wallet, change_seed_phrase, get_multifactor_info. +#[tokio::test] +async fn test_deploy_wallet_and_change_seed_phrase() { + let wallet = create_shellnet_wallet(); + + // 1. Deploy a new wallet + let name = format!("seed_test_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let password = params.password.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy_wallet failed"); + let addr = deploy_result.address.clone(); + let original_pubkey = deploy_result.pubkey.clone(); + println!("deployed wallet: {} ({})", name, addr); + println!("original owner_pubkey: {}", original_pubkey); + + let signer_keys = KeyPair { public: epk, secret: esk }; + + // 2. Generate a new seed phrase → derive new owner keys + let new_seed = create_crypto().gen_mnemonic_and_derive_keys().expect("gen_mnemonic failed"); + let new_owner_keys = + KeyPair { public: new_seed.keys.public.clone(), secret: new_seed.keys.secret.clone() }; + println!("new owner_pubkey: {}", new_owner_keys.public); + assert_ne!(original_pubkey, new_owner_keys.public); + + // 3. Change seed phrase + wallet + .change_seed_phrase(bee_wallet::ParamsOfChangeSeedPhrase { + password, + signer_keys: signer_keys.clone(), + new_owner_keys: new_owner_keys.clone(), + multifactor_address: addr.clone(), + }) + .await + .expect("change_seed_phrase failed"); + println!("seed phrase changed"); + + // 4. Verify owner_pubkey updated + let info = wallet + .get_multifactor_info(ParamsOfGetMultifactorInfo { address: addr.clone() }) + .await + .expect("get_multifactor_info failed"); + + let data = info.data.expect("multifactor data is None after change_seed_phrase"); + let actual_pubkey = data.owner_pubkey.clone(); + println!("verified owner_pubkey: {}", actual_pubkey); + + // owner_pubkey is stored with 0x prefix + let expected = format!("0x{}", new_owner_keys.public); + assert_eq!( + actual_pubkey, expected, + "owner_pubkey mismatch: expected {} got {}", + expected, actual_pubkey + ); +} + +// ============================================================ +// decode_connect_payload_b64url +// ============================================================ + +#[test] +fn test_decode_connect_payload_b64url() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + + let wallet = create_shellnet_wallet(); + + // Build a valid ConnectPayload JSON and base64url-encode it + let payload = ConnectPayload { + v: "bee_connect.dl/1".to_string(), + session_id: "test_session_123".to_string(), + description: "test_description_456".to_string(), + expires_at: now_secs() + 3600, + app_id: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), + nonce: None, + }; + let payload_json = serde_json::to_string(&payload).expect("serialize payload"); + let encoded = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + + // Round-trip: decode should return the same fields + let decoded = wallet + .decode_connect_payload_b64url(encoded.clone()) + .expect("decode_connect_payload_b64url failed"); + assert_eq!(decoded.v, payload.v); + assert_eq!(decoded.session_id, payload.session_id); + assert_eq!(decoded.description, payload.description); + assert_eq!(decoded.expires_at, payload.expires_at); + + // Error case: invalid base64 + let err = wallet.decode_connect_payload_b64url("!!!not-base64!!!".to_string()); + assert!(err.is_err(), "should fail on invalid base64"); + + // Error case: valid base64 but invalid JSON + let bad_json = URL_SAFE_NO_PAD.encode(b"not json"); + let err = wallet.decode_connect_payload_b64url(bad_json); + assert!(err.is_err(), "should fail on invalid JSON"); + + // Error case: valid JSON but wrong version + let bad_payload = serde_json::json!({ + "v": "wrong_version", + "session_id": "s", + "description": "d", + "expires_at": now_secs() + 3600, + "app_id": "0x0000000000000000000000000000000000000000000000000000000000000000" + }); + let bad_encoded = URL_SAFE_NO_PAD.encode(bad_payload.to_string().as_bytes()); + let err = wallet.decode_connect_payload_b64url(bad_encoded); + assert!(err.is_err(), "should fail on wrong version"); + + // Error case: empty session_id + let empty_session = serde_json::json!({ + "v": "bee_connect.dl/1", + "session_id": "", + "description": "d", + "expires_at": now_secs() + 3600, + "app_id": "0x0000000000000000000000000000000000000000000000000000000000000000" + }); + let empty_encoded = URL_SAFE_NO_PAD.encode(empty_session.to_string().as_bytes()); + let err = wallet.decode_connect_payload_b64url(empty_encoded); + assert!(err.is_err(), "should fail on empty session_id"); +} + +// ============================================================ +// prepare_zk_login_v1 +// ============================================================ + +#[test] +fn test_prepare_zk_login_v1() { + let wallet = create_shellnet_wallet(); + + let result1 = wallet.prepare_zk_login_v1().expect("prepare_zk_login_v1 first call"); + assert!(!result1.nonce.is_empty(), "nonce should be non-empty"); + assert!(!result1.randomness.is_empty(), "randomness should be non-empty"); + assert!(!result1.ephemeral_private_key.is_empty(), "ephemeral_private_key should be non-empty"); + assert!(result1.max_epoch > 0, "max_epoch should be positive"); + + // The ephemeral_private_key should be a valid suiprivkey bech32 + assert!( + result1.ephemeral_private_key.starts_with("suiprivkey1"), + "ephemeral_private_key should be suiprivkey bech32" + ); + + // Call twice — nonces should differ (randomness is random) + let result2 = wallet.prepare_zk_login_v1().expect("prepare_zk_login_v1 second call"); + assert_ne!(result1.nonce, result2.nonce, "two calls should produce different nonces"); + assert_ne!( + result1.randomness, result2.randomness, + "two calls should produce different randomness" + ); + assert_ne!( + result1.ephemeral_private_key, result2.ephemeral_private_key, + "two calls should produce different ephemeral keys" + ); +} + +// ============================================================ +// complete_zk_login_with_prover_v1 (requires live JWT) +// ============================================================ + +#[tokio::test] +#[ignore = "Requires a live JWT token from an OAuth provider"] +async fn test_complete_zk_login_with_prover_v1() { + let _wallet = create_shellnet_wallet(); + // To test this method you need: + // 1. A valid JWT from a supported OAuth provider (e.g. Google, Gosh) + // 2. The ephemeral key data from prepare_zk_login_v1 + // 3. A running prover service + // + // Example skeleton: + // let prepare = wallet.prepare_zk_login_v1().expect("prepare"); + // let result = + // wallet.complete_zk_login_with_prover_v1(ZkLoginCompleteWithProverParams { + // jwt: "".to_string(), + // ephemeral_private_key: prepare.ephemeral_private_key, + // randomness: prepare.randomness, + // max_epoch: prepare.max_epoch, + // .. + // }).await.expect("complete_zk_login_with_prover_v1"); + todo!("Implement test_complete_zk_login_with_prover_v1 - requires a live JWT token"); +} + +// ============================================================ +// wallet connect full flow: +// accept_connect_shared_key → query_connect_session_messages +// → destroy_connect_profile +// ============================================================ + +// Shellnet multifactor for connect/buy_shells tests +const SHELLNET_MULTIFACTOR: &str = + "0:3d51528b8ad806dea2018d24fa9a428386f1c6883fb0944684fc08c4bbbe223a"; +const SHELLNET_MF_EPK: &str = "2b9d728a42e05dfe43a10fa0d8e16b0b06ad482822309fe1aabac01aff8b34ee"; +const SHELLNET_MF_ESK: &str = "8328afbf10019fa8d0002a3764f8bb433f8f5cf84ec51af0cdc172c6ef72dd29"; + +#[tokio::test] +async fn test_wallet_connect_full_flow() { + let wallet = create_shellnet_wallet(); + + // 1. Create a ConnectClient session (dApp side) + let connect_client = ConnectClient::new(); + let session = connect_client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session failed"); + assert!(!session.session_id.is_empty()); + assert!(!session.client_dh_public.is_empty()); + assert!(!session.description.is_empty()); + + // Decode the payload to build ConnectPayload for accept + let payload = wallet + .decode_connect_payload_b64url(session.payload_b64url.clone()) + .expect("decode_connect_payload_b64url failed"); + + // 2. Wallet accepts the connect session + let accept_result = wallet + .accept_connect_shared_key(ParamsOfAcceptSharedKeyConnect { + payload: payload.clone(), + wallet_name: "shellnet_connect_test".to_string(), + wallet_address: SHELLNET_MULTIFACTOR.to_string(), + client_dh_public: session.client_dh_public.clone(), + max_attempts: Some(60), + interval_ms: Some(2_000), + challenge_signature: None, + challenge_epk_public: None, + }) + .await + .expect("accept_connect_shared_key failed"); + assert!(!accept_result.profile_address.is_empty()); + assert!(!accept_result.wallet_hello_json.is_empty()); + println!("connect profile: {}", accept_result.profile_address); + + // 3. Query session messages — poll until wallet_hello appears on-chain + let mut has_hello = false; + for attempt in 1..=30 { + let query_result = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: Some(accept_result.session_state.clone()), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + assert_eq!(query_result.profile_address, accept_result.profile_address); + has_hello = query_result.messages.iter().any(|m| m.msg_type == "wallet_hello"); + if has_hello { + println!("wallet_hello found on attempt {attempt}"); + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert!(has_hello, "wallet_hello message should be present after polling"); + + // 4. Destroy the connect profile (best-effort cleanup) + let signer_keys = + KeyPair { public: SHELLNET_MF_EPK.to_string(), secret: SHELLNET_MF_ESK.to_string() }; + let destroy_result = wallet + .destroy_connect_profile(ParamsOfDestroyConnectProfile { + profile_address: accept_result.profile_address.clone(), + multifactor_address: SHELLNET_MULTIFACTOR.to_string(), + signer_keys, + }) + .await; + match destroy_result { + Ok(_) => println!("connect profile destroyed"), + Err(e) => eprintln!("Warning: connect profile cleanup failed (non-fatal): {e}"), + } +} + +// --- connect: multiple c2w messages round-trip --- + +/// Full round-trip: dApp creates session → wallet accepts → dApp +/// wait_wallet_hello → dApp sends set_mining_keys twice → wallet sees both +/// messages with correct re-key chain. +#[tokio::test] +async fn test_connect_multiple_c2w_messages() { + use bee_connect::ParamsOfRequestSetMiningKeys; + use bee_connect::ParamsOfWaitWalletHello; + + let wallet = create_shellnet_wallet(); + let connect_client = ConnectClient::new(); + + // 1. dApp: create session + let session = connect_client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session failed"); + println!("session_id: {}", session.session_id); + + let payload = wallet + .decode_connect_payload_b64url(session.payload_b64url.clone()) + .expect("decode_connect_payload_b64url failed"); + + // 2. Wallet: accept → sends wallet_hello + let accept = wallet + .accept_connect_shared_key(ParamsOfAcceptSharedKeyConnect { + payload: payload.clone(), + wallet_name: "shellnet_connect_test".to_string(), + wallet_address: SHELLNET_MULTIFACTOR.to_string(), + client_dh_public: session.client_dh_public.clone(), + max_attempts: Some(60), + interval_ms: Some(2_000), + challenge_signature: None, + challenge_epk_public: None, + }) + .await + .expect("accept_connect_shared_key failed"); + println!("profile: {}", accept.profile_address); + + // 3. dApp: wait_wallet_hello → get session_state + let hello = connect_client + .wait_wallet_hello(ParamsOfWaitWalletHello { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + client_dh_secret: session.client_dh_secret.clone(), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_wallet_hello failed"); + println!("wallet_hello received: wallet_name={}", hello.wallet_name); + let mut dapp_session_state = hello.session_state; + + // 4. dApp: send first set_mining_keys (c2w #1) + let req1 = connect_client + .request_set_mining_keys(ParamsOfRequestSetMiningKeys { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_session_state.clone(), + app_id: "0x1".to_string(), + owner_public: "aa".repeat(32), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_set_mining_keys #1 failed"); + println!("c2w #1 sent: message_id={:?}", req1.message_id); + dapp_session_state = req1.updated_session_state; + + // 5. dApp: send second set_mining_keys (c2w #2) with updated state + let req2 = connect_client + .request_set_mining_keys(ParamsOfRequestSetMiningKeys { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_session_state.clone(), + app_id: "0x1".to_string(), + owner_public: "bb".repeat(32), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_set_mining_keys #2 failed"); + println!("c2w #2 sent: message_id={:?}", req2.message_id); + + // 6. Wallet: poll session messages — should see both set_mining_keys + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let mut wallet_session_state = Some(accept.session_state.clone()); + let query = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: wallet_session_state.clone(), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + + // Update wallet session state (re-key inbound) + if let Some(updated) = query.updated_session_state { + wallet_session_state = Some(updated); + } + + let set_mining_keys_msgs: Vec<_> = + query.messages.iter().filter(|m| m.msg_type == "set_mining_keys").collect(); + + println!( + "wallet sees {} set_mining_keys messages (total messages: {})", + set_mining_keys_msgs.len(), + query.messages.len() + ); + for msg in &set_mining_keys_msgs { + println!( + " seq={}, owner_public={:?}", + msg.seq, + msg.set_mining_keys.as_ref().map(|b| &b.owner_public) + ); + } + + assert!( + set_mining_keys_msgs.len() >= 2, + "wallet should see at least 2 set_mining_keys messages, got {}", + set_mining_keys_msgs.len() + ); + + // Verify different owner_public in each message + let pub1 = set_mining_keys_msgs[0].set_mining_keys.as_ref().map(|b| &b.owner_public); + let pub2 = set_mining_keys_msgs[1].set_mining_keys.as_ref().map(|b| &b.owner_public); + assert_ne!(pub1, pub2, "two messages should have different owner_public"); + + // 7. Re-query with already-updated state — must NOT break re-key chain. This is + // the key regression test: passing session_state that already accounts for + // processed messages should not corrupt the DH chain. + let query2 = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: wallet_session_state.clone(), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("second query_connect_session_messages failed"); + + let set_keys_2: Vec<_> = + query2.messages.iter().filter(|m| m.msg_type == "set_mining_keys").collect(); + println!("re-query: wallet sees {} set_mining_keys messages", set_keys_2.len()); + assert!( + set_keys_2.len() >= 2, + "re-query should still see both messages, got {}", + set_keys_2.len() + ); + + // 8. Cleanup: destroy profile + let signer_keys = + KeyPair { public: SHELLNET_MF_EPK.to_string(), secret: SHELLNET_MF_ESK.to_string() }; + let _ = wallet + .destroy_connect_profile(ParamsOfDestroyConnectProfile { + profile_address: accept.profile_address.clone(), + multifactor_address: SHELLNET_MULTIFACTOR.to_string(), + signer_keys, + }) + .await; + println!("profile destroyed (best-effort)"); +} + +/// Full round-trip: dApp creates session → wallet accepts → dApp +/// wait_wallet_hello → dApp sends sign_challenge → wallet sees it → wallet +/// sends challenge_response → dApp receives and verifies nonce + signature. +#[tokio::test] +async fn test_connect_sign_challenge_flow() { + use bee_connect::ParamsOfRequestSignChallenge; + use bee_connect::ParamsOfWaitChallengeResponse; + use bee_connect::ParamsOfWaitWalletHello; + + let wallet = create_shellnet_wallet(); + let connect_client = ConnectClient::new(); + + // 1. dApp: create session + let session = connect_client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session failed"); + println!("session_id: {}", session.session_id); + + let payload = wallet + .decode_connect_payload_b64url(session.payload_b64url.clone()) + .expect("decode_connect_payload_b64url failed"); + + // 2. Wallet: accept → sends wallet_hello + let accept = wallet + .accept_connect_shared_key(ParamsOfAcceptSharedKeyConnect { + payload: payload.clone(), + wallet_name: "challenge_test".to_string(), + wallet_address: SHELLNET_MULTIFACTOR.to_string(), + client_dh_public: session.client_dh_public.clone(), + max_attempts: Some(60), + interval_ms: Some(2_000), + challenge_signature: None, + challenge_epk_public: None, + }) + .await + .expect("accept_connect_shared_key failed"); + println!("profile: {}", accept.profile_address); + let mut wallet_session_state = accept.session_state.clone(); + + // 3. dApp: wait_wallet_hello → get session_state + let hello = connect_client + .wait_wallet_hello(ParamsOfWaitWalletHello { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + client_dh_secret: session.client_dh_secret.clone(), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_wallet_hello failed"); + println!("wallet_hello received: wallet_name={}", hello.wallet_name); + let mut dapp_session_state = hello.session_state; + + // 4. dApp: send sign_challenge (c→w) + let test_nonce = "deadbeef42cafe01"; + let challenge_result = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_session_state.clone(), + nonce: test_nonce.to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_sign_challenge failed"); + println!("sign_challenge sent: message_id={:?}", challenge_result.message_id); + dapp_session_state = challenge_result.updated_session_state; + + // 5. Wallet: poll query_session_messages until sign_challenge appears + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let mut found_challenge = false; + let mut received_nonce = String::new(); + for attempt in 1..=30 { + let query_result = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: Some(wallet_session_state.clone()), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + + // Update wallet session state for re-keying + if let Some(updated) = query_result.updated_session_state { + wallet_session_state = updated; + } + + for msg in &query_result.messages { + if msg.msg_type == "sign_challenge" { + if let Some(ref sc) = msg.sign_challenge { + println!( + "wallet received sign_challenge on attempt {attempt}: nonce={}", + sc.nonce + ); + received_nonce = sc.nonce.clone(); + found_challenge = true; + } + } + } + if found_challenge { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert!(found_challenge, "wallet should see sign_challenge message"); + assert_eq!(received_nonce, test_nonce); + + // 6. Wallet: sign the nonce and send challenge_response (w→c) + let fake_signature = "aa".repeat(64); // In production: sign_detached_hex(nonce, epk_secret) + + // Rekey for outbound w2c message + let rekey_seq = wallet_session_state.next_outbound_seq().expect("next_outbound_seq failed"); + let rekey = + bee_connect::dh::rekey_outbound(&wallet_session_state, &payload.session_id, rekey_seq) + .expect("rekey_outbound failed"); + + let response_json = bee_wallet::encode_challenge_response_message( + &payload.session_id, + &received_nonce, + &fake_signature, + SHELLNET_MULTIFACTOR, + Some(SHELLNET_MF_EPK), + &rekey.message_encryption_root, + rekey.new_dh_public.as_deref().unwrap_or(""), + rekey.outbound_seq, + ) + .expect("encode_challenge_response_message failed"); + + let profile = ackinacki_kit::contracts::authservice::profile::AuthProfile::new_default( + { + let mut cfg = ackinacki_kit::tvm_client::ClientConfig::default(); + cfg.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + std::sync::Arc::new(ackinacki_kit::tvm_client::ClientContext::new(cfg).unwrap()) + }, + &accept.profile_address, + ); + let signing_keys = KeyPair { + public: wallet_session_state.signing_public.clone(), + secret: wallet_session_state.signing_secret.to_string(), + }; + let send_result = profile + .add_context_text( + &response_json, + ackinacki_kit::tvm_client::abi::Signer::Keys { keys: signing_keys }, + ) + .await + .expect("add_context_text challenge_response failed"); + println!("challenge_response sent: {:?}", send_result.message_hash); + + wallet_session_state = rekey.updated_state.clone(); + + // 7. dApp: wait_challenge_response dApp state after sign_challenge already + // accounts for the outbound rekey. wait_challenge_response scans events for + // w2c messages and re-keys inbound. + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let cr = connect_client + .wait_challenge_response(ParamsOfWaitChallengeResponse { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: Some(dapp_session_state.clone()), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_challenge_response failed"); + + println!( + "challenge_response received: nonce={}, signature={}..., wallet_address={}", + cr.nonce, + &cr.signature[..16], + cr.wallet_address + ); + assert_eq!(cr.nonce, test_nonce, "nonce should match"); + assert_eq!(cr.signature, fake_signature, "signature should match"); + assert_eq!(cr.wallet_address, SHELLNET_MULTIFACTOR, "wallet_address should match"); + assert_eq!( + cr.epk_public.as_deref(), + Some(SHELLNET_MF_EPK), + "epk_public should be present and match the signing key" + ); + println!("sign_challenge/challenge_response flow PASSED"); + + // 8. Cleanup + let signer_keys = + KeyPair { public: SHELLNET_MF_EPK.to_string(), secret: SHELLNET_MF_ESK.to_string() }; + let _ = wallet + .destroy_connect_profile(ParamsOfDestroyConnectProfile { + profile_address: accept.profile_address.clone(), + multifactor_address: SHELLNET_MULTIFACTOR.to_string(), + signer_keys, + }) + .await; + println!("profile destroyed (best-effort)"); +} + +/// dApp sends sign_challenge → wallet responds → dApp sends SECOND +/// sign_challenge from the PRE-challenge state (simulating a timeout on attempt +/// 1) → wait_challenge_response must return a `session_desync` error +/// immediately instead of polling for 4 minutes. +#[tokio::test] +async fn test_connect_challenge_desync_detected() { + use bee_connect::ParamsOfRequestSignChallenge; + use bee_connect::ParamsOfWaitChallengeResponse; + use bee_connect::ParamsOfWaitWalletHello; + + let wallet = create_shellnet_wallet(); + let connect_client = ConnectClient::new(); + + // 1. Create session + handshake + let session = connect_client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session failed"); + + let payload = wallet + .decode_connect_payload_b64url(session.payload_b64url.clone()) + .expect("decode_connect_payload_b64url failed"); + + let accept = wallet + .accept_connect_shared_key(ParamsOfAcceptSharedKeyConnect { + payload: payload.clone(), + wallet_name: "desync_test".to_string(), + wallet_address: SHELLNET_MULTIFACTOR.to_string(), + client_dh_public: session.client_dh_public.clone(), + max_attempts: Some(60), + interval_ms: Some(2_000), + challenge_signature: None, + challenge_epk_public: None, + }) + .await + .expect("accept_connect_shared_key failed"); + let mut wallet_session_state = accept.session_state.clone(); + + let hello = connect_client + .wait_wallet_hello(ParamsOfWaitWalletHello { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + client_dh_secret: session.client_dh_secret.clone(), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_wallet_hello failed"); + let dapp_session_state = hello.session_state; + + // 2. Attempt 1: dApp sends sign_challenge + let challenge1 = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_session_state.clone(), + nonce: "desync_nonce_1".to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_sign_challenge failed"); + + // 3. Wallet: poll for sign_challenge, then respond + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + for attempt in 1..=30 { + let query_result = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: Some(wallet_session_state.clone()), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + if let Some(updated) = query_result.updated_session_state { + wallet_session_state = updated; + } + let found = query_result + .messages + .iter() + .any(|m| m.msg_type == "sign_challenge" && m.sign_challenge.is_some()); + if found { + println!("wallet saw sign_challenge on attempt {attempt}"); + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + + // Wallet sends challenge_response + let rekey_seq = wallet_session_state.next_outbound_seq().expect("next_outbound_seq failed"); + let rekey = + bee_connect::dh::rekey_outbound(&wallet_session_state, &payload.session_id, rekey_seq) + .expect("rekey_outbound failed"); + let response_json = bee_wallet::encode_challenge_response_message( + &payload.session_id, + "desync_nonce_1", + &"aa".repeat(64), + SHELLNET_MULTIFACTOR, + Some(SHELLNET_MF_EPK), + &rekey.message_encryption_root, + rekey.new_dh_public.as_deref().unwrap_or(""), + rekey.outbound_seq, + ) + .expect("encode_challenge_response_message failed"); + + let profile = ackinacki_kit::contracts::authservice::profile::AuthProfile::new_default( + { + let mut cfg = ackinacki_kit::tvm_client::ClientConfig::default(); + cfg.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + std::sync::Arc::new(ackinacki_kit::tvm_client::ClientContext::new(cfg).unwrap()) + }, + &accept.profile_address, + ); + let signing_keys = KeyPair { + public: wallet_session_state.signing_public.clone(), + secret: wallet_session_state.signing_secret.to_string(), + }; + profile + .add_context_text( + &response_json, + ackinacki_kit::tvm_client::abi::Signer::Keys { keys: signing_keys }, + ) + .await + .expect("add_context_text challenge_response failed"); + println!("challenge_response for attempt 1 sent"); + let _ = &challenge1; // used above for the flow; silence unused warning + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // 4. Simulate dApp timeout on attempt 1: dApp did NOT save state from + // challenge1. It retries from the ORIGINAL dapp_session_state. + let _challenge2 = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_session_state.clone(), // original state, not challenge1.updated + nonce: "desync_nonce_2".to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_sign_challenge 2 failed"); + + // 5. dApp waits for challenge_response — should get session_desync error + // immediately because the old challenge_response from attempt 1 is on-chain + // but undecryptable from challenge2's DH state. + let result = connect_client + .wait_challenge_response(ParamsOfWaitChallengeResponse { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: Some(_challenge2.updated_session_state.clone()), + created_at_from: Some(session.created_at), + max_attempts: Some(10), + interval_ms: Some(2_000), + }) + .await; + + assert!(result.is_err(), "wait_challenge_response should fail with desync"); + let err = result.unwrap_err(); + assert!( + err.message.contains("session_desync"), + "error should contain 'session_desync', got: {err}" + ); + println!("desync correctly detected: {err}"); + + // 6. Cleanup + let signer_keys = + KeyPair { public: SHELLNET_MF_EPK.to_string(), secret: SHELLNET_MF_ESK.to_string() }; + let _ = wallet + .destroy_connect_profile(ParamsOfDestroyConnectProfile { + profile_address: accept.profile_address.clone(), + multifactor_address: SHELLNET_MULTIFACTOR.to_string(), + signer_keys, + }) + .await; + println!("profile destroyed (best-effort)"); +} + +/// AuthProfile event pagination: write 6 events, query with limit=4, +/// verify first page returns 4 events + has_previous_page=true + cursor, +/// second page returns remaining events. +#[tokio::test] +async fn test_auth_profile_event_pagination() { + use ackinacki_kit::contracts::authservice::profile::AuthProfile; + use ackinacki_kit::contracts::authservice::profile::ParamsOfQueryProfileEvents; + use ackinacki_kit::tvm_client::abi::Signer; + + let wallet = create_shellnet_wallet(); + let connect_client = ConnectClient::new(); + + // 1. Create session + handshake (deploys AuthProfile) + let session = connect_client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session failed"); + + let payload = wallet + .decode_connect_payload_b64url(session.payload_b64url.clone()) + .expect("decode_connect_payload_b64url failed"); + + let accept = wallet + .accept_connect_shared_key(ParamsOfAcceptSharedKeyConnect { + payload: payload.clone(), + wallet_name: "pagination_test".to_string(), + wallet_address: SHELLNET_MULTIFACTOR.to_string(), + client_dh_public: session.client_dh_public.clone(), + max_attempts: Some(60), + interval_ms: Some(2_000), + challenge_signature: None, + challenge_epk_public: None, + }) + .await + .expect("accept_connect_shared_key failed"); + + // wallet_hello is event #1 (already written by accept) + let profile = AuthProfile::new_default( + { + let mut cfg = ackinacki_kit::tvm_client::ClientConfig::default(); + cfg.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + std::sync::Arc::new(ackinacki_kit::tvm_client::ClientContext::new(cfg).unwrap()) + }, + &accept.profile_address, + ); + let signing_keys = KeyPair { + public: accept.session_state.signing_public.clone(), + secret: accept.session_state.signing_secret.to_string(), + }; + + // 2. Write 5 more dummy events (#2..#6) + for i in 2..=6 { + let text = format!("pagination_test_event_{i}"); + profile + .add_context_text(&text, Signer::Keys { keys: signing_keys.clone() }) + .await + .unwrap_or_else(|e| panic!("add_context_text #{i} failed: {e}")); + println!("wrote event #{i}"); + } + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + // 3. Query page 1: limit=4, expect 4 events + has_previous_page + let page1 = profile + .query_context_added_events(ParamsOfQueryProfileEvents { + created_at_from: None, + limit: Some(4), + before: None, + }) + .await + .expect("page 1 query failed"); + + println!( + "page 1: {} events, has_previous_page={}, cursor={:?}", + page1.events.len(), + page1.page_info.has_previous_page, + page1.page_info.cursor, + ); + assert_eq!(page1.events.len(), 4, "page 1 should have 4 events"); + assert!(page1.page_info.has_previous_page, "should have more pages"); + assert!(page1.page_info.cursor.is_some(), "cursor should be present"); + + // 4. Query page 2: use cursor from page 1 + let page2 = profile + .query_context_added_events(ParamsOfQueryProfileEvents { + created_at_from: None, + limit: Some(4), + before: page1.page_info.cursor, + }) + .await + .expect("page 2 query failed"); + + println!( + "page 2: {} events, has_previous_page={}", + page2.events.len(), + page2.page_info.has_previous_page, + ); + assert!(!page2.events.is_empty(), "page 2 should have remaining events"); + + let total = page1.events.len() + page2.events.len(); + println!("total events across 2 pages: {total}"); + assert!(total >= 6, "should have at least 6 events total (got {total})"); + + // 5. Cleanup + let signer_keys = + KeyPair { public: SHELLNET_MF_EPK.to_string(), secret: SHELLNET_MF_ESK.to_string() }; + let _ = wallet + .destroy_connect_profile(ParamsOfDestroyConnectProfile { + profile_address: accept.profile_address.clone(), + multifactor_address: SHELLNET_MULTIFACTOR.to_string(), + signer_keys, + }) + .await; + println!("profile destroyed (best-effort)"); +} + +// ============================================================ +// prepare_multifactor_deploy_params +// ============================================================ + +#[tokio::test] +async fn test_prepare_multifactor_deploy_params() { + let wallet = create_shellnet_wallet(); + let deploy_params = create_deploy_wallet_params(format!("prep_deploy_{}", now_secs())); + + let owner_keys = create_crypto().gen_mnemonic_and_derive_keys().expect("gen_mnemonic failed"); + + let result = wallet + .prepare_multifactor_deploy_params(ParamsOfPrepareDeploy { + zkid: deploy_params.zkid, + password: deploy_params.password, + proof: deploy_params.proof, + epk: deploy_params.epk, + esk: deploy_params.esk, + jwk_modulus: deploy_params.jwk_modulus, + jwk_modulus_expire_at: deploy_params.jwk_modulus_expire_at, + index_mod_4: deploy_params.index_mod_4, + iss_base_64: deploy_params.iss_base_64, + header_base_64: deploy_params.header_base_64, + epk_expire_at: deploy_params.epk_expire_at, + keys: KeyPair { + public: owner_keys.keys.public.clone(), + secret: owner_keys.keys.secret.clone(), + }, + kid: deploy_params.kid, + wallet_name: deploy_params.wallet_name, + multifactor_address: SHELLNET_T1.to_string(), + sub: deploy_params.sub, + }) + .await + .expect("prepare_multifactor_deploy_params failed"); + + // Verify result shape: it should have non-empty name, zkid, epk, etc. + assert!(!result.name.is_empty(), "name should be non-empty"); + assert!(!result.zkid.is_empty(), "zkid should be non-empty"); + assert!(!result.epk.is_empty(), "epk should be non-empty"); + assert!(!result.proof.is_empty(), "proof should be non-empty"); + assert!(!result.kid.is_empty(), "kid should be non-empty"); + assert!(result.epk_expire_at > 0, "epk_expire_at should be positive"); +} + +// ============================================================ +// get_history (self-contained: sends a tx, then verifies shape) +// ============================================================ + +#[tokio::test] +async fn test_get_history() { + let wallet = create_shellnet_wallet(); + + assert!(SHELLNET_T1_EPK_EXPIRE_AT > bee_wallet::now_secs(), "SHELLNET_T1 EPK expired!"); + assert!(SHELLNET_T2_EPK_EXPIRE_AT > bee_wallet::now_secs(), "SHELLNET_T2 EPK expired!"); + + // 1. Send a small transfer so history is guaranteed non-empty + let ts = bee_wallet::now_secs().saturating_sub(5); + let amount: u64 = 10_000_000; // 0.01 NACKL + let signer_keys = + KeyPair { public: SHELLNET_T1_EPK.to_string(), secret: SHELLNET_T1_ESK.to_string() }; + let _res = send_ecc(&wallet, SHELLNET_T1, SHELLNET_T2, amount, signer_keys).await; + + // 2. Wait until the tx appears in sender history + wait_for_tx(&wallet, SHELLNET_T1, ts, amount as u128, "Outgoing").await; + + // 3. Now fetch history and verify shape + let result = wallet + .get_history(ParamsOfGetHistory { + multifactor_address: SHELLNET_T1.to_string(), + token_id: "1".to_string(), + page_size: 10, + cursor: None, + mining_cursor: None, + }) + .await + .expect("get_history failed"); + + assert!(!result.data.is_empty(), "history should have entries for SHELLNET_T1"); + + // Verify each entry has required fields populated + for tx in &result.data { + assert!(!tx.id.is_empty(), "tx.id should be non-empty"); + assert!( + tx.tx_type == "Mining" || tx.tx_type == "Incoming" || tx.tx_type == "Outgoing", + "unexpected tx_type: {}", + tx.tx_type + ); + assert!(!tx.value.is_empty(), "tx.value should be non-empty"); + assert!(!tx.created_at.is_empty(), "tx.created_at should be non-empty"); + let _ts: u64 = tx.created_at.parse().expect("created_at should parse as u64"); + } +} + +// ============================================================ +// poll_until +// ============================================================ + +#[tokio::test] +async fn test_poll_until() { + let wallet = create_shellnet_wallet(); + + // Case 1: Immediate success (predicate passes on first fetch) + let result = wallet + .poll_until( + || async { Ok::(42) }, + |v| *v == 42, + Some(5), + Some(10), + ) + .await + .expect("poll_until immediate success"); + assert_eq!(result, 42); + + // Case 2: Succeeds on Nth attempt + let counter = AtomicU32::new(0); + let result = wallet + .poll_until( + || { + let current = counter.fetch_add(1, Ordering::SeqCst) + 1; + async move { Ok::(current) } + }, + |v| *v >= 3, + Some(10), + Some(10), + ) + .await + .expect("poll_until Nth attempt success"); + assert_eq!(result, 3); + + // Case 3: Timeout — predicate never passes + let err = wallet + .poll_until( + || async { Ok::(0) }, + |_| false, + Some(3), + Some(10), + ) + .await; + assert!(err.is_err(), "poll_until should fail when max attempts exhausted"); + let err_msg = err.unwrap_err().message; + assert!( + err_msg.contains("Max 3 attempts reached"), + "error should mention max attempts, got: {}", + err_msg + ); +} + +// --- one-off: mint USDC to test_lc_1 --- + +// ============================================================ +// migrate_tip3_usdc: TIP-3 USDC → ECC[3] via Exchange +// ============================================================ + +/// E2E: mint TIP-3 USDC → migrate to ECC[3] → verify ECC[3] balance increased. +#[tokio::test] +async fn test_migrate_tip3_usdc() { + use ackinacki_kit::contracts::token::root::TokenRoot; + use ackinacki_kit::contracts::traits::SendMessage; + use ackinacki_kit::tvm_client::abi::CallSet; + use ackinacki_kit::tvm_client::abi::Signer; + + let wallet = create_shellnet_wallet(); + let endpoint = "shellnet.ackinacki.org"; + + // 1. Deploy fresh wallet + let name = format!("migrate_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let epk = params.epk.clone(); + let esk = params.esk.clone(); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + let mf_address = deploy_result.address.clone(); + println!("deployed: {mf_address}"); + + let signer_keys = + ackinacki_kit::tvm_client::crypto::KeyPair { public: epk.clone(), secret: esk.clone() }; + + // 2. TVM context for giver + mint + let mut config = ackinacki_kit::tvm_client::ClientConfig::default(); + config.network.endpoints = Some(vec![endpoint.to_string()]); + let tvm_ctx = std::sync::Arc::new( + ackinacki_kit::tvm_client::ClientContext::new(config).expect("tvm context"), + ); + + // 3. Fund gas (10 vmshell — enough for Exchange callback chain) + ackinacki_kit::contracts::giver::v3::send_currency_with_flag_from_default_giver( + tvm_ctx.clone(), + &mf_address, + 10_000_000_000, + std::collections::HashMap::new(), + 1, + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // 4. Mint TIP-3 USDC to the wallet (10 USDC = 10_000_000 micro) + let tip3_usdc_root = "0:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + let usdc_amount_raw: u64 = 10_000_000; // 10 USDC + + let usdc_root = TokenRoot::new( + tvm_ctx.clone(), + bee_wallet::dapp::token_contract_params( + tip3_usdc_root, + "0000000000000000000000000000000000000000000000000000000000000000", + ), + ); + // Owner keys of the TIP-3 USDC root on shellnet (current as of the + // 2026-06-04 redeploy). Every shellnet redeploy rotates them: `mint` then + // reverts in the compute phase (exit_code 101) and this test dies here in + // setup — before the migrate path under test ever runs. Refresh both keys + // below (and the matching `TIP3_MINT_PUBLIC`/`TIP3_MINT_SECRET` in + // src/bin/faucet.rs) with the live root owner keys before running. + let mint_keys = ackinacki_kit::tvm_client::crypto::KeyPair { + public: "e44b1ca07ee19c5c66eca104b9e5372f1fcadfe962b11e565132c66ec5603d91".to_string(), + secret: "55c5d4ca4f11c7721e39b1dbe407b7dc787b68d89fc8936fb798e7631f155ea0".to_string(), + }; + let mint_result = usdc_root + .send_message( + Some(CallSet { + function_name: "mint".to_string(), + header: None, + input: Some(serde_json::json!({ + "value": usdc_amount_raw.to_string(), + "walletOwner": mf_address, + })), + }), + None, + Signer::Keys { keys: mint_keys }, + ) + .await + .expect("mint TIP-3 USDC failed"); + assert_eq!(mint_result.exit_code.unwrap_or(0), 0, "mint should succeed"); + println!("minted {usdc_amount_raw} TIP-3 USDC"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // 5. Check ECC[3] balance before migration + let ecc3_before = get_ecc_balance_by_id(&mf_address, 3, endpoint).await; + println!("ECC[3] before: {ecc3_before}"); + + // 6. Migrate TIP-3 USDC → ECC[3] + let migrate_result = wallet + .migrate_tip3_usdc(bee_wallet::MigrateTip3UsdcReq { + multifactor_address: mf_address.clone(), + token_root: tip3_usdc_root.to_string(), + // shellnet 0.9.0 ignores dapp_id (System for this root on shellnet). + token_dapp: "0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + amount_raw: usdc_amount_raw, + signer_keys: signer_keys.clone(), + bounce: None, + }) + .await + .expect("migrate_tip3_usdc failed"); + println!("migrate tx: {:?}", migrate_result.message_hash); + + // 7. Poll ECC[3] balance until it increases + let mut ecc3_after = ecc3_before; + for attempt in 0..30 { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + ecc3_after = get_ecc_balance_by_id(&mf_address, 3, endpoint).await; + if ecc3_after > ecc3_before { + println!("ECC[3] increased after {attempt} attempts"); + break; + } + } + + let delta = ecc3_after - ecc3_before; + println!("ECC[3] after: {ecc3_after}, delta: {delta}"); + assert!( + delta >= usdc_amount_raw as u64, + "Expected ECC[3] increase of at least {usdc_amount_raw}, got {delta}" + ); + println!("migrate_tip3_usdc test passed: {usdc_amount_raw} TIP-3 USDC → {delta} ECC[3]"); +} + +// ============================================================ +// miner address resolution by wallet name (self-contained) +// ============================================================ + +/// E2E: deploy a fresh multifactor (which registers its name in the indexer), +/// then resolve its miner address purely by name via `bee_miner`. +/// +/// Self-contained on purpose — it deploys the wallet it resolves, so there is +/// no hardcoded on-chain fixture to rot when shellnet is redeployed (the +/// previous `bee_miner` unit test pinned `test_t1_*` and went red on every +/// wipe). It lives here, not in `bee_miner`, because `bee_miner` can't deploy a +/// wallet (no dep on `bee_wallet`). +/// +/// A bare multifactor deploy is enough: the name→multifactor hop reads the +/// freshly-registered indexer, and the multifactor→miner hop hits a global +/// system Mirror contract (one of 1000 at `0:2…`, derived from the multifactor +/// tail) that *computes* the miner address — the miner itself need not exist. +#[tokio::test] +async fn test_get_miner_address_by_wallet_name() { + let wallet = create_shellnet_wallet(); + let endpoint = "shellnet.ackinacki.org"; + + // 1. Deploy a fresh wallet under a unique name. + let name = format!("miner_resolve_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + let deploy_result = wallet.deploy_wallet(params).await.expect("deploy failed"); + println!("deployed {name}: {}", deploy_result.address); + + // 2. Wait until the name resolves through the indexer (deploy must propagate). + // Resolving by name here also gates step 3: if this returns Some, the + // indexer is active and the unit under test can read it too. + let mut registered = false; + for attempt in 0..30 { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + if let Ok(Some(_)) = wallet.get_multifactor_data_by_name(name.clone()).await { + println!("name registered in indexer after {attempt} polls"); + registered = true; + break; + } + } + assert!(registered, "wallet name never became resolvable in the indexer"); + + // 3. The unit under test: resolve the miner address purely by name. + let mut cfg = ackinacki_kit::tvm_client::ClientConfig::default(); + cfg.network.endpoints = Some(vec![endpoint.to_string()]); + let miner_address = bee_miner::core::keys::get_miner_address_by_wallet_name( + bee_miner::core::keys::ParamsOfGetMinerAddressByWalletName { + client_config: cfg, + wallet_name: name.clone(), + }, + ) + .await + .expect("get_miner_address_by_wallet_name failed"); + + println!("resolved miner: {miner_address}"); + assert!(!miner_address.is_empty(), "resolved miner address should not be empty"); + assert!( + miner_address.contains(':'), + "resolved miner address should look like a TVM address, got: {miner_address}" + ); +} + +// ============================================================ +// Connect protocol — multi-step DH chain tests +// ============================================================ + +/// Shared handshake setup for connect protocol tests. +/// Returns (connect_client, session, payload, accept, dapp_state, wallet_state, +/// profile). +async fn connect_handshake_setup( + wallet: &Wallet, + wallet_name: &str, +) -> ( + ConnectClient, + bee_connect::ResultOfCreateSharedKeySession, + ConnectPayload, + bee_wallet::ResultOfAcceptConnect, + bee_connect::dh::ConnectSessionState, + bee_connect::dh::ConnectSessionState, + ackinacki_kit::contracts::authservice::profile::AuthProfile, +) { + use bee_connect::ParamsOfWaitWalletHello; + + let connect_client = ConnectClient::new(); + let session = connect_client + .create_shared_key_session(ParamsOfCreateSharedKeySession { + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + ttl_secs: Some(600), + nonce: None, + }) + .expect("create_shared_key_session failed"); + + let payload = wallet + .decode_connect_payload_b64url(session.payload_b64url.clone()) + .expect("decode_connect_payload_b64url failed"); + + let accept = wallet + .accept_connect_shared_key(ParamsOfAcceptSharedKeyConnect { + payload: payload.clone(), + wallet_name: wallet_name.to_string(), + wallet_address: SHELLNET_MULTIFACTOR.to_string(), + client_dh_public: session.client_dh_public.clone(), + max_attempts: Some(60), + interval_ms: Some(2_000), + challenge_signature: None, + challenge_epk_public: None, + }) + .await + .expect("accept_connect_shared_key failed"); + let wallet_session_state = accept.session_state.clone(); + + let hello = connect_client + .wait_wallet_hello(ParamsOfWaitWalletHello { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + client_dh_secret: session.client_dh_secret.clone(), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_wallet_hello failed"); + let dapp_session_state = hello.session_state; + + let profile = ackinacki_kit::contracts::authservice::profile::AuthProfile::new_default( + { + let mut cfg = ackinacki_kit::tvm_client::ClientConfig::default(); + cfg.network.endpoints = Some(vec!["shellnet.ackinacki.org".to_string()]); + std::sync::Arc::new(ackinacki_kit::tvm_client::ClientContext::new(cfg).unwrap()) + }, + &accept.profile_address, + ); + + (connect_client, session, payload, accept, dapp_session_state, wallet_session_state, profile) +} + +/// Wallet-side: poll for sign_challenge, do rekey_inbound + rekey_outbound, +/// send challenge_response. Returns updated wallet state. +async fn wallet_respond_to_challenge( + wallet: &Wallet, + profile: &ackinacki_kit::contracts::authservice::profile::AuthProfile, + payload: &ConnectPayload, + wallet_state: &bee_connect::dh::ConnectSessionState, + expected_nonce: &str, +) -> bee_connect::dh::ConnectSessionState { + use ackinacki_kit::tvm_client::abi::Signer; + + let mut state = wallet_state.clone(); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let mut found = false; + for attempt in 1..=30 { + let query = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: Some(state.clone()), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + + if let Some(updated) = query.updated_session_state { + state = updated; + } + for msg in &query.messages { + if msg.msg_type == "sign_challenge" { + if let Some(ref sc) = msg.sign_challenge { + assert_eq!(sc.nonce, expected_nonce, "nonce mismatch"); + println!("wallet received sign_challenge on attempt {attempt}"); + found = true; + } + } + } + if found { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert!(found, "wallet should see sign_challenge"); + + // rekey_outbound + send challenge_response + let rekey_seq = state.next_outbound_seq().expect("next_outbound_seq failed"); + let rekey = bee_connect::dh::rekey_outbound(&state, &payload.session_id, rekey_seq) + .expect("rekey_outbound failed"); + let response_json = bee_wallet::encode_challenge_response_message( + &payload.session_id, + expected_nonce, + &"aa".repeat(64), + SHELLNET_MULTIFACTOR, + Some(SHELLNET_MF_EPK), + &rekey.message_encryption_root, + rekey.new_dh_public.as_deref().unwrap_or(""), + rekey.outbound_seq, + ) + .expect("encode_challenge_response_message failed"); + + let signing_keys = + KeyPair { public: state.signing_public.clone(), secret: state.signing_secret.to_string() }; + profile + .add_context_text(&response_json, Signer::Keys { keys: signing_keys }) + .await + .expect("add_context_text challenge_response failed"); + println!("challenge_response sent"); + + rekey.updated_state +} + +/// Cleanup helper — destroy profile (best-effort). +async fn connect_cleanup(wallet: &Wallet, profile_address: &str) { + let signer_keys = + KeyPair { public: SHELLNET_MF_EPK.to_string(), secret: SHELLNET_MF_ESK.to_string() }; + let _ = wallet + .destroy_connect_profile(ParamsOfDestroyConnectProfile { + profile_address: profile_address.to_string(), + multifactor_address: SHELLNET_MULTIFACTOR.to_string(), + signer_keys, + }) + .await; + println!("profile destroyed (best-effort)"); +} + +/// Verify → set_mining_keys: after a full sign_challenge round-trip, +/// the wallet must be able to decrypt the subsequent set_mining_keys message. +/// This is the exact scenario that broke due to the DH state sync bug. +#[tokio::test] +async fn test_connect_verify_then_set_mining_keys() { + use bee_connect::ParamsOfRequestSetMiningKeys; + use bee_connect::ParamsOfRequestSignChallenge; + use bee_connect::ParamsOfWaitChallengeResponse; + + let wallet = create_shellnet_wallet(); + let (connect_client, session, payload, accept, mut dapp_state, wallet_state, profile) = + connect_handshake_setup(&wallet, "verify_then_keys").await; + + // 1. Verify round-trip (sign_challenge → challenge_response) + let nonce = "verify_then_keys_nonce_1"; + let challenge = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_state.clone(), + nonce: nonce.to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_sign_challenge failed"); + dapp_state = challenge.updated_session_state; + + let wallet_state = + wallet_respond_to_challenge(&wallet, &profile, &payload, &wallet_state, nonce).await; + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let cr = connect_client + .wait_challenge_response(ParamsOfWaitChallengeResponse { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: Some(dapp_state.clone()), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_challenge_response failed"); + assert_eq!(cr.nonce, nonce); + if let Some(ref updated) = cr.updated_session_state { + dapp_state = updated.clone(); + } + println!("verify round-trip OK, DH chain at step 4"); + + // 2. dApp sends set_mining_keys (5th rekey in the chain) + let set_keys = connect_client + .request_set_mining_keys(ParamsOfRequestSetMiningKeys { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_state.clone(), + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + owner_public: "aa".repeat(32), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_set_mining_keys failed"); + println!("set_mining_keys sent: {:?}", set_keys.message_id); + + // 3. Wallet must decrypt set_mining_keys with post-verify state + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let mut found_keys = false; + let mut wallet_state = wallet_state; + for attempt in 1..=30 { + let query = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: Some(wallet_state.clone()), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + + println!( + " [attempt {attempt}] messages={}, types=[{}], updated_state={}", + query.messages.len(), + query + .messages + .iter() + .map(|m| format!( + "{}(body={})", + m.msg_type, + m.set_mining_keys.is_some() || m.sign_challenge.is_some() + )) + .collect::>() + .join(", "), + query.updated_session_state.is_some(), + ); + + if let Some(updated) = query.updated_session_state { + wallet_state = updated; + } + for msg in &query.messages { + if msg.msg_type == "set_mining_keys" && msg.set_mining_keys.is_some() { + println!("wallet decrypted set_mining_keys on attempt {attempt}"); + found_keys = true; + } + } + if found_keys { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert!(found_keys, "wallet must decrypt set_mining_keys after verify round-trip"); + println!("test_connect_verify_then_set_mining_keys PASSED"); + + connect_cleanup(&wallet, &accept.profile_address).await; +} + +/// Two consecutive verify round-trips: the DH chain must remain in sync +/// across 8 rekey steps (4 per round-trip). +#[tokio::test] +async fn test_connect_double_verify() { + use bee_connect::ParamsOfRequestSignChallenge; + use bee_connect::ParamsOfWaitChallengeResponse; + + let wallet = create_shellnet_wallet(); + let (connect_client, session, payload, accept, mut dapp_state, mut wallet_state, profile) = + connect_handshake_setup(&wallet, "double_verify").await; + + for round in 1..=2 { + let nonce = format!("double_verify_nonce_{round}"); + println!("--- verify round {round} ---"); + + // dApp sends sign_challenge + let challenge = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_state.clone(), + nonce: nonce.clone(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_sign_challenge failed"); + dapp_state = challenge.updated_session_state; + + // Wallet responds + wallet_state = + wallet_respond_to_challenge(&wallet, &profile, &payload, &wallet_state, &nonce).await; + + // dApp receives response + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let cr = connect_client + .wait_challenge_response(ParamsOfWaitChallengeResponse { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: Some(dapp_state.clone()), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .unwrap_or_else(|e| panic!("wait_challenge_response round {round} failed: {e}")); + + assert_eq!(cr.nonce, nonce, "nonce mismatch in round {round}"); + if let Some(ref updated) = cr.updated_session_state { + dapp_state = updated.clone(); + } + println!("verify round {round} OK"); + } + + println!("test_connect_double_verify PASSED (8 rekey steps)"); + connect_cleanup(&wallet, &accept.profile_address).await; +} + +/// session_state_after must be populated on c2w messages that triggered +/// rekey_inbound. Wallet queries messages, and the sign_challenge message must +/// carry the post-rekey state snapshot. +#[tokio::test] +async fn test_connect_session_state_after_populated() { + use bee_connect::ParamsOfRequestSignChallenge; + + let wallet = create_shellnet_wallet(); + let (connect_client, session, payload, accept, dapp_state, wallet_state, _profile) = + connect_handshake_setup(&wallet, "state_after_test").await; + + // dApp sends sign_challenge + let _challenge = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_state.clone(), + nonce: "state_after_nonce".to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("request_sign_challenge failed"); + + // Wallet polls — sign_challenge should have session_state_after + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let mut found = false; + let mut state = wallet_state; + for attempt in 1..=30 { + let query = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: Some(state.clone()), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + + if let Some(updated) = query.updated_session_state.clone() { + state = updated; + } + + for msg in &query.messages { + if msg.msg_type == "sign_challenge" && msg.sign_challenge.is_some() { + println!("sign_challenge found on attempt {attempt}"); + assert!( + msg.session_state_after.is_some(), + "session_state_after must be Some for sign_challenge (c2w with rekey)" + ); + + // session_state_after must match the batch-level updated_session_state + // (when there's only one rekeyed message in the batch) + let after = msg.session_state_after.as_ref().unwrap(); + if let Some(ref batch) = query.updated_session_state { + assert_eq!( + after.encryption_root, batch.encryption_root, + "session_state_after.encryption_root must match batch updated_session_state" + ); + assert_eq!( + after.peer_dh_public, batch.peer_dh_public, + "session_state_after.peer_dh_public must match batch updated_session_state" + ); + } + found = true; + } + } + if found { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert!(found, "wallet should find sign_challenge with session_state_after"); + println!("test_connect_session_state_after_populated PASSED"); + + connect_cleanup(&wallet, &accept.profile_address).await; +} + +/// Full chain: verify → set_mining_keys → second verify. +/// Exercises 10 rekey steps and confirms the DH chain survives mixed message +/// types. +#[tokio::test] +async fn test_connect_full_rekey_chain() { + use bee_connect::ParamsOfRequestSetMiningKeys; + use bee_connect::ParamsOfRequestSignChallenge; + use bee_connect::ParamsOfWaitChallengeResponse; + + let wallet = create_shellnet_wallet(); + let (connect_client, session, payload, accept, mut dapp_state, mut wallet_state, profile) = + connect_handshake_setup(&wallet, "full_chain").await; + + // --- Step 1: first verify --- + println!("--- step 1: first verify ---"); + let nonce1 = "full_chain_nonce_1"; + let ch1 = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_state.clone(), + nonce: nonce1.to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("sign_challenge 1 failed"); + dapp_state = ch1.updated_session_state; + + wallet_state = + wallet_respond_to_challenge(&wallet, &profile, &payload, &wallet_state, nonce1).await; + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let cr1 = connect_client + .wait_challenge_response(ParamsOfWaitChallengeResponse { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: Some(dapp_state.clone()), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_challenge_response 1 failed"); + assert_eq!(cr1.nonce, nonce1); + if let Some(ref s) = cr1.updated_session_state { + dapp_state = s.clone(); + } + println!("first verify OK"); + + // --- Step 2: set_mining_keys --- + println!("--- step 2: set_mining_keys ---"); + let set_keys = connect_client + .request_set_mining_keys(ParamsOfRequestSetMiningKeys { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_state.clone(), + app_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + owner_public: "bb".repeat(32), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("set_mining_keys failed"); + dapp_state = set_keys.updated_session_state; + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let mut found_keys = false; + for attempt in 1..=30 { + let query = wallet + .query_connect_session_messages(ParamsOfQuerySessionMessages { + session_id: payload.session_id.clone(), + description: payload.description.clone(), + session_state: Some(wallet_state.clone()), + created_at_from: None, + before: None, + limit: Some(50), + }) + .await + .expect("query_connect_session_messages failed"); + if let Some(updated) = query.updated_session_state { + wallet_state = updated; + } + for msg in &query.messages { + if msg.msg_type == "set_mining_keys" && msg.set_mining_keys.is_some() { + println!("wallet decrypted set_mining_keys on attempt {attempt}"); + found_keys = true; + } + } + if found_keys { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + assert!(found_keys, "wallet must decrypt set_mining_keys"); + + // --- Step 3: second verify --- + println!("--- step 3: second verify ---"); + let nonce2 = "full_chain_nonce_2"; + let ch2 = connect_client + .request_sign_challenge(ParamsOfRequestSignChallenge { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: dapp_state.clone(), + nonce: nonce2.to_string(), + max_attempts: Some(30), + interval_ms: Some(1_000), + }) + .await + .expect("sign_challenge 2 failed"); + dapp_state = ch2.updated_session_state; + + wallet_state = + wallet_respond_to_challenge(&wallet, &profile, &payload, &wallet_state, nonce2).await; + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let cr2 = connect_client + .wait_challenge_response(ParamsOfWaitChallengeResponse { + endpoints: vec!["shellnet.ackinacki.org".to_string()], + session_id: session.session_id.clone(), + description: session.description.clone(), + session_state: Some(dapp_state.clone()), + created_at_from: Some(session.created_at), + max_attempts: Some(60), + interval_ms: Some(2_000), + }) + .await + .expect("wait_challenge_response 2 failed"); + assert_eq!(cr2.nonce, nonce2); + println!("second verify OK"); + + println!("test_connect_full_rekey_chain PASSED (10 rekey steps)"); + connect_cleanup(&wallet, &accept.profile_address).await; +} + +// --- update_contract_flags (deploy without flags, then update separately) --- + +#[tokio::test] +async fn test_update_contract_flags() { + let wallet = create_shellnet_wallet(); + let name = format!("upd_flags_{}", now_secs()); + let params = create_deploy_wallet_params(name.clone()); + + // 1. Deploy WITHOUT contract flags update + println!("[1/5] deploying '{name}' without contract flags..."); + let deploy_result = wallet.deploy_wallet_only(params).await.expect("deploy_wallet_only failed"); + let address = deploy_result.address.clone(); + println!(" address: {address}"); + println!(" phrase: {}", deploy_result.phrase); + println!(" pubkey: {}", deploy_result.signing_keys.public); + + // 2. Read contract state before update + println!("[2/5] reading contract state..."); + let info = wallet + .get_multifactor_info(bee_wallet::ParamsOfGetMultifactorInfo { address: address.clone() }) + .await + .expect("get_multifactor_info"); + let data = info.data.expect("multifactor data should exist"); + println!(" force_remove_oldest: {}", data.force_remove_oldest); + println!(" wasm_hash: '{}'", data.wasm_hash); + println!(" owner_pubkey: {}", data.owner_pubkey); + + assert!(!data.force_remove_oldest, "force_remove_oldest should be false before update"); + + // 3. Derive owner keys from phrase (signing_keys = EPK, not owner!) + println!("[3/5] deriving owner keys from phrase..."); + let crypto = create_crypto(); + let derived = crypto + .get_keys_from_mnemonic(deploy_result.phrase.clone()) + .expect("derive keys from phrase"); + let owner_keys = KeyPair { public: derived.public.clone(), secret: derived.secret.clone() }; + println!(" owner pubkey: {}", owner_keys.public); + println!(" owner pubkey 0x: 0x{}", owner_keys.public); + println!(" contract owner: {}", data.owner_pubkey); + println!(" deploy pubkey: {}", deploy_result.pubkey); + let keys_match = data.owner_pubkey == format!("0x{}", owner_keys.public); + println!(" keys match: {keys_match}"); + assert!(keys_match, "owner keys must match contract _owner_pubkey"); + + // 4. Call set_remove_oldest first + println!("[4/5] set_remove_oldest..."); + let result = wallet + .update_contract(bee_wallet::ParamsOfUpdateContract { + multifactor_address: address.clone(), + keys: owner_keys.clone(), + }) + .await; + match &result { + Ok(r) => println!(" ok, message_ids: {:?}", r.message_ids), + Err(e) => println!(" FAILED: {e:#?}"), + } + let update_result = result.expect("update_contract failed"); + + // 5. Verify flags are set + println!("[5/5] verifying contract state after update..."); + let info = wallet + .get_multifactor_info(bee_wallet::ParamsOfGetMultifactorInfo { address: address.clone() }) + .await + .expect("get_multifactor_info after update"); + let data = info.data.expect("multifactor data should exist after update"); + println!(" force_remove_oldest: {}", data.force_remove_oldest); + println!(" wasm_hash: '{}'", data.wasm_hash); + println!(" message_ids: {:?}", update_result.message_ids); + + assert!(data.force_remove_oldest, "force_remove_oldest should be true after update"); + assert!( + !data.wasm_hash.is_empty() + && data.wasm_hash != "0000000000000000000000000000000000000000000000000000000000000000", + "wasm_hash should be set after update, got: '{}'", + data.wasm_hash + ); + println!("PASSED"); +} + +// ============================================================ +// DEX voucher generation (bee_wallet responsibility) +// ============================================================ + +#[tokio::test] +async fn test_generate_voucher_deposit() { + let wallet = create_shellnet_wallet(); + let sender = deploy_fresh_wallet_for_dex(&wallet).await; + let signer_keys = KeyPair { public: sender.epk.clone(), secret: sender.esk.clone() }; + + wallet + .generate_voucher(bee_wallet::ParamsOfGenerateVoucher { + multifactor_address: sender.address.clone(), + token_type: 1, + amount: 100_000_000_000, // Nominal::N100 NACKL + is_fee: false, + sk_u_commit: "0".to_string(), + signer_keys, + }) + .await + .expect("generate_voucher (deposit NACKL)"); +} + +#[tokio::test] +async fn test_generate_voucher_gas() { + let wallet = create_shellnet_wallet(); + let sender = deploy_fresh_wallet_for_dex(&wallet).await; + let signer_keys = KeyPair { public: sender.epk.clone(), secret: sender.esk.clone() }; + + wallet + .generate_voucher(bee_wallet::ParamsOfGenerateVoucher { + multifactor_address: sender.address.clone(), + token_type: 2, + amount: 5_000_000_000, // 5 SHELL for gas + is_fee: true, + sk_u_commit: "0".to_string(), + signer_keys, + }) + .await + .expect("generate_voucher (gas SHELL)"); +} + +// Full voucher → deploy PrivateNote flow lives in +// `tests/dex_flows/flows.rs::test_production_flow_voucher_deploy_pn_and_stake`. +// It exercises the same wallet.generate_voucher entry point but binds the +// voucher to a real halo2 proof, which is now mandatory on RootPN. diff --git a/examples/javascript/miner-react/README.md b/examples/javascript/miner-react/README.md index d2e7761..4add2ac 100644 --- a/examples/javascript/miner-react/README.md +++ b/examples/javascript/miner-react/README.md @@ -1,73 +1,77 @@ -# React + TypeScript + Vite +# miner-react -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React example for `@teamgosh/bee-sdk`: -Currently, two official plugins are available: +- initializes wasm on page load +- creates `bee_connect` session via `create_shared_key_session` +- shows QR/deeplink for wallet app +- waits for `wallet_hello` +- sends `set_mining_keys` request over connect protocol +- after connect, shows wallet metadata and miner controls +- supports client-side `disconnect_session(...)` request -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +## Prerequisites -## React Compiler +- local `bee_sdk` wasm package is built +- `examples/javascript/miner-react/package.json` points to local sdk: + - `"@teamgosh/bee-sdk": "file:../../../bee_sdk/pkg"` -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +## Build sdk wasm -## Expanding the ESLint configuration +From repo root: -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +```bash +cd bee_sdk +wasm-pack build --target web +``` - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +## Run example - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +cd examples/javascript/miner-react +npm install +npm run dev ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +`vite.config.ts` already allows fs access to local sdk package: + +- `examples/javascript/miner-react` +- `bee_sdk/pkg` + +## Connect flow used in `App.tsx` + +1. page loads and calls wasm `init(...)` +2. user clicks `Connect wallet` +3. dApp creates shared-key session: + - `create_shared_key_session(APP_ID, 300)` +4. UI shows: + - QR with `session.deep_link` + - `Open wallet` link +5. dApp waits for wallet handshake: + - `wait_wallet_hello(ENDPOINTS, ...)` +6. after success UI shows: + - wallet name + - wallet address + - miner panel +7. user requests mining keys setup: + - dApp generates mining keys (`gen_mining_keys(APP_ID)`) + - dApp sends `request_set_mining_keys(...)` with generated `owner_public` +8. after wallet handles request, user can initialize miner with the same keypair +9. user can press `Disconnect`: + - dApp sends `client_disconnect` with `disconnect_session(...)` + - UI drops local connection state + +## Connect protocol (current) + +- `wallet_hello` (wallet -> client) confirms established session +- `set_mining_keys` (client -> wallet) requests mining owner key setup +- `client_disconnect` (client -> wallet) requests session teardown + +`client_hello_ack` is not used. + +Wallet app should poll session stream and route by `msg_type` +(`query_connect_session_messages(...)`). + +## Shellnet constants in example + +- `ENDPOINTS = ["shellnet.ackinacki.org"]` diff --git a/examples/javascript/miner-react/bun.lock b/examples/javascript/miner-react/bun.lock index f57f496..b67a04a 100644 --- a/examples/javascript/miner-react/bun.lock +++ b/examples/javascript/miner-react/bun.lock @@ -1,11 +1,11 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "miner", "dependencies": { - "@bee-engine/miner": "file:../../../bindings/javascript/miner", + "@teamgosh/bee-sdk": "file:../../../bee_sdk/pkg", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", }, @@ -65,8 +65,6 @@ "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], - "@bee-engine/miner": ["@bee-engine/miner@file:../../../bindings/javascript/miner", {}], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -201,6 +199,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="], + "@teamgosh/bee-sdk": ["bee-sdk@file:../../../bee_sdk/pkg", {}], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -413,6 +413,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], diff --git a/examples/javascript/miner-react/package.json b/examples/javascript/miner-react/package.json index b21e6f0..d4a59c3 100644 --- a/examples/javascript/miner-react/package.json +++ b/examples/javascript/miner-react/package.json @@ -10,12 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@teamgosh/bee-sdk": "file:../../../bee_sdk/pkg", + "qrcode.react": "^4.2.0", "react": "^19.2.0", - "react-dom": "^19.2.0", - "@bee-engine/miner": "file:../../../bindings/javascript/miner" + "react-dom": "^19.2.0" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/bun": "latest", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -26,7 +28,6 @@ "globals": "^16.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4", - "@types/bun": "latest" + "vite": "^7.2.4" } } diff --git a/examples/javascript/miner-react/public/nackl.svg b/examples/javascript/miner-react/public/nackl.svg new file mode 100644 index 0000000..29b07ae --- /dev/null +++ b/examples/javascript/miner-react/public/nackl.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/javascript/miner-react/src/App.css b/examples/javascript/miner-react/src/App.css index b9d355d..39da8b8 100644 --- a/examples/javascript/miner-react/src/App.css +++ b/examples/javascript/miner-react/src/App.css @@ -1,42 +1,154 @@ #root { max-width: 1280px; margin: 0 auto; - padding: 2rem; + width: 100%; + min-height: 100vh; + padding: clamp(1rem, 2.5vw, 2rem); + padding-top: min(12vh, 120px); + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; text-align: center; } +.logo-shell { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.logo-shell::before { + content: ""; + position: absolute; + width: 11rem; + height: 11rem; + border-radius: 9999px; + background: radial-gradient( + circle, + rgba(245, 197, 66, 0.42) 0%, + rgba(218, 165, 32, 0.24) 45%, + rgba(218, 165, 32, 0) 75% + ); + filter: blur(10px); + pointer-events: none; + z-index: 0; + opacity: 0; + transition: opacity 280ms ease; +} + .logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; + height: 7.5em; + padding: 1.2em; + will-change: filter, transform; + transition: filter 300ms ease; + filter: none; + position: relative; + z-index: 1; } -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + +.logo-shell:hover::before { + opacity: 1; } -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); + +.logo-shell:hover .logo { + filter: drop-shadow(0 0 1.45em rgba(255, 210, 95, 0.2)); } -@keyframes logo-spin { - from { - transform: rotate(0deg); +.logo-spin { + animation: logo-sway 3.2s ease-in-out infinite; +} + +@keyframes logo-sway { + 0% { + transform: rotate(-7deg); + } + 50% { + transform: rotate(7deg); } - to { - transform: rotate(360deg); + 100% { + transform: rotate(-7deg); } } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; +@media (prefers-reduced-motion: reduce) { + .logo-spin { + animation: none; } } -.card { - padding: 2em; +.connect-card { + position: relative; + width: min(360px, 92vw); + padding: 18px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(160deg, rgba(28, 33, 47, 0.92) 0%, rgba(20, 24, 34, 0.95) 100%); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); +} + +.connect-close { + position: absolute; + top: 8px; + right: 8px; + width: 30px; + height: 30px; + border-radius: 9999px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.88); + padding: 0; + line-height: 1; +} + +.connect-card-title { + font-size: 1.02rem; + font-weight: 600; + margin-bottom: 12px; +} + +.connect-qr-wrap { + width: 100%; + display: grid; + place-items: center; + padding: 0; + border-radius: 0; + background: transparent; + border: none; +} + +.connect-qr { + border-radius: 10px; + background: white; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); +} + +.connect-actions { + margin-top: 12px; + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; +} + +.connect-open { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.22); + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + background: #1b2130; + color: rgba(255, 255, 255, 0.92); } -.read-the-docs { - color: #888; +.connect-status { + margin-top: 10px; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.72); + word-break: break-word; } diff --git a/examples/javascript/miner-react/src/App.tsx b/examples/javascript/miner-react/src/App.tsx index 4fdd6bc..41ef2f2 100644 --- a/examples/javascript/miner-react/src/App.tsx +++ b/examples/javascript/miner-react/src/App.tsx @@ -1,31 +1,53 @@ -import viteLogo from "/vite.svg"; -import reactLogo from "./assets/react.svg"; -import "./App.css"; - -import { +import init, { + BeeConnect, ensure_mining_keys_propagated, gen_mining_keys, get_miner_address_by_wallet_name, - init, + type InitOutput, Miner, -} from "bee-sdk"; -import { useState } from "react"; + Wallet, +} from "@teamgosh/bee-sdk"; + +import { QRCodeSVG } from "qrcode.react"; +import Logo from "/nackl.svg"; +import "./App.css"; + +import { useEffect, useRef, useState } from "react"; const APP_ID = "0x0000000000000000000000000000000000000000000000000000000000000000"; -const WALLET_NAME = "demo_wallet"; -const ENDPOINTS = ["mainnet.ackinacki.org"]; +const ENDPOINTS = ["https://shellnet.ackinacki.org"]; +const API_URL = "https://app-backend-dev.ackinacki.org/api"; +const ACTIVE_SESSION_STORAGE_KEY = "bee_connect_demo_active_session_v1"; +const MINING_KEYS_STORAGE_PREFIX = "bee_connect_demo_mining_keys_v1"; +const SESSION_POLL_INTERVAL_MS = 10000; +const BALANCE_POLL_INTERVAL_MS = 10000; +const MINER_DATA_POLL_INTERVAL_MS = 5000; +const BEE_SDK_WASM_URL = new URL("../../../../bee_sdk/pkg/bee_sdk_bg.wasm", import.meta.url); +let beeSdkInitPromise: Promise | null = null; + +async function initBeeSdk() { + if (!beeSdkInitPromise) { + beeSdkInitPromise = init({ + module_or_path: BEE_SDK_WASM_URL, + }); + } + await beeSdkInitPromise; +} -async function initMiner() { - await init({ module_or_path: "/bee_engine_miner_bg.wasm" }); - const resultOfGenKeys = await gen_mining_keys(APP_ID); - const minerAddress = await get_miner_address_by_wallet_name({ +async function getMinerAddress(walletName: string) { + await initBeeSdk(); + return await get_miner_address_by_wallet_name({ client_config: { network: { endpoints: ENDPOINTS, }, }, - wallet_name: WALLET_NAME, + wallet_name: walletName, }); +} + +async function waitForMiningKeysPropagation(walletName: string, ownerPublic: string) { + const minerAddress = await getMinerAddress(walletName); await ensure_mining_keys_propagated({ client_config: { @@ -35,61 +57,1270 @@ async function initMiner() { }, miner_address: minerAddress, app_id: APP_ID, - expected_owner_public: resultOfGenKeys.public, - max_attempts: 30, - interval_ms: 1000, + expected_owner_public: ownerPublic, + max_attempts: 120, + interval_ms: 2000, }); - return await Miner.new( - ENDPOINTS, - APP_ID, - minerAddress, - resultOfGenKeys.public, - resultOfGenKeys.secret, - ); + return minerAddress; +} + +async function initMiner(minerAddress: string, ownerPublic: string, ownerSecret: string) { + await initBeeSdk(); + return await Miner.new(ENDPOINTS, APP_ID, minerAddress, ownerPublic, ownerSecret); } function minerCallback(message: string) { console.log(`[MINER_CALLBACK]: ${message}`); } -function App() { - const [miner, setMiner] = useState(); +type WalletConnection = { + walletName: string; + walletAddress: string; + profileAddress: string; + sessionId: string; + description: string; + sessionStateJson: string; + /** Inline challenge: present when connected with nonce verification. */ + inlineChallenge?: { + nonce: string; + signature: string; + epkPublic: string; + }; +}; + +type StoredMiningKeys = { + ownerPublic: string; + ownerSecret: string; + minerAddress: string | null; + areKeysPropagated: boolean; +}; + +type MinerCallbackPayload = { + action?: string; + data?: { + status?: string; + [key: string]: unknown; + } | null; + error?: string | null; +}; + +type MinerDebugInfo = { + tapSum: string; + tapSum5m: string; + epochStart: string; + epoch5mStart: string; + updatedAt: string; +}; + +function readStoredWalletConnection(): WalletConnection | null { + if (typeof window === "undefined") { + return null; + } + + const raw = window.localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.walletName !== "string" || + typeof parsed.walletAddress !== "string" || + typeof parsed.profileAddress !== "string" || + typeof parsed.sessionId !== "string" || + typeof parsed.description !== "string" || + typeof parsed.sessionStateJson !== "string" + ) { + return null; + } + + return { + walletName: parsed.walletName, + walletAddress: parsed.walletAddress, + profileAddress: parsed.profileAddress, + sessionId: parsed.sessionId, + description: parsed.description, + sessionStateJson: parsed.sessionStateJson, + }; + } catch { + return null; + } +} + +function writeStoredWalletConnection(connection: WalletConnection | null) { + if (typeof window === "undefined") { + return; + } + + if (!connection) { + window.localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY); + return; + } + + window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, JSON.stringify(connection)); +} + +function miningKeysStorageKey(connection: WalletConnection): string { + return `${MINING_KEYS_STORAGE_PREFIX}:${connection.profileAddress}:${APP_ID}`; +} + +function readStoredMiningKeys(connection: WalletConnection): StoredMiningKeys | null { + if (typeof window === "undefined") { + return null; + } + + const raw = window.localStorage.getItem(miningKeysStorageKey(connection)); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.ownerPublic !== "string" || + typeof parsed.ownerSecret !== "string" || + typeof parsed.areKeysPropagated !== "boolean" || + (parsed.minerAddress !== null && typeof parsed.minerAddress !== "string") + ) { + return null; + } + + return { + ownerPublic: parsed.ownerPublic, + ownerSecret: parsed.ownerSecret, + minerAddress: parsed.minerAddress ?? null, + areKeysPropagated: parsed.areKeysPropagated, + }; + } catch { + return null; + } +} + +function writeStoredMiningKeys(connection: WalletConnection, value: StoredMiningKeys | null) { + if (typeof window === "undefined") { + return; + } + + const key = miningKeysStorageKey(connection); + if (!value) { + window.localStorage.removeItem(key); + return; + } + + window.localStorage.setItem(key, JSON.stringify(value)); +} + +function clearStoredMiningKeys(connection: WalletConnection) { + writeStoredMiningKeys(connection, null); +} + +function formatNanoToDisplay(value: string, decimals = 9, fractionDigits = 4): string { + let amount = 0n; + try { + amount = BigInt(value); + } catch { + return "0.0000"; + } + + const base = 10n ** BigInt(decimals); + const whole = amount / base; + const frac = amount % base; + const fracScaled = (frac * 10n ** BigInt(fractionDigits)) / base; + + return `${whole.toString()}.${fracScaled.toString().padStart(fractionDigits, "0")}`; +} + +async function isSessionStillActive(connection: WalletConnection): Promise { + await initBeeSdk(); + const beeConnect = new BeeConnect(); + const resolvedProfileAddress = await beeConnect.resolve_profile_address( + ENDPOINTS, + connection.description, + ); + if ( + resolvedProfileAddress.trim().toLowerCase() !== connection.profileAddress.trim().toLowerCase() + ) { + console.warn("[SESSION_MONITOR] profile address mismatch — resolved:", resolvedProfileAddress, "stored:", connection.profileAddress); + return false; + } + + const deployed = await beeConnect.is_session_profile_deployed( + ENDPOINTS, + connection.description, + ); + if (!deployed) { + console.warn("[SESSION_MONITOR] session profile NOT deployed for description:", connection.description); + } + return deployed; +} + +type WalletConnectProps = { + onConnected: (connection: WalletConnection) => void; +}; + +function WalletConnect({ onConnected }: WalletConnectProps) { + const [isCreatingSession, setIsCreatingSession] = useState(false); + const [isWaitingWallet, setIsWaitingWallet] = useState(false); + const [error, setError] = useState(null); + const [deepLink, setDeepLink] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [connectMode, setConnectMode] = useState<"simple" | "verified" | null>(null); + const waitTokenRef = useRef(0); + + const cancelWaiting = () => { + waitTokenRef.current += 1; + setIsWaitingWallet(false); + setDeepLink(null); + setSessionId(null); + setConnectMode(null); + }; + + const handleConnect = async (withChallenge: boolean) => { + try { + setIsCreatingSession(true); + setError(null); + setDeepLink(null); + setSessionId(null); + setIsWaitingWallet(false); + setConnectMode(withChallenge ? "verified" : "simple"); + + const beeConnect = new BeeConnect(); + const nonce = withChallenge ? generateNonce() : undefined; + + console.log("[CONNECT] creating session", { withChallenge, nonce }); + const session = beeConnect.create_shared_key_session(APP_ID, 300, nonce ?? null); + setDeepLink(session.deep_link); + setSessionId(session.session_id); + + const waitToken = waitTokenRef.current + 1; + waitTokenRef.current = waitToken; + setIsWaitingWallet(true); + + void (async () => { + try { + console.log("[CONNECT] calling wait_wallet_hello...", { + session_id: session.session_id, + withChallenge, + }); + const hello = await beeConnect.wait_wallet_hello( + ENDPOINTS, + session.session_id, + session.description, + session.client_dh_secret, + session.created_at, + 120, + 1000, + ); + + console.log("[CONNECT] wait_wallet_hello succeeded:", { + wallet_name: hello.wallet_name, + wallet_address: hello.wallet_address, + profile_address: hello.profile_address, + has_signature: !!hello.signature, + }); + + if (waitToken !== waitTokenRef.current) { + return; + } + + const connection: WalletConnection = { + walletName: hello.wallet_name, + walletAddress: hello.wallet_address, + profileAddress: hello.profile_address, + sessionId: session.session_id, + description: session.description, + sessionStateJson: hello.session_state_json, + }; + + if (withChallenge && nonce && hello.signature) { + const nonceMatch = hello.nonce === nonce; + console.log("[CONNECT] inline challenge result:", { + nonceMatch, + nonce: hello.nonce, + signature: hello.signature, + epk_public: hello.epk_public, + }); + connection.inlineChallenge = { + nonce: hello.nonce!, + signature: hello.signature!, + epkPublic: hello.epk_public ?? "", + }; + } else if (withChallenge) { + console.warn("[CONNECT] requested challenge but wallet did not sign — old wallet?"); + } + + onConnected(connection); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[CONNECT] wait_wallet_hello failed:", msg); + if (waitToken !== waitTokenRef.current) { + return; + } + setError(msg); + } finally { + if (waitToken === waitTokenRef.current) { + setIsWaitingWallet(false); + } + } + })(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsCreatingSession(false); + } + }; return ( - <> - -

Vite + React

+
+ {!deepLink && ( +
+ + +
+ )} + + {deepLink && ( +
+ +
+ {connectMode === "verified" + ? "Scan QR — connect with ownership verification" + : "Scan QR in wallet app"} +
+
+ +
+ +
+ {isWaitingWallet + ? `Waiting for wallet confirmation...${sessionId ? ` (${sessionId})` : ""}` + : "Session created. Open wallet and confirm connection."} +
+
+ )} + + {error &&
{error}
} +
+ ); +} + +function generateNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function VerifyWalletPanel({ + connection, + onSessionDesync, +}: { + connection: WalletConnection; + onSessionDesync: () => void; +}) { + const [isVerifying, setIsVerifying] = useState(false); + const [verifyStatus, setVerifyStatus] = useState(null); + const [verifyError, setVerifyError] = useState(null); + const [verifyResult, setVerifyResult] = useState<{ + nonce: string; + signature: string; + walletAddress: string; + } | null>(null); + + const handleVerify = async () => { + try { + setIsVerifying(true); + setVerifyError(null); + setVerifyStatus(null); + setVerifyResult(null); + + const nonce = generateNonce(); + console.log("[VERIFY] starting. nonce:", nonce, "sessionId:", connection.sessionId); + setVerifyStatus(`Backend generated nonce: ${nonce}. Sending sign_challenge...`); + + const beeConnect = new BeeConnect(); + + // 1. Send sign_challenge (c→w) + console.log("[VERIFY] sending request_sign_challenge..."); + const challengeResult = await beeConnect.request_sign_challenge( + ENDPOINTS, + connection.sessionId, + connection.description, + connection.sessionStateJson, + nonce, + 30, + 1000, + ); + console.log("[VERIFY] sign_challenge sent. sent_at:", challengeResult.sent_at, "message_id:", challengeResult.message_id); + + // Do NOT persist session state yet — if wait_challenge_response times out, + // the DH chain stays at the pre-challenge position so retry is possible. + setVerifyStatus(`sign_challenge sent. Waiting for wallet to sign (nonce: ${nonce})...`); + + // 2. Wait for challenge_response (w→c) + console.log("[VERIFY] calling wait_challenge_response..."); + const response = await beeConnect.wait_challenge_response( + ENDPOINTS, + connection.sessionId, + connection.description, + challengeResult.updated_session_state_json, + challengeResult.sent_at, + 120, + 2000, + ); + console.log("[VERIFY] challenge_response received. nonce match:", response.nonce === nonce, "wallet:", response.wallet_address); + + // Persist session state only after successful response — + // both sign_challenge outbound rekey and challenge_response inbound rekey. + const finalState = response.updated_session_state_json + ?? challengeResult.updated_session_state_json; + if (finalState) { + connection.sessionStateJson = finalState; + writeStoredWalletConnection(connection); + } + + setVerifyResult({ + nonce: response.nonce, + signature: response.signature, + walletAddress: response.wallet_address, + }); + setVerifyStatus( + response.nonce === nonce + ? "Wallet ownership verified! Nonce matches." + : `WARNING: nonce mismatch! Expected ${nonce}, got ${response.nonce}`, + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[VERIFY] error:", msg); + if (msg.includes("session_desync")) { + console.error("[VERIFY] session_desync detected — will drop connection"); + setVerifyError("Session out of sync. Please reconnect your wallet."); + onSessionDesync(); + } else { + setVerifyError(msg); + } + setVerifyStatus(null); + } finally { + setIsVerifying(false); + } + }; + + return ( +
+
Backend Auth (sign_challenge)
+ + {verifyStatus && ( +
+ {verifyStatus} +
+ )} + {verifyResult && ( +
+
nonce: {verifyResult.nonce}
+
signature: {verifyResult.signature}
+
wallet: {verifyResult.walletAddress}
+
+ )} + {verifyError &&
{verifyError}
} +
+ ); +} + +function MinerPanel({ connection }: { connection: WalletConnection }) { + const [miner, setMiner] = useState(); + const minerRef = useRef(undefined); + const storedKeys = readStoredMiningKeys(connection); + const [minerAddress, setMinerAddress] = useState(storedKeys?.minerAddress ?? null); + const [ownerPublic, setOwnerPublic] = useState(storedKeys?.ownerPublic ?? null); + const [ownerSecret, setOwnerSecret] = useState(storedKeys?.ownerSecret ?? null); + const [areKeysPropagated, setAreKeysPropagated] = useState( + storedKeys?.areKeysPropagated ?? false, + ); + const [isWaitingKeysPropagation, setIsWaitingKeysPropagation] = useState(false); + const [requestStatus, setRequestStatus] = useState( + storedKeys + ? storedKeys.areKeysPropagated + ? "Mining keys restored from browser storage." + : "Mining keys restored from browser storage, waiting re-check." + : null, + ); + const [requestError, setRequestError] = useState(null); + const [isRequesting, setIsRequesting] = useState(false); + const [isInitializing, setIsInitializing] = useState(false); + const [initStatus, setInitStatus] = useState(null); + const [initError, setInitError] = useState(null); + const [isMinerRunning, setIsMinerRunning] = useState(false); + const [canStartMiner, setCanStartMiner] = useState(false); + const [minerActionError, setMinerActionError] = useState(null); + const [minerDebugInfo, setMinerDebugInfo] = useState(null); + const [minerDebugError, setMinerDebugError] = useState(null); + const [isMinerDebugLoading, setIsMinerDebugLoading] = useState(false); + const propagationTokenRef = useRef(0); + + useEffect(() => { + minerRef.current = miner; + }, [miner]); + + useEffect(() => { + if (!miner) { + setCanStartMiner(false); + setIsMinerRunning(false); + setMinerDebugInfo(null); + setMinerDebugError(null); + setIsMinerDebugLoading(false); + return; + } + + let cancelled = false; + const refreshCanStart = () => { + try { + const value = miner.can_start(); + if (!cancelled) { + setCanStartMiner(value); + } + } catch { + if (!cancelled) { + setCanStartMiner(false); + } + } + }; -
+ refreshCanStart(); + const intervalId = window.setInterval(refreshCanStart, 1000); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [miner]); + + useEffect(() => { + if (!miner) { + return; + } + + let cancelled = false; + let inFlight = false; + + const refreshMinerDebug = async () => { + if (cancelled || inFlight) { + return; + } + inFlight = true; + setIsMinerDebugLoading(true); + + try { + const data = await miner.get_miner_data(); + const nextInfo: MinerDebugInfo = { + tapSum: data.tap_sum.toString(), + tapSum5m: data.tap_sum_5m.toString(), + epochStart: data.epoch_start.toString(), + epoch5mStart: data.epoch_5m_start.toString(), + updatedAt: new Date().toLocaleTimeString(), + }; + data.free(); + + if (!cancelled) { + setMinerDebugInfo(nextInfo); + setMinerDebugError(null); + } + } catch (e) { + if (!cancelled) { + setMinerDebugError(e instanceof Error ? e.message : String(e)); + } + } finally { + inFlight = false; + if (!cancelled) { + setIsMinerDebugLoading(false); + } + } + }; + + void refreshMinerDebug(); + const intervalId = window.setInterval(() => { + void refreshMinerDebug(); + }, MINER_DATA_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [miner]); + + useEffect(() => { + return () => { + propagationTokenRef.current += 1; + minerRef.current?.free(); + }; + }, []); + + useEffect(() => { + if (ownerPublic && ownerSecret) { + writeStoredMiningKeys(connection, { + ownerPublic, + ownerSecret, + minerAddress, + areKeysPropagated, + }); + return; + } + + clearStoredMiningKeys(connection); + }, [connection, ownerPublic, ownerSecret, minerAddress, areKeysPropagated]); + + const miningKeysStage = requestError + ? "error" + : isWaitingKeysPropagation + ? "waiting" + : areKeysPropagated + ? "ready" + : ownerPublic && ownerSecret + ? "requested" + : "empty"; + + const miningKeysStageText = + miningKeysStage === "error" + ? "error" + : miningKeysStage === "waiting" + ? "waiting for propagation" + : miningKeysStage === "ready" + ? "ready" + : miningKeysStage === "requested" + ? "requested, not propagated yet" + : "not requested"; + + const handleRequestSetMiningKeys = async () => { + try { + propagationTokenRef.current += 1; + setIsRequesting(true); + setIsWaitingKeysPropagation(false); + setAreKeysPropagated(false); + setMinerAddress(null); + setOwnerPublic(null); + setOwnerSecret(null); + clearStoredMiningKeys(connection); + miner?.free(); + setMiner(undefined); + setCanStartMiner(false); + setIsMinerRunning(false); + setMinerActionError(null); + setMinerDebugInfo(null); + setMinerDebugError(null); + setRequestError(null); + setRequestStatus(null); + setInitError(null); + setInitStatus(null); + + const generated = await gen_mining_keys(APP_ID); + const beeConnect = new BeeConnect(); + const requestId = crypto.randomUUID(); + const request = await beeConnect.request_set_mining_keys( + ENDPOINTS, + connection.sessionId, + connection.description, + connection.sessionStateJson, + APP_ID, + generated.public, + 30, + 1000, + ); + // Update session state after re-key + if (request.updated_session_state_json) { + connection.sessionStateJson = request.updated_session_state_json; + writeStoredWalletConnection(connection); + } + + // Notify push backend (fire-and-forget) + fetch(`${API_URL}/v1/push/notify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + profile_address: connection.walletAddress, + kind: "connect_set_mining_keys", + request_id: requestId, + origin_name: window.location.hostname, + }), + }).catch(() => {}); + + setOwnerPublic(generated.public); + setOwnerSecret(generated.secret); + setRequestStatus( + `set_mining_keys requested (${request.message_id ?? "message_id unavailable"}). Waiting for on-chain propagation...`, + ); + + const token = propagationTokenRef.current + 1; + propagationTokenRef.current = token; + setIsWaitingKeysPropagation(true); + + void (async () => { + try { + const resolvedMinerAddress = await waitForMiningKeysPropagation( + connection.walletName, + generated.public, + ); + if (token !== propagationTokenRef.current) { + return; + } + setMinerAddress(resolvedMinerAddress); + setAreKeysPropagated(true); + setRequestStatus( + "Mining keys propagated and saved in browser storage. You can init miner.", + ); + } catch (e) { + if (token !== propagationTokenRef.current) { + return; + } + setRequestStatus(null); + setRequestError( + `set_mining_keys propagation failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } finally { + if (token === propagationTokenRef.current) { + setIsWaitingKeysPropagation(false); + } + } + })(); + } catch (e) { + setRequestError(e instanceof Error ? e.message : String(e)); + } finally { + setIsRequesting(false); + } + }; + + const [minerCrashed, setMinerCrashed] = useState(false); + + const handleMinerCallback = (message: string) => { + minerCallback(message); + + try { + const payload = JSON.parse(message) as MinerCallbackPayload; + if (payload.error) { + setMinerActionError(`${payload.action ?? "miner"}: ${payload.error}`); + setMinerCrashed(true); + setIsMinerRunning(false); + } + + if (payload.action === "status_updated" && payload.data?.status) { + const status = payload.data.status; + if (status === "computing" || status === "submitting") { + setIsMinerRunning(true); + setMinerCrashed(false); + } + if (status === "finished" || status === "removed") { + setIsMinerRunning(false); + } + } + } catch { + // Keep callback robust for non-JSON messages. + } + + try { + setCanStartMiner(minerRef.current?.can_start() ?? false); + } catch { + setCanStartMiner(false); + } + }; + + return ( +
+
+ - - - + +
+
+ Miner runtime:{" "} + + {minerCrashed ? "crashed — re-init to recover" : isMinerRunning ? "running" : "idle"} + {" "} + | can_start: {canStartMiner ? "yes" : "no"} +
+ {miner && ( +
+
+ miner.get_miner_data() debug{" "} + {isMinerDebugLoading + ? "(updating...)" + : minerDebugInfo + ? `(updated ${minerDebugInfo.updatedAt})` + : ""} +
+ {minerDebugInfo && ( +
+ tap_sum={minerDebugInfo.tapSum}, tap_sum_5m={minerDebugInfo.tapSum5m}, epoch_start= + {minerDebugInfo.epochStart}, epoch_5m_start={minerDebugInfo.epoch5mStart} +
+ )} + {minerDebugError && ( +
+ miner_data poll failed: {minerDebugError} +
+ )} +
+ )} +
+ Mining keys status: {miningKeysStageText} +
+
+ Stored in browser: {ownerPublic && ownerSecret ? "yes" : "no"}
+ {requestStatus &&
{requestStatus}
} + {requestError &&
{requestError}
} + {initStatus &&
{initStatus}
} + {initError &&
{initError}
} + {minerActionError &&
{minerActionError}
} + {!ownerPublic && ( +
+ First request mining keys via connect protocol. +
+ )} +
+ ); +} + +function App() { + const [walletConnection, setWalletConnection] = useState(() => + readStoredWalletConnection(), + ); + const [isWasmLoading, setIsWasmLoading] = useState(true); + const [isWasmReady, setIsWasmReady] = useState(false); + const [wasmError, setWasmError] = useState(null); + const [disconnectError, setDisconnectError] = useState(null); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [sessionMonitorError, setSessionMonitorError] = useState(null); + const [isSessionPolling, setIsSessionPolling] = useState(false); + const [nacklBalance, setNacklBalance] = useState(null); + const [nacklBalanceError, setNacklBalanceError] = useState(null); + + useEffect(() => { + let mounted = true; + + (async () => { + try { + setIsWasmLoading(true); + setWasmError(null); + await initBeeSdk(); + if (!mounted) { + return; + } + setIsWasmReady(true); + } catch (e) { + if (!mounted) { + return; + } + setWasmError(e instanceof Error ? e.message : String(e)); + } finally { + if (mounted) { + setIsWasmLoading(false); + } + } + })(); + + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + writeStoredWalletConnection(walletConnection); + }, [walletConnection]); + + useEffect(() => { + if (!isWasmReady || !walletConnection) { + setIsSessionPolling(false); + return; + } + + let cancelled = false; + let inFlight = false; + + const checkSession = async () => { + if (cancelled || inFlight) { + return; + } + inFlight = true; + setIsSessionPolling(true); + + try { + const exists = await isSessionStillActive(walletConnection); + if (cancelled) { + return; + } + + if (!exists) { + console.error("[SESSION_MONITOR] session inactive — dropping connection. wallet:", walletConnection.walletName, "profile:", walletConnection.profileAddress); + clearStoredMiningKeys(walletConnection); + setSessionMonitorError( + "Wallet disconnected the session. Please reconnect.", + ); + setWalletConnection(null); + return; + } + + setSessionMonitorError(null); + } catch (e) { + if (!cancelled) { + setSessionMonitorError( + `Session monitor failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } finally { + if (!cancelled) { + setIsSessionPolling(false); + } + inFlight = false; + } + }; + + void checkSession(); + const intervalId = window.setInterval(() => { + void checkSession(); + }, SESSION_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [isWasmReady, walletConnection]); + + useEffect(() => { + if (!isWasmReady || !walletConnection) { + setNacklBalance(null); + setNacklBalanceError(null); + return; + } + + let cancelled = false; + let inFlight = false; + const wallet = new Wallet(ENDPOINTS, null, API_URL, APP_ID); + + const pollBalance = async () => { + if (cancelled || inFlight) { + return; + } + inFlight = true; + + try { + const nativeBalances = await wallet.get_multifactor_balances({ + multifactor_address: walletConnection.walletAddress, + }); + if (cancelled) { + return; + } + + const token1 = nativeBalances.ecc["1"] ?? "0"; + setNacklBalance(formatNanoToDisplay(token1, 9, 4)); + setNacklBalanceError(null); + } catch (e) { + if (!cancelled) { + setNacklBalanceError(e instanceof Error ? e.message : String(e)); + } + } finally { + inFlight = false; + } + }; + + void pollBalance(); + const intervalId = window.setInterval(() => { + void pollBalance(); + }, BALANCE_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + wallet.free(); + }; + }, [isWasmReady, walletConnection]); + + const handleDisconnect = async () => { + if (!walletConnection) { + return; + } + + try { + console.log("[DISCONNECT] user requested disconnect"); + setIsDisconnecting(true); + setDisconnectError(null); + + const beeConnect = new BeeConnect(); + await beeConnect.disconnect_session( + ENDPOINTS, + walletConnection.sessionId, + walletConnection.description, + walletConnection.sessionStateJson, + "user_requested", + 30, + 1000, + ); + + clearStoredMiningKeys(walletConnection); + setSessionMonitorError(null); + setNacklBalance(null); + setNacklBalanceError(null); + setWalletConnection(null); + } catch (e) { + setDisconnectError(e instanceof Error ? e.message : String(e)); + } finally { + setIsDisconnecting(false); + } + }; + + return ( + <> +
+ Nackl logo +
+

Acki-connect demo

+

Network: shellnet

+ + {isWasmLoading &&
Loading wasm...
} + + {!isWasmLoading && wasmError && ( +
Failed to init wasm: {wasmError}
+ )} + {sessionMonitorError && ( +
+ {sessionMonitorError} +
+ )} + + {!isWasmLoading && isWasmReady && !walletConnection ? ( + { + setSessionMonitorError(null); + setWalletConnection(connection); + }} + /> + ) : ( + walletConnection && ( +
+
+ Connected wallet: {walletConnection.walletName}{" "} + + (NACKL: {nacklBalance ?? "..."}) + + {walletConnection.inlineChallenge && ( + + verified at connect + + )} +
+ {walletConnection.inlineChallenge && ( +
+
nonce: {walletConnection.inlineChallenge.nonce}
+
signature: {walletConnection.inlineChallenge.signature}
+
epk: {walletConnection.inlineChallenge.epkPublic}
+
+ )} + {nacklBalanceError && ( +
+ NACKL polling warning: {nacklBalanceError} +
+ )} +
+ Session monitor: {isSessionPolling ? "checking..." : "active"} +
+ +
+ +
+ {disconnectError &&
{disconnectError}
} + { + console.error("[APP] onSessionDesync fired — dropping connection"); + clearStoredMiningKeys(walletConnection); + setWalletConnection(null); + }} + /> + +
+ ) + )} ); } diff --git a/examples/javascript/miner-react/src/index.css b/examples/javascript/miner-react/src/index.css index 08a3ac9..335fb79 100644 --- a/examples/javascript/miner-react/src/index.css +++ b/examples/javascript/miner-react/src/index.css @@ -3,9 +3,9 @@ line-height: 1.5; font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: dark; + color: rgba(255, 255, 255, 0.92); + background-color: #0f1117; font-synthesis: none; text-rendering: optimizeLegibility; @@ -15,19 +15,18 @@ a { font-weight: 500; - color: #646cff; + color: #8eb5ff; text-decoration: inherit; } a:hover { - color: #535bf2; + color: #a9c7ff; } body { margin: 0; - display: flex; - place-items: center; min-width: 320px; min-height: 100vh; + background: radial-gradient(1200px 700px at 50% -150px, #1b2233 0%, #0f1117 55%, #0b0d12 100%); } h1 { @@ -42,27 +41,14 @@ button { font-size: 1em; font-weight: 500; font-family: inherit; - background-color: #1a1a1a; + background-color: #171a23; cursor: pointer; transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: #8eb5ff; } button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/examples/javascript/miner-react/vite.config.ts b/examples/javascript/miner-react/vite.config.ts index 8b0f57b..9031b1b 100644 --- a/examples/javascript/miner-react/vite.config.ts +++ b/examples/javascript/miner-react/vite.config.ts @@ -1,7 +1,17 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const appDir = fileURLToPath(new URL(".", import.meta.url)); +const beeSdkPkgDir = fileURLToPath(new URL("../../../bee_sdk/pkg", import.meta.url)); // https://vite.dev/config/ export default defineConfig({ plugins: [react()], -}) + server: { + fs: { + allow: [appDir, beeSdkPkgDir], + }, + }, +}); diff --git a/scripts/build_wasm.sh b/scripts/build_wasm.sh new file mode 100755 index 0000000..7d7be59 --- /dev/null +++ b/scripts/build_wasm.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Build WASM artifacts with machine paths stripped from the binaries. +# +# rustc embeds absolute paths (panic locations, debug info) into the artifact; +# without remapping the deployed .wasm leaks /Users//... of whoever built +# it. --remap-path-prefix rewrites them at compile time. rustc applies the LAST +# matching rule, so generic prefixes go first, specific ones last. +# +# Note: changing RUSTFLAGS invalidates the cargo cache — the first build after +# this script is introduced (or after editing the flags) is a full rebuild. +# +# Usage: scripts/build_wasm.sh [sdk|verifier|all] (default: all) + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" + +export RUSTFLAGS="${RUSTFLAGS:-} \ +--remap-path-prefix=$HOME=/home \ +--remap-path-prefix=$REPO_ROOT=/build \ +--remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=/cargo" + +# Gate: fail the build if any machine path survived into the artifact. +leak_check() { + local artifact="$1" + if strings "$artifact" | grep -qE "/Users/|/home/[a-z]+/"; then + echo "LEAK: machine paths in $artifact:" >&2 + strings "$artifact" | grep -oE "(/Users/|/home/[a-z]+/)[^\"']{0,80}" | sort -u | head >&2 + exit 1 + fi + echo "OK: no machine paths in $artifact" +} + +build_sdk() { + echo "=== bee_sdk (browser WASM, wasm-pack) ===" + (cd "$REPO_ROOT/bee_sdk" && rm -rf pkg && wasm-pack build --target web) + leak_check "$REPO_ROOT/bee_sdk/pkg/bee_sdk_bg.wasm" +} + +build_verifier() { + echo "=== bee_verifier (WASI component, wasm32-wasip2) ===" + # If `wit` was updated, run `cargo component bindings` first (see bee_verifier/README.md). + # -Zbuild-std rebuilds std locally — without the remap its .rustup paths + # would leak into the artifact too. + # immediate-abort used to be the build-std feature `panic_immediate_abort`; + # on current nightly it is a real panic strategy passed via -Cpanic. + (cd "$REPO_ROOT/bee_verifier" && \ + RUSTFLAGS="$RUSTFLAGS -Zunstable-options -Cpanic=immediate-abort" cargo +nightly build \ + -Zbuild-std=std,panic_abort \ + --target wasm32-wasip2 \ + --release) + local out="${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-wasip2/release/bee_verifier.wasm" + leak_check "$out" +} + +case "${1:-all}" in + sdk) build_sdk ;; + verifier) build_verifier ;; + all) + build_sdk + build_verifier + ;; + *) + echo "usage: $0 [sdk|verifier|all]" >&2 + exit 1 + ;; +esac From e3270e0cd8b4aaed8883999838df894b5804f51f Mon Sep 17 00:00:00 2001 From: Andrei Shuvalov <6286552+dronbas@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:19:43 +0200 Subject: [PATCH 2/3] Update ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 816c80b..c1d1144 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ target Cargo.lock pkg/ + +# halo2 SRS + prover cache (~0.5 GB) for the dex_flows tests — never commit +bee_wallet/params/ From ca11295c6f7ed4ffa6eb31fe0656ca5598953aab Mon Sep 17 00:00:00 2001 From: Andrei Shuvalov <6286552+dronbas@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:41:40 +0200 Subject: [PATCH 3/3] Update ci --- .github/workflows/ci.yml | 38 +++++++++++++++++++++++++++------- .github/workflows/gitleaks.yml | 31 +++++++++++++++++++++++++++ bee_miner/LICENSE_MIT | 2 +- 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/gitleaks.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed2484b..06aa673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ concurrency: cancel-in-progress: true jobs: - rust: - name: Rust Check, Clippy, Tests + lint: + name: Lint runs-on: ubuntu-latest env: CARGO_TERM_COLOR: always @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4 # nightly is only for `cargo fmt` (rustfmt.toml uses nightly-only options). - # stable is installed last, so it is the default for build/clippy/test. + # stable is installed last, so it is the default for build/clippy. - name: Setup Rust (nightly, rustfmt only) uses: dtolnay/rust-toolchain@nightly with: @@ -47,7 +47,7 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 - - name: Rustfmt + - name: Check formatting run: cargo +nightly fmt --all -- --check # bee-sdk is the wasm aggregator (forces single-wasm on its deps, which leaks @@ -67,9 +67,33 @@ jobs: rustup target add wasm32-unknown-unknown cargo check -p bee-sdk --target wasm32-unknown-unknown - - name: Cargo clippy (workspace libs) + - name: Clippy run: cargo clippy --workspace --exclude bee-sdk --exclude bee-verifier --lib -- -D warnings -A clippy::result_large_err + test: + name: Test (unit) + runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always + CARGO_TARGET_DIR: target + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Configure git for private dep + env: + PAT: ${{ secrets.GH_PAT }} + run: | + git config --global url."https://oauth2:${PAT}@github.com/".insteadOf "ssh://git@github.com/" + sed -i '/^dodex-sdk = /d' bee_wallet/Cargo.toml + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + # `--lib` only: bee_wallet's integration tests hit shellnet / need local # halo2 params, so they are run manually, not in CI. - name: Cargo test (workspace libs) @@ -79,7 +103,7 @@ jobs: run: cargo test -p bee-verifier --lib # Skip the deep-link test: it drives wasm-bindgen futures that panic when - # the wasm feature is exercised on the host target (run it under - # wasm-pack instead). release.yml skips the same test. + # the wasm feature is exercised on the host target (run it under wasm-pack + # instead). release.yml skips the same test. - name: Cargo test (miner wasm feature on host) run: cargo test -p bee-miner --no-default-features --features wasm --lib -- --skip core::keys::tests::gen_mining_keys_generates_keys_and_valid_deep_link diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..d69dd0d --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,31 @@ +name: Gitleaks + +on: + pull_request: + branches: + - main + +concurrency: + group: gitleaks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # full history so the scan covers every commit, not just the tip + fetch-depth: 0 + + # The gitleaks tool is open source (MIT); we run its public image directly. + # We deliberately do NOT use `gitleaks/gitleaks-action`, whose wrapper + # requires a paid GITLEAKS_LICENSE for organization-owned repositories. + # Rules live in `.gitleaks.toml` (same config the team uses elsewhere). + - name: Run gitleaks + run: | + docker run --rm -v "${{ github.workspace }}:/repo" -w /repo \ + ghcr.io/gitleaks/gitleaks:latest \ + detect --config .gitleaks.toml --redact --verbose diff --git a/bee_miner/LICENSE_MIT b/bee_miner/LICENSE_MIT index 0ca16e0..5193767 100644 --- a/bee_miner/LICENSE_MIT +++ b/bee_miner/LICENSE_MIT @@ -1,4 +1,4 @@ -Copyright (c) 2018 Andrei Shuvalov <6286552+dronbas@users.noreply.github.com> +Copyright (C) 2026 GOSH TECHNOLOGY LTD. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated