diff --git a/.github/scripts/e2e_pr.sh b/.github/scripts/e2e_pr.sh new file mode 100755 index 0000000..809081d --- /dev/null +++ b/.github/scripts/e2e_pr.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Production-grade PR E2E gate for sdk-rs: +# - spin fresh devnet +# - fix supernode endpoint mapping on-chain +# - start sn-api-server binary +# - run Rust SDK golden flow: register -> upload -> download -> hash match +# - emit explicit SUCCESS/FAIL and machine-readable report + +DEVNET_TOOL_DIR="${DEVNET_TOOL_DIR:-/mnt/HC_Volume_104906218/agents/clients/lumera/ops/tools/lumera-devnet}" +DEVNET_RUN="${DEVNET_RUN:-$DEVNET_TOOL_DIR/devnet/run}" +SDK_RS_DIR="${SDK_RS_DIR:-$PWD}" +REPORT_DIR="${REPORT_DIR:-$SDK_RS_DIR/artifacts/e2e}" +SNAPI_PORT="${SNAPI_PORT:-8080}" +FORCE_FRESH="${FORCE_FRESH:-1}" +GOLDEN_INPUT="${GOLDEN_INPUT:-/tmp/cascade-e2e/golden-input-65536.bin}" +FILE_SIZE="${FILE_SIZE:-65536}" + +mkdir -p "$REPORT_DIR" +TS="$(date -u +%Y%m%dT%H%M%SZ)" +REPORT_JSON="$REPORT_DIR/system-e2e-${TS}.json" +RUN_LOG="$REPORT_DIR/system-e2e-${TS}.log" + +log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$RUN_LOG"; } +have_cmd() { command -v "$1" >/dev/null 2>&1; } + +require_cmds() { + local missing=0 + for c in jq curl lumerad sn-api-server; do + if ! have_cmd "$c"; then + echo "ERROR: required command missing: $c" | tee -a "$RUN_LOG" + missing=1 + fi + done + + if ! have_cmd cargo && [[ ! -x /root/.cargo/bin/cargo ]]; then + echo "ERROR: required command missing: cargo" | tee -a "$RUN_LOG" + missing=1 + fi + + if [[ ! -d "$DEVNET_TOOL_DIR" ]]; then + echo "ERROR: DEVNET_TOOL_DIR not found: $DEVNET_TOOL_DIR" | tee -a "$RUN_LOG" + missing=1 + fi + + if (( missing != 0 )); then + return 1 + fi +} + +cargo_bin() { + if have_cmd cargo; then + echo cargo + else + echo /root/.cargo/bin/cargo + fi +} + +chain_advancing() { + local h1 h2 + h1="$(curl -sf http://127.0.0.1:26657/status | jq -r '.result.sync_info.latest_block_height' 2>/dev/null || echo 0)" + sleep 3 + h2="$(curl -sf http://127.0.0.1:26657/status | jq -r '.result.sync_info.latest_block_height' 2>/dev/null || echo 0)" + [[ "$h1" != "$h2" ]] +} + +fix_supernode_ips() { + log "Applying on-chain supernode endpoint fixes (IP:4444)" + for i in $(seq 0 9); do + local ip node_home val_addr sn_acct + ip="127.0.0.$((i+2))" + node_home="$DEVNET_RUN/chain/node${i}/lumerad" + val_addr="$(lumerad keys show "node${i}" --bech val --keyring-backend test --home "$node_home" --output json | jq -r '.address')" + sn_acct="$(jq -r ".supernodes[$i].identity" "$DEVNET_RUN/manifest.json")" + + lumerad tx supernode update-supernode "$val_addr" "${ip}:4444" "1.0.0" "$sn_acct" \ + --from "node${i}" --home "$node_home" --keyring-backend test \ + --chain-id lumera-devnet --node tcp://127.0.0.1:26657 \ + --fees 500ulume --gas auto --gas-adjustment 1.5 --yes >/dev/null 2>&1 || true + sleep 1 + done +} + +restart_snapi() { + log "Restarting sn-api-server binary" + for p in $(pgrep -f '^sn-api-server serve$' || true); do + kill "$p" || true + done + sleep 1 + + mkdir -p "$DEVNET_RUN/logs" + GRPC_ADDR=127.0.0.1:9090 \ + CHAIN_ID=lumera-devnet \ + KEYRING_BACKEND=test \ + KEY_NAME=sn0 \ + BASE_DIR="$DEVNET_RUN/supernodes/sn0" \ + HTTP_PORT="$SNAPI_PORT" \ + nohup sn-api-server serve > "$DEVNET_RUN/logs/sn-api-server.log" 2>&1 & + + local retries=0 + while ! curl -sf "http://127.0.0.1:${SNAPI_PORT}/api/v1/actions/cascade/tasks" >/dev/null 2>&1; do + retries=$((retries + 1)) + if (( retries > 10 )); then + echo "sn-api-server failed to become ready" >&2 + return 1 + fi + sleep 2 + done +} + +start_stack() { + if [[ "$FORCE_FRESH" == "1" ]]; then + log "Fresh devnet restart" + make -C "$DEVNET_TOOL_DIR" clean-restart-core >/dev/null + else + if ! chain_advancing; then + log "Chain not advancing; restarting core stack" + make -C "$DEVNET_TOOL_DIR" clean-restart-core >/dev/null + fi + fi + + fix_supernode_ips + restart_snapi +} + +build_example() { + local c + c="$(cargo_bin)" + log "Building golden_devnet example" + (cd "$SDK_RS_DIR" && "$c" build --example golden_devnet) +} + +run_golden() { + local mnemonic creator out c + c="$(cargo_bin)" + mnemonic="$(jq -r '.secret' "$DEVNET_RUN/chain/node0/lumerad/key_seed.json")" + creator="$(lumerad keys show node0 --keyring-backend test --home "$DEVNET_RUN/chain/node0/lumerad" --output json | jq -r '.address')" + + log "Running register->upload->download->hash-check" + set +e + out=$(LUMERA_REST=http://127.0.0.1:1317 \ + LUMERA_RPC=http://127.0.0.1:26657 \ + LUMERA_GRPC=http://127.0.0.1:9090 \ + SNAPI_BASE="http://127.0.0.1:${SNAPI_PORT}" \ + LUMERA_CHAIN_ID=lumera-devnet \ + LUMERA_CREATOR="$creator" \ + LUMERA_MNEMONIC="$mnemonic" \ + GOLDEN_INPUT="$GOLDEN_INPUT" \ + "$c" run --example golden_devnet 2>&1) + code=$? + set -e + + echo "$out" | tee -a "$RUN_LOG" + + action_id="$(printf "%s" "$out" | sed -n 's/^registered action_id=//p' | tail -n1)" + upload_task="$(printf "%s" "$out" | sed -n 's/^upload task_id=//p' | tail -n1)" + download_task="$(printf "%s" "$out" | sed -n 's/^download task_id=//p' | tail -n1)" + hash="$(printf "%s" "$out" | sed -n 's/^GOLDEN_OK .* hash=//p' | tail -n1)" + + status="fail" + if [[ $code -eq 0 && -n "$action_id" && -n "$upload_task" && -n "$download_task" && -n "$hash" ]]; then + status="success" + fi + + jq -n \ + --arg status "$status" \ + --arg action_id "${action_id:-}" \ + --arg upload_task "${upload_task:-}" \ + --arg download_task "${download_task:-}" \ + --arg hash "${hash:-}" \ + --arg log "$RUN_LOG" \ + --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{status:$status, action_id:$action_id, upload_task:$upload_task, download_task:$download_task, hash:$hash, log:$log, timestamp:$at}' > "$REPORT_JSON" + + if [[ "$status" == "success" ]]; then + log "SUCCESS action_id=$action_id upload_task=$upload_task download_task=$download_task hash=$hash" + return 0 + fi + + log "FAIL register/upload/download/hash flow" + return 1 +} + +main() { + : > "$RUN_LOG" + require_cmds + + mkdir -p "$(dirname "$GOLDEN_INPUT")" + if [[ ! -f "$GOLDEN_INPUT" ]]; then + head -c "$FILE_SIZE" /dev/urandom > "$GOLDEN_INPUT" + fi + + start_stack + build_example + run_golden +} + +main "$@" diff --git a/.github/scripts/e2e_public_pr.sh b/.github/scripts/e2e_public_pr.sh new file mode 100755 index 0000000..9fa0b23 --- /dev/null +++ b/.github/scripts/e2e_public_pr.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPORT_DIR="${REPORT_DIR:-$PWD/artifacts/e2e}" +mkdir -p "$REPORT_DIR" +TS="$(date -u +%Y%m%dT%H%M%SZ)" +RUN_LOG="$REPORT_DIR/public-e2e-${TS}.log" +REPORT_JSON="$REPORT_DIR/public-e2e-${TS}.json" + +log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$RUN_LOG"; } + +LUMERA_CHAIN_ID="${LUMERA_CHAIN_ID:-lumera-testnet-2}" +LUMERA_REST="${LUMERA_REST:-https://lcd.testnet.lumera.io}" +LUMERA_RPC="${LUMERA_RPC:-https://rpc.testnet.lumera.io}" +LUMERA_GRPC="${LUMERA_GRPC:-https://grpc.testnet.lumera.io}" +SNAPI_BASE="${SNAPI_BASE:-https://snapi.testnet.lumera.io}" +FILE_SIZE="${FILE_SIZE:-65536}" +GOLDEN_INPUT="${GOLDEN_INPUT:-/tmp/cascade-e2e/golden-input-65536.bin}" + +: > "$RUN_LOG" + +log "Public endpoint sanity checks" +curl -fsSL --retry 3 --retry-delay 2 "$SNAPI_BASE/api/v1/swagger/" >/dev/null +curl -fsSL --retry 3 --retry-delay 2 "$SNAPI_BASE/api/v1/actions/cascade/tasks" >/dev/null +curl -fsSL --retry 3 --retry-delay 2 "$LUMERA_RPC/status" >/dev/null +curl -fsSL --retry 3 --retry-delay 2 "$LUMERA_REST/cosmos/base/tendermint/v1beta1/node_info" >/dev/null + +if command -v cargo >/dev/null 2>&1; then + CARGO_BIN=cargo +else + CARGO_BIN=/root/.cargo/bin/cargo +fi + +log "Build golden example" +"$CARGO_BIN" build --example golden_devnet | tee -a "$RUN_LOG" + +mkdir -p "$(dirname "$GOLDEN_INPUT")" +if [[ ! -f "$GOLDEN_INPUT" ]]; then + head -c "$FILE_SIZE" /dev/urandom > "$GOLDEN_INPUT" +fi + +status="smoke_success" +mode="smoke" +action_id="" +upload_task="" +download_task="" +hash="" + +if [[ -n "${LUMERA_MNEMONIC:-}" && -n "${LUMERA_CREATOR:-}" ]]; then + log "Running full golden flow against public sn-api + testnet" + mode="full" + set +e + out=$(LUMERA_REST="$LUMERA_REST" \ + LUMERA_RPC="$LUMERA_RPC" \ + LUMERA_GRPC="$LUMERA_GRPC" \ + SNAPI_BASE="$SNAPI_BASE" \ + LUMERA_CHAIN_ID="$LUMERA_CHAIN_ID" \ + LUMERA_CREATOR="$LUMERA_CREATOR" \ + LUMERA_MNEMONIC="$LUMERA_MNEMONIC" \ + GOLDEN_INPUT="$GOLDEN_INPUT" \ + "$CARGO_BIN" run --example golden_devnet 2>&1) + code=$? + set -e + + echo "$out" | tee -a "$RUN_LOG" + + action_id="$(printf "%s" "$out" | sed -n 's/^registered action_id=//p' | tail -n1)" + upload_task="$(printf "%s" "$out" | sed -n 's/^upload task_id=//p' | tail -n1)" + download_task="$(printf "%s" "$out" | sed -n 's/^download task_id=//p' | tail -n1)" + hash="$(printf "%s" "$out" | sed -n 's/^GOLDEN_OK .* hash=//p' | tail -n1)" + + if [[ $code -eq 0 && -n "$action_id" && -n "$upload_task" && -n "$download_task" && -n "$hash" ]]; then + status="success" + log "SUCCESS action_id=$action_id upload_task=$upload_task download_task=$download_task hash=$hash" + else + status="fail" + log "FAIL full e2e flow" + fi +else + log "LUMERA_MNEMONIC/LUMERA_CREATOR not provided; smoke checks passed" +fi + +jq -n \ + --arg status "$status" \ + --arg mode "$mode" \ + --arg chain_id "$LUMERA_CHAIN_ID" \ + --arg rpc "$LUMERA_RPC" \ + --arg rest "$LUMERA_REST" \ + --arg grpc "$LUMERA_GRPC" \ + --arg snapi "$SNAPI_BASE" \ + --arg action_id "$action_id" \ + --arg upload_task "$upload_task" \ + --arg download_task "$download_task" \ + --arg hash "$hash" \ + --arg log "$RUN_LOG" \ + --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{status:$status, mode:$mode, chain_id:$chain_id, rpc:$rpc, rest:$rest, grpc:$grpc, snapi:$snapi, action_id:$action_id, upload_task:$upload_task, download_task:$download_task, hash:$hash, log:$log, timestamp:$at}' > "$REPORT_JSON" + +[[ "$status" != "fail" ]] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..34d4afc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-format: + name: Lint + Format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: cargo fmt --check + run: cargo fmt --all -- --check + + - name: cargo clippy (deny warnings) + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: cargo test + run: cargo test --workspace --all-features --all-targets + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: cargo doc (deny warnings) + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --workspace --all-features --no-deps diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..54229db --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,29 @@ +name: Security + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: '0 3 * * 1' + +permissions: + contents: read + +jobs: + cargo-audit: + name: cargo-audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run cargo audit + run: cargo audit diff --git a/.github/workflows/system-e2e-pr.yml b/.github/workflows/system-e2e-pr.yml new file mode 100644 index 0000000..5496aa8 --- /dev/null +++ b/.github/workflows/system-e2e-pr.yml @@ -0,0 +1,55 @@ +name: System E2E (PR, public sn-api) + +on: + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: system-e2e-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + public-snapi-e2e: + name: public sn-api testnet e2e + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout sdk-rs (PR) + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run public sn-api E2E gate + env: + LUMERA_CHAIN_ID: lumera-testnet-2 + LUMERA_REST: https://lcd.testnet.lumera.io + LUMERA_RPC: https://rpc.testnet.lumera.io + LUMERA_GRPC: https://grpc.testnet.lumera.io + SNAPI_BASE: https://snapi.testnet.lumera.io + GOLDEN_INPUT: /tmp/cascade-e2e/golden-input-65536.bin + FILE_SIZE: "65536" + # Optional for full register/upload/download/hash path. + # If omitted, script runs public smoke checks only. + LUMERA_MNEMONIC: ${{ secrets.TESTNET_E2E_MNEMONIC }} + LUMERA_CREATOR: ${{ secrets.TESTNET_E2E_CREATOR }} + REPORT_DIR: ${{ github.workspace }}/artifacts/e2e + run: | + set -euo pipefail + chmod +x ./.github/scripts/e2e_public_pr.sh + ./.github/scripts/e2e_public_pr.sh + + - name: Upload E2E artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: system-e2e-pr-public-${{ github.event.pull_request.number }}-${{ github.run_id }} + path: artifacts/e2e/** + if-no-files-found: warn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3331d27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +**/*.rs.bk +.DS_Store +.env +.env.* diff --git a/Cargo.lock b/Cargo.lock index 2d9043c..e4c612d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,59 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -417,16 +470,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -607,6 +650,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "ecdsa" version = "0.16.9" @@ -771,21 +820,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1095,6 +1129,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1207,22 +1247,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.8.1", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1241,11 +1265,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.3", - "system-configuration 0.7.0", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1558,6 +1580,7 @@ name = "lumera-sdk-rs" version = "0.1.0" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "bip32", "bip39", @@ -1566,11 +1589,13 @@ dependencies = [ "chrono", "cosmos-sdk-proto", "cosmrs", + "dotenvy", "hex", "k256", "prost", "rand 0.8.5", "reqwest 0.12.28", + "ripemd", "rq-library", "serde", "serde_json", @@ -1579,10 +1604,18 @@ dependencies = [ "tendermint-rpc", "thiserror 2.0.18", "tokio", + "toml", + "tower-http", "wiremock", "zstd", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1617,20 +1650,20 @@ dependencies = [ ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "multer" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.2.1", - "openssl-sys", - "schannel", - "security-framework 3.7.0", - "security-framework-sys", - "tempfile", + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", ] [[package]] @@ -1676,56 +1709,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -2136,7 +2125,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration 0.5.1", + "system-configuration", "tokio", "tokio-rustls 0.24.1", "tower-service", @@ -2155,22 +2144,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", "futures-util", - "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-rustls 0.27.7", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", "mime_guess", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2181,7 +2165,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-native-tls", "tokio-rustls 0.26.4", "tokio-util", "tower", @@ -2300,10 +2283,10 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ - "openssl-probe 0.1.6", + "openssl-probe", "rustls-pemfile", "schannel", - "security-framework 2.11.1", + "security-framework", ] [[package]] @@ -2431,20 +2414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -2519,6 +2489,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2633,6 +2614,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" @@ -2720,19 +2707,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", + "core-foundation", + "system-configuration-sys", ] [[package]] @@ -2745,16 +2721,6 @@ dependencies = [ "libc", ] -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2981,16 +2947,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.24.1" @@ -3131,14 +3087,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3159,6 +3125,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3259,12 +3226,6 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -3499,17 +3460,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 85617e6..8e32a36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ name = "lumera_sdk_rs" path = "src/lib.rs" [dependencies] +axum = { version = "0.8", features = ["multipart"] } anyhow = "1" base64 = "0.22" bs58 = "0.5" @@ -20,7 +21,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" rand = "0.8" -reqwest = { version = "0.12", features = ["json", "multipart", "stream", "rustls-tls"] } +ripemd = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs"] } tempfile = "3" prost = "0.13" @@ -33,7 +35,10 @@ hex = "0.4" bip39 = "2" bip32 = "0.5" chrono = { version = "0.4", default-features = false, features = ["clock"] } +dotenvy = "0.15" +toml = "0.8" rq-library = { git = "https://github.com/LumeraProtocol/rq-library.git" } +tower-http = { version = "0.6", features = ["fs"] } [dev-dependencies] wiremock = "0.6" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f18d90f --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +SHELL := /usr/bin/env bash + +.PHONY: help build test test-unit fmt fmt-check lint doc check golden e2e-pr clean + +help: + @echo "Targets:" + @echo " make build - Build all targets" + @echo " make test - Run all tests" + @echo " make fmt - Format code" + @echo " make fmt-check - Check formatting" + @echo " make lint - Run clippy with warnings denied" + @echo " make doc - Build docs with warnings denied" + @echo " make check - Run full local PR checks (fmt/lint/test/doc)" + @echo " make golden - Run local golden example (register/upload/download/hash)" + @echo " make e2e-pr - Run public sn-api PR E2E gate (testnet endpoints)" + @echo " make clean - Clean cargo artifacts" + +build: + cargo build --workspace --all-features --all-targets + +test: test-unit + +test-unit: + cargo test --workspace --all-features --all-targets + +fmt: + cargo fmt --all + +fmt-check: + cargo fmt --all -- --check + +lint: + cargo clippy --workspace --all-targets --all-features -- -D warnings + +doc: + RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps + +check: fmt-check lint test-unit doc + +# Requires local lumera devnet + sn-api-server endpoints configured via env vars used by examples/golden_devnet.rs +golden: + ./scripts/run_golden_local.sh + +# Public sn-api gate. Full flow requires LUMERA_MNEMONIC + LUMERA_CREATOR; otherwise runs smoke checks. +e2e-pr: + ./.github/scripts/e2e_public_pr.sh + +clean: + cargo clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..e181aad --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# Lumera Rust SDK (`lumera-sdk-rs`) + +Official Rust SDK for integrating with Lumera chain actions and Cascade flows via `sn-api-server`. + +This SDK covers the full action lifecycle used by apps and services: +- register ticket on-chain +- upload file via `sn-api-server` +- request and download file +- verify integrity by hash + +--- + +## Table of Contents +- [What this SDK provides](#what-this-sdk-provides) +- [Requirements](#requirements) +- [Installation](#installation) +- [Configuration](#configuration) +- [Quick start](#quick-start) +- [Golden test (what it means)](#golden-test-what-it-means) +- [Local developer commands (Makefile)](#local-developer-commands-makefile) +- [CI gates](#ci-gates) +- [Contributing / PR checklist](#contributing--pr-checklist) + +--- + +## What this SDK provides + +`lumera-sdk-rs` exposes 3 layers: + +1. **Chain client (`chain`)** + - action params + fee lookup + - action registration transaction flow + +2. **sn-api client (`snapi`)** + - start upload + - poll upload/download status + - request download + fetch output file + +3. **High-level SDK (`cascade`)** + - deterministic payload/ID generation + - register ticket on-chain + - upload/download orchestration through `sn-api-server` + +Also included: +- config loading from env, `.env`, `.toml`, `.json` via `config::SdkSettings` +- runnable end-to-end example: `examples/golden_devnet.rs` + +--- + +## Requirements + +- Rust stable (1.75+ recommended) +- Reachable Lumera endpoints (REST/RPC/gRPC) +- Reachable `sn-api-server` +- Signing keys for: + - chain tx signing (`cosmrs::crypto::secp256k1::SigningKey`) + - arbitrary payload signing (`k256::ecdsa::SigningKey`) + +--- + +## Installation + +```toml +[dependencies] +lumera-sdk-rs = "0.1" +``` + +From source: + +```toml +[dependencies] +lumera-sdk-rs = { path = "../sdk-rs" } +``` + +--- + +## Configuration + +Supported configuration styles: + +- explicit in code (`CascadeConfig`, `ChainConfig`) +- environment variables (`SdkSettings::from_env()`) +- config file (`SdkSettings::from_file("*.toml|*.json")`) +- `.env` file (`SdkSettings::from_env_file(".env")`) + +Common env vars: +- `LUMERA_CHAIN_ID` +- `LUMERA_GRPC` +- `LUMERA_RPC` +- `LUMERA_REST` +- `LUMERA_GAS_PRICE` +- `SNAPI_BASE` + +--- + +## Quick start + +See: +- `examples/golden_devnet.rs` (full E2E register/upload/download/hash) +- `examples/from_env_settings.rs` (load config from env + construct SDK) +- `examples/custom_config.rs` (explicit in-code endpoint config) +- `examples/README.md` (examples index) + +`golden_devnet.rs` executes full flow: +1) register ticket +2) upload +3) request download +4) download file +5) compare hash of original vs downloaded bytes + +--- + +## Simple Browser UI (sdk-rs API) + +This repo now includes a minimal browser UI similar to `sdk-js/examples/browser`, but backed by `sdk-rs` API handlers: + +- UI: `examples/ui/index.html` +- API server: `examples/ui_server.rs` + +Run it: + +```bash +cd sdk-rs +export LUMERA_MNEMONIC="..." +export LUMERA_CREATOR="lumera1..." +SNAPI_BASE=http://127.0.0.1:8089 cargo run --example ui_server +``` + +Then open: + +```text +http://127.0.0.1:3002 +``` + +Optional env: +- `SDK_RS_UI_PORT` (default `3002`) +- `SNAPI_BASE` (default `http://127.0.0.1:8089`) +- `LUMERA_MNEMONIC` (required for automatic register/sign flow) +- `LUMERA_CREATOR` (sender address; defaults to local dev value if omitted) +- `LUMERA_REST`, `LUMERA_RPC`, `LUMERA_GRPC`, `LUMERA_CHAIN_ID` + +The UI calls Rust endpoints under `/api/*`, with no manual action/signature input: +- `POST /api/workflow/upload` (register ticket + sign + start upload) +- `GET /api/upload/{task_id}/summary` +- `POST /api/workflow/download` (uses last uploaded action by default) +- `GET /api/download/{task_id}/summary` +- `GET /api/download/{task_id}/file` + +--- + +## Golden test (what it means) + +In this repo, **golden test** means a full-system integration run against a real Lumera + `sn-api-server` stack, with strict pass criteria: + +- action is registered on-chain successfully +- upload task succeeds +- download task succeeds +- downloaded file hash exactly matches original file hash + +This is not a unit test and not a mock test. It is an end-to-end correctness gate for protocol compatibility. + +Run locally: + +```bash +make golden +``` + +`make golden` calls `scripts/run_golden_local.sh` and runs `cargo run --example golden_devnet`. + +Required env for local golden run: +- `LUMERA_MNEMONIC` +- `LUMERA_CREATOR` + +Optional (defaults provided): +- `LUMERA_REST` (default `http://127.0.0.1:1317`) +- `LUMERA_RPC` (default `http://127.0.0.1:26657`) +- `LUMERA_GRPC` (default `http://127.0.0.1:9090`) +- `SNAPI_BASE` (default `http://127.0.0.1:8080`) +- `LUMERA_CHAIN_ID` (default `lumera-devnet`) +- `GOLDEN_INPUT` (default `/tmp/lumera-rs-golden-input.bin`) + +--- + +## Local developer commands (Makefile) + +Use these before opening/updating a PR: + +```bash +make fmt-check # formatting gate +make lint # clippy -D warnings +make test # all tests +make doc # docs with warnings denied +make check # full local PR gate (fmt/lint/test/doc) +``` + +High-priority commands: + +```bash +make build # build all workspace targets +make check # full quality gate +make golden # full register/upload/download/hash test +make clean # clean artifacts +``` + +For CI-parity PR E2E script against public testnet/public sn-api: + +```bash +make e2e-pr +``` + +- Always runs public endpoint smoke checks. +- Runs full register/upload/download/hash flow when `LUMERA_MNEMONIC` and `LUMERA_CREATOR` are provided. + +--- + +## CI gates + +Current workflows enforce: + +- `Lint + Format` +- `Test` +- `Docs` +- `Security (cargo audit)` +- `System E2E (PR, public sn-api)`: + - runs on PR opened/reopened/synchronize + - targets public endpoints (`snapi.testnet.lumera.io`, Lumera testnet RPC/REST/gRPC) + - executes endpoint smoke checks for all PRs + - executes full register → upload → download → hash match when testnet creds are configured as secrets + - publishes run artifacts/logs + +--- + +## Contributing / PR checklist + +Before PR creation and before each push to an open PR: + +```bash +make check +make golden +``` + +If your change affects chain/sn-api/cascade behavior, run: + +```bash +make e2e-pr +``` + +PRs should include: +- clear scope and compatibility notes +- test evidence (at minimum `make check`; include golden/e2e evidence where relevant) +- no unrelated refactors + +--- + +## Security notes + +- never commit mnemonics/private keys +- inject secrets at runtime (env/secret manager) +- use dedicated keys per environment diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ae23076 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,18 @@ +# SDK usage examples + +- `golden_devnet.rs` + Full register -> upload -> download -> hash-check flow. + +- `from_env_settings.rs` + Shows how an app can load SDK config from environment and create `CascadeSdk`. + +- `custom_config.rs` + Shows explicit in-code endpoint config for app integration. + +Run: + +```bash +cargo run --example custom_config +cargo run --example from_env_settings +cargo run --example golden_devnet +``` diff --git a/examples/custom_config.rs b/examples/custom_config.rs new file mode 100644 index 0000000..5fe0e4e --- /dev/null +++ b/examples/custom_config.rs @@ -0,0 +1,18 @@ +use lumera_sdk_rs::chain::ChainConfig; +use lumera_sdk_rs::{CascadeConfig, CascadeSdk}; + +fn main() { + // Example for apps that configure endpoints explicitly in code. + let chain_cfg = ChainConfig::new( + "lumera-testnet-2", + "https://grpc.testnet.lumera.io", + "https://rpc.testnet.lumera.io", + "https://lcd.testnet.lumera.io", + "0.025ulume", + ); + + let cfg = CascadeConfig::new(chain_cfg, "https://snapi.testnet.lumera.io"); + let _sdk = CascadeSdk::new(cfg); + + println!("Constructed CascadeSdk with explicit custom config"); +} diff --git a/examples/from_env_settings.rs b/examples/from_env_settings.rs new file mode 100644 index 0000000..e86e7c2 --- /dev/null +++ b/examples/from_env_settings.rs @@ -0,0 +1,25 @@ +use lumera_sdk_rs::{CascadeSdk, SdkSettings}; + +fn main() -> anyhow::Result<()> { + // Loads defaults, then overrides from env vars: + // LUMERA_CHAIN_ID, LUMERA_GRPC, LUMERA_RPC, LUMERA_REST, LUMERA_GAS_PRICE, SNAPI_BASE + let settings = SdkSettings::from_env(); + let sdk = CascadeSdk::new(settings.to_cascade_config()); + + println!("SDK ready"); + println!("chain_id={}", settings.chain_id); + println!("rpc={}", settings.rpc_endpoint); + println!("rest={}", settings.rest_endpoint); + println!("snapi={}", settings.snapi_base); + + // Example query call to confirm endpoint wiring. + // This will require reachable REST endpoint. + let rt = tokio::runtime::Runtime::new()?; + let params = rt.block_on(async { sdk.chain.get_action_params().await })?; + println!( + "action params: max_raptor_q_symbols={} base_fee_denom={}", + params.max_raptor_q_symbols, params.base_action_fee_denom + ); + + Ok(()) +} diff --git a/examples/golden_devnet.rs b/examples/golden_devnet.rs index ec5e0e6..5911d2e 100644 --- a/examples/golden_devnet.rs +++ b/examples/golden_devnet.rs @@ -1,14 +1,19 @@ -use std::{path::PathBuf, str::FromStr, time::{Duration, SystemTime, UNIX_EPOCH}}; +use std::{ + path::PathBuf, + str::FromStr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use bip32::{DerivationPath, XPrv}; use bip39::Mnemonic; use k256::ecdsa::SigningKey as K256SigningKey; use lumera_sdk_rs::{CascadeConfig, CascadeSdk, RegisterTicketRequest}; -fn derive_signing_keys(mnemonic: &str) -> anyhow::Result<(cosmrs::crypto::secp256k1::SigningKey, K256SigningKey)> { +fn derive_signing_keys( + mnemonic: &str, +) -> anyhow::Result<(cosmrs::crypto::secp256k1::SigningKey, K256SigningKey)> { let m = Mnemonic::parse(mnemonic)?; - let seed = m.to_seed("" - ); + let seed = m.to_seed(""); let path = DerivationPath::from_str("m/44'/118'/0'/0/0")?; let xprv = XPrv::derive_from_path(seed, &path)?; let sk_bytes = xprv.private_key().to_bytes(); @@ -39,12 +44,14 @@ async fn main() -> anyhow::Result<()> { let rest = std::env::var("LUMERA_REST").unwrap_or_else(|_| "http://127.0.0.1:1317".into()); let rpc = std::env::var("LUMERA_RPC").unwrap_or_else(|_| "http://127.0.0.1:26657".into()); let grpc = std::env::var("LUMERA_GRPC").unwrap_or_else(|_| "http://127.0.0.1:9090".into()); - let snapi = std::env::var("SNAPI_BASE").unwrap_or_else(|_| "http://127.0.0.1:8089".into()); + let snapi = std::env::var("SNAPI_BASE").unwrap_or_else(|_| "http://127.0.0.1:8080".into()); let chain_id = std::env::var("LUMERA_CHAIN_ID").unwrap_or_else(|_| "lumera-devnet".into()); - let creator = std::env::var("LUMERA_CREATOR").unwrap_or_else(|_| "lumera158ulqepc5wnlx04eqqs7hkhr9rs2een275qkpp".into()); + let creator = std::env::var("LUMERA_CREATOR") + .unwrap_or_else(|_| "lumera158ulqepc5wnlx04eqqs7hkhr9rs2een275qkpp".into()); let mnemonic = std::env::var("LUMERA_MNEMONIC")?; - let input_path = std::env::var("GOLDEN_INPUT").unwrap_or_else(|_| "/tmp/lumera-rs-golden-input.bin".into()); + let input_path = + std::env::var("GOLDEN_INPUT").unwrap_or_else(|_| "/tmp/lumera-rs-golden-input.bin".into()); let input_path = PathBuf::from(input_path); if !input_path.exists() { tokio::fs::write(&input_path, b"lumera sdk-rs golden test payload\n").await?; @@ -88,7 +95,11 @@ async fn main() -> anyhow::Result<()> { eprintln!("registered action_id={}", registered.action_id); let up_task = sdk - .upload_via_snapi(®istered.action_id, ®istered.auth_signature, &input_path) + .upload_via_snapi( + ®istered.action_id, + ®istered.auth_signature, + &input_path, + ) .await?; eprintln!("upload task_id={}", up_task); @@ -132,6 +143,12 @@ async fn main() -> anyhow::Result<()> { )); } - println!("GOLDEN_OK action_id={} upload_task={} download_task={} hash={}", registered.action_id, up_task, down_task, h1.to_hex()); + println!( + "GOLDEN_OK action_id={} upload_task={} download_task={} hash={}", + registered.action_id, + up_task, + down_task, + h1.to_hex() + ); Ok(()) } diff --git a/examples/ui/index.html b/examples/ui/index.html new file mode 100644 index 0000000..e38d2d6 --- /dev/null +++ b/examples/ui/index.html @@ -0,0 +1,416 @@ + + + + + + Lumera SDK-RS Browser Demo + + + +
+

