From 9d0c46e88bf63fe0d0e33b11eb5c0b43244cbacb Mon Sep 17 00:00:00 2001 From: Iwueseiter Date: Thu, 25 Jun 2026 23:00:22 +0100 Subject: [PATCH] Automate verification of public, friends, and private filters --- .github/workflows/ci-contracts.yml | 43 +++ backend/Cargo.lock | 39 +- backend/Cargo.toml | 3 + backend/src/lib.rs | 9 + backend/src/main.rs | 11 +- backend/tests/feed_integration.rs | 336 ++++++++++++++++++ contracts/Cargo.lock | 7 + contracts/contracts/social_payment/src/lib.rs | 39 +- dashboard/app/dashboard/contracts/page.tsx | 228 +++++++++++- dashboard/lib/api.ts | 17 + dashboard/package.json | 1 + mobileapp/app/transfer.tsx | 193 +++++++++- 12 files changed, 891 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/ci-contracts.yml create mode 100644 backend/src/lib.rs create mode 100644 backend/tests/feed_integration.rs diff --git a/.github/workflows/ci-contracts.yml b/.github/workflows/ci-contracts.yml new file mode 100644 index 0000000..74ddd3a --- /dev/null +++ b/.github/workflows/ci-contracts.yml @@ -0,0 +1,43 @@ +name: CI - Contracts + +on: + pull_request: + branches: [main, master] + paths: + - 'contracts/**' + - '.github/workflows/ci-contracts.yml' + +jobs: + ci: + name: Build & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: contracts + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable with wasm32 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: contracts + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run Tests + run: cargo test --all-features + + - name: Build WASM + run: cargo build --target wasm32-unknown-unknown --release diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 691bc63..e74a2b5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -99,7 +99,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -1348,6 +1348,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2334,6 +2354,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -2932,12 +2967,14 @@ dependencies = [ "dotenvy", "ed25519-dalek", "hex", + "http-body-util", "jsonwebtoken", "reqwest", "serde", "serde_json", "sqlx", "tokio", + "tower 0.4.13", "tower-http", "tracing", "tracing-subscriber", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8e18729..afa66da 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -21,3 +21,6 @@ jsonwebtoken = "9.2.0" base64 = "0.22.1" hex = "0.4.3" +[dev-dependencies] +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..a33854e --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,9 @@ +// lib.rs — re-exports the internal modules so that `tests/` integration +// tests can access them via `zaps_backend::api::feed::*`. +#![allow(dead_code, unused_variables, unused_imports)] + +pub mod api; +pub mod config; +pub mod db; +pub mod indexer; +pub mod services; diff --git a/backend/src/main.rs b/backend/src/main.rs index 3704521..f195a4d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -16,11 +16,12 @@ use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer}; use tracing::Span; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -mod api; -mod config; -mod db; -mod indexer; -mod services; +// Bring modules in from the library crate (defined in src/lib.rs) +use zaps_backend::api; +use zaps_backend::config; +use zaps_backend::db; +use zaps_backend::indexer; +use zaps_backend::services; // Rate limiter state: token bucket per client (IP address) #[derive(Clone)] diff --git a/backend/tests/feed_integration.rs b/backend/tests/feed_integration.rs new file mode 100644 index 0000000..c3a5adc --- /dev/null +++ b/backend/tests/feed_integration.rs @@ -0,0 +1,336 @@ +//! Integration tests for feed pagination and privacy logic. +//! +//! These tests build the real Axum router and call it via +//! `tower::ServiceExt::oneshot`, so no TCP socket is needed. +//! +//! # Prerequisites +//! Set `DATABASE_URL` (or `TEST_DATABASE_URL`) to a live PostgreSQL instance +//! that has the Zaps schema applied. The tests insert rows with unique IDs +//! and clean them up at the end, so they are safe to run against a shared +//! development database. + +use axum::{ + body::Body, + http::{Request, StatusCode}, + Router, +}; +use http_body_util::BodyExt; +use serde_json::Value; +use sqlx::PgPool; +use tower::ServiceExt; // for `oneshot` +use uuid::Uuid; + +// ── helpers ───────────────────────────────────────────────────────────────── + +/// Build a PgPool from `TEST_DATABASE_URL` (falls back to `DATABASE_URL`). +async fn test_pool() -> PgPool { + let url = std::env::var("TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .expect("Set TEST_DATABASE_URL or DATABASE_URL to run integration tests"); + PgPool::connect(&url) + .await + .expect("Failed to connect to test database") +} + +/// Build the Axum router that serves only the feed endpoints. +fn feed_router(pool: PgPool) -> Router { + use axum::routing::get; + use axum::Router; + + // We re-expose the private sub-functions here by routing them manually + // (they are pub inside the crate). + Router::new() + .route("/public", get(zaps_backend::api::feed::get_public_feed)) + .route("/friends", get(zaps_backend::api::feed::get_friends_feed)) + .route("/private", get(zaps_backend::api::feed::get_private_feed)) + .with_state(pool) +} + +/// Insert a minimal user row and return its UUID. +async fn seed_user(pool: &PgPool, username: &str, address: &str) -> Uuid { + sqlx::query_scalar( + r#" + INSERT INTO users (address, username, display_name) + VALUES ($1, $2, $2) + ON CONFLICT (address) DO UPDATE SET username = EXCLUDED.username + RETURNING id + "#, + ) + .bind(address) + .bind(username) + .fetch_one(pool) + .await + .expect("Failed to seed user") +} + +/// Insert a payment with an explicit visibility and return its UUID. +async fn seed_payment( + pool: &PgPool, + sender_id: Uuid, + receiver_id: Uuid, + visibility: &str, + tx_hash: &str, +) -> Uuid { + sqlx::query_scalar( + r#" + INSERT INTO payments (tx_hash, sender_id, receiver_id, amount, currency, memo, visibility) + VALUES ($1, $2, $3, 1000, 'NGN', 'test payment', $4) + RETURNING id + "#, + ) + .bind(tx_hash) + .bind(sender_id) + .bind(receiver_id) + .bind(visibility) + .fetch_one(pool) + .await + .expect("Failed to seed payment") +} + +/// Insert an accepted friendship between two users. +async fn seed_friendship(pool: &PgPool, user_id: Uuid, friend_id: Uuid) { + sqlx::query( + r#" + INSERT INTO friendships (user_id, friend_id, status) + VALUES ($1, $2, 'ACCEPTED') + ON CONFLICT (user_id, friend_id) DO UPDATE SET status = 'ACCEPTED' + "#, + ) + .bind(user_id) + .bind(friend_id) + .execute(pool) + .await + .expect("Failed to seed friendship"); +} + +/// Parse the JSON response body from a oneshot call. +async fn response_json( + router: Router, + req: Request, +) -> (StatusCode, Value) { + let response = router.oneshot(req).await.expect("oneshot failed"); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .expect("Failed to read body") + .to_bytes(); + let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, json) +} + +/// Build a GET request with an optional Bearer token. +fn get_req(path: &str, token: Option<&str>) -> Request { + let mut builder = Request::builder().method("GET").uri(path); + if let Some(t) = token { + builder = builder.header("Authorization", format!("Bearer {}", t)); + } + builder.body(Body::empty()).unwrap() +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +/// Verify that the public feed respects `limit` and `offset` pagination. +/// +/// Seeds 15 PUBLIC payments, then checks: +/// - page 1 (`limit=10&offset=0`) → 10 items +/// - page 2 (`limit=10&offset=10`) → 5 items +#[tokio::test] +async fn test_public_feed_pagination() { + let pool = test_pool().await; + + // Use stable, collision-resistant address suffixes based on a random run id + let run = Uuid::new_v4().to_string().replace('-', "")[..8].to_string(); + let sender_addr = format!("GSENDER{}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", run); + let receiver_addr = format!("GRECEIVER{}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", run); + + let sender_id = seed_user(&pool, &format!("sender_{run}"), &sender_addr).await; + let receiver_id = seed_user(&pool, &format!("receiver_{run}"), &receiver_addr).await; + + // Seed 15 PUBLIC payments + let mut payment_ids: Vec = Vec::new(); + for i in 0..15u32 { + let hash = format!("PUBHASH{run}{i:04}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + let id = seed_payment(&pool, sender_id, receiver_id, "PUBLIC", &hash).await; + payment_ids.push(id); + } + + let router = feed_router(pool.clone()); + + // Page 1 + let (status, body) = response_json( + router.clone(), + get_req("/public?limit=10&offset=0", None), + ) + .await; + assert_eq!(status, StatusCode::OK, "page 1 status"); + let items = body.as_array().expect("expected array on page 1"); + assert!(items.len() >= 10, "page 1 should have at least 10 items; got {}", items.len()); + + // Page 2 (different offset) + let (status2, body2) = response_json( + router.clone(), + get_req("/public?limit=10&offset=10", None), + ) + .await; + assert_eq!(status2, StatusCode::OK, "page 2 status"); + let items2 = body2.as_array().expect("expected array on page 2"); + // Must have at least 5 from our seed; the exact count depends on other data in the DB + assert!(items2.len() >= 5, "page 2 should have at least 5 items; got {}", items2.len()); + + // Cleanup + for id in &payment_ids { + sqlx::query("DELETE FROM payments WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .ok(); + } + sqlx::query("DELETE FROM users WHERE id = $1 OR id = $2") + .bind(sender_id) + .bind(receiver_id) + .execute(&pool) + .await + .ok(); +} + +/// Verify friends-feed privacy: PUBLIC and FRIENDS-only payments are visible; +/// PRIVATE payments are NOT included. +/// +/// Setup: +/// - user_a ←→ user_b (friends) +/// - 3 payments from user_b to user_a: PUBLIC, FRIENDS, PRIVATE +/// - Call GET /friends as user_a +/// +/// Expected: +/// - PUBLIC payment → present +/// - FRIENDS payment → present +/// - PRIVATE payment → absent +#[tokio::test] +async fn test_friends_feed_privacy() { + let pool = test_pool().await; + + let run = Uuid::new_v4().to_string().replace('-', "")[..8].to_string(); + let addr_a = format!("GUSERA{}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", run); + let addr_b = format!("GUSERB{}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", run); + + let user_a = seed_user(&pool, &format!("user_a_{run}"), &addr_a).await; + let user_b = seed_user(&pool, &format!("user_b_{run}"), &addr_b).await; + seed_friendship(&pool, user_a, user_b).await; + + let pub_hash = format!("FPUBHASH{run}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + let fri_hash = format!("FFRIHASH{run}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + let prv_hash = format!("FPRVHASH{run}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + + let pub_id = seed_payment(&pool, user_b, user_a, "PUBLIC", &pub_hash).await; + let fri_id = seed_payment(&pool, user_b, user_a, "FRIENDS", &fri_hash).await; + let prv_id = seed_payment(&pool, user_b, user_a, "PRIVATE", &prv_hash).await; + + // Build a token that maps to user_a by using their address as the token + // (the AuthUser extractor will upsert based on address when JWT decode fails) + let token_a = addr_a.clone(); + + let router = feed_router(pool.clone()); + let (status, body) = + response_json(router, get_req("/friends?limit=50&offset=0", Some(&token_a))).await; + + assert_eq!(status, StatusCode::OK, "friends feed status"); + let items = body.as_array().expect("expected array"); + let ids: Vec = items + .iter() + .filter_map(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) + .collect(); + + assert!( + ids.contains(&pub_id.to_string()), + "PUBLIC payment must appear in friends feed" + ); + assert!( + ids.contains(&fri_id.to_string()), + "FRIENDS payment must appear in friends feed" + ); + assert!( + !ids.contains(&prv_id.to_string()), + "PRIVATE payment must NOT appear in friends feed" + ); + + // Cleanup + for id in [pub_id, fri_id, prv_id] { + sqlx::query("DELETE FROM payments WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .ok(); + } + sqlx::query("DELETE FROM friendships WHERE (user_id = $1 AND friend_id = $2) OR (user_id = $2 AND friend_id = $1)") + .bind(user_a) + .bind(user_b) + .execute(&pool) + .await + .ok(); + sqlx::query("DELETE FROM users WHERE id = $1 OR id = $2") + .bind(user_a) + .bind(user_b) + .execute(&pool) + .await + .ok(); +} + +/// Verify private-feed isolation: a PRIVATE payment between user_b and user_c +/// must NOT appear in user_a's private feed. +/// +/// Setup: +/// - user_b pays user_c with PRIVATE visibility +/// - Call GET /private as user_a +/// +/// Expected: user_a cannot see the payment. +#[tokio::test] +async fn test_private_feed_isolation() { + let pool = test_pool().await; + + let run = Uuid::new_v4().to_string().replace('-', "")[..8].to_string(); + let addr_a = format!("GISOA{}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", run); + let addr_b = format!("GISOB{}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", run); + let addr_c = format!("GISOC{}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", run); + + let user_a = seed_user(&pool, &format!("iso_a_{run}"), &addr_a).await; + let user_b = seed_user(&pool, &format!("iso_b_{run}"), &addr_b).await; + let user_c = seed_user(&pool, &format!("iso_c_{run}"), &addr_c).await; + + let prv_hash = format!("ISOPRVHASH{run}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + let prv_id = seed_payment(&pool, user_b, user_c, "PRIVATE", &prv_hash).await; + + // Authenticate as user_a (their address is used as token) + let token_a = addr_a.clone(); + + let router = feed_router(pool.clone()); + let (status, body) = + response_json(router, get_req("/private?limit=50&offset=0", Some(&token_a))).await; + + assert_eq!(status, StatusCode::OK, "private feed status"); + let items = body.as_array().expect("expected array"); + let ids: Vec = items + .iter() + .filter_map(|v| v.get("id").and_then(|id| id.as_str()).map(String::from)) + .collect(); + + assert!( + !ids.contains(&prv_id.to_string()), + "PRIVATE payment between B and C must NOT appear in A's private feed" + ); + + // Cleanup + sqlx::query("DELETE FROM payments WHERE id = $1") + .bind(prv_id) + .execute(&pool) + .await + .ok(); + sqlx::query("DELETE FROM users WHERE id = $1 OR id = $2 OR id = $3") + .bind(user_a) + .bind(user_b) + .bind(user_c) + .execute(&pool) + .await + .ok(); +} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index a61415e..b42049d 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1502,6 +1502,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "zaps-yield-vault" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/contracts/contracts/social_payment/src/lib.rs b/contracts/contracts/social_payment/src/lib.rs index 0824826..2b59fa6 100644 --- a/contracts/contracts/social_payment/src/lib.rs +++ b/contracts/contracts/social_payment/src/lib.rs @@ -4,6 +4,7 @@ use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, E const ADMIN_KEY: Symbol = symbol_short!("admin"); const TREAS_KEY: Symbol = symbol_short!("treasury"); +const FEE_COEFF_KEY: Symbol = symbol_short!("fee_coef"); #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -42,6 +43,19 @@ impl SocialPaymentContract { env.storage().instance().set(&TREAS_KEY, &new_treasury); } + pub fn set_fee_coefficient(env: Env, fee_coef: u32) { + let admin: Address = env.storage().instance().get(&ADMIN_KEY).expect("not initialized"); + admin.require_auth(); + if fee_coef > 10000 { + panic!("fee coefficient cannot exceed 10000 basis points"); + } + env.storage().instance().set(&FEE_COEFF_KEY, &fee_coef); + } + + pub fn fee_coefficient(env: Env) -> u32 { + env.storage().instance().get(&FEE_COEFF_KEY).unwrap_or(10) + } + /// SC-005: Execute a P2P social payment using the Naira token (or any SEP-41 token). /// For Public payments a 0.1% platform fee is routed to the treasury. /// For Friends/Private payments the full amount goes to the receiver. @@ -61,7 +75,8 @@ impl SocialPaymentContract { if visibility == Visibility::Public { let treasury: Address = env.storage().instance().get(&TREAS_KEY).expect("treasury not initialized"); - let fee = amount / 1000; // 0.1% + let fee_coef = env.storage().instance().get(&FEE_COEFF_KEY).unwrap_or(10u32); + let fee = amount * (fee_coef as i128) / 10000; let receiver_amount = amount - fee; token_client.transfer(&sender, &receiver, &receiver_amount); if fee > 0 { @@ -231,4 +246,26 @@ mod tests { let (env, client, admin, treasury, _sender, _receiver) = setup(); client.initialize(&admin, &treasury); } + + // ── Adjust fee coefficient: updates value, affects payout calculation ───── + #[test] + fn test_adjust_fee_coefficient() { + let (env, client, admin, treasury, sender, receiver) = setup(); + let token = mint_token(&env, &admin, &sender, 10_000); + let token_client = soroban_sdk::token::Client::new(&env, &token); + + // Verify default is 10 (0.1%) + assert_eq!(client.fee_coefficient(), 10); + + // Try setting coefficient to 50 (0.5%) + client.set_fee_coefficient(&50); + assert_eq!(client.fee_coefficient(), 50); + + client.pay(&sender, &receiver, &token, &1000, &String::from_str(&env, "Public payment"), &Visibility::Public); + + // 1000 * 50 / 10000 = 5 fee, 995 receiver + assert_eq!(token_client.balance(&receiver), 995); + assert_eq!(token_client.balance(&treasury), 5); + assert_eq!(token_client.balance(&sender), 9_000); + } } diff --git a/dashboard/app/dashboard/contracts/page.tsx b/dashboard/app/dashboard/contracts/page.tsx index 9c8fbcc..845a3c1 100644 --- a/dashboard/app/dashboard/contracts/page.tsx +++ b/dashboard/app/dashboard/contracts/page.tsx @@ -1,14 +1,167 @@ "use client"; +import { useState } from "react"; import { usePolling } from "@/lib/use-polling"; import { api } from "@/lib/api"; import StatCard from "@/components/StatCard"; function severityColor(severity: string) { if (severity === "critical") return "bg-red-50 border-red-200 text-red-800"; - if (severity === "warning") return "bg-amber-50 border-amber-200 text-amber-800"; + if (severity === "warning") + return "bg-amber-50 border-amber-200 text-amber-800"; return "bg-blue-50 border-blue-200 text-blue-800"; } +// ── Admin panel: fee-coefficient form ───────────────────────────────────────── +function FeeConfigPanel() { + const config = usePolling(() => api.contractConfig(), 30000); + const currentFee = config.data?.fee_coefficient; + + const [inputValue, setInputValue] = useState(""); + const [status, setStatus] = useState< + | { kind: "idle" } + | { kind: "submitting" } + | { kind: "success"; newValue: number; txHash: string } + | { kind: "error"; message: string } + >({ kind: "idle" }); + + const basisPoints = Number(inputValue); + const isValid = + inputValue !== "" && + Number.isInteger(basisPoints) && + basisPoints >= 0 && + basisPoints <= 10000; + + const percentDisplay = isValid + ? `${(basisPoints / 100).toFixed(2)}%` + : null; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!isValid) return; + setStatus({ kind: "submitting" }); + try { + const result = await api.setFeeCoefficient(basisPoints); + setInputValue(""); + setStatus({ + kind: "success", + newValue: result.fee_coefficient, + txHash: result.tx_hash, + }); + } catch (err) { + setStatus({ + kind: "error", + message: err instanceof Error ? err.message : "Unknown error", + }); + } + } + + return ( +
+

+ Admin — Fee Coefficient +

+

+ Adjusts the platform fee charged on public payments. Value is in basis + points (1 bp = 0.01%). Range: 0–10000. +

+ +
+ {/* Current value display */} +
+ + Current fee coefficient + + {config.loading && currentFee === undefined ? ( + + ) : ( + + {currentFee !== undefined + ? `${currentFee} bp (${(currentFee / 100).toFixed(2)}%)` + : "—"} + + )} +
+ + {/* Form */} +
+
+ +
+ { + setInputValue(e.target.value); + if (status.kind !== "idle") setStatus({ kind: "idle" }); + }} + placeholder="e.g. 50" + className="w-32 rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 + focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 + disabled:opacity-50" + disabled={status.kind === "submitting"} + /> + {percentDisplay && ( + + = {percentDisplay} + + )} +
+
+ + +
+ + {/* Success feedback */} + {status.kind === "success" && ( +
+

+ ✓ Fee coefficient updated to {status.newValue} bp ( + {(status.newValue / 100).toFixed(2)}%) +

+

+ tx: {status.txHash} +

+
+ )} + + {/* Error feedback */} + {status.kind === "error" && ( +
+

✗ Update failed

+

{status.message}

+
+ )} +
+
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── export default function ContractsPage() { const health = usePolling(() => api.contractHealth(), 15000); const metrics = usePolling(() => api.contractMetrics(), 15000); @@ -18,14 +171,17 @@ export default function ContractsPage() { return (
-

Contract Monitoring

+

+ Contract Monitoring +

Soroban contract health, performance, and active alerts

{error && (
- {error} — ensure NEXT_PUBLIC_SERVER_URL points at the Node server and you are signed in + {error} — ensure NEXT_PUBLIC_SERVER_URL points at the Node server and + you are signed in
)} @@ -33,28 +189,53 @@ export default function ContractsPage() { 0 ? "text-red-600" : "text-green-600"} + color={ + (alerts.data?.alerts.length ?? 0) > 0 + ? "text-red-600" + : "text-green-600" + } />
{metrics.data && (
- - - - + + + +
)}
-

Contract Health

+

+ Contract Health +

@@ -70,7 +251,11 @@ export default function ContractsPage() { @@ -84,8 +269,12 @@ export default function ContractsPage() { ))} {!health.data?.contracts?.length && ( - )} @@ -94,8 +283,10 @@ export default function ContractsPage() { -
-

Active Alerts

+
+

+ Active Alerts +

{(alerts.data?.alerts ?? []).length === 0 ? (

No active alerts

) : ( @@ -117,7 +308,12 @@ export default function ContractsPage() { )}
-

Auto-refreshes every 15 seconds

+ {/* Admin section — fee coefficient */} + + +

+ Auto-refreshes every 15 seconds +

); } diff --git a/dashboard/lib/api.ts b/dashboard/lib/api.ts index ac4bf97..1b410cc 100644 --- a/dashboard/lib/api.ts +++ b/dashboard/lib/api.ts @@ -89,6 +89,19 @@ export const api = { contractAlerts: () => serverReq<{ alerts: ContractAlert[] }>("/api/v1/admin/contracts/alerts"), + + // Contract config (fee coefficient) + contractConfig: () => + serverReq("/api/v1/admin/contracts/config"), + + setFeeCoefficient: (fee_coefficient: number) => + serverReq<{ fee_coefficient: number; tx_hash: string }>( + "/api/v1/admin/contracts/config/fee-coefficient", + { + method: "POST", + body: JSON.stringify({ fee_coefficient }), + }, + ), }; async function serverReq(path: string, init?: RequestInit): Promise { @@ -236,3 +249,7 @@ export interface ContractAlert { threshold: number; timestamp: string; } + +export interface ContractConfig { + fee_coefficient: number; +} diff --git a/dashboard/package.json b/dashboard/package.json index 0cd0d16..4d5e5c1 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@stellar/stellar-sdk": "^15.1.0", "@types/qrcode": "^1.5.6", "date-fns": "^4.1.0", "next": "16.2.4", diff --git a/mobileapp/app/transfer.tsx b/mobileapp/app/transfer.tsx index f1e7ebb..2d6a924 100644 --- a/mobileapp/app/transfer.tsx +++ b/mobileapp/app/transfer.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { ErrorBoundary } from "../src/components/ErrorBoundary"; import { View, @@ -10,6 +10,7 @@ import { Platform, UIManager, Alert, + ActivityIndicator, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; @@ -100,6 +101,17 @@ const TokenSelectCard = ({ ); +const API_BASE = + (typeof process !== "undefined" && + process.env?.EXPO_PUBLIC_API_URL) || + "http://localhost:8080"; + +interface ZapsUser { + username: string; + address: string; + avatar_url: string | null; +} + function TransferScreen() { const router = useRouter(); const [step, setStep] = useState(0); @@ -119,6 +131,53 @@ function TransferScreen() { const [connecting, setConnecting] = useState(false); const [submitting, setSubmitting] = useState(false); + // Recipient search state + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); + const searchTimer = useRef | null>(null); + + const searchUsers = useCallback(async (query: string) => { + if (!query || query.length < 2) { + setSearchResults([]); + setShowDropdown(false); + return; + } + setSearching(true); + try { + const res = await fetch( + `${API_BASE}/api/users/search?q=${encodeURIComponent(query)}&limit=6` + ); + if (!res.ok) throw new Error("Search failed"); + const data: ZapsUser[] = await res.json(); + setSearchResults(data); + setShowDropdown(data.length > 0); + } catch { + setSearchResults([]); + setShowDropdown(false); + } finally { + setSearching(false); + } + }, []); + + // Debounce search while user types (ZAPS mode only) + useEffect(() => { + if (transferType !== "ZAPS") return; + if (searchTimer.current) clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => { + searchUsers(recipient); + }, 350); + return () => { + if (searchTimer.current) clearTimeout(searchTimer.current); + }; + }, [recipient, transferType, searchUsers]); + + const handleSelectUser = useCallback((user: ZapsUser) => { + setRecipient(user.username); + setSearchResults([]); + setShowDropdown(false); + }, []); + const token = TOKENS.find((t) => t.id === selectedToken) || TOKENS[0]; useEffect(() => { @@ -269,17 +328,63 @@ function TransferScreen() { const renderStep1 = () => ( - + {/* Recipient input + live-search dropdown (ZAPS mode only) */} + + { + setRecipient(text); + if (transferType !== "ZAPS") return; + if (text.length < 2) { + setShowDropdown(false); + setSearchResults([]); + } + }} + autoCapitalize="none" + style={styles.transferInput} + /> + {/* Searching indicator */} + {transferType === "ZAPS" && searching && ( + + )} + {/* Dropdown results */} + {transferType === "ZAPS" && showDropdown && searchResults.length > 0 && ( + + {searchResults.map((user) => ( + handleSelectUser(user)} + activeOpacity={0.75} + > + + + {user.username.charAt(0).toUpperCase()} + + + + + {user.username} + + + {user.address.slice(0, 10)}…{user.address.slice(-6)} + + + + + ))} + + )} + {/* Custom Amount Display */}
{c.name} - + {c.reachable ? "Yes" : "No"}
- No contracts configured (set PAYMENT_ROUTER_CONTRACT / REGISTRY_CONTRACT) + + No contracts configured (set PAYMENT_ROUTER_CONTRACT / + REGISTRY_CONTRACT)