diff --git a/backend/.dockerignore b/backend/.dockerignore index 090f35e..43c24b7 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -2,3 +2,5 @@ target .git .env .DS_Store +riskmesh-b0fe9-firebase-adminsdk-fbsvc-46e7e0876e.json +solana/id.json diff --git a/backend/.env.example b/backend/.env.example index 0f42f15..519f4d8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,11 +14,14 @@ SWITCHBOARD_QUEUE=EYiAmGSdsQTuCw413V5BzaruWuCCSDgTPtBGvLkXHbe7 # 스케줄러 실행 간격 (cron 표현식, 6필드: sec min hour dom month dow) # 기본값: 15분마다 ORACLE_CHECK_CRON="0 */15 * * * *" +FIREBASE_SYNC_CRON="0/30 * * * * *" # Firebase / Firestore (Admin / service account) FIREBASE_PROJECT_ID=riskmesh-b0fe9 FIREBASE_DATABASE=(default) FIREBASE_TEST_COLLECTION=riskmesh_test -# One of these is required: -FIREBASE_SERVICE_ACCOUNT_PATH= -FIREBASE_SERVICE_ACCOUNT_JSON= +FIREBASE_MASTER_POLICIES_COLLECTION=master_policies +FIREBASE_FLIGHT_POLICIES_COLLECTION=flight_policies +FIREBASE_SYNC_METADATA_COLLECTION=sync_metadata +# Required: fixed service account file name +FIREBASE_SERVICE_ACCOUNT_PATH={file path}/riskmesh-b0fe9-firebase-adminsdk-fbsvc-46e7e0876e.json diff --git a/backend/.gitignore b/backend/.gitignore index d918126..ec12dad 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ /target/ .env +riskmesh-b0fe9-firebase-adminsdk-fbsvc-46e7e0876e.json diff --git a/backend/Dockerfile b/backend/Dockerfile index 41787fa..5651675 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.78-bookworm AS builder +FROM rust:1.88-bookworm AS builder WORKDIR /app @@ -9,7 +9,7 @@ RUN apt-get update \ COPY Cargo.toml Cargo.lock ./ COPY src ./src -RUN cargo build --release --bin oracle-daemon +RUN cargo build --release --locked --bin oracle-daemon FROM debian:bookworm-slim AS runtime @@ -17,13 +17,19 @@ WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates libssl3 \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --create-home --home-dir /app appuser \ + && mkdir -p /run/secrets/firebase /run/secrets/solana \ + && chown -R appuser:appuser /app /run/secrets COPY --from=builder /app/target/release/oracle-daemon /usr/local/bin/oracle-daemon ENV RUST_LOG=info ENV WEB_BIND_ADDR=0.0.0.0:3000 -ENV LEADER_KEYPAIR_PATH=/app/secrets/id.json +ENV LEADER_KEYPAIR_PATH=/run/secrets/solana/id.json +ENV FIREBASE_SERVICE_ACCOUNT_PATH=/run/secrets/firebase/service-account.json + +USER appuser EXPOSE 3000 diff --git a/backend/src/api.rs b/backend/src/api.rs index 3e2bfab..6a414bb 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1,7 +1,7 @@ mod client; mod error; mod handlers; -mod repository; +pub(crate) mod repository; mod router; mod service; mod state; @@ -10,16 +10,28 @@ mod types; use std::{net::SocketAddr, sync::Arc}; use anyhow::{Context, Result}; +use tower_http::cors::{Any, CorsLayer}; -use crate::config::Config; +use crate::{config::Config, events::EventBus}; -pub async fn start(config: Arc) -> Result<()> { +pub async fn start(config: Arc, event_bus: Arc) -> Result<()> { let addr: SocketAddr = config .web_bind_addr .parse() .with_context(|| format!("WEB_BIND_ADDR 파싱 실패: {}", config.web_bind_addr))?; - let app = router::build_router(state::AppState { config }); + let firebase_repository = Arc::new(repository::FirebaseRepository::from_env()?); + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = router::build_router(state::AppState { + config, + firebase_repository, + event_bus, + }) + .layer(cors); tracing::info!("[api] listening on http://{addr}"); diff --git a/backend/src/api/error.rs b/backend/src/api/error.rs index a11e49f..692a21a 100644 --- a/backend/src/api/error.rs +++ b/backend/src/api/error.rs @@ -8,12 +8,31 @@ pub(super) struct ApiError(pub anyhow::Error); impl IntoResponse for ApiError { fn into_response(self) -> Response { + let message = self.0.to_string(); + let status = if is_not_found_error(&message) { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + let error_message = if status == StatusCode::NOT_FOUND { + "account not found".to_string() + } else { + message + }; + ( - StatusCode::INTERNAL_SERVER_ERROR, + status, Json(serde_json::json!({ - "error": self.0.to_string(), + "error": error_message, })), ) .into_response() } } + +fn is_not_found_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("accountnotfound") + || lower.contains("account not found") + || lower.contains("could not find account") +} diff --git a/backend/src/api/handlers.rs b/backend/src/api/handlers.rs index 2b5f6a3..ac753a3 100644 --- a/backend/src/api/handlers.rs +++ b/backend/src/api/handlers.rs @@ -1,19 +1,23 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, + response::sse::{Event, Sse}, Json, }; +use futures_util::Stream; +use std::convert::Infallible; use crate::solana::client::SolanaClient; +use solana_sdk::pubkey::Pubkey; use super::{ error::ApiError, service, state::AppState, types::{ - CreateFlightPolicyRequest, CreateFlightPolicyResponse, FlightPoliciesResponse, - FlightPolicyResponse, FirebaseTestDocumentResponse, - HealthResponse, MasterFlightPoliciesResponse, MasterPoliciesResponse, - MasterPoliciesTreeResponse, MasterPolicyAccountsResponse, MasterPolicyResponse, + CreateFlightPolicyRequest, CreateFlightPolicyResponse, EventsQuery, + FirebaseTestDocumentResponse, FlightPoliciesQuery, FlightPoliciesResponse, HealthResponse, + MasterFlightPoliciesResponse, MasterPoliciesQuery, MasterPoliciesResponse, + MasterPoliciesTreeResponse, MasterPolicyAccountsResponse, }, }; @@ -23,9 +27,10 @@ pub(super) async fn health(State(state): State) -> Json, + Query(query): Query, ) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); - service::list_master_policies(&client, &state.config) + service::list_master_policies(&state.firebase_repository, &query) + .await .map(Json) .map_err(ApiError) } @@ -42,13 +47,13 @@ pub(super) async fn get_master_policy_accounts( pub(super) async fn get_master_policy( State(state): State, Path(master_policy_pubkey): Path, -) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); - let master_policy_pubkey = master_policy_pubkey - .parse() +) -> Result, ApiError> { + master_policy_pubkey + .parse::() .map_err(|e| ApiError(anyhow::anyhow!("master_policy_pubkey 주소 파싱 실패: {e}")))?; - service::get_master_policy(&client, &state.config, &master_policy_pubkey) + service::get_master_policy(&state.firebase_repository, &master_policy_pubkey) + .await .map(Json) .map_err(ApiError) } @@ -62,11 +67,19 @@ pub(super) async fn post_firebase_test_document( .map_err(ApiError) } +pub(super) async fn get_events( + State(state): State, + Query(query): Query, +) -> Sse>> { + service::stream_events(state.event_bus, query) +} + pub(super) async fn get_flight_policies( State(state): State, + Query(query): Query, ) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); - service::list_flight_policies(&client, &state.config) + service::list_flight_policies(&state.firebase_repository, &query) + .await .map(Json) .map_err(ApiError) } @@ -74,13 +87,13 @@ pub(super) async fn get_flight_policies( pub(super) async fn get_flight_policy( State(state): State, Path(flight_policy_pubkey): Path, -) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); - let flight_policy_pubkey = flight_policy_pubkey - .parse() +) -> Result, ApiError> { + flight_policy_pubkey + .parse::() .map_err(|e| ApiError(anyhow::anyhow!("flight_policy_pubkey 주소 파싱 실패: {e}")))?; - service::get_flight_policy(&client, &state.config, &flight_policy_pubkey) + service::get_flight_policy(&state.firebase_repository, &flight_policy_pubkey) + .await .map(Json) .map_err(ApiError) } @@ -88,8 +101,8 @@ pub(super) async fn get_flight_policy( pub(super) async fn get_master_policies_tree( State(state): State, ) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); - service::list_master_policies_tree(&client, &state.config) + service::list_master_policies_tree(&state.firebase_repository, &state.config) + .await .map(Json) .map_err(ApiError) } @@ -98,12 +111,16 @@ pub(super) async fn get_flight_policies_by_master( State(state): State, Path(master_policy_pubkey): Path, ) -> Result, ApiError> { - let client = SolanaClient::new(&state.config.rpc_url); let master_policy_pubkey = master_policy_pubkey .parse() .map_err(|e| ApiError(anyhow::anyhow!("master_policy_pubkey 주소 파싱 실패: {e}")))?; - service::list_flight_policies_by_master(&client, &state.config, &master_policy_pubkey) + service::list_flight_policies_by_master( + &state.firebase_repository, + &state.config, + &master_policy_pubkey, + ) + .await .map(Json) .map_err(ApiError) } diff --git a/backend/src/api/repository.rs b/backend/src/api/repository.rs index dbebb75..17c4ebd 100644 --- a/backend/src/api/repository.rs +++ b/backend/src/api/repository.rs @@ -1,27 +1,32 @@ use anyhow::{Context, Result}; -use serde::Serialize; -use serde_json::{json, Value}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Map, Value}; use std::{ sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; -use crate::firebase::{FirebaseClient, FirestoreDocument}; +use crate::{ + config::Config, + firebase::{FirebaseClient, FirestoreDocument}, + oracle::program_accounts::{FlightPolicyInfo, MasterPolicyInfo}, +}; -pub(super) struct FirebaseRepository { +#[derive(Clone)] +pub(crate) struct FirebaseRepository { client: FirebaseClient, } impl FirebaseRepository { - pub(super) fn new(client: FirebaseClient) -> Self { + pub(crate) fn new(client: FirebaseClient) -> Self { Self { client } } - pub(super) fn from_env() -> Result { + pub(crate) fn from_env() -> Result { Ok(Self::new(FirebaseClient::from_env()?)) } - pub(super) async fn insert_test_document(&self) -> Result { + pub(crate) async fn insert_test_document(&self) -> Result { let unix_ms = current_unix_ms()?; let document_id = next_document_id(unix_ms); let auth = self.client.resolve_auth().await?; @@ -43,16 +48,135 @@ impl FirebaseRepository { document, }) } + + pub(crate) async fn sync_policy_snapshots( + &self, + config: &Config, + master_policies: &[MasterPolicyInfo], + flight_policies: &[FlightPolicyInfo], + ) -> Result { + let auth = self.client.resolve_auth().await?; + let synced_at = current_unix_seconds()?; + + for master_policy in master_policies { + let fields = build_master_policy_document(config, synced_at, master_policy)?; + let document_path = format!( + "{}/{}", + self.client.config().master_policies_collection, + master_policy.pubkey + ); + self.client + .upsert_document(&auth.access_token, &document_path, fields) + .await + .with_context(|| format!("MasterPolicy 문서 저장 실패: {}", master_policy.pubkey))?; + } + + for flight_policy in flight_policies { + let fields = build_flight_policy_document(config, synced_at, flight_policy)?; + let document_path = format!( + "{}/{}", + self.client.config().flight_policies_collection, + flight_policy.pubkey + ); + self.client + .upsert_document(&auth.access_token, &document_path, fields) + .await + .with_context(|| format!("FlightPolicy 문서 저장 실패: {}", flight_policy.pubkey))?; + } + + let metadata_fields = build_sync_metadata_document(config, synced_at, master_policies, flight_policies); + let metadata_path = format!( + "{}/current", + self.client.config().sync_metadata_collection + ); + self.client + .upsert_document(&auth.access_token, &metadata_path, metadata_fields) + .await + .context("동기화 메타데이터 저장 실패")?; + + Ok(SyncSummary { + synced_at, + master_policy_count: master_policies.len(), + flight_policy_count: flight_policies.len(), + }) + } + + pub(crate) async fn list_master_policies(&self) -> Result> { + self.list_payload_documents::(&self.client.config().master_policies_collection) + .await + .context("Firebase MasterPolicy 목록 조회 실패") + } + + pub(crate) async fn get_master_policy(&self, pubkey: &str) -> Result> { + self.get_payload_document::( + &self.client.config().master_policies_collection, + pubkey, + ) + .await + .with_context(|| format!("Firebase MasterPolicy 조회 실패: {pubkey}")) + } + + pub(crate) async fn list_flight_policies(&self) -> Result> { + self.list_payload_documents::(&self.client.config().flight_policies_collection) + .await + .context("Firebase FlightPolicy 목록 조회 실패") + } + + pub(crate) async fn get_flight_policy(&self, pubkey: &str) -> Result> { + self.get_payload_document::( + &self.client.config().flight_policies_collection, + pubkey, + ) + .await + .with_context(|| format!("Firebase FlightPolicy 조회 실패: {pubkey}")) + } + + async fn list_payload_documents(&self, collection_id: &str) -> Result> + where + T: DeserializeOwned, + { + let auth = self.client.resolve_auth().await?; + let documents = self + .client + .list_documents(&auth.access_token, collection_id) + .await?; + + documents + .into_iter() + .map(document_payload::) + .collect() + } + + async fn get_payload_document(&self, collection_id: &str, document_id: &str) -> Result> + where + T: DeserializeOwned, + { + let auth = self.client.resolve_auth().await?; + let document_path = format!("{collection_id}/{document_id}"); + let document = self + .client + .get_document(&auth.access_token, &document_path) + .await?; + + document.map(document_payload::).transpose() + } } #[derive(Debug, Serialize)] -pub(super) struct SeedResult { +pub(crate) struct SeedResult { pub collection_id: String, pub document_id: String, pub auth_local_id: String, pub document: FirestoreDocument, } +#[derive(Debug, Serialize)] +pub(crate) struct SyncSummary { + pub synced_at: u64, + pub master_policy_count: usize, + pub flight_policy_count: usize, +} + fn sample_firestore_fields( config: &crate::firebase::FirebaseConfig, unix_ms: u128, @@ -69,6 +193,166 @@ fn sample_firestore_fields( }) } +fn build_master_policy_document( + config: &Config, + synced_at: u64, + master_policy: &MasterPolicyInfo, +) -> Result { + let payload = serde_json::to_value(master_policy).context("MasterPolicy JSON 직렬화 실패")?; + + Ok(json!({ + "kind": { "stringValue": "master_policy" }, + "pubkey": { "stringValue": master_policy.pubkey.clone() }, + "leader": { "stringValue": master_policy.leader.clone() }, + "operator": { "stringValue": master_policy.operator.clone() }, + "status": { "integerValue": master_policy.status.to_string() }, + "status_label": { "stringValue": master_policy.status_label }, + "program_id": { "stringValue": config.program_id.to_string() }, + "rpc_url": { "stringValue": config.rpc_url.clone() }, + "synced_at": { "integerValue": synced_at.to_string() }, + "payload": firestore_value_from_json(&payload), + })) +} + +fn build_flight_policy_document( + config: &Config, + synced_at: u64, + flight_policy: &FlightPolicyInfo, +) -> Result { + let payload = serde_json::to_value(flight_policy).context("FlightPolicy JSON 직렬화 실패")?; + + Ok(json!({ + "kind": { "stringValue": "flight_policy" }, + "pubkey": { "stringValue": flight_policy.pubkey.clone() }, + "master": { "stringValue": flight_policy.master.clone() }, + "status": { "integerValue": flight_policy.status.to_string() }, + "status_label": { "stringValue": flight_policy.status_label }, + "program_id": { "stringValue": config.program_id.to_string() }, + "rpc_url": { "stringValue": config.rpc_url.clone() }, + "synced_at": { "integerValue": synced_at.to_string() }, + "payload": firestore_value_from_json(&payload), + })) +} + +fn build_sync_metadata_document( + config: &Config, + synced_at: u64, + master_policies: &[MasterPolicyInfo], + flight_policies: &[FlightPolicyInfo], +) -> Value { + json!({ + "kind": { "stringValue": "policy_sync_metadata" }, + "program_id": { "stringValue": config.program_id.to_string() }, + "rpc_url": { "stringValue": config.rpc_url.clone() }, + "synced_at": { "integerValue": synced_at.to_string() }, + "master_policy_count": { "integerValue": master_policies.len().to_string() }, + "flight_policy_count": { "integerValue": flight_policies.len().to_string() }, + }) +} + +fn document_payload(document: FirestoreDocument) -> Result +where + T: DeserializeOwned, +{ + let fields = document + .fields + .as_object() + .context("Firestore fields는 object 여야 합니다")?; + let payload = fields + .get("payload") + .context("Firestore 문서에 payload 필드가 없습니다")?; + let payload = json_from_firestore_value(payload).context("Firestore payload 파싱 실패")?; + + serde_json::from_value(payload).context("Firestore payload 역직렬화 실패") +} + +fn firestore_value_from_json(value: &Value) -> Value { + match value { + Value::Null => json!({ "nullValue": null }), + Value::Bool(value) => json!({ "booleanValue": value }), + Value::Number(value) => { + if let Some(int) = value.as_i64() { + json!({ "integerValue": int.to_string() }) + } else if let Some(uint) = value.as_u64() { + json!({ "integerValue": uint.to_string() }) + } else if let Some(float) = value.as_f64() { + json!({ "doubleValue": float }) + } else { + json!({ "stringValue": value.to_string() }) + } + } + Value::String(value) => json!({ "stringValue": value }), + Value::Array(values) => json!({ + "arrayValue": { + "values": values.iter().map(firestore_value_from_json).collect::>() + } + }), + Value::Object(map) => { + let mut fields = Map::new(); + for (key, value) in map { + fields.insert(key.clone(), firestore_value_from_json(value)); + } + json!({ "mapValue": { "fields": fields } }) + } + } +} + +fn json_from_firestore_value(value: &Value) -> Result { + let map = value + .as_object() + .context("Firestore value는 object 여야 합니다")?; + + if map.contains_key("nullValue") { + return Ok(Value::Null); + } + if let Some(value) = map.get("booleanValue").and_then(Value::as_bool) { + return Ok(Value::Bool(value)); + } + if let Some(value) = map.get("stringValue").and_then(Value::as_str) { + return Ok(Value::String(value.to_string())); + } + if let Some(value) = map.get("integerValue") { + if let Some(raw) = value.as_str() { + if let Ok(parsed) = raw.parse::() { + return Ok(json!(parsed)); + } + if let Ok(parsed) = raw.parse::() { + return Ok(json!(parsed)); + } + } + } + if let Some(value) = map.get("doubleValue").and_then(Value::as_f64) { + return Ok(json!(value)); + } + if let Some(array_value) = map.get("arrayValue").and_then(Value::as_object) { + let values = array_value + .get("values") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + return values + .into_iter() + .map(|entry| json_from_firestore_value(&entry)) + .collect::>>() + .map(Value::Array); + } + if let Some(map_value) = map.get("mapValue").and_then(Value::as_object) { + let fields = map_value + .get("fields") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut result = Map::new(); + for (key, value) in fields { + result.insert(key, json_from_firestore_value(&value)?); + } + return Ok(Value::Object(result)); + } + + anyhow::bail!("지원하지 않는 Firestore value 형식") +} + fn current_unix_ms() -> Result { Ok(SystemTime::now() .duration_since(UNIX_EPOCH) @@ -76,6 +360,13 @@ fn current_unix_ms() -> Result { .as_millis()) } +fn current_unix_seconds() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("시스템 시간이 UNIX_EPOCH보다 이전입니다")? + .as_secs()) +} + fn next_document_id(unix_ms: u128) -> String { static SEQ: AtomicU64 = AtomicU64::new(0); let seq = SEQ.fetch_add(1, Ordering::Relaxed); diff --git a/backend/src/api/router.rs b/backend/src/api/router.rs index 8ef44ce..8688354 100644 --- a/backend/src/api/router.rs +++ b/backend/src/api/router.rs @@ -11,6 +11,7 @@ pub(super) fn build_router(state: AppState) -> Router { .route("/api/master-policies", get(get_master_policies)) .route("/api/master-policies/accounts", get(get_master_policy_accounts)) .route("/api/master-policies/:master_policy_pubkey", get(get_master_policy)) + .route("/api/events", get(get_events)) .route("/api/flight-policies", get(get_flight_policies)) .route("/api/flight-policies/:flight_policy_pubkey", get(get_flight_policy)) .route("/api/master-policies/tree", get(get_master_policies_tree)) diff --git a/backend/src/api/service.rs b/backend/src/api/service.rs index cb4bdd3..b92de8d 100644 --- a/backend/src/api/service.rs +++ b/backend/src/api/service.rs @@ -1,13 +1,16 @@ use anyhow::{Context, Result}; +use axum::response::sse::{Event, Sse}; +use futures_util::{stream::select, Stream, StreamExt}; use solana_sdk::pubkey::Pubkey; use solana_sdk::signature::Signer; -use std::str::FromStr; +use std::{convert::Infallible, str::FromStr, sync::Arc, time::Duration}; +use tokio::time::{interval_at, Instant}; +use tokio_stream::wrappers::{BroadcastStream, IntervalStream}; use crate::{ config::Config, - oracle::program_accounts::{ - fetch_flight_policy, fetch_master_policy, scan_flight_policies, scan_master_policies, - }, + events::{EventBus, SseMessage}, + oracle::program_accounts::{fetch_master_policy, scan_flight_policies, scan_master_policies}, solana::client::SolanaClient, }; @@ -16,34 +19,72 @@ use super::{ repository::FirebaseRepository, types::{ CreateFlightPolicyParamsWire, CreateFlightPolicyRequest, CreateFlightPolicyResponse, - FirebaseTestDocumentResponse, FlightPoliciesResponse, FlightPolicyResponse, HealthResponse, - MasterFlightPoliciesResponse, - MasterPoliciesResponse, MasterPoliciesTreeResponse, MasterPolicyAccountTree, - MasterPolicyAccountsResponse, MasterPolicyResponse, + EventsQuery, FirebaseTestDocumentResponse, FlightPoliciesQuery, FlightPoliciesResponse, + HealthResponse, MasterFlightPoliciesResponse, MasterPoliciesQuery, MasterPoliciesResponse, + MasterPoliciesTreeResponse, MasterPolicyAccountTree, MasterPolicyAccountsResponse, }, }; pub(super) fn health_response(config: &Config) -> HealthResponse { HealthResponse { status: "ok", - service: "riskmesh-backend", rpc_url: config.rpc_url.clone(), leader_pubkey: config.leader_pubkey.to_string(), } } -pub(super) fn list_master_policies( - client: &SolanaClient, - config: &Config, +pub(super) fn stream_events( + event_bus: Arc, + query: EventsQuery, +) -> Sse>> { + let updates = BroadcastStream::new(event_bus.subscribe()).filter_map(move |message| { + let master_filter = query.master.clone(); + async move { + match message { + Ok(message) if message_matches_filter(&message, master_filter.as_deref()) => { + Some(Ok(Event::default().event(message.event).data(message.data))) + } + Ok(_) => None, + Err(_) => None, + } + } + }); + + let heartbeats = IntervalStream::new(interval_at( + Instant::now() + Duration::from_secs(30), + Duration::from_secs(30), + )) + .map(|_| { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Ok(Event::default() + .event("heartbeat") + .data(format!(r#"{{"ts":{ts}}}"#))) + }); + + Sse::new(select(updates, heartbeats)) +} + +pub(super) async fn list_master_policies( + repository: &FirebaseRepository, + query: &MasterPoliciesQuery, ) -> Result { - let master_policies = - scan_master_policies(client, &config.program_id).context("MasterPolicy 조회 실패")?; + let master_policies = repository.list_master_policies().await?; + let master_policies = master_policies + .into_iter() + .filter(|master_policy| { + query + .leader + .as_deref() + .map(|leader| master_policy.leader == leader) + .unwrap_or(true) + }) + .collect(); - Ok(MasterPoliciesResponse { - program_id: config.program_id.to_string(), - count: master_policies.len(), - master_policies, - }) + Ok(MasterPoliciesResponse { master_policies }) } pub(super) fn list_master_policy_accounts( @@ -63,18 +104,16 @@ pub(super) fn list_master_policy_accounts( }) } -pub(super) fn get_master_policy( - client: &SolanaClient, - config: &Config, - master_policy_pubkey: &Pubkey, -) -> Result { - let master_policy = - fetch_master_policy(client, master_policy_pubkey).context("MasterPolicy 조회 실패")?; +pub(super) async fn get_master_policy( + repository: &FirebaseRepository, + master_policy_pubkey: &str, +) -> Result { + let master_policy = repository + .get_master_policy(master_policy_pubkey) + .await? + .ok_or_else(|| anyhow::anyhow!("account not found"))?; - Ok(MasterPolicyResponse { - program_id: config.program_id.to_string(), - master_policy, - }) + Ok(master_policy) } pub(super) async fn create_firebase_test_document( @@ -92,65 +131,73 @@ pub(super) async fn create_firebase_test_document( }) } -pub(super) fn list_flight_policies( - client: &SolanaClient, - config: &Config, +pub(super) async fn list_flight_policies( + repository: &FirebaseRepository, + query: &FlightPoliciesQuery, ) -> Result { - let flight_policies = - scan_flight_policies(client, &config.program_id).context("FlightPolicy 조회 실패")?; + let flight_policies = repository.list_flight_policies().await?; + let flight_policies = flight_policies + .into_iter() + .filter(|flight_policy| { + let master_matches = query + .master + .as_deref() + .map(|master| flight_policy.master == master) + .unwrap_or(true); + let status_matches = query + .status + .map(|status| flight_policy.status == status) + .unwrap_or(true); + master_matches && status_matches + }) + .collect(); - Ok(FlightPoliciesResponse { - program_id: config.program_id.to_string(), - count: flight_policies.len(), - flight_policies, - }) + Ok(FlightPoliciesResponse { flight_policies }) } -pub(super) fn get_flight_policy( - client: &SolanaClient, - config: &Config, - flight_policy_pubkey: &Pubkey, -) -> Result { - let flight_policy = - fetch_flight_policy(client, flight_policy_pubkey).context("FlightPolicy 조회 실패")?; +pub(super) async fn get_flight_policy( + repository: &FirebaseRepository, + flight_policy_pubkey: &str, +) -> Result { + let flight_policy = repository + .get_flight_policy(flight_policy_pubkey) + .await? + .ok_or_else(|| anyhow::anyhow!("account not found"))?; - Ok(FlightPolicyResponse { - program_id: config.program_id.to_string(), - flight_policy, - }) + Ok(flight_policy) } -pub(super) fn list_flight_policies_by_master( - client: &SolanaClient, +pub(super) async fn list_flight_policies_by_master( + repository: &FirebaseRepository, config: &Config, master_policy_pubkey: &Pubkey, ) -> Result { - let _master_policy = - fetch_master_policy(client, master_policy_pubkey).context("MasterPolicy 조회 실패")?; - let flight_policies = - scan_flight_policies(client, &config.program_id).context("FlightPolicy 조회 실패")?; + let master_policy_key = master_policy_pubkey.to_string(); + let _master_policy = repository + .get_master_policy(&master_policy_key) + .await? + .ok_or_else(|| anyhow::anyhow!("account not found"))?; + let flight_policies = repository.list_flight_policies().await?; let flight_policies = flight_policies .into_iter() - .filter(|flight_policy| flight_policy.master == master_policy_pubkey.to_string()) + .filter(|flight_policy| flight_policy.master == master_policy_key) .collect::>(); Ok(MasterFlightPoliciesResponse { program_id: config.program_id.to_string(), - master_policy_pubkey: master_policy_pubkey.to_string(), + master_policy_pubkey: master_policy_key, count: flight_policies.len(), flight_policies, }) } -pub(super) fn list_master_policies_tree( - client: &SolanaClient, +pub(super) async fn list_master_policies_tree( + repository: &FirebaseRepository, config: &Config, ) -> Result { - let master_policies = - scan_master_policies(client, &config.program_id).context("MasterPolicy 조회 실패")?; - let flight_policies = - scan_flight_policies(client, &config.program_id).context("FlightPolicy 조회 실패")?; + let master_policies = repository.list_master_policies().await?; + let flight_policies = repository.list_flight_policies().await?; let master_policies = master_policies .into_iter() @@ -203,9 +250,23 @@ pub(super) fn create_flight_policy( anyhow::bail!("subscriber_ref, flight_no, route는 비어 있을 수 없습니다"); } + let child_policy_id = scan_flight_policies(client, &config.program_id) + .context("FlightPolicy 조회 실패")? + .into_iter() + .filter(|flight_policy| flight_policy.master == master_policy_pubkey.to_string()) + .map(|flight_policy| flight_policy.child_policy_id) + .max() + .map(|max_id| { + max_id + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("child_policy_id가 u64 범위를 초과했습니다")) + }) + .transpose()? + .unwrap_or(1); + let leader = program_client.load_leader_signer()?; let flight_policy_pubkey = - program_client.derive_flight_policy_pubkey(master_policy_pubkey, req.child_policy_id); + program_client.derive_flight_policy_pubkey(master_policy_pubkey, child_policy_id); let currency_mint = parse_pubkey("currency_mint", &master_policy.currency_mint) .context("currency_mint 파싱 실패")?; let payer_token_pubkey = @@ -220,7 +281,7 @@ pub(super) fn create_flight_policy( &payer_token_pubkey, &leader_deposit_token, CreateFlightPolicyParamsWire { - child_policy_id: req.child_policy_id, + child_policy_id, subscriber_ref: req.subscriber_ref, flight_no: req.flight_no, route: req.route, @@ -231,6 +292,7 @@ pub(super) fn create_flight_policy( Ok(CreateFlightPolicyResponse { program_id: config.program_id.to_string(), master_policy_pubkey: master_policy_pubkey.to_string(), + child_policy_id, flight_policy_pubkey: flight_policy_pubkey.to_string(), tx_signature, }) @@ -239,3 +301,32 @@ pub(super) fn create_flight_policy( fn parse_pubkey(field_name: &str, value: &str) -> Result { Pubkey::from_str(value).with_context(|| format!("{field_name} 주소 파싱 실패: {value}")) } + +fn message_matches_filter(message: &SseMessage, master_filter: Option<&str>) -> bool { + let Some(master_filter) = master_filter else { + return true; + }; + + let parsed = serde_json::from_str::(&message.data); + let Ok(json) = parsed else { + tracing::warn!( + "[events] SSE payload 필터링 JSON 파싱 실패: {}", + message.event + ); + return false; + }; + + match message.event.as_str() { + "flight_policy_updated" => json + .get("master") + .and_then(|value| value.as_str()) + .map(|master| master == master_filter) + .unwrap_or(false), + "master_policy_updated" => json + .get("pubkey") + .and_then(|value| value.as_str()) + .map(|pubkey| pubkey == master_filter) + .unwrap_or(false), + _ => true, + } +} diff --git a/backend/src/api/state.rs b/backend/src/api/state.rs index 815fde0..39f3edb 100644 --- a/backend/src/api/state.rs +++ b/backend/src/api/state.rs @@ -1,8 +1,13 @@ use std::sync::Arc; use crate::config::Config; +use crate::events::EventBus; + +use super::repository::FirebaseRepository; #[derive(Clone)] pub(super) struct AppState { pub config: Arc, + pub firebase_repository: Arc, + pub event_bus: Arc, } diff --git a/backend/src/api/types.rs b/backend/src/api/types.rs index 40721eb..cbad445 100644 --- a/backend/src/api/types.rs +++ b/backend/src/api/types.rs @@ -4,15 +4,12 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize)] pub(super) struct HealthResponse { pub status: &'static str, - pub service: &'static str, pub rpc_url: String, pub leader_pubkey: String, } #[derive(Serialize)] pub(super) struct MasterPoliciesResponse { - pub program_id: String, - pub count: usize, pub master_policies: Vec, } @@ -23,27 +20,11 @@ pub(super) struct MasterPolicyAccountsResponse { pub master_policy_pubkeys: Vec, } -#[derive(Serialize)] -pub(super) struct MasterPolicyResponse { - pub program_id: String, - pub master_policy: - crate::oracle::program_accounts::MasterPolicyInfo, -} - #[derive(Serialize)] pub(super) struct FlightPoliciesResponse { - pub program_id: String, - pub count: usize, pub flight_policies: Vec, } -#[derive(Serialize)] -pub(super) struct FlightPolicyResponse { - pub program_id: String, - pub flight_policy: - crate::oracle::program_accounts::FlightPolicyInfo, -} - #[derive(Serialize)] pub(super) struct MasterPoliciesTreeResponse { pub program_id: String, @@ -67,7 +48,6 @@ pub(super) struct MasterPolicyAccountTree { #[derive(Deserialize)] pub(super) struct CreateFlightPolicyRequest { - pub child_policy_id: u64, pub subscriber_ref: String, pub flight_no: String, pub route: String, @@ -78,6 +58,7 @@ pub(super) struct CreateFlightPolicyRequest { pub(super) struct CreateFlightPolicyResponse { pub program_id: String, pub master_policy_pubkey: String, + pub child_policy_id: u64, pub flight_policy_pubkey: String, pub tx_signature: String, } @@ -99,3 +80,19 @@ pub(super) struct CreateFlightPolicyParamsWire { pub route: String, pub departure_ts: i64, } + +#[derive(Deserialize, Default)] +pub(super) struct MasterPoliciesQuery { + pub leader: Option, +} + +#[derive(Deserialize, Default)] +pub(super) struct FlightPoliciesQuery { + pub master: Option, + pub status: Option, +} + +#[derive(Deserialize, Default, Clone)] +pub(super) struct EventsQuery { + pub master: Option, +} diff --git a/backend/src/bin/firebase_seed.rs b/backend/src/bin/firebase_seed.rs index 1da5ae7..f177541 100644 --- a/backend/src/bin/firebase_seed.rs +++ b/backend/src/bin/firebase_seed.rs @@ -1,7 +1,17 @@ use anyhow::Result; +#[path = "../config.rs"] +mod config; #[path = "../firebase/mod.rs"] mod firebase; +#[path = "../flight_api.rs"] +mod flight_api; +#[path = "../oracle/mod.rs"] +mod oracle; +#[path = "../solana/mod.rs"] +mod solana; +#[path = "../switchboard.rs"] +mod switchboard; #[path = "../api/repository.rs"] mod repository; diff --git a/backend/src/cache.rs b/backend/src/cache.rs deleted file mode 100644 index 799f933..0000000 --- a/backend/src/cache.rs +++ /dev/null @@ -1,120 +0,0 @@ -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/config.rs b/backend/src/config.rs index 58ac94e..bc41a1a 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -11,6 +11,7 @@ pub struct Config { pub aviationstack_api_key: String, pub switchboard_queue: Pubkey, pub oracle_check_cron: String, + pub firebase_sync_cron: String, pub web_bind_addr: String, } @@ -33,6 +34,7 @@ impl Config { switchboard_queue: pubkey_env("SWITCHBOARD_QUEUE") .context("SWITCHBOARD_QUEUE 환경변수 필요")?, oracle_check_cron: env("ORACLE_CHECK_CRON", "0 */15 * * * *"), + firebase_sync_cron: env("FIREBASE_SYNC_CRON", "0/30 * * * * *"), web_bind_addr: env("WEB_BIND_ADDR", "0.0.0.0:3000"), }) } diff --git a/backend/src/events.rs b/backend/src/events.rs new file mode 100644 index 0000000..7600748 --- /dev/null +++ b/backend/src/events.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +use tokio::sync::{broadcast, RwLock}; + +use crate::oracle::program_accounts::{FlightPolicyInfo, MasterPolicyInfo}; + +#[derive(Clone, Debug)] +pub(crate) struct SseMessage { + pub(crate) event: String, + pub(crate) data: String, +} + +pub(crate) struct EventBus { + tx: broadcast::Sender, + snapshot: RwLock, +} + +#[derive(Default)] +struct SnapshotState { + initialized: bool, + masters: HashMap, + flights: HashMap, +} + +impl EventBus { + pub(crate) fn new(buffer: usize) -> Self { + let (tx, _) = broadcast::channel(buffer); + Self { + tx, + snapshot: RwLock::new(SnapshotState::default()), + } + } + + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + + pub(crate) async fn publish_policy_updates( + &self, + master_policies: &[MasterPolicyInfo], + flight_policies: &[FlightPolicyInfo], + ) { + let mut snapshot = self.snapshot.write().await; + + if !snapshot.initialized { + snapshot.masters = master_policies + .iter() + .cloned() + .map(|policy| (policy.pubkey.clone(), policy)) + .collect(); + snapshot.flights = flight_policies + .iter() + .cloned() + .map(|policy| (policy.pubkey.clone(), policy)) + .collect(); + snapshot.initialized = true; + return; + } + + for policy in master_policies { + let changed = snapshot + .masters + .get(&policy.pubkey) + .map(|prev| prev != policy) + .unwrap_or(true); + + if changed { + self.send_json("master_policy_updated", policy); + } + } + + for policy in flight_policies { + let changed = snapshot + .flights + .get(&policy.pubkey) + .map(|prev| prev != policy) + .unwrap_or(true); + + if changed { + self.send_json("flight_policy_updated", policy); + } + } + + snapshot.masters = master_policies + .iter() + .cloned() + .map(|policy| (policy.pubkey.clone(), policy)) + .collect(); + snapshot.flights = flight_policies + .iter() + .cloned() + .map(|policy| (policy.pubkey.clone(), policy)) + .collect(); + } + + fn send_json(&self, event: &str, payload: &T) { + match serde_json::to_string(payload) { + Ok(data) => { + let _ = self.tx.send(SseMessage { + event: event.to_string(), + data, + }); + } + Err(error) => { + tracing::warn!("[events] SSE payload 직렬화 실패 ({event}): {error}"); + } + } + } +} diff --git a/backend/src/firebase/mod.rs b/backend/src/firebase/mod.rs index c86eae9..8fdce1a 100644 --- a/backend/src/firebase/mod.rs +++ b/backend/src/firebase/mod.rs @@ -12,6 +12,9 @@ pub struct FirebaseConfig { pub project_id: String, pub firestore_database: String, pub test_collection: String, + pub master_policies_collection: String, + pub flight_policies_collection: String, + pub sync_metadata_collection: String, service_account: GoogleServiceAccount, } @@ -26,6 +29,18 @@ impl FirebaseConfig { .unwrap_or_else(|| service_account.project_id.clone()), firestore_database: optional_env("FIREBASE_DATABASE", "(default)"), test_collection: optional_env("FIREBASE_TEST_COLLECTION", "riskmesh_test"), + master_policies_collection: optional_env( + "FIREBASE_MASTER_POLICIES_COLLECTION", + "master_policies", + ), + flight_policies_collection: optional_env( + "FIREBASE_FLIGHT_POLICIES_COLLECTION", + "flight_policies", + ), + sync_metadata_collection: optional_env( + "FIREBASE_SYNC_METADATA_COLLECTION", + "sync_metadata", + ), service_account, }) } @@ -42,6 +57,7 @@ impl FirebaseConfig { } } +#[derive(Clone)] pub struct FirebaseClient { http: reqwest::Client, config: FirebaseConfig, @@ -109,6 +125,136 @@ impl FirebaseClient { .context("Firestore 문서 생성 응답 파싱 실패") } + pub(crate) async fn upsert_document( + &self, + access_token: &str, + document_path: &str, + fields: Value, + ) -> Result { + let url = format!( + "{}/{}", + self.config.firestore_documents_base_url(), + document_path + ); + + let response = self + .http + .patch(url) + .bearer_auth(access_token) + .json(&json!({ "fields": fields })) + .send() + .await + .context("Firestore 문서 upsert 요청 실패")?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + bail!( + "Firestore 문서 upsert 실패: status={} body={}", + status, + body + ); + } + + response + .json::() + .await + .context("Firestore 문서 upsert 응답 파싱 실패") + } + + pub(crate) async fn get_document( + &self, + access_token: &str, + document_path: &str, + ) -> Result> { + let url = format!( + "{}/{}", + self.config.firestore_documents_base_url(), + document_path + ); + + let response = self + .http + .get(url) + .bearer_auth(access_token) + .send() + .await + .context("Firestore 문서 조회 요청 실패")?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + bail!( + "Firestore 문서 조회 실패: status={} body={}", + status, + body + ); + } + + response + .json::() + .await + .map(Some) + .context("Firestore 문서 조회 응답 파싱 실패") + } + + pub(crate) async fn list_documents( + &self, + access_token: &str, + collection_id: &str, + ) -> Result> { + let mut documents = Vec::new(); + let mut page_token: Option = None; + + loop { + let mut url = format!( + "{}/{}?pageSize=500", + self.config.firestore_documents_base_url(), + collection_id + ); + if let Some(token) = &page_token { + url.push_str("&pageToken="); + url.push_str(token); + } + + let response = self + .http + .get(&url) + .bearer_auth(access_token) + .send() + .await + .context("Firestore 목록 조회 요청 실패")?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + bail!( + "Firestore 목록 조회 실패: status={} body={}", + status, + body + ); + } + + let payload = response + .json::() + .await + .context("Firestore 목록 조회 응답 파싱 실패")?; + + documents.extend(payload.documents); + + match payload.next_page_token { + Some(token) if !token.is_empty() => page_token = Some(token), + _ => break, + } + } + + Ok(documents) + } + async fn fetch_access_token(&self) -> Result { let now = current_unix_seconds()?; let claims = ServiceAccountClaims { @@ -192,6 +338,14 @@ pub struct FirestoreDocument { pub update_time: Option, } +#[derive(Debug, Deserialize)] +struct FirestoreListDocumentsResponse { + #[serde(default)] + documents: Vec, + #[serde(rename = "nextPageToken")] + next_page_token: Option, +} + pub(crate) struct FirebaseWriteAuth { pub(crate) access_token: String, pub(crate) principal: String, diff --git a/backend/src/main.rs b/backend/src/main.rs index 22c3cc3..3c64e98 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,5 +1,6 @@ mod api; mod config; +mod events; mod firebase; mod flight_api; mod oracle; @@ -30,9 +31,11 @@ async fn main() -> Result<()> { config.web_bind_addr ); + let event_bus = Arc::new(events::EventBus::new(256)); + // 스케줄러와 API 서버를 함께 실행한다. - let _scheduler_task = tokio::spawn(scheduler::start(config.clone())); - api::start(config.clone()).await?; + let _scheduler_task = tokio::spawn(scheduler::start(config.clone(), event_bus.clone())); + api::start(config.clone(), event_bus).await?; Ok(()) } diff --git a/backend/src/oracle/program_accounts.rs b/backend/src/oracle/program_accounts.rs index 557dd82..1d3b9c4 100644 --- a/backend/src/oracle/program_accounts.rs +++ b/backend/src/oracle/program_accounts.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use solana_sdk::pubkey::Pubkey; use crate::{ @@ -10,7 +10,7 @@ use crate::{ solana::client::SolanaClient, }; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MasterParticipantInfo { pub insurer: String, pub share_bps: u16, @@ -19,7 +19,7 @@ pub struct MasterParticipantInfo { pub deposit_wallet: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MasterPolicyInfo { pub pubkey: String, pub master_id: u64, @@ -42,13 +42,13 @@ pub struct MasterPolicyInfo { pub reinsurer_deposit_wallet: String, pub leader_deposit_wallet: String, pub participants: Vec, + pub oracle_feed: String, pub status: u8, - pub status_label: &'static str, + pub status_label: String, pub created_at: i64, - pub bump: u8, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FlightPolicyInfo { pub pubkey: String, pub child_policy_id: u64, @@ -63,11 +63,10 @@ pub struct FlightPolicyInfo { pub cancelled: bool, pub payout_amount: u64, pub status: u8, - pub status_label: &'static str, + pub status_label: String, pub premium_distributed: bool, pub created_at: i64, pub updated_at: i64, - pub bump: u8, } pub fn scan_master_policies( @@ -152,9 +151,10 @@ fn parse_master_policy(pubkey: &Pubkey, data: &[u8]) -> Result let reinsurer_deposit_wallet = read_pubkey(data, &mut offset)?; let leader_deposit_wallet = read_pubkey(data, &mut offset)?; let participants = read_master_participants(data, &mut offset)?; + let oracle_feed = read_pubkey(data, &mut offset)?; let status = read_u8(data, &mut offset)?; let created_at = read_i64(data, &mut offset)?; - let bump = read_u8(data, &mut offset)?; + let _bump = read_u8(data, &mut offset)?; Ok(MasterPolicyInfo { pubkey: pubkey.to_string(), @@ -178,10 +178,10 @@ fn parse_master_policy(pubkey: &Pubkey, data: &[u8]) -> Result reinsurer_deposit_wallet: reinsurer_deposit_wallet.to_string(), leader_deposit_wallet: leader_deposit_wallet.to_string(), participants, + oracle_feed: oracle_feed.to_string(), status, - status_label: master_policy_status_label(status), + status_label: master_policy_status_label(status).to_string(), created_at, - bump, }) } @@ -203,7 +203,7 @@ fn parse_flight_policy(pubkey: &Pubkey, data: &[u8]) -> Result let premium_distributed = read_bool(data, &mut offset)?; let created_at = read_i64(data, &mut offset)?; let updated_at = read_i64(data, &mut offset)?; - let bump = read_u8(data, &mut offset)?; + let _bump = read_u8(data, &mut offset)?; Ok(FlightPolicyInfo { pubkey: pubkey.to_string(), @@ -219,11 +219,10 @@ fn parse_flight_policy(pubkey: &Pubkey, data: &[u8]) -> Result cancelled, payout_amount, status, - status_label: flight_policy_status_label(status), + status_label: flight_policy_status_label(status).to_string(), premium_distributed, created_at, updated_at, - bump, }) } diff --git a/backend/src/scheduler.rs b/backend/src/scheduler.rs index cb9f6a7..5244d2a 100644 --- a/backend/src/scheduler.rs +++ b/backend/src/scheduler.rs @@ -1,12 +1,17 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use std::sync::Arc; use tokio_cron_scheduler::{Job, JobScheduler}; -use crate::config::Config; +use crate::{api::repository::FirebaseRepository, config::Config, events::EventBus}; /// 스케줄러를 시작하고 cron 표현식에 따라 오라클 체크 잡을 등록한다. -pub async fn start(config: Arc) -> Result<()> { +pub async fn start(config: Arc, event_bus: Arc) -> Result<()> { let sched = JobScheduler::new().await?; + let repository = Arc::new(FirebaseRepository::from_env()?); + + if let Err(e) = run_firebase_sync(&config, &repository, &event_bus).await { + tracing::error!("[scheduler] 초기 Firebase 동기화 실패: {e:#}"); + } let cfg = config.clone(); let job = Job::new_async(config.oracle_check_cron.as_str(), move |_uuid, _lock| { @@ -18,11 +23,27 @@ pub async fn start(config: Arc) -> Result<()> { }) })?; + let sync_cfg = config.clone(); + let sync_repo = repository.clone(); + let sync_events = event_bus.clone(); + let sync_job = Job::new_async(config.firebase_sync_cron.as_str(), move |_uuid, _lock| { + let sync_cfg = sync_cfg.clone(); + let sync_repo = sync_repo.clone(); + let sync_events = sync_events.clone(); + Box::pin(async move { + if let Err(e) = run_firebase_sync(&sync_cfg, &sync_repo, &sync_events).await { + tracing::error!("[scheduler] Firebase 동기화 실패: {e:#}"); + } + }) + })?; + sched.add(job).await?; + sched.add(sync_job).await?; sched.start().await?; tracing::info!( - "[scheduler] 시작. cron='{}'", - config.oracle_check_cron + "[scheduler] 시작. oracle_cron='{}' firebase_sync_cron='{}'", + config.oracle_check_cron, + config.firebase_sync_cron ); // 스케줄러가 계속 실행되도록 대기 @@ -31,6 +52,41 @@ pub async fn start(config: Arc) -> Result<()> { } } +async fn run_firebase_sync( + config: &Config, + repository: &FirebaseRepository, + event_bus: &EventBus, +) -> Result<()> { + use crate::{ + oracle::program_accounts::{scan_flight_policies, scan_master_policies}, + solana::client::SolanaClient, + }; + + let client = SolanaClient::new(&config.rpc_url); + let master_policies = scan_master_policies(&client, &config.program_id) + .context("MasterPolicy RPC 스캔 실패")?; + let flight_policies = scan_flight_policies(&client, &config.program_id) + .context("FlightPolicy RPC 스캔 실패")?; + + event_bus + .publish_policy_updates(&master_policies, &flight_policies) + .await; + + let summary = repository + .sync_policy_snapshots(config, &master_policies, &flight_policies) + .await + .context("Firebase 정책 스냅샷 저장 실패")?; + + tracing::info!( + "[scheduler] Firebase 동기화 완료. master_policies={} flight_policies={} synced_at={}", + summary.master_policy_count, + summary.flight_policy_count, + summary.synced_at + ); + + Ok(()) +} + /// 단일 오라클 체크 사이클: Track A + Track B 모두 실행 async fn run_oracle_check(config: &Config) -> Result<()> { use crate::{ diff --git a/backend/src/web.rs b/backend/src/web.rs deleted file mode 100644 index 13ee0d2..0000000 --- a/backend/src/web.rs +++ /dev/null @@ -1,228 +0,0 @@ -use std::{net::SocketAddr, sync::Arc}; - -use anyhow::{Context, Result}; -use axum::{ - extract::{Path, Query, State}, - http::{Method, StatusCode}, - response::{ - sse::{Event, KeepAlive, Sse}, - IntoResponse, Response, - }, - routing::get, - Json, Router, -}; -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, -}; - -#[derive(Clone)] -pub struct AppState { - pub config: Arc, - pub cache: Arc, -} - -// ── Response types ────────────────────────────────────────────────────────── - -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, - service: &'static str, - rpc_url: String, - leader_pubkey: String, -} - -#[derive(Serialize)] -struct MasterPoliciesResponse { - program_id: String, - count: usize, - master_policies: Vec, -} - -#[derive(Serialize)] -struct FlightPoliciesResponse { - program_id: String, - count: usize, - 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); - -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)) - .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}"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - - Ok(()) -} - -// ── Handlers ──────────────────────────────────────────────────────────────── - -async fn health(State(state): State) -> Json { - Json(HealthResponse { - status: "ok", - service: "riskmesh-backend", - rpc_url: state.config.rpc_url.clone(), - leader_pubkey: state.config.leader_pubkey.to_string(), - }) -} - -async fn master_policies( - State(state): State, - 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: filtered, - }) -} - -async fn master_policy_by_pubkey( - State(state): State, - 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" })), - )), - } -} - -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: filtered, - }) -} - -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/contract/scripts/manual-create-flight.ts b/contract/scripts/manual-create-flight.ts new file mode 100644 index 0000000..a24e571 --- /dev/null +++ b/contract/scripts/manual-create-flight.ts @@ -0,0 +1,134 @@ +/** + * yarn demo:manual-create-flight + * + * .state.json 없이 FlightPolicy를 직접 생성합니다. + * child_policy_id는 온체인의 기존 FlightPolicy를 조회하여 자동 증가합니다. + * + * 환경변수: + * MASTER_PDA MasterPolicy 주소 (필수) + * FLIGHT_NO 항공편명 (기본값: KE001) + * ROUTE 노선 (기본값: ICN-NRT) + * DEPARTURE_TS 출발 Unix timestamp (기본값: 현재+24시간) + * SUBSCRIBER_REF 가입자 참조 (기본값: manual-test) + * CHILD_POLICY_ID 직접 지정 시 사용 (생략하면 자동 증가) + * KEYPAIR_PATH leader 키페어 경로 (기본값: ~/.config/solana/id.json) + */ +import * as anchor from "@coral-xyz/anchor"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { AnchorProvider, BN, Wallet } from "@coral-xyz/anchor"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import * as fs from "fs"; +import * as path from "path"; + +const RPC_URL = process.env.ANCHOR_PROVIDER_URL ?? "https://api.devnet.solana.com"; + +const MASTER_PDA = process.env.MASTER_PDA ?? (() => { throw new Error("MASTER_PDA 환경변수가 필요합니다"); })(); +const FLIGHT_NO = process.env.FLIGHT_NO ?? "KE001"; +const ROUTE = process.env.ROUTE ?? "ICN-NRT"; +const SUBSCRIBER_REF = process.env.SUBSCRIBER_REF ?? "manual-test"; +const KEYPAIR_PATH = process.env.KEYPAIR_PATH + ?? path.join(process.env.HOME ?? "~", ".config/solana/id.json"); + +// 출발시각: 기본 현재+24시간 +const DEPARTURE_TS = parseInt( + process.env.DEPARTURE_TS ?? String(Math.floor(Date.now() / 1000) + 86400) +); + +function loadKeypair(p: string): Keypair { + const expanded = p.replace(/^~/, process.env.HOME ?? ""); + const raw: number[] = JSON.parse(fs.readFileSync(expanded, "utf-8")); + return Keypair.fromSecretKey(Uint8Array.from(raw)); +} + +function flightPolicyPub(masterPolicy: PublicKey, childId: number, programId: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("flight_policy"), + masterPolicy.toBuffer(), + new BN(childId).toArrayLike(Buffer, "le", 8), + ], + programId + )[0]; +} + +async function main() { + const leader = loadKeypair(KEYPAIR_PATH); + const conn = new Connection(RPC_URL, "confirmed"); + const provider = new AnchorProvider(conn, new Wallet(leader), { commitment: "confirmed" }); + anchor.setProvider(provider); + + const idlPath = path.join(__dirname, "../target/idl/open_parametric.json"); + const idl = JSON.parse(fs.readFileSync(idlPath, "utf-8")); + const programId = new PublicKey(idl.address); + const pg = new anchor.Program(idl, provider); + + const masterPda = new PublicKey(MASTER_PDA); + + // 온체인 MasterPolicy 조회 + const master = await (pg.account as any).masterPolicy.fetch(masterPda); + + // child_policy_id 결정: 환경변수 지정 또는 자동 증가 + let childId: number; + if (process.env.CHILD_POLICY_ID) { + childId = parseInt(process.env.CHILD_POLICY_ID); + } else { + // 기존 FlightPolicy 조회하여 max+1 + const allFlights = await (pg.account as any).flightPolicy.all([ + { memcmp: { offset: 16, bytes: masterPda.toBase58() } }, + ]); + const maxId = allFlights.reduce( + (max: number, f: any) => Math.max(max, f.account.childPolicyId.toNumber()), + 0 + ); + childId = maxId + 1; + } + + const flightPda = flightPolicyPub(masterPda, childId, programId); + + // leader의 ATA (premium 지불용) + const { getAssociatedTokenAddress } = await import("@solana/spl-token"); + const payerAta = await getAssociatedTokenAddress(master.currencyMint, leader.publicKey); + + console.log("=== manual-create-flight ==="); + console.log("leader :", leader.publicKey.toBase58()); + console.log("masterPda :", masterPda.toBase58()); + console.log("childPolicyId :", childId); + console.log("flightPda :", flightPda.toBase58()); + console.log("flightNo :", FLIGHT_NO); + console.log("route :", ROUTE); + console.log("subscriberRef :", SUBSCRIBER_REF); + console.log("departureTs :", DEPARTURE_TS, `(${new Date(DEPARTURE_TS * 1000).toISOString()})`); + console.log("premiumPerPolicy:", master.premiumPerPolicy.toString()); + console.log("payerAta :", payerAta.toBase58()); + + const params = { + childPolicyId: new BN(childId), + subscriberRef: SUBSCRIBER_REF, + flightNo: FLIGHT_NO, + route: ROUTE, + departureTs: new BN(DEPARTURE_TS), + }; + + const tx = await pg.methods + .createFlightPolicyFromMaster(params) + .accountsPartial({ + creator: leader.publicKey, + masterPolicy: masterPda, + flightPolicy: flightPda, + payerToken: payerAta, + leaderDepositToken: master.leaderDepositWallet, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([leader]) + .rpc(); + + const fp = await (pg.account as any).flightPolicy.fetch(flightPda); + + console.log("\n=== 완료 ==="); + console.log("tx :", tx); + console.log("status :", fp.status, "(1=AwaitingOracle)"); + console.log("premiumPaid :", fp.premiumPaid.toString()); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/contract/scripts/manual-list.ts b/contract/scripts/manual-list.ts new file mode 100644 index 0000000..cd2bda2 --- /dev/null +++ b/contract/scripts/manual-list.ts @@ -0,0 +1,96 @@ +/** + * yarn demo:manual-list + * + * 특정 MasterPolicy에 속한 모든 FlightPolicy를 조회합니다. + * .state.json 없이 온체인에서 직접 읽어옵니다. + * + * 환경변수: + * MASTER_PDA MasterPolicy 주소 (필수) + * KEYPAIR_PATH 키페어 경로 (기본값: ~/.config/solana/id.json) + */ +import * as anchor from "@coral-xyz/anchor"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { AnchorProvider, Wallet } from "@coral-xyz/anchor"; +import * as fs from "fs"; +import * as path from "path"; + +const RPC_URL = process.env.ANCHOR_PROVIDER_URL ?? "https://api.devnet.solana.com"; + +const MASTER_PDA = process.env.MASTER_PDA ?? (() => { throw new Error("MASTER_PDA 환경변수가 필요합니다"); })(); +const KEYPAIR_PATH = process.env.KEYPAIR_PATH + ?? path.join(process.env.HOME ?? "~", ".config/solana/id.json"); + +const STATUS: Record = { + 0: "Issued", + 1: "AwaitingOracle", + 2: "Claimable", + 3: "Paid", + 4: "NoClaim", + 5: "Expired", +}; + +function loadKeypair(p: string): Keypair { + const expanded = p.replace(/^~/, process.env.HOME ?? ""); + const raw: number[] = JSON.parse(fs.readFileSync(expanded, "utf-8")); + return Keypair.fromSecretKey(Uint8Array.from(raw)); +} + +async function main() { + const payer = loadKeypair(KEYPAIR_PATH); + const conn = new Connection(RPC_URL, "confirmed"); + const provider = new AnchorProvider(conn, new Wallet(payer), { commitment: "confirmed" }); + anchor.setProvider(provider); + + const idlPath = path.join(__dirname, "../target/idl/open_parametric.json"); + const idl = JSON.parse(fs.readFileSync(idlPath, "utf-8")); + const pg = new anchor.Program(idl, provider); + + const masterPda = new PublicKey(MASTER_PDA); + + // memcmp 필터: FlightPolicy.master 필드 (offset = 8 discriminator + 8 child_policy_id = 16) + const allFlights = await (pg.account as any).flightPolicy.all([ + { memcmp: { offset: 16, bytes: masterPda.toBase58() } }, + ]); + + if (allFlights.length === 0) { + console.log(`\nMasterPolicy (${masterPda.toBase58().slice(0, 12)}...)에 속한 FlightPolicy가 없습니다.`); + return; + } + + // child_policy_id 기준 정렬 + allFlights.sort((a: any, b: any) => + a.account.childPolicyId.toNumber() - b.account.childPolicyId.toNumber() + ); + + console.log(`\n=== FlightPolicy 목록 (MasterPolicy: ${masterPda.toBase58().slice(0, 12)}...) ===\n`); + console.log( + "ID".padStart(4), + "flightNo".padEnd(10), + "status".padEnd(20), + "delay".padStart(5), + "cancelled".padEnd(9), + "payout".padStart(12), + "premium".padStart(12), + "PDA", + ); + console.log("-".repeat(110)); + + for (const { publicKey, account: fp } of allFlights) { + const id = fp.childPolicyId.toNumber(); + const statusLabel = `${STATUS[fp.status] ?? "?"}(${fp.status})`; + console.log( + String(id).padStart(4), + fp.flightNo.padEnd(10), + statusLabel.padEnd(20), + String(fp.delayMinutes).padStart(5), + String(fp.cancelled).padEnd(9), + fp.payoutAmount.toString().padStart(12), + fp.premiumPaid.toString().padStart(12), + publicKey.toBase58().slice(0, 16) + "...", + ); + } + + console.log(`\n총 ${allFlights.length}개 FlightPolicy`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/contract/scripts/manual-resolve.ts b/contract/scripts/manual-resolve.ts new file mode 100644 index 0000000..99afc60 --- /dev/null +++ b/contract/scripts/manual-resolve.ts @@ -0,0 +1,87 @@ +/** + * yarn ts-node -P tsconfig.json scripts/manual-resolve.ts + * + * AviationStack 없이 resolve_flight_delay를 직접 호출합니다. + * + * 환경변수: + * MASTER_PDA MasterPolicy 주소 (필수) + * CHILD_POLICY_ID FlightPolicy child ID (기본값: 4) + * DELAY_MINUTES 지연 분 (기본값: 150 → Claimable) + * CANCELLED 결항 여부 "true" / "false" (기본값: false) + * KEYPAIR_PATH leader 키페어 경로 (기본값: ~/.config/solana/id.json) + */ +import * as anchor from "@coral-xyz/anchor"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { AnchorProvider, BN, Wallet } from "@coral-xyz/anchor"; +import * as fs from "fs"; +import * as path from "path"; + +const RPC_URL = process.env.ANCHOR_PROVIDER_URL ?? "https://api.devnet.solana.com"; + +const MASTER_PDA = process.env.MASTER_PDA ?? (() => { throw new Error("MASTER_PDA 환경변수가 필요합니다"); })(); +const CHILD_ID = parseInt(process.env.CHILD_POLICY_ID ?? "4"); +const DELAY_MIN = parseInt(process.env.DELAY_MINUTES ?? "150"); +const CANCELLED = process.env.CANCELLED === "true"; +const KEYPAIR_PATH = process.env.KEYPAIR_PATH + ?? path.join(process.env.HOME ?? "~", ".config/solana/id.json"); + +function loadKeypair(p: string): Keypair { + const expanded = p.replace(/^~/, process.env.HOME ?? ""); + const raw: number[] = JSON.parse(fs.readFileSync(expanded, "utf-8")); + return Keypair.fromSecretKey(Uint8Array.from(raw)); +} + +function flightPolicyPub(masterPolicy: PublicKey, childId: number, programId: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("flight_policy"), + masterPolicy.toBuffer(), + new BN(childId).toArrayLike(Buffer, "le", 8), + ], + programId + )[0]; +} + +async function main() { + const leader = loadKeypair(KEYPAIR_PATH); + const conn = new Connection(RPC_URL, "confirmed"); + const provider = new AnchorProvider(conn, new Wallet(leader), { commitment: "confirmed" }); + anchor.setProvider(provider); + + const idlPath = path.join(__dirname, "../target/idl/open_parametric.json"); + const idl = JSON.parse(fs.readFileSync(idlPath, "utf-8")); + const programId = new PublicKey(idl.address); + const pg = new anchor.Program(idl, provider); + + const masterPda = new PublicKey(MASTER_PDA); + const flightPda = flightPolicyPub(masterPda, CHILD_ID, programId); + + console.log("=== manual-resolve ==="); + console.log("leader :", leader.publicKey.toBase58()); + console.log("masterPda :", masterPda.toBase58()); + console.log("flightPda :", flightPda.toBase58()); + console.log("delay_minutes :", DELAY_MIN); + console.log("cancelled :", CANCELLED); + + const before = await (pg.account as any).flightPolicy.fetch(flightPda); + console.log("\n현재 status :", before.status); + + const tx = await pg.methods + .resolveFlightDelay(DELAY_MIN, CANCELLED) + .accountsPartial({ + resolver: leader.publicKey, + masterPolicy: masterPda, + flightPolicy: flightPda, + }) + .signers([leader]) + .rpc(); + + const after = await (pg.account as any).flightPolicy.fetch(flightPda); + + console.log("\n=== 완료 ==="); + console.log("tx :", tx); + console.log("delay(온체인) :", after.delayMinutes, "분"); + console.log("status :", after.status, "(2=Claimable, 4=NoClaim)"); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/contract/scripts/manual-settle.ts b/contract/scripts/manual-settle.ts new file mode 100644 index 0000000..9343469 --- /dev/null +++ b/contract/scripts/manual-settle.ts @@ -0,0 +1,149 @@ +/** + * yarn demo:manual-settle + * + * .state.json 없이 온체인 MasterPolicy에서 wallet 주소를 읽어 정산합니다. + * + * 환경변수: + * MASTER_PDA MasterPolicy 주소 (필수) + * CHILD_POLICY_ID FlightPolicy child ID (기본값: 4) + * KEYPAIR_PATH leader 키페어 경로 (기본값: ~/.config/solana/id.json) + */ +import * as anchor from "@coral-xyz/anchor"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { AnchorProvider, BN, Wallet } from "@coral-xyz/anchor"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import * as fs from "fs"; +import * as path from "path"; + +const RPC_URL = process.env.ANCHOR_PROVIDER_URL ?? "https://api.devnet.solana.com"; + +const MASTER_PDA = process.env.MASTER_PDA ?? (() => { throw new Error("MASTER_PDA 환경변수가 필요합니다"); })(); +const CHILD_ID = parseInt(process.env.CHILD_POLICY_ID ?? "4"); +const KEYPAIR_PATH = process.env.KEYPAIR_PATH + ?? path.join(process.env.HOME ?? "~", ".config/solana/id.json"); + +const STATUS: Record = { + 0: "Issued", + 1: "AwaitingOracle", + 2: "Claimable", + 3: "Paid", + 4: "NoClaim", + 5: "Expired", +}; + +function loadKeypair(p: string): Keypair { + const expanded = p.replace(/^~/, process.env.HOME ?? ""); + const raw: number[] = JSON.parse(fs.readFileSync(expanded, "utf-8")); + return Keypair.fromSecretKey(Uint8Array.from(raw)); +} + +function flightPolicyPub(masterPolicy: PublicKey, childId: number, programId: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("flight_policy"), + masterPolicy.toBuffer(), + new BN(childId).toArrayLike(Buffer, "le", 8), + ], + programId + )[0]; +} + +async function main() { + const leader = loadKeypair(KEYPAIR_PATH); + const conn = new Connection(RPC_URL, "confirmed"); + const provider = new AnchorProvider(conn, new Wallet(leader), { commitment: "confirmed" }); + anchor.setProvider(provider); + + const idlPath = path.join(__dirname, "../target/idl/open_parametric.json"); + const idl = JSON.parse(fs.readFileSync(idlPath, "utf-8")); + const programId = new PublicKey(idl.address); + const pg = new anchor.Program(idl, provider); + + const masterPda = new PublicKey(MASTER_PDA); + const flightPda = flightPolicyPub(masterPda, CHILD_ID, programId); + + // 온체인 계정 fetch + const master = await (pg.account as any).masterPolicy.fetch(masterPda); + const fp = await (pg.account as any).flightPolicy.fetch(flightPda); + + console.log("=== manual-settle ==="); + console.log("leader :", leader.publicKey.toBase58()); + console.log("masterPda :", masterPda.toBase58()); + console.log("flightPda :", flightPda.toBase58()); + console.log("flightNo :", fp.flightNo); + console.log("delayMinutes :", fp.delayMinutes); + console.log("cancelled :", fp.cancelled); + console.log("payoutAmount :", fp.payoutAmount.toString()); + console.log("현재 status :", fp.status, `(${STATUS[fp.status] ?? "?"})`); + + if (fp.status === 2) { + // ── Claimable → Paid ────────────────────────────────────────────────────── + console.log("\n→ Claimable: settle_flight_claim 실행 중..."); + + const participantPoolWallets = master.participants.map((p: any) => ({ + pubkey: p.poolWallet as PublicKey, + isWritable: true, + isSigner: false, + })); + + const tx = await pg.methods + .settleFlightClaim() + .accountsPartial({ + executor: leader.publicKey, + masterPolicy: masterPda, + flightPolicy: flightPda, + leaderDepositToken: master.leaderDepositWallet, + reinsurerPoolToken: master.reinsurerPoolWallet, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .remainingAccounts(participantPoolWallets) + .signers([leader]) + .rpc(); + + const after = await (pg.account as any).flightPolicy.fetch(flightPda); + console.log("\n=== 완료 ==="); + console.log("tx :", tx); + console.log("status :", after.status, `(${STATUS[after.status] ?? "?"})`); + console.log("payout :", fp.payoutAmount.toString(), "토큰 → leaderDepositWallet"); + + } else if (fp.status === 4) { + // ── NoClaim → Expired ───────────────────────────────────────────────────── + console.log("\n→ NoClaim: settle_flight_no_claim 실행 중..."); + + const participantDepositWallets = master.participants.map((p: any) => ({ + pubkey: p.depositWallet as PublicKey, + isWritable: true, + isSigner: false, + })); + + const tx = await pg.methods + .settleFlightNoClaim() + .accountsPartial({ + executor: leader.publicKey, + masterPolicy: masterPda, + flightPolicy: flightPda, + leaderDepositToken: master.leaderDepositWallet, + reinsurerDepositToken: master.reinsurerDepositWallet, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .remainingAccounts(participantDepositWallets) + .signers([leader]) + .rpc(); + + const after = await (pg.account as any).flightPolicy.fetch(flightPda); + console.log("\n=== 완료 ==="); + console.log("tx :", tx); + console.log("status :", after.status, `(${STATUS[after.status] ?? "?"})`); + console.log("premium :", fp.premiumPaid.toString(), "토큰 → 참여사 deposit wallets"); + + } else { + console.log(`\n⚠ 정산 불가 (현재 status=${fp.status})`); + if (fp.status === 1) { + console.log(" → manual-resolve로 먼저 오라클 결과를 기록하세요."); + } else if (fp.status === 3 || fp.status === 5) { + console.log(" → 이미 정산 완료된 FlightPolicy입니다."); + } + } +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/docs/API_SPECIFICATION.md b/docs/API_SPECIFICATION.md new file mode 100644 index 0000000..72ceebd --- /dev/null +++ b/docs/API_SPECIFICATION.md @@ -0,0 +1,506 @@ +# RiskMesh Oracle Backend — API Specification + +> Base URL: `http://{WEB_BIND_ADDR}` (기본값: `http://0.0.0.0:3000`) +> +> Framework: Axum (Rust) +> +> CORS: 모든 Origin / Method / Header 허용 + +--- + +## 목차 + +1. [공통 사항](#공통-사항) +2. [GET /health](#get-health) +3. [GET /api/master-policies](#get-apimaster-policies) +4. [GET /api/master-policies/accounts](#get-apimaster-policiesaccounts) +5. [GET /api/master-policies/tree](#get-apimaster-policiestree) +6. [GET /api/master-policies/:master_policy_pubkey](#get-apimaster-policiesmaster_policy_pubkey) +7. [GET /api/master-policies/:master_policy_pubkey/flight-policies](#get-apimaster-policiesmaster_policy_pubkeyflight-policies) +8. [POST /api/master-policies/:master_policy_pubkey/flight-policies](#post-apimaster-policiesmaster_policy_pubkeyflight-policies) +9. [GET /api/flight-policies](#get-apiflight-policies) +10. [GET /api/flight-policies/:flight_policy_pubkey](#get-apiflight-policiesflight_policy_pubkey) +11. [GET /api/events](#get-apievents) +12. [POST /api/firebase/test-document](#post-apifirebasetest-document) +13. [공통 타입 정의](#공통-타입-정의) + +--- + +## 공통 사항 + +### Error Response + +모든 API에서 에러 발생 시 동일한 JSON 형식으로 응답합니다. + +| HTTP Status | 조건 | +|---|---| +| `404 Not Found` | 메시지에 "account not found" 포함 시 | +| `500 Internal Server Error` | 그 외 모든 에러 | + +```json +{ + "error": "에러 메시지 문자열" +} +``` + +### Status 코드 매핑 + +**MasterPolicy Status** + +| 값 | 라벨 | +|---|---| +| `0` | Draft | +| `1` | PendingConfirm | +| `2` | Active | +| `3` | Closed | +| `4` | Cancelled | + +**FlightPolicy Status** + +| 값 | 라벨 | +|---|---| +| `0` | Issued | +| `1` | AwaitingOracle | +| `2` | Claimable | +| `3` | Paid | +| `4` | NoClaim | +| `5` | Expired | + +--- + +## GET /health + +서버 상태 확인 (헬스체크). + +### Parameters + +없음 + +### Response `200 OK` + +```typescript +{ + status: "ok", // string — 항상 "ok" + rpc_url: string, // Solana RPC endpoint URL + leader_pubkey: string // 서버가 사용하는 leader 지갑 공개키 (Base58) +} +``` + +### 예시 + +```json +{ + "status": "ok", + "rpc_url": "https://api.devnet.solana.com", + "leader_pubkey": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" +} +``` + +--- + +## GET /api/master-policies + +Firebase에 저장된 MasterPolicy 목록을 조회합니다. leader 필터링을 지원합니다. + +### Query Parameters + +| 이름 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `leader` | `string` | No | leader 공개키(Base58)로 필터링. 미지정 시 전체 반환 | + +### Response `200 OK` + +```typescript +{ + master_policies: MasterPolicyInfo[] +} +``` + +### 예시 + +``` +GET /api/master-policies?leader=7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU +``` + +--- + +## GET /api/master-policies/accounts + +Solana 온체인에서 직접 MasterPolicy 계정의 공개키 목록을 조회합니다. Firebase가 아닌 `getProgramAccounts` RPC를 사용합니다. + +### Parameters + +없음 + +### Response `200 OK` + +```typescript +{ + program_id: string, // 프로그램 ID (Base58) + count: number, // MasterPolicy 계정 수 + master_policy_pubkeys: string[] // MasterPolicy 공개키 목록 (Base58) +} +``` + +### 예시 + +```json +{ + "program_id": "FKLP2...xxxx", + "count": 2, + "master_policy_pubkeys": [ + "8dF3q...", + "9eG4r..." + ] +} +``` + +--- + +## GET /api/master-policies/tree + +전체 MasterPolicy와 하위 FlightPolicy의 트리 구조를 반환합니다. 각 MasterPolicy에 속한 FlightPolicy 공개키 목록이 포함됩니다. + +### Parameters + +없음 + +### Response `200 OK` + +```typescript +{ + program_id: string, // 프로그램 ID (Base58) + count: number, // MasterPolicy 수 + master_policies: MasterPolicyAccountTree[] +} +``` + +**MasterPolicyAccountTree** + +```typescript +{ + master_policy_pubkey: string, // MasterPolicy 공개키 (Base58) + flight_policy_pubkeys: string[] // 하위 FlightPolicy 공개키 목록 (Base58) +} +``` + +### 예시 + +```json +{ + "program_id": "FKLP2...xxxx", + "count": 1, + "master_policies": [ + { + "master_policy_pubkey": "8dF3q...", + "flight_policy_pubkeys": ["Abc12...", "Def34..."] + } + ] +} +``` + +--- + +## GET /api/master-policies/:master_policy_pubkey + +특정 MasterPolicy의 상세 정보를 Firebase에서 조회합니다. + +### Path Parameters + +| 이름 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `master_policy_pubkey` | `string` | Yes | MasterPolicy 계정의 공개키 (Base58) | + +### Response `200 OK` + +`MasterPolicyInfo` 객체를 반환합니다. (하단 [공통 타입 정의](#공통-타입-정의) 참조) + +### Error + +| Status | 조건 | +|---|---| +| `404` | 해당 공개키의 MasterPolicy가 존재하지 않을 때 | +| `500` | 공개키 파싱 실패 등 | + +--- + +## GET /api/master-policies/:master_policy_pubkey/flight-policies + +특정 MasterPolicy에 속한 FlightPolicy 목록을 조회합니다. + +### Path Parameters + +| 이름 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `master_policy_pubkey` | `string` | Yes | MasterPolicy 계정의 공개키 (Base58) | + +### Response `200 OK` + +```typescript +{ + program_id: string, // 프로그램 ID (Base58) + master_policy_pubkey: string, // 조회한 MasterPolicy 공개키 + count: number, // 하위 FlightPolicy 수 + flight_policies: FlightPolicyInfo[] +} +``` + +### Error + +| Status | 조건 | +|---|---| +| `404` | 해당 MasterPolicy가 존재하지 않을 때 | + +--- + +## POST /api/master-policies/:master_policy_pubkey/flight-policies + +특정 MasterPolicy 하위에 새 FlightPolicy를 생성합니다. 온체인 트랜잭션을 전송합니다. + +### Path Parameters + +| 이름 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `master_policy_pubkey` | `string` | Yes | MasterPolicy 계정의 공개키 (Base58) | + +### Request Body (`application/json`) + +```typescript +{ + subscriber_ref: string, // 가입자 참조 ID (빈 문자열 불가) + flight_no: string, // 항공편 번호 (예: "KE123") (빈 문자열 불가) + route: string, // 노선 (예: "ICN-NRT") (빈 문자열 불가) + departure_ts: number // 출발 예정 시각 (Unix timestamp, 초 단위, i64) +} +``` + +### Response `200 OK` + +```typescript +{ + program_id: string, // 프로그램 ID (Base58) + master_policy_pubkey: string, // 부모 MasterPolicy 공개키 + child_policy_id: number, // 자동 부여된 FlightPolicy ID (u64) + flight_policy_pubkey: string, // 생성된 FlightPolicy PDA 공개키 (Base58) + tx_signature: string // Solana 트랜잭션 서명 (Base58) +} +``` + +### Error + +| Status | 조건 | +|---|---| +| `500` | MasterPolicy가 Active 상태가 아닐 때 | +| `500` | 서버 키가 leader/operator 권한이 없을 때 | +| `500` | subscriber_ref, flight_no, route가 비어 있을 때 | +| `500` | 온체인 트랜잭션 실패 시 | + +### 예시 + +```bash +curl -X POST http://localhost:3000/api/master-policies/8dF3q.../flight-policies \ + -H "Content-Type: application/json" \ + -d '{ + "subscriber_ref": "user-001", + "flight_no": "KE123", + "route": "ICN-NRT", + "departure_ts": 1717200000 + }' +``` + +--- + +## GET /api/flight-policies + +Firebase에 저장된 FlightPolicy 목록을 조회합니다. MasterPolicy 공개키와 상태로 필터링을 지원합니다. + +### Query Parameters + +| 이름 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `master` | `string` | No | 부모 MasterPolicy 공개키(Base58)로 필터링 | +| `status` | `number` (u8) | No | FlightPolicy 상태 코드로 필터링 (0~5) | + +### Response `200 OK` + +```typescript +{ + flight_policies: FlightPolicyInfo[] +} +``` + +### 예시 + +``` +GET /api/flight-policies?master=8dF3q...&status=0 +``` + +--- + +## GET /api/flight-policies/:flight_policy_pubkey + +특정 FlightPolicy의 상세 정보를 Firebase에서 조회합니다. + +### Path Parameters + +| 이름 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `flight_policy_pubkey` | `string` | Yes | FlightPolicy 계정의 공개키 (Base58) | + +### Response `200 OK` + +`FlightPolicyInfo` 객체를 반환합니다. (하단 [공통 타입 정의](#공통-타입-정의) 참조) + +### Error + +| Status | 조건 | +|---|---| +| `404` | 해당 공개키의 FlightPolicy가 존재하지 않을 때 | + +--- + +## GET /api/events + +SSE (Server-Sent Events) 스트림을 반환합니다. 온체인 계정 상태 변경을 실시간으로 수신할 수 있습니다. + +### Query Parameters + +| 이름 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `master` | `string` | No | 특정 MasterPolicy 공개키로 이벤트 필터링. 미지정 시 전체 이벤트 수신 | + +### Response `200 OK` (`text/event-stream`) + +Connection은 유지되며, 아래 이벤트가 스트림으로 전송됩니다. + +**이벤트 타입** + +| event | data 형식 | 설명 | +|---|---|---| +| `master_policy_updated` | `MasterPolicyInfo` (JSON) | MasterPolicy 계정 상태가 변경됨 | +| `flight_policy_updated` | `FlightPolicyInfo` (JSON) | FlightPolicy 계정 상태가 변경됨 | +| `heartbeat` | `{"ts": }` | 30초 간격 keepalive | + +**필터 동작** + +- `master` 파라미터 지정 시: + - `flight_policy_updated`: data의 `master` 필드가 필터 값과 일치하는 이벤트만 전송 + - `master_policy_updated`: data의 `pubkey` 필드가 필터 값과 일치하는 이벤트만 전송 + - 그 외 이벤트: 필터 무시하고 전송 + +### 예시 + +```bash +curl -N "http://localhost:3000/api/events?master=8dF3q..." +``` + +``` +event: master_policy_updated +data: {"pubkey":"8dF3q...","master_id":1,"leader":"7xKXtg...","status":2,...} + +event: flight_policy_updated +data: {"pubkey":"Abc12...","child_policy_id":1,"master":"8dF3q...","status":1,...} + +event: heartbeat +data: {"ts":1717200030} +``` + +--- + +## POST /api/firebase/test-document + +Firebase Firestore 연결 테스트용 엔드포인트. 테스트 문서를 생성하여 Firebase 인증 및 연결 상태를 확인합니다. + +### Parameters + +없음 (Request Body 없음) + +### Response `200 OK` + +```typescript +{ + firebase_saved: boolean, // 저장 성공 여부 (항상 true) + collection_id: string, // Firestore 컬렉션 ID + document_id: string, // 생성된 문서 ID + firebase_document_path: string, // Firestore 문서 전체 경로 + auth_principal: string // 인증에 사용된 서비스 계정 ID +} +``` + +--- + +## 공통 타입 정의 + +### MasterPolicyInfo + +MasterPolicy(공동보험 계약) 온체인 계정의 역직렬화된 정보입니다. + +```typescript +{ + pubkey: string, // 계정 공개키 (Base58) + master_id: number, // MasterPolicy 고유 ID (u64) + leader: string, // leader 지갑 공개키 (Base58) + operator: string, // operator 지갑 공개키 (Base58) + currency_mint: string, // SPL 토큰 민트 주소 (Base58) + coverage_start_ts: number, // 보장 시작 시각 (Unix timestamp, i64) + coverage_end_ts: number, // 보장 종료 시각 (Unix timestamp, i64) + premium_per_policy: number, // 개별 보험증권 보험료 (lamports 단위, u64) + payout_delay_2h: number, // 2시간 지연 시 보험금 (u64) + payout_delay_3h: number, // 3시간 지연 시 보험금 (u64) + payout_delay_4to5h: number, // 4~5시간 지연 시 보험금 (u64) + payout_delay_6h_or_cancelled: number, // 6시간 이상 또는 결항 시 보험금 (u64) + ceded_ratio_bps: number, // 출재 비율 (basis points, u16, 10000 = 100%) + reins_commission_bps: number, // 재보험 수수료 비율 (bps, u16) + reinsurer_effective_bps: number, // 재보험자 실효 비율 (bps, u16) + reinsurer: string, // 재보험자 공개키 (Base58) + reinsurer_confirmed: boolean, // 재보험자 확인 여부 + reinsurer_pool_wallet: string, // 재보험자 풀 월렛 ATA (Base58) + reinsurer_deposit_wallet: string, // 재보험자 예치 월렛 ATA (Base58) + leader_deposit_wallet: string, // leader 예치 월렛 ATA (Base58) + participants: MasterParticipantInfo[], // 참여자 목록 + oracle_feed: string, // 오라클 피드 주소 (Base58) + status: number, // 상태 코드 (u8, 0~4) + status_label: string, // 상태 라벨 ("Draft"|"PendingConfirm"|"Active"|"Closed"|"Cancelled") + created_at: number // 생성 시각 (Unix timestamp, i64) +} +``` + +### MasterParticipantInfo + +MasterPolicy 내 개별 참여자(보험사) 정보입니다. + +```typescript +{ + insurer: string, // 참여자 공개키 (Base58) + share_bps: number, // 인수 비율 (basis points, u16) + confirmed: boolean, // 참여 확인 여부 + pool_wallet: string, // 참여자 풀 월렛 ATA (Base58) + deposit_wallet: string // 참여자 예치 월렛 ATA (Base58) +} +``` + +### FlightPolicyInfo + +FlightPolicy(항공편 보험증권) 온체인 계정의 역직렬화된 정보입니다. + +```typescript +{ + pubkey: string, // 계정 공개키 (Base58) + child_policy_id: number, // MasterPolicy 하위 증권 ID (u64) + master: string, // 부모 MasterPolicy 공개키 (Base58) + creator: string, // 증권 생성자 공개키 (Base58) + subscriber_ref: string, // 가입자 참조 ID + flight_no: string, // 항공편 번호 (예: "KE123") + route: string, // 노선 (예: "ICN-NRT") + departure_ts: number, // 출발 예정 시각 (Unix timestamp, i64) + premium_paid: number, // 납부한 보험료 (lamports 단위, u64) + delay_minutes: number, // 실제 지연 시간(분) (u16, 오라클이 설정) + cancelled: boolean, // 결항 여부 (오라클이 설정) + payout_amount: number, // 산정된 보험금 (u64) + status: number, // 상태 코드 (u8, 0~5) + status_label: string, // 상태 라벨 ("Issued"|"AwaitingOracle"|"Claimable"|"Paid"|"NoClaim"|"Expired") + premium_distributed: boolean,// 보험료 분배 완료 여부 + created_at: number, // 생성 시각 (Unix timestamp, i64) + updated_at: number // 최종 수정 시각 (Unix timestamp, i64) +} +``` diff --git a/docs/BACKEND_API_SPEC.md b/docs/BACKEND_API_SPEC.md deleted file mode 100644 index c069f82..0000000 --- a/docs/BACKEND_API_SPEC.md +++ /dev/null @@ -1,371 +0,0 @@ -# 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/docs/MANUAL_SCRIPTS_GUIDE.md b/docs/MANUAL_SCRIPTS_GUIDE.md new file mode 100644 index 0000000..dc4a6e1 --- /dev/null +++ b/docs/MANUAL_SCRIPTS_GUIDE.md @@ -0,0 +1,212 @@ +# Manual Scripts 사용 가이드 + +AviationStack API, Switchboard Oracle, `.state.json` 없이 FlightPolicy 상태를 직접 제어하는 스크립트입니다. + +## 전제 조건 + +- MasterPolicy가 이미 생성되어 Active 상태여야 합니다 (`yarn demo:3-master-setup`) +- FlightPolicy가 이미 생성되어 있어야 합니다 (`yarn demo:4-flight-create`) +- Leader 키페어 파일이 로컬에 있어야 합니다 (기본: `~/.config/solana/id.json`) +- `target/idl/open_parametric.json` IDL 파일이 존재해야 합니다 (`anchor build` 후 생성) + +--- + +## 1. manual-resolve.ts + +FlightPolicy의 오라클 결과를 수동으로 기록합니다. +`AwaitingOracle(1)` 상태의 FlightPolicy를 `Claimable(2)` 또는 `NoClaim(4)`로 전환합니다. + +### 환경변수 + +| 변수 | 필수 | 기본값 | 설명 | +|---|---|---|---| +| `MASTER_PDA` | **필수** | - | MasterPolicy 온체인 주소 | +| `CHILD_POLICY_ID` | 선택 | `4` | FlightPolicy의 child ID | +| `DELAY_MINUTES` | 선택 | `150` | 지연 시간 (분) | +| `CANCELLED` | 선택 | `false` | 결항 여부 (`true` / `false`) | +| `KEYPAIR_PATH` | 선택 | `~/.config/solana/id.json` | Leader 키페어 경로 | +| `ANCHOR_PROVIDER_URL` | 선택 | `https://api.devnet.solana.com` | Solana RPC URL | + +### 지연 시간별 결과 + +| DELAY_MINUTES | CANCELLED | 결과 상태 | Payout 티어 | +|---|---|---|---| +| 0 ~ 119 | `false` | **NoClaim(4)** | 0 (미지급) | +| 120 ~ 179 | `false` | **Claimable(2)** | `payout_delay_2h` | +| 180 ~ 239 | `false` | **Claimable(2)** | `payout_delay_3h` | +| 240 ~ 359 | `false` | **Claimable(2)** | `payout_delay_4to5h` | +| 360+ | `false` | **Claimable(2)** | `payout_delay_6h_or_cancelled` | +| 아무 값 | `true` | **Claimable(2)** | `payout_delay_6h_or_cancelled` | + +### 실행 예시 + +```bash +# 2시간 지연 → Claimable +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 DELAY_MINUTES=150 \ + yarn ts-node -P tsconfig.json scripts/manual-resolve.ts + +# 지연 없음 → NoClaim +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 DELAY_MINUTES=60 \ + yarn ts-node -P tsconfig.json scripts/manual-resolve.ts + +# 결항 → Claimable (최대 payout) +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 DELAY_MINUTES=0 CANCELLED=true \ + yarn ts-node -P tsconfig.json scripts/manual-resolve.ts + +# 6시간 이상 지연 → Claimable (최대 payout) +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 DELAY_MINUTES=400 \ + yarn ts-node -P tsconfig.json scripts/manual-resolve.ts +``` + +### 출력 예시 + +``` +=== manual-resolve === +leader : 7xKX... +masterPda : Abc1... +flightPda : Def4... +delay_minutes : 150 +cancelled : false + +현재 status : 1 + +=== 완료 === +tx : 5yNp... +delay(온체인) : 150 분 +status : 2 (2=Claimable, 4=NoClaim) +``` + +--- + +## 2. manual-settle.ts + +FlightPolicy의 정산을 실행합니다. 온체인 MasterPolicy 계정에서 wallet 주소를 자동으로 읽어오므로 `.state.json`이 필요 없습니다. + +- `Claimable(2)` → `settle_flight_claim` → **Paid(3)**: 참여사 pool에서 leader deposit으로 payout 이체 +- `NoClaim(4)` → `settle_flight_no_claim` → **Expired(5)**: leader deposit에서 참여사 deposit으로 premium 분배 + +### 환경변수 + +| 변수 | 필수 | 기본값 | 설명 | +|---|---|---|---| +| `MASTER_PDA` | **필수** | - | MasterPolicy 온체인 주소 | +| `CHILD_POLICY_ID` | 선택 | `4` | FlightPolicy의 child ID | +| `KEYPAIR_PATH` | 선택 | `~/.config/solana/id.json` | Leader 키페어 경로 | +| `ANCHOR_PROVIDER_URL` | 선택 | `https://api.devnet.solana.com` | Solana RPC URL | + +### 실행 예시 + +```bash +# Claimable → Paid (보험금 지급) +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 \ + yarn demo:manual-settle + +# NoClaim → Expired (프리미엄 분배) +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 \ + yarn demo:manual-settle +``` + +### 출력 예시 (Claimable → Paid) + +``` +=== manual-settle === +leader : 7xKX... +masterPda : Abc1... +flightPda : Def4... +flightNo : KE653 +delayMinutes : 150 +cancelled : false +payoutAmount : 40000000 +현재 status : 2 (Claimable) + +→ Claimable: settle_flight_claim 실행 중... + +=== 완료 === +tx : 3kMn... +status : 3 (Paid) +payout : 40000000 토큰 → leaderDepositWallet +``` + +### 출력 예시 (NoClaim → Expired) + +``` +=== manual-settle === +leader : 7xKX... +masterPda : Abc1... +flightPda : Def4... +flightNo : KE653 +delayMinutes : 60 +cancelled : false +payoutAmount : 0 +현재 status : 4 (NoClaim) + +→ NoClaim: settle_flight_no_claim 실행 중... + +=== 완료 === +tx : 8pQr... +status : 5 (Expired) +premium : 10000000 토큰 → 참여사 deposit wallets +``` + +--- + +## 전체 플로우 예시 + +API 의존성 없이 FlightPolicy의 전체 라이프사이클을 수동으로 실행하는 시나리오입니다. + +### 시나리오 A: 보험금 지급 (지연 발생) + +```bash +# 1. resolve: 2시간 30분 지연 → Claimable +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 DELAY_MINUTES=150 \ + yarn ts-node -P tsconfig.json scripts/manual-resolve.ts + +# 2. settle: payout 지급 → Paid +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 \ + yarn demo:manual-settle +``` + +### 시나리오 B: 프리미엄 분배 (지연 없음) + +```bash +# 1. resolve: 지연 없음 → NoClaim +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 DELAY_MINUTES=30 \ + yarn ts-node -P tsconfig.json scripts/manual-resolve.ts + +# 2. settle: premium 분배 → Expired +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 \ + yarn demo:manual-settle +``` + +### 시나리오 C: 결항 (최대 보험금) + +```bash +# 1. resolve: 결항 → Claimable (최대 payout) +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 DELAY_MINUTES=0 CANCELLED=true \ + yarn ts-node -P tsconfig.json scripts/manual-resolve.ts + +# 2. settle: payout 지급 → Paid +MASTER_PDA=Abc123... CHILD_POLICY_ID=1 \ + yarn demo:manual-settle +``` + +--- + +## FlightPolicy 상태 전환 전체 맵 + +``` + manual-resolve.ts + (DELAY >= 120 or CANCELLED=true) + ┌─────────────────────────────────→ Claimable(2) + │ │ +AwaitingOracle(1) ┤ │ manual-settle.ts + │ │ (settle_flight_claim) + │ manual-resolve.ts ▼ + │ (DELAY < 120) Paid(3) ✓ + └─────────────────────────────────→ NoClaim(4) + │ + │ manual-settle.ts + │ (settle_flight_no_claim) + ▼ + Expired(5) ✓ +``` diff --git a/docs/MASTER_POLICY_REDESIGN_PLAN_KO.md b/docs/MASTER_POLICY_REDESIGN_PLAN_KO.md deleted file mode 100644 index b73722c..0000000 --- a/docs/MASTER_POLICY_REDESIGN_PLAN_KO.md +++ /dev/null @@ -1,244 +0,0 @@ -# 항공 지연 보험 구조 개편 계획 (마스터 계약 + 개별 계약) - -## 1) 목표 - -기존 `항공편 1건 = 보험 1계약` 구조를 아래 2계약 구조로 변경한다. - -1. `마스터 계약(Master Contract)` -- 기간(예: 2026-01-01 ~ 2026-12-31) 단위로 운영 -- 공통 약관/지분/출재율/수수료/승인 상태 관리 -- 개별 항공 지연 보험(Child Contract) 생성 팩토리 역할 - -2. `개별 항공 지연 보험 계약(Child Flight Delay Contract)` -- 실제 가입 건(항공편 + 가입자 + 보험료 + 보장) 관리 -- 청구 시 오라클 결과 기반 지급/부지급 처리 -- 정산 시 참여사/재보험사 pool에서 리더사 deposit으로 자동 집금 - ---- - -## 2) 확정 비즈니스 규칙 - -### 마스터 약관 -- 계약 기간: `2026-01-01` ~ `2026-12-31` -- 보험료: 가입 건당 `1 USDC` -- 지연 보상: -1. 2시간대(2:00~2:59): `40 USDC` -2. 3시간대(3:00~3:59): `60 USDC` -3. 4~5시간대(4:00~5:59): `80 USDC` -4. 6시간 이상 지연 또는 결항: `100 USDC` - -### 지분 및 출재 -- 원수사 내부 지분: 리더 50%, 참여사 A 30%, 참여사 B 20% -- 원수사 그룹 ↔ 재보험사 기준 지분: 50% : 50% -- 출재율: 각 원수사 지분의 50%를 재보험사로 출재 -- 출재 수수료(재보험 수수료): 10% -- 수수료 규칙: 원수사가 재보험사로 넘기는 출재금에서 10% 공제 후 이전 - -### 수수료 반영 결과(확정) -- 재보험 실질 비율: 50%가 아니라 45% -- 원수사 그룹 실질 비율: 55% -- 위 실질 비율은 `Premium 분배`와 `Claim 정산` 모두 동일하게 적용 - -### 승인(활성화) 플로우 -1. 리더사가 마스터 계약 생성 및 지분 정의 -2. 참여사 + 재보험사 컨펌 -3. Operator 최종 컨펌 -4. 마스터 계약 Active 전환 - -Operator는 리더사 또는 OpenParametric 운영자 계정이 될 수 있다. - ---- - -## 3) 온체인 아키텍처 변경안 - -## 3.1 신규 계정(마스터 계약) - -`MasterPolicy` -- master_id -- leader -- operator -- currency_mint (USDC) -- coverage_start_ts / coverage_end_ts -- premium_per_policy (=1 USDC) -- payout_tiers (2h/3h/4-5h/6h+) -- ceded_ratio_bps (=5000) -- reins_commission_bps (=1000) -- status (Draft / PendingConfirm / Active / Closed) -- confirmation bitmap (leader/participants/reinsurer/operator) -- leader_deposit_wallet (최종 집금/분배 기준 지갑) - -`MasterParticipants` -- 원수사 참여사 목록 (leader, A, B) -- 내부 지분 bps (5000/3000/2000) -- insurer_pool_wallet (부담금 출금 지갑) -- insurer_deposit_wallet (정산 유입 지갑, pool과 분리) - -`MasterReinsurance` -- reinsurer -- reinsurer_pool_wallet -- reinsurer_deposit_wallet -- cession rule, commission rule - -## 3.2 신규 계정(개별 계약) - -`FlightPolicy` (개별 항공 지연 보험) -- child_policy_id -- parent_master (MasterPolicy pubkey) -- subscriber(가입자 식별자 또는 오프체인 ref hash) -- flight_no / departure_ts / route(optional) -- premium_paid (=1 USDC) -- status (Issued / AwaitingOracle / Claimable / Paid / NoClaim / Expired) -- oracle_ref / resolved_delay_min / cancellation_flag -- payout_amount - -`FlightSettlement` -- 청구/부지급 정산 결과 로그 -- 참여자별 부담액/분배액 스냅샷 -- 실행 tx 정보 및 재실행 방지 플래그 - ---- - -## 4) 핵심 인스트럭션 설계 - -## 4.1 마스터 계약 -1. `create_master_policy(...)` -- 리더사가 기간형 마스터 계약 생성 -- 약관(보험료/지연 tier/지분/출재/수수료) + 지갑 주소 저장 - -2. `register_participant_wallets(...)` -- 각 보험사가 `pool_wallet` + `deposit_wallet` 등록 -- 모든 보험사는 리더사가 될 수 있으므로 등록 필수 - -3. `confirm_master_share(role, actor)` -- 참여사/재보험사가 각자 컨펌 - -4. `operator_activate_master()` -- Operator 최종 승인 -- 상태를 `Active` 전환 - -5. `close_master_policy()` -- 기간 종료 후 정리 상태 전환 - -## 4.2 개별 항공 지연 보험 -1. `create_flight_policy_from_master(...)` -- `MasterPolicy::Active`에서만 호출 가능 -- 생성 권한: `리더사 + Operator` - -2. `resolve_flight_delay(...)` -- 오라클 데이터로 지연구간 판정 -- payout tier 계산 - -3. `settle_claim_with_master_shares(...)` -- 지급 케이스 -- 각 참여사/재보험사 pool에서 부담액을 출금해 `마스터 리더사 deposit_wallet`로 자동 집금 - -4. `settle_no_claim_premium_distribution(...)` -- 부지급 케이스 -- 보험료를 재보험사 몫/수수료까지 포함해 분배 - ---- - -## 5) 정산 로직(확정 수식) - -기본 변수: -- `P`: 개별 계약 보험료 -- `C`: 개별 계약 지급금 -- `s_i`: 원수사 내부 지분 (리더 0.5, A 0.3, B 0.2) -- `q`: 출재율 (0.5) -- `k`: 수수료율 (0.1) -- `r_eff`: 재보험 실질 비율 = `q * (1-k)` = `0.45` -- `i_eff`: 원수사 실질 비율 = `1 - r_eff` = `0.55` - -Premium 분배: -- 재보험사 수익: `P * r_eff` -- 원수사 i 수익: `P * i_eff * s_i` - -Claim 부담: -- 재보험사 비용: `C * r_eff` -- 원수사 i 비용: `C * i_eff * s_i` - -위 수식은 Claim 시점과 Premium 분배 시점에 동일하게 적용한다. - -예시(`P=1,000,000`, `C=500,000`): -- Premium: 재보험 450,000 / 원수사 총 550,000 -- Claim: 재보험 225,000 / 원수사 총 275,000 -- 최종 이익: -1. 리더 137,500 -2. 참여사 A 82,500 -3. 참여사 B 55,000 -4. 재보험 225,000 - ---- - -## 6) 상태 머신 변경 - -## 6.1 MasterPolicyStatus -1. Draft -2. PendingConfirm -3. Active -4. Closed -5. Cancelled - -## 6.2 FlightPolicyStatus -1. Issued -2. AwaitingOracle -3. Claimable -4. Paid -5. NoClaim -6. Expired - ---- - -## 7) 권한/보안 규칙 - -1. 마스터 생성/조건 수정: 리더사만 가능 (Active 이후 수정 불가) -2. 컨펌: 각 참여사/재보험사 자기 계정만 가능 -3. 최종 활성화: Operator만 가능 -4. Child 생성: Master Active + 호출자 `리더사 또는 Operator` -5. Claim 정산: 오라클 검증 + 중복정산 방지 플래그 필수 -6. 토큰 이체: PDA authority 고정, 임의 계정 이체 금지 -7. 모든 보험사 계정은 `pool_wallet`과 별도로 `deposit_wallet` 등록 필수 - ---- - -## 8) 현재 코드베이스 기준 변경 포인트 - -대상 프로그램: `contract/programs/open_parametric` - -주요 변경: -1. `state.rs` -- `MasterPolicy + FlightPolicy` 2계층 구조 추가 -- 참여사별 `pool/deposit wallet` 필드 추가 -- 정산 스냅샷(분배 결과) 저장 구조 추가 - -2. `instructions/` -- 신규: `create_master_policy.rs` -- 신규: `register_participant_wallets.rs` -- 신규: `confirm_master.rs` -- 신규: `activate_master.rs` -- 신규: `create_flight_policy_from_master.rs` -- 신규: `resolve_flight_delay.rs` -- 신규: `settle_flight_claim.rs` -- 신규: `settle_flight_no_claim.rs` -- 기존 단건 중심 플로우 인스트럭션은 단계적 대체 - -3. `lib.rs` -- 신규 인스트럭션 엔트리 추가 -- 기존 단건 플로우 엔트리 Deprecated 처리 - -4. 테스트(`contract/tests/open_parametric.ts`) -- 마스터 승인 플로우 -- Child 생성 권한(리더/Operator 허용, 기타 거부) -- Claim 정산(45:55 실질 비율 반영) 검증 -- No Claim 보험료 분배(재보험 몫/수수료 포함) 검증 -- 중복 정산/권한 위반/잔액 부족 실패 케이스 검증 - ---- - -## 9) 구현 순서 (코드 작업) - -1. 상태/에러/상수 확장 -2. 마스터 계약 생성/지갑 등록/컨펌/활성화 -3. Child 계약 생성 팩토리 -4. Claim/NoClaim 정산 로직 + 토큰 이체 -5. 회귀 테스트 + 문서 업데이트 diff --git a/docs/emotion-migration-handoff.md b/docs/emotion-migration-handoff.md deleted file mode 100644 index b3301d0..0000000 --- a/docs/emotion-migration-handoff.md +++ /dev/null @@ -1,101 +0,0 @@ -# Emotion Migration Handoff - -> Date: 2026-02-24 -> Scope: Global CSS -> Emotion styled-components (Phase 0~4) -> Status: **Complete** (all phases done, build passing) - ---- - -## 1. Migration Summary - -| Phase | 내용 | 파일 수 | 상태 | -|-------|------|---------|------| -| Phase 0 | Theme 인프라 (`theme.ts`, `emotion.d.ts`, `ThemeProvider`) | 3 | Done | -| Phase 1 | Common 컴포넌트 (`Card`, `Button`, `Tag`, `Form`, `SummaryRow`, `Divider`, `Mono`) | 8 | Done | -| Phase 2 | Header/Layout (16개 styled components + `blink` keyframes) | 1 | Done | -| Phase 3 | Dashboard 컴포넌트 9개 (StateMachine, InstructionRunner, Participants, RiskPoolAccount, OracleConsole, RiskTokenToggle, EventLog, OnChainInspector, Dashboard page) | 9 | Done | -| Phase 4 | `globalStyles.ts` 정리 (152줄 -> 32줄) | 1 | Done | - -**총 제거된 글로벌 CSS 클래스: ~120개** -**`className=` 잔존 사용: 0건** - ---- - -## 2. 현재 아키텍처 - -### globalStyles.ts (32줄, 유지 대상만 남음) -- `:root` CSS 변수 (16개 컬러 토큰) -- `*` reset, `html`, `body` 기본 스타일 -- `body::after` noise overlay -- `::-webkit-scrollbar` 커스텀 스크롤바 -- `.sub` 유틸리티 클래스 -- `.wallet-adapter-button` 외부 라이브러리 오버라이드 - -### Theme (src/styles/theme.ts) -- `colors`, `glow`, `fonts`, `radii`, `spacing` 토큰 -- `as const` 타입 추론 -- `emotion.d.ts`로 `p.theme.colors.*` 타입 안전 접근 - -### Common Components (src/components/common/) -- `Card` (Card, CardHeader, CardTitle, CardBody) -- `Button` (variant: primary/accent/outline/danger/warning, size: sm/md, fullWidth) -- `Tag` (variant: default/subtle) -- `Form` (FormGroup, FormLabel, FormInput, FormSelect, Row2) -- `SummaryRow`, `Divider`, `Mono` - ---- - -## 3. Code Review Findings (CCG/Codex) - -### MEDIUM (3건) — 기능 개발 시 점진적 해결 권장 - -#### M1. RiskTokenToggle 접근성 부재 -- **파일**: `src/components/dashboard/RiskTokenToggle.tsx:60` -- **현상**: 토글이 정적 `
`로만 구현. 키보드/스크린리더 접근 불가 -- **권장**: ` -
- )} - - - - - {/* Manual oracle trigger */} -
- {t('oracle.trackBManualTrigger')} - - - Policy - setOraclePolicyKey(e.target.value)} - > - - {trackBPolicies - .filter(p => p.account.state === PolicyState.Active) - .map(p => ( - - ))} - - - - { - if (!oraclePolicyKey) return; - try { - const res = await checkOracle(new PublicKey(oraclePolicyKey)); - if (res.success) { - toast(t('oracle.trackBOracleSuccess'), 's'); - } else { - toast(t('oracle.trackBOracleFail', { error: res.error }), 'd'); - } - } catch { - toast(t('oracle.trackBOracleFail', { error: 'Switchboard SDK error' }), 'd'); - } - }} - > - {oracleCheckLoading ? '…' : t('oracle.trackBCheckOracleBtn')} - -
- {t('oracle.trackBManualHint')} -
-
- - ); -} diff --git a/frontend/src/hooks/useFlightPolicies.ts b/frontend/src/hooks/useFlightPolicies.ts index 4bffc30..0d31c0f 100644 --- a/frontend/src/hooks/useFlightPolicies.ts +++ b/frontend/src/hooks/useFlightPolicies.ts @@ -30,7 +30,7 @@ interface BackendFlightPolicy { premium_distributed: boolean; created_at: number; updated_at: number; - bump: number; + status_label: string; } function toFlightPolicyWithKey(data: BackendFlightPolicy): FlightPolicyWithKey { @@ -56,7 +56,7 @@ function toFlightPolicyWithKey(data: BackendFlightPolicy): FlightPolicyWithKey { 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, + bump: 0, } as unknown as FlightPolicyAccount, }; } @@ -132,8 +132,10 @@ export function useFlightPolicies( if (!masterKey) return; const es = new EventSource(`${BACKEND_URL}/api/events?master=${masterKey}`); + console.log('[SSE] connected:', `${BACKEND_URL}/api/events?master=${masterKey}`); es.addEventListener('flight_policy_updated', (e: MessageEvent) => { + console.log('[SSE] flight_policy_updated', JSON.parse(e.data)); try { const data: BackendFlightPolicy = JSON.parse(e.data); if (data.master !== masterKey) return; @@ -143,17 +145,15 @@ export function useFlightPolicies( 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); - } + + const prevStatus = existing?.account.status; + const nextStatus = updated.account.status; + if (prevStatus !== undefined && prevStatus !== nextStatus) { + const cb = onStatusChangeRef.current; + if (cb) setTimeout(() => cb(updated, prevStatus, nextStatus), 0); } - prevStatusRef.current.set(id, updated.account.status); + prevStatusRef.current.set(id, nextStatus); if (idx >= 0) { const next = [...prev]; diff --git a/frontend/src/hooks/useMasterPolicies.ts b/frontend/src/hooks/useMasterPolicies.ts index 7d5ad2a..f9fbff3 100644 --- a/frontend/src/hooks/useMasterPolicies.ts +++ b/frontend/src/hooks/useMasterPolicies.ts @@ -7,6 +7,7 @@ interface BackendMasterPolicyItem { pubkey: string; master_id: number; status: number; + status_label: string; coverage_end_ts: number; } @@ -32,6 +33,7 @@ export function useMasterPolicies() { pda: m.pubkey, masterId: String(m.master_id), status: m.status, + statusLabel: m.status_label, coverageEndTs: m.coverage_end_ts, })); diff --git a/frontend/src/hooks/useMasterPolicyAccount.ts b/frontend/src/hooks/useMasterPolicyAccount.ts index 9eb41a7..ec52176 100644 --- a/frontend/src/hooks/useMasterPolicyAccount.ts +++ b/frontend/src/hooks/useMasterPolicyAccount.ts @@ -39,7 +39,7 @@ interface BackendMasterPolicy { reinsurer_deposit_wallet: string; leader_deposit_wallet: string; created_at: number; - bump: number; + status_label: string; } function toMasterPolicyAccount(data: BackendMasterPolicy): MasterPolicyAccount { @@ -78,7 +78,7 @@ function toMasterPolicyAccount(data: BackendMasterPolicy): MasterPolicyAccount { })), status: data.status, createdAt: fakeBN(data.created_at) as unknown as import('@coral-xyz/anchor').BN, - bump: data.bump, + bump: 0, } as unknown as MasterPolicyAccount; } diff --git a/frontend/src/hooks/useMyPolicies.ts b/frontend/src/hooks/useMyPolicies.ts index 552b21c..9640516 100644 --- a/frontend/src/hooks/useMyPolicies.ts +++ b/frontend/src/hooks/useMyPolicies.ts @@ -1,6 +1,8 @@ import { useEffect, useState, useCallback } from 'react'; +import { useWallet } from '@solana/wallet-adapter-react'; import { useProgram } from './useProgram'; -import type { MasterPolicyAccount, PolicyAccount } from '@/lib/idl/open_parametric'; +import type { PolicyAccount } from '@/lib/idl/open_parametric'; +import { BACKEND_URL } from '@/lib/constants'; export interface MyPolicyRole { role: 'leader' | 'partA' | 'partB' | 'rein'; @@ -12,6 +14,7 @@ export interface MyPolicySummary { pda: string; masterId: string; status: number; + statusLabel: string; roles: MyPolicyRole[]; track: 'A' | 'B'; /** Track B only fields */ @@ -20,110 +23,119 @@ export interface MyPolicySummary { payoutAmount?: number; } +interface BackendMasterPolicyFull { + pubkey: string; + master_id: number; + leader: string; + operator: string; + status: number; + status_label: string; + reinsurer: string; + reinsurer_confirmed: boolean; + reinsurer_effective_bps: number; + participants: Array<{ + insurer: string; + share_bps: number; + confirmed: boolean; + }>; +} + /** - * Fetch MasterPolicy accounts where the connected wallet appears - * as leader or reinsurer, plus Track B Policy accounts. - * - * Uses memcmp-filtered queries instead of fetching all accounts: - * - leader: offset 16 (discriminator 8 + master_id 8) - * - reinsurer: offset 174 (leader 32 + operator 32 + currency_mint 32 + - * coverage_start/end 16 + premium 8 + 4 payouts 32 + - * ceded/reins/effective bps 6) - * - * Note: participant role (inside Vec) cannot be memcmp-filtered. - * Participants can access their policies via direct portal URL. + * Fetch policies where the connected wallet appears as leader, reinsurer, + * or participant. Uses backend API for Master Policies (Track A) and + * direct Solana RPC for Track B Policy accounts. */ -// MasterPolicy field offsets (bytes) -const LEADER_OFFSET = 16; // discriminator(8) + master_id(u64=8) -const REINSURER_OFFSET = 174; // leader(32) + operator(32) + currency_mint(32) + 2×i64(16) + 5×u64(40) + 3×u16(6) - export function useMyPolicies() { + const { publicKey } = useWallet(); const { program, wallet } = useProgram(); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); const fetchPolicies = useCallback(async () => { - if (!program || !wallet?.publicKey) { + if (!publicKey) { setPolicies([]); return; } setLoading(true); try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prog = program as any; - const walletKey = wallet.publicKey; - const walletBase58 = walletKey.toBase58(); + const walletBase58 = publicKey.toBase58(); const grouped = new Map(); - // Parallel filtered queries: leader + reinsurer + Track B - const [leaderAccounts, reinsurerAccounts, trackBAccounts] = await Promise.all([ - prog.account.masterPolicy.all([ - { memcmp: { offset: LEADER_OFFSET, bytes: walletBase58 } }, - ]), - prog.account.masterPolicy.all([ - { memcmp: { offset: REINSURER_OFFSET, bytes: walletBase58 } }, - ]), - prog.account.policy.all([ - { memcmp: { offset: 16, bytes: walletBase58 } }, - ]).catch(() => []), // Track B account type may not exist on-chain yet - ]); - - // Process leader results - for (const a of leaderAccounts) { - const acc: MasterPolicyAccount = a.account; - const pda = a.publicKey.toBase58(); - const roles: MyPolicyRole[] = [{ role: 'leader', shareBps: 10000, confirmed: true }]; - // Also check if reinsurer in same account - if (acc.reinsurer.equals(walletKey)) { - roles.push({ role: 'rein', shareBps: acc.reinsurerEffectiveBps, confirmed: acc.reinsurerConfirmed }); - } - // Check participants in already-fetched accounts - const participants = acc.participants || []; - for (let i = 0; i < participants.length; i++) { - const p = participants[i]; - if (p && p.insurer.equals(walletKey)) { - roles.push({ role: i === 0 ? 'partA' : 'partB', shareBps: p.shareBps, confirmed: p.confirmed }); + // Fetch all master policies from backend API + const res = await fetch(`${BACKEND_URL}/api/master-policies`); + if (res.ok) { + const json: { master_policies: BackendMasterPolicyFull[] } = await res.json(); + + for (const mp of json.master_policies) { + const roles: MyPolicyRole[] = []; + + // Check leader + if (mp.leader === walletBase58) { + roles.push({ role: 'leader', shareBps: 10000, confirmed: true }); } - } - grouped.set(pda, { pda, masterId: acc.masterId.toString(), status: acc.status, roles, track: 'A' }); - } - // Process reinsurer results (merge with existing if already found as leader) - for (const a of reinsurerAccounts) { - const acc: MasterPolicyAccount = a.account; - const pda = a.publicKey.toBase58(); - const existing = grouped.get(pda); - if (existing) { - // Already added via leader query — roles already merged above - continue; - } - const roles: MyPolicyRole[] = [{ role: 'rein', shareBps: acc.reinsurerEffectiveBps, confirmed: acc.reinsurerConfirmed }]; - // Check participants - const participants = acc.participants || []; - for (let i = 0; i < participants.length; i++) { - const p = participants[i]; - if (p && p.insurer.equals(walletKey)) { - roles.push({ role: i === 0 ? 'partA' : 'partB', shareBps: p.shareBps, confirmed: p.confirmed }); + // Check reinsurer + if (mp.reinsurer === walletBase58) { + roles.push({ + role: 'rein', + shareBps: mp.reinsurer_effective_bps, + confirmed: mp.reinsurer_confirmed, + }); + } + + // Check participants + for (let i = 0; i < mp.participants.length; i++) { + const p = mp.participants[i]!; + if (p.insurer === walletBase58) { + roles.push({ + role: i === 0 ? 'partA' : 'partB', + shareBps: p.share_bps, + confirmed: p.confirmed, + }); + } + } + + if (roles.length > 0) { + grouped.set(mp.pubkey, { + pda: mp.pubkey, + masterId: String(mp.master_id), + status: mp.status, + statusLabel: mp.status_label, + roles, + track: 'A', + }); } } - grouped.set(pda, { pda, masterId: acc.masterId.toString(), status: acc.status, roles, track: 'A' }); } - // Process Track B results - for (const a of trackBAccounts) { - const acc: PolicyAccount = a.account; - const pda = a.publicKey.toBase58(); - grouped.set(pda, { - pda, - masterId: acc.policyId.toString(), - status: acc.state, - roles: [{ role: 'leader', shareBps: 10000, confirmed: true }], - track: 'B', - flightNo: acc.flightNo, - route: acc.route, - payoutAmount: acc.payoutAmount.toNumber() / 1e6, - }); + // Track B: direct RPC (not available via backend API) + if (program && wallet?.publicKey) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prog = program as any; + const trackBAccounts = await prog.account.policy.all([ + { memcmp: { offset: 16, bytes: walletBase58 } }, + ]); + + for (const a of trackBAccounts) { + const acc: PolicyAccount = a.account; + const pda = a.publicKey.toBase58(); + grouped.set(pda, { + pda, + masterId: acc.policyId.toString(), + status: acc.state, + statusLabel: '', + roles: [{ role: 'leader', shareBps: 10000, confirmed: true }], + track: 'B', + flightNo: acc.flightNo, + route: acc.route, + payoutAmount: acc.payoutAmount.toNumber() / 1e6, + }); + } + } catch { + // Track B account type may not exist on-chain yet + } } const results = Array.from(grouped.values()); @@ -134,7 +146,7 @@ export function useMyPolicies() { } finally { setLoading(false); } - }, [program, wallet]); + }, [publicKey, program, wallet]); useEffect(() => { fetchPolicies(); diff --git a/frontend/src/hooks/usePolicies.ts b/frontend/src/hooks/usePolicies.ts deleted file mode 100644 index 90a0fc7..0000000 --- a/frontend/src/hooks/usePolicies.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; -import { PublicKey } from '@solana/web3.js'; -import { useProgram } from './useProgram'; -import type { PolicyAccount, ClaimAccount } from '@/lib/idl/open_parametric'; -import type { PolicyWithKey, ClaimWithKey } from '@/store/useProtocolStore'; - -/** - * Fetch all Track B Policy accounts for a given leader. - * Policy.leader is at offset 16: discriminator(8) + policy_id(u64=8). - * Also fetches associated Claim accounts. - */ -export function usePolicies( - leaderPubkey: PublicKey | null, - options?: { - onStatusChange?: (policy: PolicyWithKey, prevState: number, newState: number) => void; - pollInterval?: number; - }, -) { - const { program, connection } = useProgram(); - const [policies, setPolicies] = useState([]); - const [claims, setClaims] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const prevStateRef = 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 fetchPolicies = useCallback(async () => { - if (!program || !leaderPubkey || !connection) { - setPolicies([]); - setClaims([]); - return; - } - - setLoading(true); - setError(null); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prog = program as any; - - // Fetch Policy accounts filtered by leader (offset 16) - const accounts = await prog.account.policy.all([ - { - memcmp: { - offset: 16, // discriminator(8) + policy_id(u64=8) - bytes: leaderPubkey.toBase58(), - }, - }, - ]); - - const mapped: PolicyWithKey[] = accounts.map( - (a: { publicKey: PublicKey; account: PolicyAccount }) => ({ - publicKey: a.publicKey, - account: a.account, - }), - ); - - // Sort by policyId ascending - mapped.sort((a, b) => { - const aId = a.account.policyId.toNumber(); - const bId = b.account.policyId.toNumber(); - return aId - bId; - }); - - // Status change detection - const prevMap = prevStateRef.current; - const cb = onStatusChangeRef.current; - if (prevMap.size > 0 && cb) { - for (const p of mapped) { - const key = p.publicKey.toBase58(); - const prev = prevMap.get(key); - if (prev !== undefined && prev !== p.account.state) { - cb(p, prev, p.account.state); - } - } - } - prevStateRef.current = new Map( - mapped.map(p => [p.publicKey.toBase58(), p.account.state]), - ); - - setPolicies(mapped); - - // Fetch Claim accounts filtered per policy (memcmp on policy field, offset 8) - // ClaimAccount layout: discriminator(8) | policy(Pubkey=32) | ... - if (mapped.length > 0) { - const claimResults = await Promise.all( - mapped.map(p => - prog.account.claim.all([ - { memcmp: { offset: 8, bytes: p.publicKey.toBase58() } }, - ]) as Promise<{ publicKey: PublicKey; account: ClaimAccount }[]>, - ), - ); - const allClaims: ClaimWithKey[] = claimResults.flat().map(a => ({ - publicKey: a.publicKey, - account: a.account, - })); - setClaims(allClaims); - } else { - setClaims([]); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } finally { - setLoading(false); - } - }, [program, leaderPubkey, connection]); - - // Initial fetch - useEffect(() => { - fetchPolicies(); - }, [fetchPolicies]); - - // WebSocket subscriptions for real-time updates - const policyKeys = useMemo( - () => policies.map(p => p.publicKey.toBase58()).join(','), - [policies], - ); - - 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(p => - connection.onAccountChange( - p.publicKey, - (accountInfo) => { - try { - const decoded = coder.decode('policy', accountInfo.data) as PolicyAccount; - const key = p.publicKey; - setPolicies(prev => prev.map(item => - item.publicKey.equals(key) ? { publicKey: key, account: decoded } : item, - )); - // Status change detection - const prevState = prevStateRef.current.get(key.toBase58()); - const cb = onStatusChangeRef.current; - if (prevState !== undefined && prevState !== decoded.state && cb) { - cb({ publicKey: key, account: decoded }, prevState, decoded.state); - } - prevStateRef.current.set(key.toBase58(), decoded.state); - } 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]); - - // Polling for new Policy accounts - useEffect(() => { - if (!leaderPubkey || pollInterval <= 0) return; - const interval = setInterval(() => { fetchPolicies(); }, pollInterval); - return () => clearInterval(interval); - }, [fetchPolicies, leaderPubkey, pollInterval]); - - return { policies, claims, loading, error, refetch: fetchPolicies }; -} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 7f49efa..739f3c9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1,7 +1,8 @@ const en = { // === Common === 'common.active': 'Active', - 'common.claimed': 'Claimed', + 'common.claimed': 'Claimable', + 'common.paid': 'Paid', 'common.noClaim': 'No Claim', 'common.expired': 'Expired', 'common.settled': 'Settled', @@ -169,9 +170,6 @@ const en = { // === Tab3: Oracle & Claims === 'oracle.title': 'Oracle Console', - 'oracle.tabManual': 'Manual', - 'oracle.tabTrackA': 'Track A', - 'oracle.tabTrackB': 'Track B', 'oracle.targetContract': 'Target Contract', 'oracle.selectContract': '-- Select Contract --', 'oracle.delayLabel': 'Actual Delay (min) — ≥0, multiple of 10', @@ -192,76 +190,8 @@ const en = { 'oracle.resolvedOnChain': 'Resolved on-chain. TX: {{tx}}...', 'oracle.resolvedSuccess': 'Flight delay resolved on-chain!', 'oracle.sendingTx': 'Sending TX...', - 'oracle.daemonBadge': 'Recent Resolve Activity', - 'oracle.trackAMonitorHintPlain': 'Check the FlightPolicy Status Monitor on the right.', - 'oracle.trackASimTitle': 'Track A Daemon', - 'oracle.trackASimDesc': 'In on-chain mode, the Rust daemon automatically handles oracle checks → claim creation → settlement.\n\nIn simulation mode, use the Manual tab.', - 'oracle.trackBTitle': 'Track B', - 'oracle.trackBDesc': 'Track B oracle integration coming soon.', - 'oracle.trackBMonitor': 'Track B Policy Status Monitor', - 'oracle.trackBApproveBtn': 'Approve Claim', - 'oracle.trackBSettleBtn': 'Settle Payout', - 'oracle.trackBDaemonBadge': 'Track B Daemon Activity', - 'oracle.trackBManualTrigger': 'Manual Oracle Trigger (Daemon Fallback)', - 'oracle.trackBOracleRound': 'Oracle Round', - 'oracle.trackBManualHint': 'Will be activated after Switchboard SDK integration.', - 'oracle.trackBNoPolicies': 'No Track B policies registered', - 'oracle.trackBApproved': 'Claim approved', - 'oracle.trackBSettled': 'Payout settled', - 'oracle.trackBCreatePolicy': 'Create Policy', - 'oracle.trackBPolicyId': 'Policy ID', - 'oracle.trackBRoute': 'Route (e.g. ICN-NRT)', - 'oracle.trackBFlightNo': 'Flight No (e.g. KE001)', - 'oracle.trackBDepartureDate': 'Departure Date', - 'oracle.trackBPayoutAmount': 'Payout Amount (token units)', - 'oracle.trackBOracleFeed': 'Oracle Feed Address', - 'oracle.trackBActiveFrom': 'Coverage Start', - 'oracle.trackBActiveTo': 'Coverage End', - 'oracle.trackBCurrencyMint': 'Currency Mint Address', - 'oracle.trackBReinsurer': 'Reinsurer', - 'oracle.trackBReinsurerAddr': 'Reinsurer Address', - 'oracle.trackBCededBps': 'Ceded Ratio (bps)', - 'oracle.trackBReinsBps': 'Reins Commission (bps)', - 'oracle.trackBParticipants': 'Participants', - 'oracle.trackBInsurer': 'Participant Address', - 'oracle.trackBRatioBps': 'Share (bps)', - 'oracle.trackBAddParticipant': 'Add Participant', - 'oracle.trackBRemoveParticipant': 'Remove', - 'oracle.trackBRatioSum': 'Share Total: {{sum}} / 10000 bps', - 'oracle.trackBCreateBtn': 'Create Policy', - 'oracle.trackBCreated': 'Policy created', - 'oracle.trackBOpenUW': 'Open Underwriting', - 'oracle.trackBOpenUWDone': 'Underwriting opened', - 'oracle.trackBAcceptShare': 'Accept Share', - 'oracle.trackBRejectShare': 'Reject Share', - 'oracle.trackBAcceptDone': 'Share accepted', - 'oracle.trackBRejectDone': 'Share rejected', - 'oracle.trackBActivate': 'Activate Policy', - 'oracle.trackBActivateDone': 'Policy activated', - 'oracle.trackBExpire': 'Expire Policy', - 'oracle.trackBExpireDone': 'Policy expired', - 'oracle.trackBRefund': 'Request Refund', - 'oracle.trackBRefundDone': 'Refund completed', - 'oracle.trackBRegisterPH': 'Register Policyholder', - 'oracle.trackBRegisterPHDone': 'Policyholder registered', - 'oracle.trackBExternalRef': 'External Reference', - 'oracle.trackBPassengerCount': 'Passenger Count', - 'oracle.trackBPremiumPaid': 'Premium Paid', - 'oracle.trackBCoverageAmount': 'Coverage Amount', - 'oracle.trackBCheckOracle': 'Check Oracle', - 'oracle.trackBCheckOracleBtn': 'Check Oracle (Switchboard)', - 'oracle.trackBCheckOracleDone': 'Oracle checked — claim created', - 'oracle.trackBOracleSuccess': 'Oracle check succeeded — claim created', - 'oracle.trackBOracleFail': 'Oracle check failed: {{error}}', - 'oracle.trackBSwitchboardWarning': 'Switchboard SDK required. Using daemon is recommended.', - 'oracle.trackBUWStatus': 'Underwriting Status', - 'oracle.trackBParticipantList': 'Participant Status', - 'oracle.trackBDepositAmount': 'Deposit Amount (token units)', - 'portal.trackBPolicy': 'Track B Policy', - 'portal.trackBState': 'State', - 'oracle.daemonLastActive': 'Last active: {{minutes}}m ago', - 'oracle.daemonInactive': 'Daemon possibly inactive (30min+ no response)', - 'oracle.daemonNoData': 'No daemon activity recorded', + 'oracle.tagManual': 'Manual', + 'oracle.manualNote': 'Manual override console. Backend daemon is auto-processing oracle → claims → settlement.', 'oracle.policyMonitor': 'FlightPolicy Status Monitor', 'oracle.th.flight': 'Flight', 'oracle.th.contract': 'Contract', @@ -309,7 +239,7 @@ const en = { 'claim.th.status': 'Status', 'claim.th.time': 'Time', 'claim.status.claimable': 'Claimable', - 'claim.status.settled': 'Settled', + 'claim.status.settled': 'Paid', 'claim.status.approved': 'Approved', 'claim.status.pending': 'Pending', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 1338e82..6e273d4 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -1,7 +1,8 @@ const ko = { // === Common === 'common.active': 'Active', - 'common.claimed': 'Claimed', + 'common.claimed': 'Claimable', + 'common.paid': 'Paid', 'common.noClaim': 'No Claim', 'common.expired': 'Expired', 'common.settled': 'Settled', @@ -169,9 +170,6 @@ const ko = { // === Tab3: Oracle & Claims === 'oracle.title': '오라클 콘솔', - 'oracle.tabManual': '수동', - 'oracle.tabTrackA': 'Track A', - 'oracle.tabTrackB': 'Track B', 'oracle.targetContract': '대상 계약', 'oracle.selectContract': '-- 계약 선택 --', 'oracle.delayLabel': '실제 지연 (분) — 0 이상, 10의 배수', @@ -192,77 +190,8 @@ const ko = { 'oracle.resolvedOnChain': '온체인 처리 완료. TX: {{tx}}...', 'oracle.resolvedSuccess': '항공편 지연 온체인 처리 완료!', 'oracle.sendingTx': 'TX 전송 중...', - 'oracle.daemonBadge': '최근 처리 활동', - 'oracle.trackAMonitorHint': '정책 상태는 우측 FlightPolicy 상태 모니터를 확인하세요.', - 'oracle.trackAMonitorHintPlain': '정책 상태는 우측 FlightPolicy 상태 모니터를 확인하세요.', - 'oracle.trackASimTitle': 'Track A 데몬', - 'oracle.trackASimDesc': '온체인 모드에서 Rust 데몬이 자동으로 오라클 확인 → 클레임 생성 → 정산을 처리합니다.\n\n현재 시뮬레이션 모드에서는 수동 탭을 이용하세요.', - 'oracle.trackBTitle': 'Track B', - 'oracle.trackBDesc': 'Track B 오라클 통합이 곧 추가될 예정입니다.', - 'oracle.trackBMonitor': 'Track B Policy 상태 모니터', - 'oracle.trackBApproveBtn': '클레임 승인', - 'oracle.trackBSettleBtn': '보험금 정산', - 'oracle.trackBDaemonBadge': 'Track B 데몬 활동', - 'oracle.trackBManualTrigger': '수동 오라클 트리거 (데몬 폴백)', - 'oracle.trackBOracleRound': '오라클 라운드', - 'oracle.trackBManualHint': 'Switchboard SDK 통합 후 활성화됩니다.', - 'oracle.trackBNoPolicies': '등록된 Track B Policy가 없습니다', - 'oracle.trackBApproved': '클레임 승인 완료', - 'oracle.trackBSettled': '보험금 정산 완료', - 'oracle.trackBCreatePolicy': 'Policy 생성', - 'oracle.trackBPolicyId': 'Policy ID', - 'oracle.trackBRoute': '노선 (ex. ICN-NRT)', - 'oracle.trackBFlightNo': '편명 (ex. KE001)', - 'oracle.trackBDepartureDate': '출발일', - 'oracle.trackBPayoutAmount': '보장금액 (토큰 단위)', - 'oracle.trackBOracleFeed': '오라클 피드 주소', - 'oracle.trackBActiveFrom': '보장 시작', - 'oracle.trackBActiveTo': '보장 종료', - 'oracle.trackBCurrencyMint': '통화 Mint 주소', - 'oracle.trackBReinsurer': '재보험사', - 'oracle.trackBReinsurerAddr': '재보험사 주소', - 'oracle.trackBCededBps': '출재 비율 (bps)', - 'oracle.trackBReinsBps': '재보험 수수료 (bps)', - 'oracle.trackBParticipants': '참여자 목록', - 'oracle.trackBInsurer': '참여자 주소', - 'oracle.trackBRatioBps': '지분 (bps)', - 'oracle.trackBAddParticipant': '참여자 추가', - 'oracle.trackBRemoveParticipant': '삭제', - 'oracle.trackBRatioSum': '지분 합계: {{sum}} / 10000 bps', - 'oracle.trackBCreateBtn': 'Policy 생성', - 'oracle.trackBCreated': 'Policy 생성 완료', - 'oracle.trackBOpenUW': '언더라이팅 오픈', - 'oracle.trackBOpenUWDone': '언더라이팅 오픈 완료', - 'oracle.trackBAcceptShare': '지분 수락', - 'oracle.trackBRejectShare': '지분 거절', - 'oracle.trackBAcceptDone': '지분 수락 완료', - 'oracle.trackBRejectDone': '지분 거절 완료', - 'oracle.trackBActivate': '정책 활성화', - 'oracle.trackBActivateDone': '정책 활성화 완료', - 'oracle.trackBExpire': '만기 처리', - 'oracle.trackBExpireDone': '만기 처리 완료', - 'oracle.trackBRefund': '환급 요청', - 'oracle.trackBRefundDone': '환급 완료', - 'oracle.trackBRegisterPH': '보험가입자 등록', - 'oracle.trackBRegisterPHDone': '보험가입자 등록 완료', - 'oracle.trackBExternalRef': '외부 참조', - 'oracle.trackBPassengerCount': '탑승자 수', - 'oracle.trackBPremiumPaid': '납입 프리미엄', - 'oracle.trackBCoverageAmount': '보장 금액', - 'oracle.trackBCheckOracle': '오라클 확인', - 'oracle.trackBCheckOracleBtn': '오라클 확인 (Switchboard)', - 'oracle.trackBCheckOracleDone': '오라클 확인 완료 — 클레임 생성됨', - 'oracle.trackBOracleSuccess': '오라클 확인 성공 — 클레임이 생성되었습니다', - 'oracle.trackBOracleFail': '오라클 확인 실패: {{error}}', - 'oracle.trackBSwitchboardWarning': 'Switchboard SDK가 필요합니다. 데몬 사용을 권장합니다.', - 'oracle.trackBUWStatus': '언더라이팅 상태', - 'oracle.trackBParticipantList': '참여자 현황', - 'oracle.trackBDepositAmount': '예치 금액 (토큰 단위)', - 'portal.trackBPolicy': 'Track B Policy', - 'portal.trackBState': '상태', - 'oracle.daemonLastActive': '마지막 활동: {{minutes}}분 전', - 'oracle.daemonInactive': '데몬 비활성 의심 (30분+ 무응답)', - 'oracle.daemonNoData': '데몬 활동 기록 없음', + 'oracle.tagManual': '수동', + 'oracle.manualNote': '수동 처리 콘솔입니다. 백엔드 데몬이 오라클 확인 → 클레임 → 정산을 자동으로 처리 중입니다.', 'oracle.policyMonitor': 'FlightPolicy 상태 모니터', 'oracle.th.flight': '편명', 'oracle.th.contract': '계약명', @@ -310,7 +239,7 @@ const ko = { 'claim.th.status': '상태', 'claim.th.time': '시각', 'claim.status.claimable': 'Claimable', - 'claim.status.settled': 'Settled', + 'claim.status.settled': 'Paid', 'claim.status.approved': 'Approved', 'claim.status.pending': 'Pending', diff --git a/frontend/src/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index d50c4d9..3086555 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -1,7 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { MasterPolicyStatus, FlightPolicyStatus, type MasterPolicyAccount, type PolicyAccount, type ClaimAccount } from '@/lib/idl/open_parametric'; -import type { PublicKey } from '@solana/web3.js'; +import { MasterPolicyStatus, FlightPolicyStatus, type MasterPolicyAccount } from '@/lib/idl/open_parametric'; import type { FlightPolicyWithKey } from '@/hooks/useFlightPolicies'; import i18n from '@/i18n'; @@ -23,7 +22,7 @@ export interface Contract { aNet: number; bNet: number; rNet: number; - status: 'active' | 'claimed' | 'noClaim' | 'expired' | 'settled'; + status: 'active' | 'claimed' | 'paid' | 'noClaim' | 'expired' | 'settled'; ts: string; } @@ -74,6 +73,7 @@ export interface MasterPolicySummary { pda: string; masterId: string; status: number; + statusLabel: string; coverageEndTs: number; } @@ -163,18 +163,6 @@ const nowDate = () => hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); -/* ── Track B Types ── */ - -export interface PolicyWithKey { - publicKey: PublicKey; - account: PolicyAccount; -} - -export interface ClaimWithKey { - publicKey: PublicKey; - account: ClaimAccount; -} - /* ── Store ── */ interface ProtocolState { mode: ProtocolMode; @@ -240,10 +228,6 @@ interface ProtocolState { syncMasterFromChain: (data: MasterPolicyAccount) => void; syncFlightPoliciesFromChain: (policies: FlightPolicyWithKey[]) => void; - // Track B on-chain state - trackBPolicies: PolicyWithKey[]; - trackBClaims: ClaimWithKey[]; - syncTrackBPoliciesFromChain: (policies: PolicyWithKey[], claims: ClaimWithKey[]) => void; } const INITIAL_ACC: Acc = { leaderPrem: 0, partAPrem: 0, partBPrem: 0, reinPrem: 0, leaderClaim: 0, partAClaim: 0, partBClaim: 0, reinClaim: 0 }; @@ -283,8 +267,6 @@ export const useProtocolStore = create()(persist((set, get) => ({ lastTxSignature: null, masterPolicies: [], lastDaemonActivityTs: null, - trackBPolicies: [], - trackBClaims: [], setMode: (m) => { set({ mode: m }); @@ -766,7 +748,7 @@ export const useProtocolStore = create()(persist((set, get) => ({ } else if (a.status === FlightPolicyStatus.Expired) { contractStatus = 'expired'; } else if (a.status === FlightPolicyStatus.Paid) { - contractStatus = 'settled'; + contractStatus = 'paid'; } else { contractStatus = 'claimed'; // Claimable (2) only } @@ -865,9 +847,6 @@ export const useProtocolStore = create()(persist((set, get) => ({ }); }, - syncTrackBPoliciesFromChain: (policies, claims) => { - set({ trackBPolicies: policies, trackBClaims: claims }); - }, }), { name: 'riskmesh-protocol', partialize: (state) => {