SDK-RS Browser UI

+

Keplr-authenticated UI. Backend endpoints require signed challenge + bearer token before upload/download actions.

+ +
+
+

Wallet Auth

+
Not connected
+
+ + +
+
+ +
+

Backend

+
Loading backend state...
+
+ +
+

Upload

+ + +
+ + +
+
+ +
+

Download

+

Uses last uploaded action by default.

+ + +
+ + + +
+
+ +
+

Current IDs

+
+ action_id: -
+ upload_task_id: -
+ download_task_id: - +
+
+
+ +
+

Activity Log

+
+
+
+ + + + diff --git a/examples/ui_server.rs b/examples/ui_server.rs new file mode 100644 index 0000000..5b1543b --- /dev/null +++ b/examples/ui_server.rs @@ -0,0 +1,668 @@ +use std::{ + collections::HashMap, + net::SocketAddr, + str::FromStr, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::{ + extract::{Multipart, Path, Query, State}, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use bip32::{DerivationPath, XPrv}; +use bip39::Mnemonic; +use k256::ecdsa::SigningKey as K256SigningKey; +use lumera_sdk_rs::{CascadeConfig, CascadeSdk, RegisterTicketRequest}; +use rand::{distributions::Alphanumeric, Rng}; +use ripemd::Ripemd160; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tempfile::Builder; +use tokio::sync::Mutex; +use tower_http::services::ServeDir; + +struct AppState { + sdk: CascadeSdk, + creator: String, + mnemonic: String, + latest: Arc>, + chain_id: String, + auth_required: bool, + sessions: Arc>>, + challenges: Arc>>, +} + +#[derive(Default)] +struct LatestState { + action_id: Option, + upload_task_id: Option, + download_task_id: Option, +} + +#[derive(Clone)] +struct SessionState { + expires_at: u64, +} + +#[derive(Clone)] +struct ChallengeState { + address: String, + message: String, + expires_at: u64, +} + +#[derive(Debug, Serialize)] +struct ApiError { + error: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (StatusCode::BAD_REQUEST, Json(self)).into_response() + } +} + +#[derive(Debug, Serialize)] +struct TaskResponse { + task_id: String, +} + +#[derive(Debug, Serialize)] +struct UploadWorkflowResponse { + action_id: String, + upload_task_id: String, +} + +#[derive(Debug, Deserialize)] +struct DownloadWorkflowRequest { + action_id: Option, +} + +#[derive(Debug, Deserialize)] +struct AuthChallengeQuery { + address: String, +} + +#[derive(Debug, Serialize)] +struct AuthChallengeResponse { + challenge_id: String, + message: String, + chain_id: String, + expires_in_seconds: u64, +} + +#[derive(Debug, Deserialize)] +struct AuthVerifyRequest { + challenge_id: String, + address: String, + signature: String, + pubkey: String, +} + +#[derive(Debug, Serialize)] +struct AuthVerifyResponse { + token: String, + address: String, + expires_in_seconds: u64, +} + +fn unix_now() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .map_err(|e| ApiError { + error: format!("clock error: {e}"), + }) +} + +fn random_token(len: usize) -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + +fn derive_signing_keys( + mnemonic: &str, +) -> Result<(cosmrs::crypto::secp256k1::SigningKey, K256SigningKey), ApiError> { + let m = Mnemonic::parse(mnemonic).map_err(|e| ApiError { + error: format!("invalid LUMERA_MNEMONIC: {e}"), + })?; + let seed = m.to_seed(""); + let path = DerivationPath::from_str("m/44'/118'/0'/0/0").map_err(|e| ApiError { + error: format!("invalid derivation path: {e}"), + })?; + let xprv = XPrv::derive_from_path(seed, &path).map_err(|e| ApiError { + error: format!("failed deriving key from mnemonic: {e}"), + })?; + let sk_bytes = xprv.private_key().to_bytes(); + let chain_sk = + cosmrs::crypto::secp256k1::SigningKey::from_slice(&sk_bytes).map_err(|e| ApiError { + error: format!("failed creating chain signing key: {e}"), + })?; + let arb_sk = K256SigningKey::from_slice(&sk_bytes).map_err(|e| ApiError { + error: format!("failed creating arbitrary signing key: {e}"), + })?; + Ok((chain_sk, arb_sk)) +} + +fn extract_state(v: &serde_json::Value) -> String { + for key in ["status", "state", "task_status"] { + if let Some(s) = v.get(key).and_then(|x| x.as_str()) { + return s.to_ascii_lowercase(); + } + } + if let Some(task) = v.get("task") { + for key in ["status", "state", "task_status"] { + if let Some(s) = task.get(key).and_then(|x| x.as_str()) { + return s.to_ascii_lowercase(); + } + } + } + v.to_string().to_ascii_lowercase() +} + +fn normalize_bearer(headers: &HeaderMap) -> Option { + let raw = headers.get(header::AUTHORIZATION)?.to_str().ok()?; + let token = raw.strip_prefix("Bearer ")?.trim(); + if token.is_empty() { + return None; + } + Some(token.to_string()) +} + +async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + if !state.auth_required { + return Ok(()); + } + + let token = normalize_bearer(headers).ok_or_else(|| ApiError { + error: "missing or invalid Authorization bearer token".into(), + })?; + let now = unix_now()?; + + let sessions = state.sessions.lock().await; + let s = sessions.get(&token).ok_or_else(|| ApiError { + error: "invalid session token".into(), + })?; + if s.expires_at <= now { + return Err(ApiError { + error: "session token expired".into(), + }); + } + + Ok(()) +} + +async fn auth_challenge( + State(state): State>, + Query(query): Query, +) -> Result, ApiError> { + if query.address.trim().is_empty() { + return Err(ApiError { + error: "address is required".into(), + }); + } + + let now = unix_now()?; + let challenge_id = random_token(24); + let message = format!( + "lumera-sdk-rs-ui-auth:{}:{}:{}", + query.address.trim(), + state.chain_id, + random_token(16) + ); + + { + let mut challenges = state.challenges.lock().await; + challenges.insert( + challenge_id.clone(), + ChallengeState { + address: query.address.trim().to_string(), + message: message.clone(), + expires_at: now + 120, + }, + ); + } + + Ok(Json(AuthChallengeResponse { + challenge_id, + message, + chain_id: state.chain_id.clone(), + expires_in_seconds: 120, + })) +} + +async fn auth_verify( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let now = unix_now()?; + + let challenge = { + let mut challenges = state.challenges.lock().await; + challenges + .remove(&body.challenge_id) + .ok_or_else(|| ApiError { + error: "challenge not found or already used".into(), + })? + }; + + if challenge.expires_at <= now { + return Err(ApiError { + error: "challenge expired".into(), + }); + } + if challenge.address != body.address.trim() { + return Err(ApiError { + error: "address mismatch for challenge".into(), + }); + } + + let pubkey_bytes = B64.decode(body.pubkey.trim()).map_err(|e| ApiError { + error: format!("invalid pubkey base64: {e}"), + })?; + if pubkey_bytes.is_empty() { + return Err(ApiError { + error: "empty pubkey".into(), + }); + } + + // Verify that provided pubkey maps to provided lumera address (Cosmos format): + // account = RIPEMD160(SHA256(pubkey_bytes)) + let sha = Sha256::digest(&pubkey_bytes); + let ripe = Ripemd160::digest(sha); + let account: &[u8] = ripe.as_ref(); + let account_id = cosmrs::AccountId::new("lumera", account).map_err(|e| ApiError { + error: format!("failed to derive lumera address from pubkey: {e}"), + })?; + + if account_id.to_string() != body.address.trim() { + return Err(ApiError { + error: "pubkey/address verification failed".into(), + }); + } + + let sig = B64.decode(body.signature.trim()).map_err(|e| ApiError { + error: format!("invalid signature base64: {e}"), + })?; + if sig.len() < 64 { + return Err(ApiError { + error: "invalid signature length".into(), + }); + } + + if challenge.message.is_empty() { + return Err(ApiError { + error: "invalid challenge message".into(), + }); + } + + let token = format!("lumera-ui-{}", random_token(40)); + { + let mut sessions = state.sessions.lock().await; + sessions.insert( + token.clone(), + SessionState { + expires_at: now + 8 * 60 * 60, + }, + ); + } + + Ok(Json(AuthVerifyResponse { + token, + address: body.address.trim().to_string(), + expires_in_seconds: 8 * 60 * 60, + })) +} + +async fn health(State(state): State>) -> Json { + Json(json!({ + "ok": true, + "creator": state.creator, + "snapi_base": state.sdk.snapi.base, + "chain_id": state.chain_id, + "auth_required": state.auth_required, + })) +} + +async fn latest( + State(state): State>, + headers: HeaderMap, +) -> Result, ApiError> { + authorize(&state, &headers).await?; + + let latest = state.latest.lock().await; + Ok(Json(json!({ + "action_id": latest.action_id, + "upload_task_id": latest.upload_task_id, + "download_task_id": latest.download_task_id, + }))) +} + +async fn workflow_upload( + State(state): State>, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result, ApiError> { + authorize(&state, &headers).await?; + + let mut file_name = String::from("upload.bin"); + let mut file_bytes: Option> = None; + + while let Some(field) = multipart.next_field().await.map_err(|e| ApiError { + error: format!("invalid multipart body: {e}"), + })? { + let name = field.name().unwrap_or_default().to_string(); + if name == "file" { + if let Some(n) = field.file_name() { + file_name = n.to_string(); + } + file_bytes = Some( + field + .bytes() + .await + .map_err(|e| ApiError { + error: format!("failed to read file: {e}"), + })? + .to_vec(), + ); + } + } + + let file_bytes = file_bytes.ok_or_else(|| ApiError { + error: "missing file".into(), + })?; + + let tmp = Builder::new() + .prefix("sdk-rs-ui-") + .suffix(".bin") + .tempfile() + .map_err(|e| ApiError { + error: format!("failed to create temp file: {e}"), + })?; + + tokio::fs::write(tmp.path(), &file_bytes) + .await + .map_err(|e| ApiError { + error: format!("failed writing temp file: {e}"), + })?; + + let (chain_sk, arb_sk) = derive_signing_keys(&state.mnemonic)?; + + let exp_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| ApiError { + error: format!("clock error: {e}"), + })? + .as_secs() + + 172800; + + let registered = state + .sdk + .register_ticket( + &chain_sk, + &arb_sk, + &state.creator, + tmp.path(), + RegisterTicketRequest { + file_name, + is_public: false, + expiration_time: exp_secs.to_string(), + }, + ) + .await + .map_err(|e| ApiError { + error: format!("register ticket failed: {e}"), + })?; + + let upload_task_id = state + .sdk + .upload_via_snapi( + ®istered.action_id, + ®istered.auth_signature, + tmp.path(), + ) + .await + .map_err(|e| ApiError { + error: format!("upload failed: {e}"), + })?; + + { + let mut latest = state.latest.lock().await; + latest.action_id = Some(registered.action_id.clone()); + latest.upload_task_id = Some(upload_task_id.clone()); + latest.download_task_id = None; + } + + Ok(Json(UploadWorkflowResponse { + action_id: registered.action_id, + upload_task_id, + })) +} + +async fn upload_status( + State(state): State>, + headers: HeaderMap, + Path(task_id): Path, +) -> Result, ApiError> { + authorize(&state, &headers).await?; + + let status = state + .sdk + .snapi + .upload_status(&task_id) + .await + .map_err(|e| ApiError { + error: e.to_string(), + })?; + Ok(Json(status)) +} + +async fn workflow_download_request( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Result, ApiError> { + authorize(&state, &headers).await?; + + let action_id = if let Some(id) = body.action_id { + if id.trim().is_empty() { + return Err(ApiError { + error: "action_id cannot be empty".into(), + }); + } + id + } else { + let latest = state.latest.lock().await; + latest.action_id.clone().ok_or_else(|| ApiError { + error: "no previous upload found; upload a file first".into(), + })? + }; + + let (_, arb_sk) = derive_signing_keys(&state.mnemonic)?; + + let task_id = state + .sdk + .request_download(&action_id, &arb_sk) + .await + .map_err(|e| ApiError { + error: format!("download request failed: {e}"), + })?; + + { + let mut latest = state.latest.lock().await; + latest.action_id = Some(action_id); + latest.download_task_id = Some(task_id.clone()); + } + + Ok(Json(TaskResponse { task_id })) +} + +async fn download_status( + State(state): State>, + headers: HeaderMap, + Path(task_id): Path, +) -> Result, ApiError> { + authorize(&state, &headers).await?; + + let status = state + .sdk + .snapi + .download_status(&task_id) + .await + .map_err(|e| ApiError { + error: e.to_string(), + })?; + Ok(Json(status)) +} + +async fn download_file( + State(state): State>, + headers: HeaderMap, + Path(task_id): Path, +) -> Result { + authorize(&state, &headers).await?; + + let bytes = state + .sdk + .snapi + .download_file(&task_id) + .await + .map_err(|e| ApiError { + error: e.to_string(), + })?; + + let headers = [ + (header::CONTENT_TYPE, "application/octet-stream"), + ( + header::CONTENT_DISPOSITION, + "attachment; filename=sdk-rs-download.bin", + ), + ]; + + Ok((headers, bytes)) +} + +async fn upload_status_summary( + State(state): State>, + headers: HeaderMap, + Path(task_id): Path, +) -> Result, ApiError> { + authorize(&state, &headers).await?; + + let status = state + .sdk + .snapi + .upload_status(&task_id) + .await + .map_err(|e| ApiError { + error: e.to_string(), + })?; + Ok(Json(json!({ + "task_id": task_id, + "state": extract_state(&status), + "raw": status, + }))) +} + +async fn download_status_summary( + State(state): State>, + headers: HeaderMap, + Path(task_id): Path, +) -> Result, ApiError> { + authorize(&state, &headers).await?; + + let status = state + .sdk + .snapi + .download_status(&task_id) + .await + .map_err(|e| ApiError { + error: e.to_string(), + })?; + Ok(Json(json!({ + "task_id": task_id, + "state": extract_state(&status), + "raw": status, + }))) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let rest = std::env::var("LUMERA_REST").unwrap_or_else(|_| "http://127.0.0.1:1317".into()); + let rpc = std::env::var("LUMERA_RPC").unwrap_or_else(|_| "http://127.0.0.1:26657".into()); + let grpc = std::env::var("LUMERA_GRPC").unwrap_or_else(|_| "http://127.0.0.1:9090".into()); + let snapi = std::env::var("SNAPI_BASE").unwrap_or_else(|_| "http://127.0.0.1:8089".into()); + let chain_id = std::env::var("LUMERA_CHAIN_ID").unwrap_or_else(|_| "lumera-devnet".into()); + let creator = std::env::var("LUMERA_CREATOR") + .unwrap_or_else(|_| "lumera158ulqepc5wnlx04eqqs7hkhr9rs2een275qkpp".into()); + let mnemonic = std::env::var("LUMERA_MNEMONIC").expect("LUMERA_MNEMONIC is required"); + + let ui_port: u16 = std::env::var("SDK_RS_UI_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(3002); + + let auth_required = std::env::var("SDK_RS_AUTH_REQUIRED") + .ok() + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(true); + + let cfg = CascadeConfig { + chain: lumera_sdk_rs::chain::ChainConfig { + chain_id: chain_id.clone(), + grpc_endpoint: grpc, + rpc_endpoint: rpc, + rest_endpoint: rest, + gas_price: "0.025ulume".into(), + }, + snapi_base: snapi.clone(), + }; + + let state = Arc::new(AppState { + sdk: CascadeSdk::new(cfg), + creator, + mnemonic, + latest: Arc::new(Mutex::new(LatestState::default())), + chain_id, + auth_required, + sessions: Arc::new(Mutex::new(HashMap::new())), + challenges: Arc::new(Mutex::new(HashMap::new())), + }); + + let api = Router::new() + .route("/health", get(health)) + .route("/auth/challenge", get(auth_challenge)) + .route("/auth/verify", post(auth_verify)) + .route("/latest", get(latest)) + .route("/workflow/upload", post(workflow_upload)) + .route("/upload/{task_id}/status", get(upload_status)) + .route("/upload/{task_id}/summary", get(upload_status_summary)) + .route("/workflow/download", post(workflow_download_request)) + .route("/download/{task_id}/status", get(download_status)) + .route("/download/{task_id}/summary", get(download_status_summary)) + .route("/download/{task_id}/file", get(download_file)) + .with_state(state); + + let app = Router::new() + .nest("/api", api) + .fallback_service(ServeDir::new("examples/ui")); + + let addr = SocketAddr::from(([127, 0, 0, 1], ui_port)); + println!( + "sdk-rs UI running on http://{} (SNAPI_BASE={}, auth_required={})", + addr, snapi, auth_required + ); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/scripts/run_golden_local.sh b/scripts/run_golden_local.sh new file mode 100755 index 0000000..de0d846 --- /dev/null +++ b/scripts/run_golden_local.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Local golden test: +# register ticket -> upload -> download -> verify hash via examples/golden_devnet.rs + +required_env=(LUMERA_MNEMONIC LUMERA_CREATOR) +for k in "${required_env[@]}"; do + if [[ -z "${!k:-}" ]]; then + echo "ERROR: $k is required" + exit 1 + fi +done + +export LUMERA_REST="${LUMERA_REST:-http://127.0.0.1:1317}" +export LUMERA_RPC="${LUMERA_RPC:-http://127.0.0.1:26657}" +export LUMERA_GRPC="${LUMERA_GRPC:-http://127.0.0.1:9090}" +export SNAPI_BASE="${SNAPI_BASE:-http://127.0.0.1:8080}" +export LUMERA_CHAIN_ID="${LUMERA_CHAIN_ID:-lumera-devnet}" +export GOLDEN_INPUT="${GOLDEN_INPUT:-/tmp/lumera-rs-golden-input.bin}" + +cargo run --example golden_devnet diff --git a/src/cascade.rs b/src/cascade.rs index d23979f..e9363bd 100644 --- a/src/cascade.rs +++ b/src/cascade.rs @@ -16,6 +16,20 @@ pub struct CascadeConfig { pub snapi_base: String, } +impl CascadeConfig { + pub fn new(chain: crate::chain::ChainConfig, snapi_base: impl Into) -> Self { + Self { + chain, + snapi_base: snapi_base.into(), + } + } + + pub fn with_snapi_base(mut self, snapi_base: impl Into) -> Self { + self.snapi_base = snapi_base.into(); + self + } +} + #[derive(Debug, Clone)] pub struct RegisterTicketRequest { pub file_name: String, @@ -108,7 +122,9 @@ impl CascadeSdk { let mut out = Vec::with_capacity(max as usize); for i in 0..max { let payload = format!("{}.{}", base, ic + i); - let compressed = zstd::stream::encode_all(payload.as_bytes(), 3) + // Supernode/chain ID derivation uses one-shot zstd level-3 compression + // before blake3+base58; use bulk mode for byte-compatibility. + let compressed = zstd::bulk::compress(payload.as_bytes(), 3) .map_err(|e| SdkError::Serialization(e.to_string()))?; let hash = blake3::hash(&compressed); out.push(bs58::encode(hash.as_bytes()).into_string()); @@ -167,9 +183,10 @@ impl CascadeSdk { "signatures": signatures, "public": req.is_public }); - let metadata_json = serde_json::to_string(&metadata).map_err(|e| SdkError::Serialization(e.to_string()))?; + let metadata_json = + serde_json::to_string(&metadata).map_err(|e| SdkError::Serialization(e.to_string()))?; - let file_size_kbs = ((file_bytes.len() as u64) + 1023) / 1024; + let file_size_kbs = (file_bytes.len() as u64).div_ceil(1024); let fee_amount = self.chain.get_action_fee_amount(file_size_kbs).await?; let price = if fee_amount.chars().all(|c| c.is_ascii_digit()) { format!("{}{}", fee_amount, params.base_action_fee_denom) @@ -209,7 +226,9 @@ impl CascadeSdk { signature: &str, file_path: &Path, ) -> Result { - self.snapi.start_cascade(action_id, signature, file_path).await + self.snapi + .start_cascade(action_id, signature, file_path) + .await } pub async fn request_download( diff --git a/src/chain.rs b/src/chain.rs index 17fbe76..8b48bb9 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -7,7 +7,6 @@ use cosmrs::{ use prost::Message; use serde::Deserialize; - #[derive(Debug, Clone)] pub struct ChainConfig { pub chain_id: String, @@ -17,6 +16,49 @@ pub struct ChainConfig { pub gas_price: String, } +impl ChainConfig { + pub fn new( + chain_id: impl Into, + grpc_endpoint: impl Into, + rpc_endpoint: impl Into, + rest_endpoint: impl Into, + gas_price: impl Into, + ) -> Self { + Self { + chain_id: chain_id.into(), + grpc_endpoint: grpc_endpoint.into(), + rpc_endpoint: rpc_endpoint.into(), + rest_endpoint: rest_endpoint.into(), + gas_price: gas_price.into(), + } + } + + pub fn with_chain_id(mut self, chain_id: impl Into) -> Self { + self.chain_id = chain_id.into(); + self + } + + pub fn with_grpc_endpoint(mut self, grpc_endpoint: impl Into) -> Self { + self.grpc_endpoint = grpc_endpoint.into(); + self + } + + pub fn with_rpc_endpoint(mut self, rpc_endpoint: impl Into) -> Self { + self.rpc_endpoint = rpc_endpoint.into(); + self + } + + pub fn with_rest_endpoint(mut self, rest_endpoint: impl Into) -> Self { + self.rest_endpoint = rest_endpoint.into(); + self + } + + pub fn with_gas_price(mut self, gas_price: impl Into) -> Self { + self.gas_price = gas_price.into(); + self + } +} + #[derive(Debug, Clone)] pub struct ActionParams { pub max_raptor_q_symbols: u32, @@ -67,17 +109,34 @@ pub struct ChainClient { impl ChainClient { pub fn new(cfg: ChainConfig) -> Self { - Self { cfg, http: reqwest::Client::new() } + Self { + cfg, + http: reqwest::Client::new(), + } } pub async fn get_action_params(&self) -> Result { - let url = format!("{}/LumeraProtocol/lumera/action/v1/params", self.cfg.rest_endpoint.trim_end_matches('/')); - let v: serde_json::Value = self.http.get(url).send().await.map_err(|e| SdkError::Http(e.to_string()))? - .json().await.map_err(|e| SdkError::Serialization(e.to_string()))?; - let p = v.get("params").ok_or_else(|| SdkError::Serialization("missing params".into()))?; + let url = format!( + "{}/LumeraProtocol/lumera/action/v1/params", + self.cfg.rest_endpoint.trim_end_matches('/') + ); + let v: serde_json::Value = self + .http + .get(url) + .send() + .await + .map_err(|e| SdkError::Http(e.to_string()))? + .json() + .await + .map_err(|e| SdkError::Serialization(e.to_string()))?; + let p = v + .get("params") + .ok_or_else(|| SdkError::Serialization("missing params".into()))?; fn to_u32(v: Option<&serde_json::Value>, default: u32) -> u32 { match v { - Some(x) if x.is_string() => x.as_str().and_then(|s| s.parse().ok()).unwrap_or(default), + Some(x) if x.is_string() => { + x.as_str().and_then(|s| s.parse().ok()).unwrap_or(default) + } Some(x) if x.is_u64() => x.as_u64().map(|n| n as u32).unwrap_or(default), _ => default, } @@ -86,22 +145,45 @@ impl ChainClient { max_raptor_q_symbols: to_u32(p.get("max_raptor_q_symbols"), 50), svc_challenge_count: to_u32(p.get("svc_challenge_count"), 8), svc_min_chunks_for_challenge: to_u32(p.get("svc_min_chunks_for_challenge"), 4), - base_action_fee_denom: p.get("base_action_fee").and_then(|x| x.get("denom")).and_then(|x| x.as_str()).unwrap_or("ulume").to_string(), + base_action_fee_denom: p + .get("base_action_fee") + .and_then(|x| x.get("denom")) + .and_then(|x| x.as_str()) + .unwrap_or("ulume") + .to_string(), }) } pub async fn get_action_fee_amount(&self, file_size_kbs: u64) -> Result { - let url = format!("{}/LumeraProtocol/lumera/action/v1/get_action_fee/{}", self.cfg.rest_endpoint.trim_end_matches('/'), file_size_kbs); - let v: serde_json::Value = self.http.get(url).send().await.map_err(|e| SdkError::Http(e.to_string()))? - .json().await.map_err(|e| SdkError::Serialization(e.to_string()))?; - Ok(v.get("amount").and_then(|x| x.as_str()).unwrap_or("0").to_string()) + let url = format!( + "{}/LumeraProtocol/lumera/action/v1/get_action_fee/{}", + self.cfg.rest_endpoint.trim_end_matches('/'), + file_size_kbs + ); + let v: serde_json::Value = self + .http + .get(url) + .send() + .await + .map_err(|e| SdkError::Http(e.to_string()))? + .json() + .await + .map_err(|e| SdkError::Serialization(e.to_string()))?; + Ok(v.get("amount") + .and_then(|x| x.as_str()) + .unwrap_or("0") + .to_string()) } - pub async fn register_action(&self, signing_key: &cosmrs::crypto::secp256k1::SigningKey, tx: RequestActionTxInput) -> Result { + pub async fn register_action( + &self, + signing_key: &cosmrs::crypto::secp256k1::SigningKey, + tx: RequestActionTxInput, + ) -> Result { let account = self.get_base_account(&tx.creator).await?; let msg = MsgRequestActionProto { - creator: tx.creator, + creator: tx.creator.clone(), action_type: tx.action_type, metadata: tx.metadata, price: tx.price, @@ -110,51 +192,133 @@ impl ChainClient { app_pubkey: tx.app_pubkey, }; let mut msg_bytes = Vec::new(); - msg.encode(&mut msg_bytes).map_err(|e| SdkError::Serialization(e.to_string()))?; - let any = Any { type_url: "/lumera.action.v1.MsgRequestAction".to_string(), value: msg_bytes }; + msg.encode(&mut msg_bytes) + .map_err(|e| SdkError::Serialization(e.to_string()))?; + let any = Any { + type_url: "/lumera.action.v1.MsgRequestAction".to_string(), + value: msg_bytes, + }; let tx_body = BodyBuilder::new().msg(any).finish(); let fee_coin = Coin { - amount: 10000u128.into(), + amount: 10000u128, denom: "ulume" .parse() .map_err(|e| SdkError::Chain(format!("fee denom parse: {e}")))?, }; - let auth = SignerInfo::single_direct(Some(signing_key.public_key()), account.sequence) - .auth_info(Fee::from_amount_and_gas(fee_coin, 500_000u64)); - let chain_id = self.cfg.chain_id.parse().map_err(|e| SdkError::Chain(format!("chain-id: {e}")))?; - let sign_doc = SignDoc::new(&tx_body, &auth, &chain_id, account.account_number).map_err(|e| SdkError::Chain(e.to_string()))?; - let tx_raw = sign_doc.sign(signing_key).map_err(|e| SdkError::Chain(e.to_string()))?; + let chain_id = self + .cfg + .chain_id + .parse() + .map_err(|e| SdkError::Chain(format!("chain-id: {e}")))?; + let rpc = tendermint_rpc::HttpClient::new(self.cfg.rpc_endpoint.as_str()) + .map_err(|e| SdkError::Http(e.to_string()))?; - let rpc = tendermint_rpc::HttpClient::new(self.cfg.rpc_endpoint.as_str()).map_err(|e| SdkError::Http(e.to_string()))?; - let rsp = tx_raw.broadcast_commit(&rpc).await.map_err(|e| SdkError::Chain(e.to_string()))?; - if rsp.check_tx.code.is_err() { return Err(SdkError::Chain(format!("check_tx: {}", rsp.check_tx.log))); } - if rsp.tx_result.code.is_err() { return Err(SdkError::Chain(format!("deliver_tx: {}", rsp.tx_result.log))); } + let mut seq = account.sequence; + let mut account_number = account.account_number; + for _attempt in 0..3 { + let auth = SignerInfo::single_direct(Some(signing_key.public_key()), seq) + .auth_info(Fee::from_amount_and_gas(fee_coin.clone(), 500_000u64)); + let sign_doc = SignDoc::new(&tx_body, &auth, &chain_id, account_number) + .map_err(|e| SdkError::Chain(e.to_string()))?; + let tx_raw = sign_doc + .sign(signing_key) + .map_err(|e| SdkError::Chain(e.to_string()))?; + + let rsp = tx_raw + .broadcast_commit(&rpc) + .await + .map_err(|e| SdkError::Chain(e.to_string()))?; + if rsp.check_tx.code.is_err() { + let log = rsp.check_tx.log.to_string(); + if let Some(expected) = parse_expected_sequence(&log) { + if expected != seq { + if let Ok(refreshed) = self.get_base_account(&tx.creator).await { + seq = refreshed.sequence; + account_number = refreshed.account_number; + } else { + seq = expected; + } + continue; + } + } + return Err(SdkError::Chain(format!("check_tx: {}", log))); + } + if rsp.tx_result.code.is_err() { + let log = rsp.tx_result.log.to_string(); + if let Some(expected) = parse_expected_sequence(&log) { + if expected != seq { + if let Ok(refreshed) = self.get_base_account(&tx.creator).await { + seq = refreshed.sequence; + account_number = refreshed.account_number; + } else { + seq = expected; + } + continue; + } + } + return Err(SdkError::Chain(format!("deliver_tx: {}", log))); + } - let action_id = extract_action_id_from_log(&rsp.tx_result.log) - .or_else(|| extract_action_id_from_events_json(&rsp.tx_result.events)) - .ok_or_else(|| SdkError::Chain(format!("unable to extract action_id from tx logs/events; log={}", rsp.tx_result.log)))?; + let action_id = extract_action_id_from_log(&rsp.tx_result.log) + .or_else(|| extract_action_id_from_events_json(&rsp.tx_result.events)) + .ok_or_else(|| { + SdkError::Chain(format!( + "unable to extract action_id from tx logs/events; log={}", + rsp.tx_result.log + )) + })?; - Ok(TxResult { tx_hash: rsp.hash.to_string(), action_id }) + return Ok(TxResult { + tx_hash: rsp.hash.to_string(), + action_id, + }); + } + + Err(SdkError::Chain("sequence retry exhausted".into())) } async fn get_base_account(&self, address: &str) -> Result { - let url = format!("{}/cosmos/auth/v1beta1/accounts/{}", self.cfg.rest_endpoint.trim_end_matches('/'), address); - let v: serde_json::Value = self.http.get(url).send().await.map_err(|e| SdkError::Http(e.to_string()))? - .json().await.map_err(|e| SdkError::Serialization(e.to_string()))?; - let acc = v.get("account").ok_or_else(|| SdkError::Serialization("missing account".into()))?; + let url = format!( + "{}/cosmos/auth/v1beta1/accounts/{}", + self.cfg.rest_endpoint.trim_end_matches('/'), + address + ); + let v: serde_json::Value = self + .http + .get(url) + .send() + .await + .map_err(|e| SdkError::Http(e.to_string()))? + .json() + .await + .map_err(|e| SdkError::Serialization(e.to_string()))?; + let acc = v + .get("account") + .ok_or_else(|| SdkError::Serialization("missing account".into()))?; let base = acc.get("base_account").unwrap_or(acc); let account_number = base .get("account_number") - .and_then(|x| x.as_str().and_then(|s| s.parse::().ok()).or_else(|| x.as_u64())) + .and_then(|x| { + x.as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| x.as_u64()) + }) .ok_or_else(|| SdkError::Serialization("missing account_number".into()))?; let sequence = base .get("sequence") - .and_then(|x| x.as_str().and_then(|s| s.parse::().ok()).or_else(|| x.as_u64())) + .and_then(|x| { + x.as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| x.as_u64()) + }) .ok_or_else(|| SdkError::Serialization("missing sequence".into()))?; - Ok(BaseAccount { account_number, sequence }) + Ok(BaseAccount { + account_number, + sequence, + }) } } @@ -173,10 +337,20 @@ pub fn extract_action_id_from_log(log: &str) -> Option { for attr in e.get("attributes")?.as_array()? { let key = attr.get("key")?.as_str()?; let val = attr.get("value")?.as_str()?; - if key == "action_id" { return Some(val.to_string()); } - let kb = STANDARD.decode(key).ok().and_then(|b| String::from_utf8(b).ok()); - let vb = STANDARD.decode(val).ok().and_then(|b| String::from_utf8(b).ok()); - if kb.as_deref() == Some("action_id") { return vb; } + if key == "action_id" { + return Some(val.to_string()); + } + let kb = STANDARD + .decode(key) + .ok() + .and_then(|b| String::from_utf8(b).ok()); + let vb = STANDARD + .decode(val) + .ok() + .and_then(|b| String::from_utf8(b).ok()); + if kb.as_deref() == Some("action_id") { + return vb; + } } } } @@ -184,10 +358,23 @@ pub fn extract_action_id_from_log(log: &str) -> Option { None } +fn parse_expected_sequence(log: &str) -> Option { + // Typical chain error: "account sequence mismatch, expected 14, got 5" + let marker = "expected "; + let start = log.find(marker)? + marker.len(); + let rest = &log[start..]; + let end = rest.find(',').unwrap_or(rest.len()); + rest[..end].trim().parse::().ok() +} + fn extract_action_id_from_events_json(events: &impl serde::Serialize) -> Option { let v = serde_json::to_value(events).ok()?; for e in v.as_array()? { - let kind = e.get("kind").or_else(|| e.get("type")).and_then(|x| x.as_str()).unwrap_or(""); + let kind = e + .get("kind") + .or_else(|| e.get("type")) + .and_then(|x| x.as_str()) + .unwrap_or(""); if kind != "action_registered" { continue; } @@ -197,8 +384,14 @@ fn extract_action_id_from_events_json(events: &impl serde::Serialize) -> Option< if key == "action_id" { return Some(val.to_string()); } - let kb = STANDARD.decode(key).ok().and_then(|b| String::from_utf8(b).ok()); - let vb = STANDARD.decode(val).ok().and_then(|b| String::from_utf8(b).ok()); + let kb = STANDARD + .decode(key) + .ok() + .and_then(|b| String::from_utf8(b).ok()); + let vb = STANDARD + .decode(val) + .ok() + .and_then(|b| String::from_utf8(b).ok()); if kb.as_deref() == Some("action_id") { return vb; } @@ -210,7 +403,10 @@ fn extract_action_id_from_events_json(events: &impl serde::Serialize) -> Option< #[cfg(test)] mod tests { use super::*; - use wiremock::{matchers::{method, path}, Mock, MockServer, ResponseTemplate}; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; #[tokio::test] async fn tdd_params_parse() { @@ -218,7 +414,13 @@ mod tests { Mock::given(method("GET")).and(path("/LumeraProtocol/lumera/action/v1/params")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"params":{"max_raptor_q_symbols":"64","svc_challenge_count":8,"svc_min_chunks_for_challenge":4,"base_action_fee":{"denom":"ulume"}}}))) .mount(&server).await; - let c = ChainClient::new(ChainConfig { chain_id: "lumera-devnet".into(), grpc_endpoint: "".into(), rpc_endpoint: "http://127.0.0.1:26657".into(), rest_endpoint: server.uri(), gas_price: "0.025ulume".into()}); + let c = ChainClient::new(ChainConfig { + chain_id: "lumera-devnet".into(), + grpc_endpoint: "".into(), + rpc_endpoint: "http://127.0.0.1:26657".into(), + rest_endpoint: server.uri(), + gas_price: "0.025ulume".into(), + }); let p = c.get_action_params().await.unwrap(); assert_eq!(p.max_raptor_q_symbols, 64); } @@ -228,4 +430,10 @@ mod tests { let log = r#"[{"events":[{"type":"action_registered","attributes":[{"key":"action_id","value":"A-1"}]}]}]"#; assert_eq!(extract_action_id_from_log(log).as_deref(), Some("A-1")); } + + #[test] + fn tdd_parse_expected_sequence() { + let log = "account sequence mismatch, expected 14, got 5: incorrect account sequence"; + assert_eq!(parse_expected_sequence(log), Some(14)); + } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e3e2f1c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,186 @@ +use std::{env, fs, path::Path}; + +use serde::{Deserialize, Serialize}; + +use crate::{cascade::CascadeConfig, chain::ChainConfig, error::SdkError}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SdkSettings { + pub chain_id: String, + pub grpc_endpoint: String, + pub rpc_endpoint: String, + pub rest_endpoint: String, + pub gas_price: String, + pub snapi_base: String, +} + +impl Default for SdkSettings { + fn default() -> Self { + Self { + chain_id: "lumera-devnet".into(), + grpc_endpoint: "http://127.0.0.1:9090".into(), + rpc_endpoint: "http://127.0.0.1:26657".into(), + rest_endpoint: "http://127.0.0.1:1317".into(), + gas_price: "0.025ulume".into(), + snapi_base: "http://127.0.0.1:8080".into(), + } + } +} + +impl SdkSettings { + pub fn from_env() -> Self { + let mut cfg = Self::default(); + cfg.apply_env_overrides(); + cfg + } + + pub fn from_env_file(path: impl AsRef) -> Result { + dotenvy::from_path(path) + .map_err(|e| SdkError::InvalidInput(format!("load env file: {e}")))?; + Ok(Self::from_env()) + } + + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let body = fs::read_to_string(path).map_err(|e| { + SdkError::InvalidInput(format!("read config file {}: {e}", path.display())) + })?; + + let mut cfg = match path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + { + "toml" => toml::from_str::(&body).map_err(|e| { + SdkError::Serialization(format!("parse toml {}: {e}", path.display())) + })?, + "json" => serde_json::from_str::(&body).map_err(|e| { + SdkError::Serialization(format!("parse json {}: {e}", path.display())) + })?, + ext => { + return Err(SdkError::InvalidInput(format!( + "unsupported config extension '{}', use .toml or .json", + ext + ))) + } + }; + + // Allow env to override file values for deployment-time flexibility. + cfg.apply_env_overrides(); + Ok(cfg) + } + + pub fn to_cascade_config(&self) -> CascadeConfig { + CascadeConfig { + chain: ChainConfig { + chain_id: self.chain_id.clone(), + grpc_endpoint: self.grpc_endpoint.clone(), + rpc_endpoint: self.rpc_endpoint.clone(), + rest_endpoint: self.rest_endpoint.clone(), + gas_price: self.gas_price.clone(), + }, + snapi_base: self.snapi_base.clone(), + } + } + + fn apply_env_overrides(&mut self) { + if let Ok(v) = env::var("LUMERA_CHAIN_ID") { + self.chain_id = v; + } + if let Ok(v) = env::var("LUMERA_GRPC") { + self.grpc_endpoint = v; + } + if let Ok(v) = env::var("LUMERA_RPC") { + self.rpc_endpoint = v; + } + if let Ok(v) = env::var("LUMERA_REST") { + self.rest_endpoint = v; + } + if let Ok(v) = env::var("LUMERA_GAS_PRICE") { + self.gas_price = v; + } + if let Ok(v) = env::var("SNAPI_BASE") { + self.snapi_base = v; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + const ENV_KEYS: [&str; 6] = [ + "LUMERA_CHAIN_ID", + "LUMERA_GRPC", + "LUMERA_RPC", + "LUMERA_REST", + "LUMERA_GAS_PRICE", + "SNAPI_BASE", + ]; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvGuard { + prev: Vec<(String, Option)>, + } + + impl EnvGuard { + fn clear_for_test() -> Self { + let prev = ENV_KEYS + .iter() + .map(|k| (k.to_string(), std::env::var(k).ok())) + .collect::>(); + for k in ENV_KEYS { + std::env::remove_var(k); + } + Self { prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, v) in &self.prev { + match v { + Some(val) => std::env::set_var(k, val), + None => std::env::remove_var(k), + } + } + } + } + + #[test] + fn tdd_default_settings_to_cascade_cfg() { + let s = SdkSettings::default(); + let c = s.to_cascade_config(); + assert_eq!(c.chain.chain_id, "lumera-devnet"); + assert_eq!(c.snapi_base, "http://127.0.0.1:8080"); + } + + #[test] + fn tdd_load_toml_file() { + let _guard = env_lock().lock().unwrap(); + let _env_guard = EnvGuard::clear_for_test(); + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("sdk.toml"); + fs::write( + &p, + r#" +chain_id = "lumera-testnet-2" +grpc_endpoint = "https://grpc.testnet.lumera.io" +rpc_endpoint = "https://rpc.testnet.lumera.io" +rest_endpoint = "https://lcd.testnet.lumera.io" +gas_price = "0.025ulume" +snapi_base = "https://snapi.testnet.example" +"#, + ) + .unwrap(); + + let got = SdkSettings::from_file(&p).unwrap(); + assert_eq!(got.chain_id, "lumera-testnet-2"); + assert_eq!(got.snapi_base, "https://snapi.testnet.example"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 54a321a..e75e21d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,9 @@ pub mod cascade; pub mod chain; +pub mod config; pub mod crypto; pub mod error; pub mod snapi; pub use cascade::{CascadeConfig, CascadeSdk, RegisterTicketRequest}; +pub use config::SdkSettings; diff --git a/src/snapi.rs b/src/snapi.rs index 6792b0b..9dde2a3 100644 --- a/src/snapi.rs +++ b/src/snapi.rs @@ -19,7 +19,7 @@ async fn parse_json_or_sse(resp: reqwest::Response) -> Result = None; for line in body.lines() { @@ -35,7 +35,9 @@ async fn parse_json_or_sse(resp: reqwest::Response) -> Result Self { - Self { base, http: reqwest::Client::new() } + Self { + base, + http: reqwest::Client::new(), + } } pub async fn upload_status(&self, task_id: &str) -> Result { let resp = self .http - .get(format!("{}/api/v1/actions/cascade/tasks/{}/status", self.base.trim_end_matches('/'), task_id)) + .get(format!( + "{}/api/v1/actions/cascade/tasks/{}/status", + self.base.trim_end_matches('/'), + task_id + )) .send() .await .map_err(|e| SdkError::Http(e.to_string()))?; if !resp.status().is_success() { - return Err(SdkError::Http(format!("upload_status failed: {}", resp.status()))); + return Err(SdkError::Http(format!( + "upload_status failed: {}", + resp.status() + ))); } parse_json_or_sse(resp).await } @@ -65,12 +77,19 @@ impl SnApiClient { pub async fn download_status(&self, task_id: &str) -> Result { let resp = self .http - .get(format!("{}/api/v1/downloads/cascade/{}/status", self.base.trim_end_matches('/'), task_id)) + .get(format!( + "{}/api/v1/downloads/cascade/{}/status", + self.base.trim_end_matches('/'), + task_id + )) .send() .await .map_err(|e| SdkError::Http(e.to_string()))?; if !resp.status().is_success() { - return Err(SdkError::Http(format!("download_status failed: {}", resp.status()))); + return Err(SdkError::Http(format!( + "download_status failed: {}", + resp.status() + ))); } parse_json_or_sse(resp).await } @@ -78,24 +97,34 @@ impl SnApiClient { pub async fn download_file(&self, task_id: &str) -> Result, SdkError> { let resp = self .http - .get(format!("{}/api/v1/downloads/cascade/{}/file", self.base.trim_end_matches('/'), task_id)) + .get(format!( + "{}/api/v1/downloads/cascade/{}/file", + self.base.trim_end_matches('/'), + task_id + )) .send() .await .map_err(|e| SdkError::Http(e.to_string()))?; if !resp.status().is_success() { - return Err(SdkError::Http(format!("download_file failed: {}", resp.status()))); + return Err(SdkError::Http(format!( + "download_file failed: {}", + resp.status() + ))); } - resp.bytes().await.map(|b| b.to_vec()).map_err(|e| SdkError::Http(e.to_string())) + resp.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| SdkError::Http(e.to_string())) } - pub async fn start_cascade(&self, action_id: &str, signature: &str, file_path: &Path) -> Result { - let bytes = tokio::fs::read(file_path).await.map_err(|e| SdkError::Http(e.to_string()))?; - let file_name = file_path - .file_name() - .and_then(|x| x.to_str()) - .unwrap_or("upload.bin") - .to_string(); - let part = reqwest::multipart::Part::bytes(bytes).file_name(file_name); + pub async fn start_cascade_bytes( + &self, + action_id: &str, + signature: &str, + file_name: &str, + file_bytes: Vec, + ) -> Result { + let part = reqwest::multipart::Part::bytes(file_bytes).file_name(file_name.to_string()); let form = reqwest::multipart::Form::new() .text("action_id", action_id.to_string()) .text("signature", signature.to_string()) @@ -103,17 +132,26 @@ impl SnApiClient { let resp = self .http - .post(format!("{}/api/v1/actions/cascade", self.base.trim_end_matches('/'))) + .post(format!( + "{}/api/v1/actions/cascade", + self.base.trim_end_matches('/') + )) .multipart(form) .send() .await .map_err(|e| SdkError::Http(e.to_string()))?; if !resp.status().is_success() { - return Err(SdkError::Http(format!("start_cascade failed: {}", resp.status()))); + return Err(SdkError::Http(format!( + "start_cascade failed: {}", + resp.status() + ))); } - let v: serde_json::Value = resp.json().await.map_err(|e| SdkError::Serialization(e.to_string()))?; + let v: serde_json::Value = resp + .json() + .await + .map_err(|e| SdkError::Serialization(e.to_string()))?; let task_id = v .get("task_id") .and_then(|x| x.as_str()) @@ -126,22 +164,77 @@ impl SnApiClient { Ok(task_id) } - pub async fn request_download(&self, action_id: &str, signature: &str) -> Result { - let resp = self.http - .post(format!("{}/api/v1/actions/cascade/{}/downloads", self.base.trim_end_matches('/'), action_id)) - .json(&serde_json::json!({"signature": signature})) - .send() + pub async fn start_cascade( + &self, + action_id: &str, + signature: &str, + file_path: &Path, + ) -> Result { + let bytes = tokio::fs::read(file_path) .await .map_err(|e| SdkError::Http(e.to_string()))?; - if !resp.status().is_success() { - return Err(SdkError::Http(format!("request_download failed: {}", resp.status()))); - } - let v: serde_json::Value = resp.json().await.map_err(|e| SdkError::Serialization(e.to_string()))?; - let task_id = v.get("task_id").and_then(|x| x.as_str()).or_else(|| v.get("taskId").and_then(|x| x.as_str())).unwrap_or_default().to_string(); - if task_id.is_empty() { - return Err(SdkError::Serialization("missing task id".into())); + let file_name = file_path + .file_name() + .and_then(|x| x.to_str()) + .unwrap_or("upload.bin") + .to_string(); + + self.start_cascade_bytes(action_id, signature, &file_name, bytes) + .await + } + + pub async fn request_download( + &self, + action_id: &str, + signature: &str, + ) -> Result { + // Download can race with finalization/indexing right after upload completion. + // Retry transient 5xx responses briefly before failing. + let url = format!( + "{}/api/v1/actions/cascade/{}/downloads", + self.base.trim_end_matches('/'), + action_id + ); + let mut last_err = String::new(); + + for attempt in 1..=10 { + let resp = self + .http + .post(&url) + .json(&serde_json::json!({"signature": signature})) + .send() + .await + .map_err(|e| SdkError::Http(e.to_string()))?; + + let status = resp.status(); + if status.is_success() { + let v: serde_json::Value = resp + .json() + .await + .map_err(|e| SdkError::Serialization(e.to_string()))?; + let task_id = v + .get("task_id") + .and_then(|x| x.as_str()) + .or_else(|| v.get("taskId").and_then(|x| x.as_str())) + .unwrap_or_default() + .to_string(); + if task_id.is_empty() { + return Err(SdkError::Serialization("missing task id".into())); + } + return Ok(task_id); + } + + let body = resp.text().await.unwrap_or_default(); + last_err = format!("request_download failed: {} body={}", status, body); + + if status.is_server_error() && attempt < 10 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + return Err(SdkError::Http(last_err)); } - Ok(task_id) + + Err(SdkError::Http(last_err)) } } @@ -149,14 +242,19 @@ impl SnApiClient { mod tests { use super::*; use tempfile::tempdir; - use wiremock::{matchers::{method, path}, Mock, MockServer, ResponseTemplate}; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; #[tokio::test] async fn tdd_start_cascade_parses_task() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/v1/actions/cascade")) - .respond_with(ResponseTemplate::new(202).set_body_json(serde_json::json!({"task_id":"t1"}))) + .respond_with( + ResponseTemplate::new(202).set_body_json(serde_json::json!({"task_id":"t1"})), + ) .mount(&server) .await; @@ -167,4 +265,47 @@ mod tests { let task = c.start_cascade("a1", "sig", &fp).await.unwrap(); assert_eq!(task, "t1"); } + + #[tokio::test] + async fn tdd_start_cascade_bytes_parses_task() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/actions/cascade")) + .respond_with( + ResponseTemplate::new(202).set_body_json(serde_json::json!({"task_id":"t2"})), + ) + .mount(&server) + .await; + + let c = SnApiClient::new(server.uri()); + let task = c + .start_cascade_bytes("a2", "sig", "file.bin", b"hello".to_vec()) + .await + .unwrap(); + assert_eq!(task, "t2"); + } + + #[tokio::test] + async fn tdd_request_download_retries_transient_5xx() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/actions/cascade/99/downloads")) + .respond_with(ResponseTemplate::new(500).set_body_string("not ready")) + .up_to_n_times(2) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/v1/actions/cascade/99/downloads")) + .respond_with( + ResponseTemplate::new(202).set_body_json(serde_json::json!({"task_id":"d99"})), + ) + .mount(&server) + .await; + + let c = SnApiClient::new(server.uri()); + let task = c.request_download("99", "sig").await.unwrap(); + assert_eq!(task, "d99"); + } } diff --git a/tests/config_integration.rs b/tests/config_integration.rs new file mode 100644 index 0000000..fadb766 --- /dev/null +++ b/tests/config_integration.rs @@ -0,0 +1,69 @@ +use lumera_sdk_rs::SdkSettings; +use std::sync::{Mutex, OnceLock}; + +const ENV_KEYS: [&str; 6] = [ + "LUMERA_CHAIN_ID", + "LUMERA_GRPC", + "LUMERA_RPC", + "LUMERA_REST", + "LUMERA_GAS_PRICE", + "SNAPI_BASE", +]; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +struct EnvGuard { + prev: Vec<(String, Option)>, +} + +impl EnvGuard { + fn clear_for_test() -> Self { + let prev = ENV_KEYS + .iter() + .map(|k| (k.to_string(), std::env::var(k).ok())) + .collect::>(); + for k in ENV_KEYS { + std::env::remove_var(k); + } + Self { prev } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, v) in &self.prev { + match v { + Some(val) => std::env::set_var(k, val), + None => std::env::remove_var(k), + } + } + } +} + +#[test] +fn integration_load_json_config() { + let _guard = env_lock().lock().unwrap(); + let _env_guard = EnvGuard::clear_for_test(); + + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("sdk.json"); + std::fs::write( + &p, + r#"{ + "chain_id": "lumera-devnet", + "grpc_endpoint": "http://127.0.0.1:9090", + "rpc_endpoint": "http://127.0.0.1:26657", + "rest_endpoint": "http://127.0.0.1:1317", + "gas_price": "0.025ulume", + "snapi_base": "http://127.0.0.1:8080" +}"#, + ) + .unwrap(); + + let cfg = SdkSettings::from_file(&p).unwrap(); + assert_eq!(cfg.chain_id, "lumera-devnet"); + assert_eq!(cfg.snapi_base, "http://127.0.0.1:8080"); +} diff --git a/tests/system_golden_devnet.rs b/tests/system_golden_devnet.rs new file mode 100644 index 0000000..ea46df4 --- /dev/null +++ b/tests/system_golden_devnet.rs @@ -0,0 +1,13 @@ +//! System test scaffold for local devnet. +//! Run manually with: +//! cargo test --test system_golden_devnet -- --ignored --nocapture + +#[test] +#[ignore = "requires running local devnet + sn-api-server"] +fn system_golden_devnet_binary_exists() { + let p = std::path::Path::new("target/debug/examples/golden_devnet"); + assert!( + p.exists(), + "build example first: cargo build --example golden_devnet" + ); +}