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...
+
+
+
+
+
+
+
+ Current IDs
+
+ action_id: -
+ upload_task_id: -
+ download_task_id: -
+
+
+
+
+
+
+
+
+
+
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"
+ );
+}