From ece08aa6291a5ffad7727d886ef24410f5fb69b1 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:08:42 +0900 Subject: [PATCH] =?UTF-8?q?feature:=20=EB=B0=B1=EC=97=94=EB=93=9C=20api=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EB=B0=8F=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Cargo.lock | 20 + backend/Cargo.toml | 3 + backend/src/cache.rs | 120 ++++++ backend/src/main.rs | 14 +- backend/src/oracle/program_accounts.rs | 6 +- backend/src/web.rs | 189 +++++++-- docs/BACKEND_API_SPEC.md | 371 ++++++++++++++++++ frontend/.env.example | 1 + .../tabs/tab-contract/PoolStatus.tsx | 19 +- frontend/src/hooks/useBackendEvents.ts | 26 ++ frontend/src/hooks/useFlightPolicies.ts | 201 +++++----- frontend/src/hooks/useMasterPolicies.ts | 49 ++- frontend/src/hooks/useMasterPolicyAccount.ts | 151 +++++-- frontend/src/hooks/useParticipantRole.ts | 64 ++- frontend/src/lib/constants.ts | 2 + 15 files changed, 991 insertions(+), 245 deletions(-) create mode 100644 backend/src/cache.rs create mode 100644 docs/BACKEND_API_SPEC.md create mode 100644 frontend/.env.example create mode 100644 frontend/src/hooks/useBackendEvents.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index aad5ff3..d3e3ef9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -3257,6 +3257,7 @@ dependencies = [ "borsh 0.10.4", "bs58 0.5.1", "dotenv", + "futures-util", "reqwest 0.12.4", "serde", "serde_json", @@ -3267,6 +3268,8 @@ dependencies = [ "solana-sdk", "tokio", "tokio-cron-scheduler", + "tokio-stream", + "tower-http", "tracing", "tracing-subscriber", ] @@ -5049,6 +5052,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -5151,6 +5155,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9ecf8ae..ffb29fb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" [dependencies] # Web server axum = "0.7" +tower-http = { version = "0.5", features = ["cors"] } # Solana solana-client = "1.18" @@ -40,3 +41,5 @@ solana-account-decoder = "1.18" sha2 = "0.10" bincode = "1" base64 = "0.22" +tokio-stream = { version = "0.1", features = ["sync"] } +futures-util = "0.3" diff --git a/backend/src/cache.rs b/backend/src/cache.rs new file mode 100644 index 0000000..799f933 --- /dev/null +++ b/backend/src/cache.rs @@ -0,0 +1,120 @@ +use std::{collections::HashMap, sync::Arc}; + +use anyhow::Result; +use tokio::{ + sync::{broadcast, RwLock}, + time::{sleep, Duration}, +}; + +use crate::{ + config::Config, + oracle::program_accounts::{scan_flight_policies, scan_master_policies}, + solana::client::SolanaClient, +}; + +/// 단일 SSE 메시지 (event 타입 + JSON data) +#[derive(Clone, Debug)] +pub struct SseMessage { + pub event: String, + pub data: String, +} + +/// 앱 전체에서 공유하는 캐시 + 브로드캐스트 채널 +pub struct CacheState { + pub master_policies: RwLock>, + pub flight_policies: RwLock>, + pub event_tx: broadcast::Sender, +} + +impl CacheState { + pub fn new() -> Self { + let (event_tx, _) = broadcast::channel(256); + Self { + master_policies: RwLock::new(Vec::new()), + flight_policies: RwLock::new(Vec::new()), + event_tx, + } + } +} + +/// 백그라운드 캐시 갱신 루프. +/// CACHE_POLL_INTERVAL_SEC (기본 5초)마다 Solana RPC 스캔 후 +/// 상태 변경 감지 시 SSE 브로드캐스트. +pub async fn start(config: Arc, cache: Arc) -> Result<()> { + let poll_secs: u64 = std::env::var("CACHE_POLL_INTERVAL_SEC") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5); + let heartbeat_secs: u64 = std::env::var("SSE_HEARTBEAT_INTERVAL_SEC") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + + let mut last_heartbeat = std::time::Instant::now(); + + loop { + let client = SolanaClient::new(&config.rpc_url); + + // ── MasterPolicy 스캔 ────────────────────────────────────────── + match scan_master_policies(&client, &config.program_id) { + Ok(new_masters) => { + let mut guard = cache.master_policies.write().await; + // 이전 상태 맵: pubkey → status + let prev: HashMap = + guard.iter().map(|m| (m.pubkey.clone(), m.status)).collect(); + + for m in &new_masters { + let changed = prev.get(&m.pubkey).map(|&s| s != m.status).unwrap_or(true); + if changed { + if let Ok(data) = serde_json::to_string(m) { + let _ = cache.event_tx.send(SseMessage { + event: "master_policy_updated".to_string(), + data, + }); + } + } + } + *guard = new_masters; + } + Err(e) => tracing::warn!("[cache] MasterPolicy 스캔 실패: {e}"), + } + + // ── FlightPolicy 스캔 ───────────────────────────────────────── + match scan_flight_policies(&client, &config.program_id) { + Ok(new_flights) => { + let mut guard = cache.flight_policies.write().await; + let prev: HashMap = + guard.iter().map(|f| (f.pubkey.clone(), f.status)).collect(); + + for f in &new_flights { + let changed = prev.get(&f.pubkey).map(|&s| s != f.status).unwrap_or(true); + if changed { + if let Ok(data) = serde_json::to_string(f) { + let _ = cache.event_tx.send(SseMessage { + event: "flight_policy_updated".to_string(), + data, + }); + } + } + } + *guard = new_flights; + } + Err(e) => tracing::warn!("[cache] FlightPolicy 스캔 실패: {e}"), + } + + // ── Heartbeat ───────────────────────────────────────────────── + if last_heartbeat.elapsed().as_secs() >= heartbeat_secs { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let _ = cache.event_tx.send(SseMessage { + event: "heartbeat".to_string(), + data: format!(r#"{{"ts":{ts}}}"#), + }); + last_heartbeat = std::time::Instant::now(); + } + + sleep(Duration::from_secs(poll_secs)).await; + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index e4e8141..5b62d6e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,3 +1,4 @@ +mod cache; mod config; mod flight_api; mod oracle; @@ -12,14 +13,12 @@ use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> Result<()> { - // 로깅 초기화 (RUST_LOG 환경변수로 레벨 제어, 기본 info) tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) .init(); - // 설정 로드 let config = Arc::new(config::Config::from_env()?); tracing::info!( "RiskMesh Backend 시작\n RPC: {}\n Program: {}\n Leader: {}\n Web: {}", @@ -29,8 +28,11 @@ async fn main() -> Result<()> { config.web_bind_addr ); + let cache = Arc::new(cache::CacheState::new()); + let scheduler_task = tokio::spawn(scheduler::start(config.clone())); - let web_task = tokio::spawn(web::start(config.clone())); + let cache_task = tokio::spawn(cache::start(config.clone(), cache.clone())); + let web_task = tokio::spawn(web::start(config.clone(), cache.clone())); tokio::select! { res = scheduler_task => { @@ -39,6 +41,12 @@ async fn main() -> Result<()> { Err(e) => return Err(e.into()), } } + res = cache_task => { + match res { + Ok(inner) => inner?, + Err(e) => return Err(e.into()), + } + } res = web_task => { match res { Ok(inner) => inner?, diff --git a/backend/src/oracle/program_accounts.rs b/backend/src/oracle/program_accounts.rs index 0e723cf..de7a37e 100644 --- a/backend/src/oracle/program_accounts.rs +++ b/backend/src/oracle/program_accounts.rs @@ -10,7 +10,7 @@ use crate::{ solana::client::SolanaClient, }; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct MasterParticipantInfo { pub insurer: String, pub share_bps: u16, @@ -19,7 +19,7 @@ pub struct MasterParticipantInfo { pub deposit_wallet: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct MasterPolicyInfo { pub pubkey: String, pub master_id: u64, @@ -48,7 +48,7 @@ pub struct MasterPolicyInfo { pub bump: u8, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct FlightPolicyInfo { pub pubkey: String, pub child_policy_id: u64, diff --git a/backend/src/web.rs b/backend/src/web.rs index bda7fbb..13ee0d2 100644 --- a/backend/src/web.rs +++ b/backend/src/web.rs @@ -2,25 +2,33 @@ use std::{net::SocketAddr, sync::Arc}; use anyhow::{Context, Result}; use axum::{ - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, + extract::{Path, Query, State}, + http::{Method, StatusCode}, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, Response, + }, routing::get, Json, Router, }; -use serde::Serialize; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio_stream::wrappers::BroadcastStream; +use tower_http::cors::{Any, CorsLayer}; use crate::{ + cache::CacheState, config::Config, - oracle::program_accounts::{scan_flight_policies, scan_master_policies}, - solana::client::SolanaClient, }; #[derive(Clone)] -struct AppState { - config: Arc, +pub struct AppState { + pub config: Arc, + pub cache: Arc, } +// ── Response types ────────────────────────────────────────────────────────── + #[derive(Serialize)] struct HealthResponse { status: &'static str, @@ -43,19 +51,62 @@ struct FlightPoliciesResponse { flight_policies: Vec, } +// ── Query params ──────────────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] +struct MasterPoliciesQuery { + leader: Option, +} + +#[derive(Deserialize, Default)] +struct FlightPoliciesQuery { + master: Option, + status: Option, +} + +#[derive(Deserialize, Default)] +struct EventsQuery { + master: Option, +} + +// ── Error type ────────────────────────────────────────────────────────────── + struct ApiError(anyhow::Error); -pub async fn start(config: Arc) -> Result<()> { +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": self.0.to_string() })), + ) + .into_response() + } +} + +// ── Server entry point ────────────────────────────────────────────────────── + +pub async fn start(config: Arc, cache: Arc) -> Result<()> { let addr: SocketAddr = config .web_bind_addr .parse() .with_context(|| format!("WEB_BIND_ADDR 파싱 실패: {}", config.web_bind_addr))?; + let state = AppState { config, cache }; + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods([Method::GET]) + .allow_headers(Any); + let app = Router::new() .route("/health", get(health)) .route("/api/master-policies", get(master_policies)) + .route("/api/master-policies/:pubkey", get(master_policy_by_pubkey)) .route("/api/flight-policies", get(flight_policies)) - .with_state(AppState { config }); + .route("/api/flight-policies/:pubkey", get(flight_policy_by_pubkey)) + .route("/api/events", get(events)) + .layer(cors) + .with_state(state); tracing::info!("[web] listening on http://{addr}"); @@ -65,6 +116,8 @@ pub async fn start(config: Arc) -> Result<()> { Ok(()) } +// ── Handlers ──────────────────────────────────────────────────────────────── + async fn health(State(state): State) -> Json { Json(HealthResponse { status: "ok", @@ -76,42 +129,100 @@ async fn health(State(state): State) -> Json { async fn master_policies( State(state): State, -) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); - let master_policies = scan_master_policies(&client, &state.config.program_id) - .context("MasterPolicy 조회 실패") - .map_err(ApiError)?; - - Ok(Json(MasterPoliciesResponse { + Query(params): Query, +) -> Json { + let all = state.cache.master_policies.read().await; + let filtered: Vec<_> = all + .iter() + .filter(|m| { + params.leader.as_deref().map(|l| m.leader == l).unwrap_or(true) + }) + .cloned() + .collect(); + let count = filtered.len(); + Json(MasterPoliciesResponse { program_id: state.config.program_id.to_string(), - count: master_policies.len(), - master_policies, - })) + count, + master_policies: filtered, + }) } -async fn flight_policies( +async fn master_policy_by_pubkey( State(state): State, -) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); - let flight_policies = scan_flight_policies(&client, &state.config.program_id) - .context("FlightPolicy 조회 실패") - .map_err(ApiError)?; + Path(pubkey): Path, +) -> Result, (StatusCode, Json)> { + let all = state.cache.master_policies.read().await; + match all.iter().find(|m| m.pubkey == pubkey) { + Some(m) => Ok(Json(m.clone())), + None => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "account not found" })), + )), + } +} - Ok(Json(FlightPoliciesResponse { +async fn flight_policies( + State(state): State, + Query(params): Query, +) -> Json { + let all = state.cache.flight_policies.read().await; + let filtered: Vec<_> = all + .iter() + .filter(|f| { + let master_ok = params.master.as_deref().map(|m| f.master == m).unwrap_or(true); + let status_ok = params.status.map(|s| f.status == s).unwrap_or(true); + master_ok && status_ok + }) + .cloned() + .collect(); + let count = filtered.len(); + Json(FlightPoliciesResponse { program_id: state.config.program_id.to_string(), - count: flight_policies.len(), - flight_policies, - })) + count, + flight_policies: filtered, + }) } -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": self.0.to_string(), - })), - ) - .into_response() +async fn flight_policy_by_pubkey( + State(state): State, + Path(pubkey): Path, +) -> Result, (StatusCode, Json)> { + let all = state.cache.flight_policies.read().await; + match all.iter().find(|f| f.pubkey == pubkey) { + Some(f) => Ok(Json(f.clone())), + None => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "account not found" })), + )), } } + +async fn events( + State(state): State, + Query(params): Query, +) -> Sse>> { + let rx = state.cache.event_tx.subscribe(); + let master_filter = params.master.clone(); + + let stream = BroadcastStream::new(rx).filter_map(move |msg| { + let master_filter = master_filter.clone(); + let result = match msg { + Ok(msg) => { + // FlightPolicy 이벤트는 master 필터 적용 + if msg.event == "flight_policy_updated" { + if let Some(ref filter) = master_filter { + if !msg.data.contains(filter.as_str()) { + return std::future::ready(None); + } + } + } + let event = Event::default().event(&msg.event).data(&msg.data); + Some(Ok(event)) + } + Err(_) => None, // 채널 lagged — 스킵 + }; + std::future::ready(result) + }); + + Sse::new(stream).keep_alive(KeepAlive::default()) +} diff --git a/docs/BACKEND_API_SPEC.md b/docs/BACKEND_API_SPEC.md new file mode 100644 index 0000000..c069f82 --- /dev/null +++ b/docs/BACKEND_API_SPEC.md @@ -0,0 +1,371 @@ +# Backend API Specification + +> **작성 목적**: 프론트엔드의 Solana devnet 직접 RPC 호출을 백엔드 API로 대체하기 위한 스펙. +> **배경**: 프론트에서 `onAccountChange` WebSocket 구독 및 `getProgramAccounts` 직접 호출 시 devnet 429 rate limit 발생 → 백엔드가 RPC를 단일 구독하고 SSE로 프론트에 분배. + +--- + +## 아키텍처 변경 요약 + +``` +[기존] +Frontend → Solana devnet RPC (직접, 클라이언트마다 구독) → 429 에러 발생 + +[변경 후] +Frontend → Backend API (REST + SSE) + ↓ + Backend → Solana devnet RPC (단일 구독, in-memory 캐시) +``` + +--- + +## 기존 API (변경 필요) + +### `GET /health` + +현재 상태 그대로 유지. + +**Response:** +```json +{ + "status": "ok", + "rpc_url": "https://api.devnet.solana.com", + "leader_pubkey": "..." +} +``` + +--- + +### `GET /api/master-policies` + +**변경사항**: `leader` 쿼리 파라미터 추가 (필터링). + +**Query Parameters:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `leader` | `string (base58 pubkey)` | 선택 | 이 pubkey가 leader인 정책만 반환 | + +**Request 예시:** +``` +GET /api/master-policies?leader=7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU +``` + +**Response:** +```json +{ + "master_policies": [ + { + "pubkey": "3yGp...", + "master_id": 1, + "leader": "7xKX...", + "operator": "7xKX...", + "currency_mint": "5YsA...", + "coverage_start_ts": 1710000000, + "coverage_end_ts": 1712592000, + "premium_per_policy": 5000000, + "payout_delay_2h": 5000000, + "payout_delay_3h": 8000000, + "payout_delay_4to5h": 12000000, + "payout_delay_6h_or_cancelled": 15000000, + "ceded_ratio_bps": 3000, + "reins_commission_bps": 500, + "reinsurer_effective_bps": 2850, + "reinsurer": "Abc1...", + "reinsurer_confirmed": true, + "reinsurer_pool_wallet": "Def2...", + "reinsurer_deposit_wallet": "Ghi3...", + "leader_deposit_wallet": "Jkl4...", + "participants": [ + { + "insurer": "Mno5...", + "share_bps": 5000, + "confirmed": true, + "pool_wallet": "Pqr6...", + "deposit_wallet": "Stu7..." + } + ], + "oracle_feed": "Vwx8...", + "status": 2, + "status_label": "Active", + "created_at": 1710000000 + } + ] +} +``` + +--- + +### `GET /api/flight-policies` + +**변경사항**: `master` 쿼리 파라미터 추가 (필터링). + +**Query Parameters:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `master` | `string (base58 pubkey)` | 선택 | 이 master policy pubkey에 속한 항공편 정책만 반환 | +| `status` | `number` | 선택 | 특정 상태의 정책만 반환 (0=Issued, 1=AwaitingOracle, 2=Claimable, 3=Paid, 4=NoClaim, 5=Expired) | + +**Request 예시:** +``` +GET /api/flight-policies?master=3yGp... +GET /api/flight-policies?master=3yGp...&status=1 +``` + +**Response:** +```json +{ + "flight_policies": [ + { + "pubkey": "9zAb...", + "child_policy_id": 1, + "master": "3yGp...", + "creator": "7xKX...", + "subscriber_ref": "USR-001", + "flight_no": "KE001", + "route": "ICN-NRT", + "departure_ts": 1710500000, + "premium_paid": 5000000, + "delay_minutes": 0, + "cancelled": false, + "payout_amount": 0, + "status": 1, + "status_label": "AwaitingOracle", + "premium_distributed": false, + "created_at": 1710000000, + "updated_at": 1710100000 + } + ] +} +``` + +--- + +## 신규 API + +### `GET /api/master-policies/:pubkey` + +단일 MasterPolicy 계정 조회. + +**Path Parameters:** + +| 파라미터 | 타입 | 설명 | +|---|---|---| +| `pubkey` | `string (base58)` | MasterPolicy 계정 주소 | + +**Response:** 위 목록 응답의 단일 `MasterPolicyInfo` 객체. + +**Error:** +```json +{ "error": "account not found" } // 404 +{ "error": "failed to fetch: ..." } // 500 +``` + +--- + +### `GET /api/flight-policies/:pubkey` + +단일 FlightPolicy 계정 조회. + +**Path Parameters:** + +| 파라미터 | 타입 | 설명 | +|---|---|---| +| `pubkey` | `string (base58)` | FlightPolicy 계정 주소 | + +**Response:** 위 목록 응답의 단일 `FlightPolicyInfo` 객체. + +--- + +### `GET /api/events` ⭐ (핵심 신규 엔드포인트) + +**Server-Sent Events** 스트림. 백엔드가 Solana 계정 변경을 감지하면 연결된 프론트에 즉시 푸시. + +**Query Parameters:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `master` | `string (base58 pubkey)` | 선택 | 이 master에 속한 FlightPolicy 변경 이벤트만 수신. 생략 시 전체 수신. | + +**Response Headers:** +``` +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive +``` + +**이벤트 형식:** + +각 SSE 이벤트는 `event:` 타입과 `data:` JSON 페이로드로 구성됩니다. + +#### 1. `master_policy_updated` + +MasterPolicy 계정 상태 변경 시 발생. + +``` +event: master_policy_updated +data: {"pubkey":"3yGp...","status":2,"status_label":"Active",...} +``` + +`data` 구조는 `/api/master-policies` 응답의 `MasterPolicyInfo`와 동일. + +#### 2. `flight_policy_updated` + +FlightPolicy 계정 상태 변경 시 발생. + +``` +event: flight_policy_updated +data: {"pubkey":"9zAb...","master":"3yGp...","status":2,"status_label":"Claimable","delay_minutes":135,...} +``` + +`data` 구조는 `/api/flight-policies` 응답의 `FlightPolicyInfo`와 동일. + +#### 3. `heartbeat` + +연결 유지용. 30초마다 전송. + +``` +event: heartbeat +data: {"ts":1710500000} +``` + +--- + +## 백엔드 구현 변경 사항 + +### 1. `AppState` 확장 + +```rust +pub struct AppState { + pub config: Arc, + // 기존 필드 유지 + + // 신규: in-memory 캐시 + pub master_policies: Arc>>, + pub flight_policies: Arc>>, + + // 신규: SSE broadcast 채널 + pub event_tx: broadcast::Sender, +} + +pub enum SseEvent { + MasterPolicyUpdated(MasterPolicyInfo), + FlightPolicyUpdated(FlightPolicyInfo), + Heartbeat, +} +``` + +### 2. 신규 백그라운드 태스크: `cache_watcher` + +`main.rs`에 세 번째 태스크 추가: + +```rust +// main.rs +tokio::select! { + _ = scheduler::start(&config) => {}, + _ = web::start(&state) => {}, + _ = cache::start(&state) => {}, // 신규 +} +``` + +`cache::start()` 역할: +- Solana RPC `programSubscribe`(또는 주기적 폴링)로 MasterPolicy/FlightPolicy 계정 변경 감지 +- 캐시 업데이트 +- `event_tx.send(SseEvent::...)` 로 SSE 브로드캐스트 + +### 3. 기존 엔드포인트 수정 + +`/api/master-policies`, `/api/flight-policies` 엔드포인트를 직접 RPC 스캔 대신 **캐시에서 읽도록** 변경: + +```rust +// 기존 (매 요청마다 RPC 스캔) +let policies = scan_master_policies(&config).await?; + +// 변경 후 (캐시에서 읽기 + 필터) +let policies = state.master_policies.read().await.clone(); +let filtered = if let Some(leader) = query.leader { + policies.into_iter().filter(|p| p.leader == leader).collect() +} else { + policies +}; +``` + +--- + +## 프론트엔드 변경 사항 + +### 제거 대상 (RPC 직접 호출) + +| 파일 | 현재 RPC 호출 | 대체 방법 | +|---|---|---| +| `hooks/useMasterPolicies.ts` | `program.account.masterPolicy.all(...)` | `GET /api/master-policies?leader=` | +| `hooks/useMasterPolicyAccount.ts` | `program.account.masterPolicy.fetch(pda)` + `onAccountChange` | `GET /api/master-policies/:pubkey` + SSE | +| `hooks/useFlightPolicies.ts` | `program.account.flightPolicy.all(...)` + `onAccountChange` per policy | `GET /api/flight-policies?master=` + SSE | + +### 유지 대상 (트랜잭션은 여전히 wallet 서명 필요) + +모든 write 훅(`useCreateMasterPolicy`, `useConfirmMaster`, `useActivateMaster`, `useCreateFlightPolicy`, `useResolveFlightDelay`, `useSettleFlight` 등)은 변경 없음. + +### SSE 연결 예시 (프론트) + +```typescript +// src/hooks/useBackendEvents.ts +export function useBackendEvents(masterPubkey?: string) { + const { syncMasterFromChain, syncFlightPoliciesFromChain } = useProtocolStore(); + + useEffect(() => { + const url = masterPubkey + ? `${BACKEND_URL}/api/events?master=${masterPubkey}` + : `${BACKEND_URL}/api/events`; + + const es = new EventSource(url); + + es.addEventListener('master_policy_updated', (e) => { + const data = JSON.parse(e.data); + syncMasterFromChain(data); + }); + + es.addEventListener('flight_policy_updated', (e) => { + const data = JSON.parse(e.data); + syncFlightPoliciesFromChain([data]); + }); + + return () => es.close(); + }, [masterPubkey]); +} +``` + +--- + +## 환경 변수 추가 + +`.env.example`에 추가 필요: + +```env +# 기존 +WEB_BIND_ADDR=0.0.0.0:8080 + +# 신규 +CACHE_POLL_INTERVAL_SEC=5 # 캐시 갱신 주기 (초). 기본값: 5 +SSE_HEARTBEAT_INTERVAL_SEC=30 # SSE heartbeat 주기 (초). 기본값: 30 +``` + +--- + +## API 응답 상태 코드 정리 + +| 코드 | 상황 | +|---|---| +| `200` | 정상 | +| `400` | 잘못된 파라미터 (e.g., base58 디코딩 실패) | +| `404` | 단일 계정 조회 시 없음 | +| `500` | RPC 오류 또는 파싱 실패 | + +--- + +## 구현 우선순위 + +1. **Phase 1** (핵심): 캐시 + 기존 GET 엔드포인트 필터링 추가 → 프론트 `useMasterPolicies`, `useFlightPolicies` 교체 +2. **Phase 2** (실시간): SSE 엔드포인트 + `useBackendEvents` 훅 → `useMasterPolicyAccount`, `useFlightPolicies` onAccountChange 제거 +3. **Phase 3** (단일 조회): `/:pubkey` 엔드포인트 추가 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d12ef4d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_BACKEND_URL=http://localhost:3000 diff --git a/frontend/src/components/tabs/tab-contract/PoolStatus.tsx b/frontend/src/components/tabs/tab-contract/PoolStatus.tsx index 2bed485..5a1fe39 100644 --- a/frontend/src/components/tabs/tab-contract/PoolStatus.tsx +++ b/frontend/src/components/tabs/tab-contract/PoolStatus.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { PublicKey } from '@solana/web3.js'; import { Card, CardHeader, CardTitle, CardBody, SummaryRow } from '@/components/common'; import { useProtocolStore, formatNum } from '@/store/useProtocolStore'; import { useProgram } from '@/hooks/useProgram'; +import { useMasterPolicyAccount } from '@/hooks/useMasterPolicyAccount'; import { Chart, registerables } from 'chart.js'; import { useTranslation } from 'react-i18next'; @@ -10,22 +11,24 @@ Chart.register(...registerables); export function PoolStatus() { const { mode, masterPolicyPDA, poolBalance, totalClaim, poolHist, poolRefreshKey, setPoolBalance } = useProtocolStore(); - const { program, connection } = useProgram(); + const { connection } = useProgram(); const { t, i18n: { language } } = useTranslation(); const canvasRef = useRef(null); const chartRef = useRef(null); const [onChainBalance, setOnChainBalance] = useState(null); + const pdaKey = useMemo( + () => mode === 'onchain' && masterPolicyPDA ? new PublicKey(masterPolicyPDA) : null, + [mode, masterPolicyPDA], + ); + const { account: masterData } = useMasterPolicyAccount(pdaKey); + const fetchOnChainBalance = useCallback(async () => { - if (mode !== 'onchain' || !masterPolicyPDA || !program || !connection) { + if (mode !== 'onchain' || !masterData || !connection) { setOnChainBalance(null); return; } try { - const masterPK = new PublicKey(masterPolicyPDA); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const masterData = await (program as any).account.masterPolicy.fetch(masterPK); - let total = 0; // reinsurer pool 잔액 try { @@ -47,7 +50,7 @@ export function PoolStatus() { } catch { setOnChainBalance(null); } - }, [mode, masterPolicyPDA, program, connection, poolRefreshKey, setPoolBalance]); + }, [mode, masterData, connection, poolRefreshKey, setPoolBalance]); useEffect(() => { fetchOnChainBalance(); }, [fetchOnChainBalance]); diff --git a/frontend/src/hooks/useBackendEvents.ts b/frontend/src/hooks/useBackendEvents.ts new file mode 100644 index 0000000..a83e709 --- /dev/null +++ b/frontend/src/hooks/useBackendEvents.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; +import { BACKEND_URL } from '@/lib/constants'; + +/** + * Generic SSE hook that connects to /api/events for a given master pubkey. + * Used by components that need raw SSE access outside of the policy hooks. + * Policy-specific SSE is handled within useMasterPolicyAccount and useFlightPolicies. + */ +export function useBackendEvents(masterPubkey?: string | null) { + useEffect(() => { + if (!masterPubkey) return; + + const url = `${BACKEND_URL}/api/events?master=${masterPubkey}`; + const es = new EventSource(url); + + es.addEventListener('heartbeat', () => { + // Connection alive — no action needed + }); + + es.onerror = () => { + // Auto-reconnects + }; + + return () => es.close(); + }, [masterPubkey]); +} diff --git a/frontend/src/hooks/useFlightPolicies.ts b/frontend/src/hooks/useFlightPolicies.ts index 91ea742..4bffc30 100644 --- a/frontend/src/hooks/useFlightPolicies.ts +++ b/frontend/src/hooks/useFlightPolicies.ts @@ -1,17 +1,66 @@ -import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { PublicKey } from '@solana/web3.js'; -import { useProgram } from './useProgram'; import type { FlightPolicyAccount } from '@/lib/idl/open_parametric'; +import { BACKEND_URL } from '@/lib/constants'; export interface FlightPolicyWithKey { publicKey: PublicKey; account: FlightPolicyAccount; } -/** - * Fetch all FlightPolicy accounts belonging to a specific MasterPolicy. - * Uses getProgramAccounts with a memcmp filter on the master field. - */ +const fakeBN = (n: number) => ({ + toNumber: () => n, + toString: () => String(n), +}); + +interface BackendFlightPolicy { + pubkey: string; + child_policy_id: number; + master: string; + creator: string; + subscriber_ref: string; + flight_no: string; + route: string; + departure_ts: number; + premium_paid: number; + delay_minutes: number; + cancelled: boolean; + payout_amount: number; + status: number; + premium_distributed: boolean; + created_at: number; + updated_at: number; + bump: number; +} + +function toFlightPolicyWithKey(data: BackendFlightPolicy): FlightPolicyWithKey { + const SYSTEM_PROGRAM = '11111111111111111111111111111111'; + const safePubkey = (s: string | undefined | null) => + new PublicKey(s && s.length > 0 ? s : SYSTEM_PROGRAM); + + return { + publicKey: safePubkey(data.pubkey), + account: { + childPolicyId: fakeBN(data.child_policy_id) as unknown as import('@coral-xyz/anchor').BN, + master: safePubkey(data.master), + creator: safePubkey(data.creator), + subscriberRef: data.subscriber_ref, + flightNo: data.flight_no, + route: data.route, + departureTs: fakeBN(data.departure_ts) as unknown as import('@coral-xyz/anchor').BN, + premiumPaid: fakeBN(data.premium_paid) as unknown as import('@coral-xyz/anchor').BN, + delayMinutes: data.delay_minutes, + cancelled: data.cancelled, + payoutAmount: fakeBN(data.payout_amount) as unknown as import('@coral-xyz/anchor').BN, + status: data.status, + premiumDistributed: data.premium_distributed, + createdAt: fakeBN(data.created_at) as unknown as import('@coral-xyz/anchor').BN, + updatedAt: fakeBN(data.updated_at) as unknown as import('@coral-xyz/anchor').BN, + bump: data.bump, + } as unknown as FlightPolicyAccount, + }; +} + export function useFlightPolicies( masterPolicyPDA: PublicKey | null, options?: { @@ -19,20 +68,18 @@ export function useFlightPolicies( pollInterval?: number; }, ) { - const { program, connection } = useProgram(); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const prevStatusRef = useRef>(new Map()); - // [FIX] 기존: onStatusChange가 useCallback deps에 포함 → t() 등 불안정 참조로 무한 refetch 루프 - // 수정: ref로 분리하여 fetchPolicies deps에서 제거 const onStatusChangeRef = useRef(options?.onStatusChange); onStatusChangeRef.current = options?.onStatusChange; - const pollInterval = options?.pollInterval ?? 300_000; + + const masterKey = masterPolicyPDA?.toBase58() ?? null; const fetchPolicies = useCallback(async () => { - if (!program || !masterPolicyPDA || !connection) { + if (!masterKey) { setPolicies([]); return; } @@ -40,32 +87,14 @@ export function useFlightPolicies( setLoading(true); setError(null); try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prog = program as any; - // FlightPolicy has `master: Pubkey` as the 2nd field (after discriminator + child_policy_id) - // Discriminator (8 bytes) + child_policy_id (8 bytes) = offset 16 for master field - const accounts = await prog.account.flightPolicy.all([ - { - memcmp: { - offset: 16, // 8 (discriminator) + 8 (child_policy_id u64) - bytes: masterPolicyPDA.toBase58(), - }, - }, - ]); - - const mapped: FlightPolicyWithKey[] = accounts.map( - (a: { publicKey: PublicKey; account: FlightPolicyAccount }) => ({ - publicKey: a.publicKey, - account: a.account, - }), - ); + const res = await fetch(`${BACKEND_URL}/api/flight-policies?master=${masterKey}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json: { flight_policies: BackendFlightPolicy[] } = await res.json(); - // Sort by child_policy_id ascending - mapped.sort((a, b) => { - const aId = a.account.childPolicyId.toNumber(); - const bId = b.account.childPolicyId.toNumber(); - return aId - bId; - }); + const mapped = json.flight_policies.map(toFlightPolicyWithKey); + mapped.sort( + (a, b) => a.account.childPolicyId.toNumber() - b.account.childPolicyId.toNumber(), + ); // Status diff detection const prevMap = prevStatusRef.current; @@ -81,80 +110,72 @@ export function useFlightPolicies( } } prevStatusRef.current = new Map( - mapped.map(fp => [fp.account.childPolicyId.toNumber(), fp.account.status]) + mapped.map((fp) => [fp.account.childPolicyId.toNumber(), fp.account.status]), ); setPolicies(mapped); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - setError(message); + setError(err instanceof Error ? err.message : String(err)); setPolicies([]); } finally { setLoading(false); } - }, [program, masterPolicyPDA, connection]); + }, [masterKey]); // Initial fetch useEffect(() => { fetchPolicies(); }, [fetchPolicies]); - // Stable key for WebSocket subscription dependencies - const policyKeys = useMemo( - () => policies.map(p => p.publicKey.toBase58()).join(','), - [policies], - ); - - // Subscribe to individual FlightPolicy account changes (real-time, in-place decode) + // SSE for real-time flight policy updates useEffect(() => { - if (!connection || !program || policies.length === 0) return; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const coder = (program as any).coder.accounts; - const subscriptionIds = policies.map(fp => - connection.onAccountChange( - fp.publicKey, - (accountInfo) => { - try { - const decoded = coder.decode('flightPolicy', accountInfo.data) as FlightPolicyAccount; - const key = fp.publicKey; - setPolicies(prev => prev.map(p => - p.publicKey.equals(key) ? { publicKey: key, account: decoded } : p, - )); - // Status change detection - const prevStatus = prevStatusRef.current.get(decoded.childPolicyId.toNumber()); - const cb = onStatusChangeRef.current; - if (prevStatus !== undefined && prevStatus !== decoded.status && cb) { - cb({ publicKey: key, account: decoded }, prevStatus, decoded.status); + if (!masterKey) return; + + const es = new EventSource(`${BACKEND_URL}/api/events?master=${masterKey}`); + + es.addEventListener('flight_policy_updated', (e: MessageEvent) => { + try { + const data: BackendFlightPolicy = JSON.parse(e.data); + if (data.master !== masterKey) return; + + const updated = toFlightPolicyWithKey(data); + const id = updated.account.childPolicyId.toNumber(); + + setPolicies((prev) => { + const idx = prev.findIndex((p) => p.account.childPolicyId.toNumber() === id); + + // Status change detection + const cb = onStatusChangeRef.current; + const existing = idx >= 0 ? prev[idx]! : undefined; + if (existing && cb) { + const prevStatus = existing.account.status; + if (prevStatus !== updated.account.status) { + cb(updated, prevStatus, updated.account.status); } - prevStatusRef.current.set(decoded.childPolicyId.toNumber(), decoded.status); - } catch { - // [FIX] 기존: decode 실패 → fetchPolicies() 전체 재호출 → 무한 refetch 연쇄 - // 수정: 무시하고 polling이 자연 보정 } - }, - 'confirmed', - ), - ); - - return () => { - subscriptionIds.forEach(id => - connection.removeAccountChangeListener(id), - ); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connection, program, policyKeys]); + prevStatusRef.current.set(id, updated.account.status); - // Long-interval polling for discovering new FlightPolicy accounts - useEffect(() => { - if (!masterPolicyPDA || pollInterval <= 0) return; + if (idx >= 0) { + const next = [...prev]; + next[idx] = updated; + return next; + } + // New policy — append and sort + return [...prev, updated].sort( + (a, b) => a.account.childPolicyId.toNumber() - b.account.childPolicyId.toNumber(), + ); + }); + } catch { + // ignore parse errors + } + }); - const interval = setInterval(() => { - fetchPolicies(); - }, pollInterval); + es.onerror = () => { + // SSE auto-reconnects + }; - return () => clearInterval(interval); - }, [fetchPolicies, masterPolicyPDA, pollInterval]); + return () => es.close(); + }, [masterKey]); return { policies, loading, error, refetch: fetchPolicies }; } diff --git a/frontend/src/hooks/useMasterPolicies.ts b/frontend/src/hooks/useMasterPolicies.ts index f639fa3..7d5ad2a 100644 --- a/frontend/src/hooks/useMasterPolicies.ts +++ b/frontend/src/hooks/useMasterPolicies.ts @@ -1,45 +1,40 @@ import { useEffect, useState, useCallback } from 'react'; -import { useProgram } from './useProgram'; +import { useWallet } from '@solana/wallet-adapter-react'; import type { MasterPolicySummary } from '@/store/useProtocolStore'; +import { BACKEND_URL } from '@/lib/constants'; + +interface BackendMasterPolicyItem { + pubkey: string; + master_id: number; + status: number; + coverage_end_ts: number; +} -/** - * Fetch all MasterPolicy accounts where the connected wallet is the leader. - * Uses getProgramAccounts with a memcmp filter on the leader field. - * MasterPolicy layout: discriminator(8) + master_id(8) = offset 16 for leader field. - */ export function useMasterPolicies() { - const { program, wallet } = useProgram(); + const { publicKey } = useWallet(); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); const fetchPolicies = useCallback(async () => { - if (!program || !wallet) { + if (!publicKey) { setPolicies([]); return; } setLoading(true); try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prog = program as any; - const accounts = await prog.account.masterPolicy.all([ - { - memcmp: { - offset: 16, // discriminator(8) + master_id u64(8) - bytes: wallet.publicKey.toBase58(), - }, - }, - ]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mapped: MasterPolicySummary[] = accounts.map((a: any) => ({ - pda: a.publicKey.toBase58(), - masterId: a.account.masterId.toString(), - status: a.account.status, - coverageEndTs: a.account.coverageEndTs.toNumber(), + const url = `${BACKEND_URL}/api/master-policies?leader=${publicKey.toBase58()}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json: { master_policies: BackendMasterPolicyItem[] } = await res.json(); + + const mapped: MasterPolicySummary[] = json.master_policies.map((m) => ({ + pda: m.pubkey, + masterId: String(m.master_id), + status: m.status, + coverageEndTs: m.coverage_end_ts, })); - // Most recently created first (masterId is a sequential counter) mapped.sort((a, b) => Number(b.masterId) - Number(a.masterId)); setPolicies(mapped); } catch { @@ -47,7 +42,7 @@ export function useMasterPolicies() { } finally { setLoading(false); } - }, [program, wallet]); + }, [publicKey]); useEffect(() => { fetchPolicies(); diff --git a/frontend/src/hooks/useMasterPolicyAccount.ts b/frontend/src/hooks/useMasterPolicyAccount.ts index a2244d2..9eb41a7 100644 --- a/frontend/src/hooks/useMasterPolicyAccount.ts +++ b/frontend/src/hooks/useMasterPolicyAccount.ts @@ -1,26 +1,99 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { PublicKey } from '@solana/web3.js'; -import { useProgram } from './useProgram'; import type { MasterPolicyAccount } from '@/lib/idl/open_parametric'; +import { BACKEND_URL } from '@/lib/constants'; + +/** Minimal BN-like wrapper for number fields the store calls .toNumber() on */ +const fakeBN = (n: number) => ({ + toNumber: () => n, + toString: () => String(n), +}); + +interface BackendMasterPolicy { + pubkey: string; + master_id: number; + status: number; + participants: Array<{ + insurer: string; + share_bps: number; + confirmed: boolean; + pool_wallet: string; + deposit_wallet: string; + }>; + reinsurer_confirmed: boolean; + ceded_ratio_bps: number; + reins_commission_bps: number; + reinsurer_effective_bps: number; + premium_per_policy: number; + payout_delay_2h: number; + payout_delay_3h: number; + payout_delay_4to5h: number; + payout_delay_6h_or_cancelled: number; + coverage_end_ts: number; + coverage_start_ts: number; + leader: string; + operator: string; + currency_mint: string; + reinsurer: string; + reinsurer_pool_wallet: string; + reinsurer_deposit_wallet: string; + leader_deposit_wallet: string; + created_at: number; + bump: number; +} + +function toMasterPolicyAccount(data: BackendMasterPolicy): MasterPolicyAccount { + const SYSTEM_PROGRAM = '11111111111111111111111111111111'; + const safePubkey = (s: string | undefined | null) => + new PublicKey(s && s.length > 0 ? s : SYSTEM_PROGRAM); + + return { + masterId: fakeBN(data.master_id) as unknown as import('@coral-xyz/anchor').BN, + leader: safePubkey(data.leader), + operator: safePubkey(data.operator), + currencyMint: safePubkey(data.currency_mint), + coverageStartTs: fakeBN(data.coverage_start_ts) as unknown as import('@coral-xyz/anchor').BN, + coverageEndTs: fakeBN(data.coverage_end_ts) as unknown as import('@coral-xyz/anchor').BN, + premiumPerPolicy: fakeBN(data.premium_per_policy) as unknown as import('@coral-xyz/anchor').BN, + payoutDelay2h: fakeBN(data.payout_delay_2h) as unknown as import('@coral-xyz/anchor').BN, + payoutDelay3h: fakeBN(data.payout_delay_3h) as unknown as import('@coral-xyz/anchor').BN, + payoutDelay4to5h: fakeBN(data.payout_delay_4to5h) as unknown as import('@coral-xyz/anchor').BN, + payoutDelay6hOrCancelled: fakeBN( + data.payout_delay_6h_or_cancelled, + ) as unknown as import('@coral-xyz/anchor').BN, + cededRatioBps: data.ceded_ratio_bps, + reinsCommissionBps: data.reins_commission_bps, + reinsurerEffectiveBps: data.reinsurer_effective_bps, + reinsurer: safePubkey(data.reinsurer), + reinsurerConfirmed: data.reinsurer_confirmed, + reinsurerPoolWallet: safePubkey(data.reinsurer_pool_wallet), + reinsurerDepositWallet: safePubkey(data.reinsurer_deposit_wallet), + leaderDepositWallet: safePubkey(data.leader_deposit_wallet), + participants: data.participants.map((p) => ({ + insurer: safePubkey(p.insurer), + shareBps: p.share_bps, + confirmed: p.confirmed, + poolWallet: safePubkey(p.pool_wallet), + depositWallet: safePubkey(p.deposit_wallet), + })), + status: data.status, + createdAt: fakeBN(data.created_at) as unknown as import('@coral-xyz/anchor').BN, + bump: data.bump, + } as unknown as MasterPolicyAccount; +} -/** - * Fetch and subscribe to a MasterPolicy account by its PDA address. - */ export function useMasterPolicyAccount(masterPolicyPDA: PublicKey | null) { - const { program, connection } = useProgram(); const [account, setAccount] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // [FIX] PublicKey 객체는 매 렌더 새 인스턴스 → deps 불안정 → 무한 fetch 루프 (429) - // string deps + ref로 안정화 const pdaKey = masterPolicyPDA?.toBase58() ?? null; - const pdaRef = useRef(masterPolicyPDA); - pdaRef.current = masterPolicyPDA; + const pdaRef = useRef(pdaKey); + pdaRef.current = pdaKey; const fetchAccount = useCallback(async () => { const pda = pdaRef.current; - if (!program || !pda) { + if (!pda) { setAccount(null); return; } @@ -28,53 +101,49 @@ export function useMasterPolicyAccount(masterPolicyPDA: PublicKey | null) { setLoading(true); setError(null); try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prog = program as any; - const data = await prog.account.masterPolicy.fetch(pda); - setAccount(data as MasterPolicyAccount); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes('Account does not exist')) { + const res = await fetch(`${BACKEND_URL}/api/master-policies/${pda}`); + if (res.status === 404) { setAccount(null); - } else { - setError(message); + return; } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: BackendMasterPolicy = await res.json(); + setAccount(toMasterPolicyAccount(data)); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [program, pdaKey]); + }, [pdaKey]); // Initial fetch useEffect(() => { fetchAccount(); }, [fetchAccount]); - // [FIX] 기존: onAccountChange → fetchAccount() RPC 재호출 → 매 슬롯마다 요청 → 429 - // 수정: coder.decode()로 인라인 디코딩, RPC 호출 제거 + // SSE subscription for real-time updates useEffect(() => { - const pda = pdaRef.current; - if (!connection || !program || !pda) return; + if (!pdaKey) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const coder = (program as any).coder.accounts; - const subscriptionId = connection.onAccountChange( - pda, - (accountInfo) => { - try { - const decoded = coder.decode('masterPolicy', accountInfo.data) as MasterPolicyAccount; - setAccount(decoded); - } catch { - // Decode failed — ignore, polling/manual refetch will catch up + const es = new EventSource(`${BACKEND_URL}/api/events?master=${pdaKey}`); + + es.addEventListener('master_policy_updated', (e: MessageEvent) => { + try { + const data: BackendMasterPolicy = JSON.parse(e.data); + if (data.pubkey === pdaKey) { + setAccount(toMasterPolicyAccount(data)); } - }, - 'confirmed', - ); + } catch { + // ignore parse errors + } + }); - return () => { - connection.removeAccountChangeListener(subscriptionId); + es.onerror = () => { + // SSE reconnects automatically — no action needed }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connection, program, pdaKey]); + + return () => es.close(); + }, [pdaKey]); return { account, loading, error, refetch: fetchAccount }; } diff --git a/frontend/src/hooks/useParticipantRole.ts b/frontend/src/hooks/useParticipantRole.ts index d8e7059..51bc832 100644 --- a/frontend/src/hooks/useParticipantRole.ts +++ b/frontend/src/hooks/useParticipantRole.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { PublicKey } from '@solana/web3.js'; -import { useProgram } from './useProgram'; -import type { MasterPolicyAccount, MasterParticipant } from '@/lib/idl/open_parametric'; +import { useWallet } from '@solana/wallet-adapter-react'; +import { BACKEND_URL } from '@/lib/constants'; export type ParticipantRole = 'leader' | 'partA' | 'partB' | 'rein' | null; @@ -12,19 +12,29 @@ export interface ParticipantInfo { participantIndex: number; } +interface BackendMasterPolicy { + leader: string; + reinsurer: string; + reinsurer_effective_bps: number; + reinsurer_confirmed: boolean; + participants: Array<{ insurer: string; share_bps: number; confirmed: boolean }>; +} + /** * Detect all roles the connected wallet holds in a MasterPolicy. - * A wallet may be leader + reinsurer, leader + participant, etc. - * Returns an array of all matching roles. + * Uses backend API instead of direct RPC. */ export function useParticipantRole(masterPolicyPDA: PublicKey | null) { - const { program, wallet } = useProgram(); + const { publicKey } = useWallet(); const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const pda = masterPolicyPDA?.toBase58() ?? null; + const walletKey = publicKey?.toBase58() ?? null; + useEffect(() => { - if (!program || !masterPolicyPDA || !wallet?.publicKey) { + if (!pda || !walletKey) { setRoles([]); return; } @@ -35,49 +45,35 @@ export function useParticipantRole(masterPolicyPDA: PublicKey | null) { setLoading(true); setError(null); try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prog = program as any; - const account: MasterPolicyAccount = await prog.account.masterPolicy.fetch(masterPolicyPDA); - const walletKey = wallet!.publicKey!; + const res = await fetch(`${BACKEND_URL}/api/master-policies/${pda}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const account: BackendMasterPolicy = await res.json(); const found: ParticipantInfo[] = []; - // Check leader - if (account.leader.equals(walletKey)) { - found.push({ - role: 'leader', - shareBps: 10000, - confirmed: true, - participantIndex: -1, - }); + if (account.leader === walletKey) { + found.push({ role: 'leader', shareBps: 10000, confirmed: true, participantIndex: -1 }); } - - // Check reinsurer - if (account.reinsurer.equals(walletKey)) { + if (account.reinsurer === walletKey) { found.push({ role: 'rein', - shareBps: account.reinsurerEffectiveBps, - confirmed: account.reinsurerConfirmed, + shareBps: account.reinsurer_effective_bps, + confirmed: account.reinsurer_confirmed, participantIndex: -1, }); } - - // Check participants array - const participants: MasterParticipant[] = account.participants || []; - for (let i = 0; i < participants.length; i++) { - const p = participants[i]; - if (p && p.insurer.equals(walletKey)) { + for (let i = 0; i < account.participants.length; i++) { + const p = account.participants[i]!; + if (p.insurer === walletKey) { found.push({ role: i === 0 ? 'partA' : 'partB', - shareBps: p.shareBps, + shareBps: p.share_bps, confirmed: p.confirmed, participantIndex: i, }); } } - if (!cancelled) { - setRoles(found); - } + if (!cancelled) setRoles(found); } catch (err: unknown) { if (!cancelled) { setError(err instanceof Error ? err.message : String(err)); @@ -90,7 +86,7 @@ export function useParticipantRole(masterPolicyPDA: PublicKey | null) { detect(); return () => { cancelled = true; }; - }, [program, masterPolicyPDA, wallet]); + }, [pda, walletKey]); // Backward-compatible: primary role = first found (leader > rein > participant) const info = roles.length > 0 ? roles[0] : null; diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 6c5484d..39d39cd 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -1,5 +1,7 @@ import { PublicKey } from '@solana/web3.js'; +export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:3000'; + export const RPC_ENDPOINT = 'https://api.devnet.solana.com'; export const PROGRAM_ID = new PublicKey('BXxqMY3f9y7dzvoQWJjhX95GMEyuRjD61kgfgherhSX7');