From bbf297ed77f8c1e7e7085559fc1153208c01d5c2 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:22:23 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feature:=20backend=20cors=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api.rs | 9 ++++++++- backend/src/oracle/program_accounts.rs | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/src/api.rs b/backend/src/api.rs index f8806c0..6a414bb 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -10,6 +10,7 @@ mod types; use std::{net::SocketAddr, sync::Arc}; use anyhow::{Context, Result}; +use tower_http::cors::{Any, CorsLayer}; use crate::{config::Config, events::EventBus}; @@ -20,11 +21,17 @@ pub async fn start(config: Arc, event_bus: Arc) -> Result<()> .with_context(|| format!("WEB_BIND_ADDR 파싱 실패: {}", config.web_bind_addr))?; 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/oracle/program_accounts.rs b/backend/src/oracle/program_accounts.rs index d2ab934..1d3b9c4 100644 --- a/backend/src/oracle/program_accounts.rs +++ b/backend/src/oracle/program_accounts.rs @@ -42,6 +42,7 @@ 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: String, pub created_at: i64, @@ -150,6 +151,7 @@ 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)?; @@ -176,6 +178,7 @@ 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).to_string(), created_at, From 595d267776a4f42be6a097dbcfcb7a603c55bd48 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:22:41 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feature:=20frontend=20>=20backend=5Fv1-2=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useFlightPolicies.ts | 4 +- frontend/src/hooks/useMasterPolicies.ts | 2 + frontend/src/hooks/useMasterPolicyAccount.ts | 4 +- frontend/src/hooks/useMyPolicies.ts | 180 ++++++++++--------- frontend/src/store/useProtocolStore.ts | 1 + 5 files changed, 103 insertions(+), 88 deletions(-) diff --git a/frontend/src/hooks/useFlightPolicies.ts b/frontend/src/hooks/useFlightPolicies.ts index 4bffc30..ef7b8b7 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, }; } 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/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index d50c4d9..80b7a2a 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -74,6 +74,7 @@ export interface MasterPolicySummary { pda: string; masterId: string; status: number; + statusLabel: string; coverageEndTs: number; } From dbdceae7564656576cba8c7286e465af4e602220 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:05:22 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feature:=20docker=20base=20rust=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0da26b2..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 From ddae5af1b9680b3c14ff54694074608e1adc3f17 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:16:18 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feature:=20frontend=20>=20=EC=98=A4?= =?UTF-8?q?=EB=9D=BC=ED=81=B4=ED=83=AD=20UI=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EC=9A=A9=EC=96=B4=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 17 - .../tabs/tab-feed/ContractFeedTable.tsx | 2 +- .../components/tabs/tab-oracle/ClaimTable.tsx | 14 +- .../tabs/tab-oracle/OracleConsole.tsx | 156 +--- .../tabs/tab-oracle/PolicyMonitorTable.tsx | 6 +- .../tabs/tab-oracle/TrackBPanel.tsx | 714 ------------------ frontend/src/hooks/useFlightPolicies.ts | 18 +- frontend/src/hooks/usePolicies.ts | 169 ----- frontend/src/i18n/locales/en.ts | 80 +- frontend/src/i18n/locales/ko.ts | 81 +- frontend/src/store/useProtocolStore.ts | 28 +- 11 files changed, 70 insertions(+), 1215 deletions(-) delete mode 100644 frontend/src/components/tabs/tab-oracle/TrackBPanel.tsx delete mode 100644 frontend/src/hooks/usePolicies.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9111eb..b57635c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,8 +17,6 @@ import { PublicKey } from '@solana/web3.js'; import { useWallet } from '@solana/wallet-adapter-react'; import { useMasterPolicyAccount } from '@/hooks/useMasterPolicyAccount'; import { useFlightPolicies, type FlightPolicyWithKey } from '@/hooks/useFlightPolicies'; -import { usePolicies } from '@/hooks/usePolicies'; -import type { PolicyWithKey } from '@/store/useProtocolStore'; import { useToast } from '@/components/common'; const STATUS_NAMES: Record = { @@ -36,7 +34,6 @@ function ChainSyncer() { const masterPolicyPDA = useProtocolStore(s => s.masterPolicyPDA); const syncMasterFromChain = useProtocolStore(s => s.syncMasterFromChain); const syncFlightPoliciesFromChain = useProtocolStore(s => s.syncFlightPoliciesFromChain); - const syncTrackBPoliciesFromChain = useProtocolStore(s => s.syncTrackBPoliciesFromChain); const addLog = useProtocolStore(s => s.addLog); const pdaKey = useMemo( @@ -57,18 +54,8 @@ function ChainSyncer() { ); }, [t, toast, addLog]); - const handleTrackBStatusChange = useCallback((p: PolicyWithKey, prev: number, next: number) => { - const name = `Policy #${p.account.policyId.toNumber()} ${p.account.flightNo}`; - toast(t('oracle.statusChanged', { flight: name, from: String(prev), to: String(next) }), 'w'); - addLog(`${name}: state ${prev} → ${next}`, '#9945FF', 'trackb_state_change'); - }, [t, toast, addLog]); - const { account } = useMasterPolicyAccount(pdaKey); const { policies } = useFlightPolicies(pdaKey, { onStatusChange: handleStatusChange }); - const { policies: trackBPolicies, claims: trackBClaims } = usePolicies( - mode === 'onchain' && publicKey ? publicKey : null, - { onStatusChange: handleTrackBStatusChange }, - ); useEffect(() => { if (account) syncMasterFromChain(account); @@ -78,10 +65,6 @@ function ChainSyncer() { if (pdaKey) syncFlightPoliciesFromChain(policies); }, [policies]); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - if (mode === 'onchain') syncTrackBPoliciesFromChain(trackBPolicies, trackBClaims); - }, [trackBPolicies, trackBClaims]); // eslint-disable-line react-hooks/exhaustive-deps - return null; } diff --git a/frontend/src/components/tabs/tab-feed/ContractFeedTable.tsx b/frontend/src/components/tabs/tab-feed/ContractFeedTable.tsx index df00b7e..c8286a0 100644 --- a/frontend/src/components/tabs/tab-feed/ContractFeedTable.tsx +++ b/frontend/src/components/tabs/tab-feed/ContractFeedTable.tsx @@ -41,7 +41,7 @@ export function ContractFeedTable() { {formatNum(c.bNet, 4)} {formatNum(c.rNet, 4)} {c.ts} - {t(`common.${c.status}`)} + {t(`common.${c.status}`)} ))} diff --git a/frontend/src/components/tabs/tab-oracle/ClaimTable.tsx b/frontend/src/components/tabs/tab-oracle/ClaimTable.tsx index 20e1e08..5bfc9f2 100644 --- a/frontend/src/components/tabs/tab-oracle/ClaimTable.tsx +++ b/frontend/src/components/tabs/tab-oracle/ClaimTable.tsx @@ -24,7 +24,13 @@ export function ClaimTable() { {[...claims].reverse().map(c => { - const statusColor = c.status === 'settled' ? 'accent' : c.status === 'approved' ? 'warning' : c.status === 'claimable' ? 'danger' : 'danger'; + const CLAIM_COLOR: Record = { + claimable: '#F59E0B', + approved: '#14F195', + settled: '#9945FF', + pending: '#94A3B8', + }; + const clr = CLAIM_COLOR[c.status] ?? '#94A3B8'; const statusLabel = c.status === 'settled' ? t('claim.status.settled') : c.status === 'approved' ? t('claim.status.approved') : c.status === 'claimable' ? t('claim.status.claimable') : t('claim.status.pending'); return ( @@ -44,9 +50,9 @@ export function ClaimTable() { {formatNum(c.totRC, 2)} {statusLabel} {c.ts} diff --git a/frontend/src/components/tabs/tab-oracle/OracleConsole.tsx b/frontend/src/components/tabs/tab-oracle/OracleConsole.tsx index fbc32bc..6c9468e 100644 --- a/frontend/src/components/tabs/tab-oracle/OracleConsole.tsx +++ b/frontend/src/components/tabs/tab-oracle/OracleConsole.tsx @@ -9,13 +9,8 @@ import { useToast } from '@/components/common'; import { useResolveFlightDelay } from '@/hooks/useResolveFlightDelay'; import { useProgram } from '@/hooks/useProgram'; import { getFlightPolicyPDA } from '@/lib/pda'; -import { TrackBPanel } from './TrackBPanel'; -/* ── Types ── */ - -type OracleControlMode = 'manual' | 'trackA' | 'trackB'; - -/* ── Segmented Control ── */ +/* ── Segmented Control (resolve type) ── */ const SegmentWrap = styled.div` display: flex; @@ -75,30 +70,6 @@ const MsgText = styled.div<{ variant: 'error' | 'ok' }>` font-weight: ${p => p.variant === 'ok' ? 700 : 400}; `; -/* ── Daemon badge ── */ - -const DaemonBadge = styled.div<{ active: boolean }>` - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - border-radius: 8px; - font-size: 12px; - font-weight: 600; - margin-bottom: 14px; - border: 1px solid ${p => p.active ? 'var(--success)' : 'var(--warning, #F59E0B)'}; - background: ${p => p.active ? 'rgba(34,197,94,.06)' : 'rgba(245,158,11,.06)'}; - color: ${p => p.active ? 'var(--success)' : 'var(--warning, #F59E0B)'}; -`; - -const DaemonDot = styled.span<{ active: boolean }>` - width: 8px; - height: 8px; - flex-shrink: 0; - border-radius: 50%; - background: ${p => p.active ? 'var(--success)' : 'var(--warning, #F59E0B)'}; - box-shadow: ${p => p.active ? '0 0 6px rgba(34,197,94,0.6)' : 'none'}; -`; /* ── Policy monitor ── */ @@ -112,34 +83,29 @@ const SectionLabel = styled.div` margin-top: 4px; `; -/* ── Coming Soon placeholder ── */ +/* ── Info note ── */ -const ComingSoonWrap = styled.div` +const InfoNote = styled.div` display: flex; - flex-direction: column; align-items: center; - justify-content: center; - padding: 40px 20px; - gap: 12px; - text-align: center; -`; - -const ComingSoonIcon = styled.div` - font-size: 32px; -`; - -const ComingSoonTitle = styled.div` - font-size: 14px; - font-weight: 700; - color: ${p => p.theme.colors.text}; -`; - -const ComingSoonSub = styled.div` + gap: 8px; + padding: 10px 14px; + border-radius: 8px; font-size: 12px; + font-weight: 600; + margin-bottom: 14px; + border: 1px solid rgba(99,179,237,0.3); + background: rgba(99,179,237,0.06); color: ${p => p.theme.colors.sub}; - line-height: 1.5; `; +const InfoDot = styled.span` + width: 8px; + height: 8px; + flex-shrink: 0; + border-radius: 50%; + background: rgba(99,179,237,0.6); +`; /* ── OracleConsole ── */ @@ -153,7 +119,6 @@ export function OracleConsole() { const { resolveFlightDelay, loading } = useResolveFlightDelay(); const { wallet } = useProgram(); - const [oracleMode, setOracleMode] = useState('manual'); const [contractId, setContractId] = useState(0); const [resolveType, setResolveType] = useState<'delay' | 'noDelay'>('delay'); const [delay, setDelay] = useState(130); @@ -161,13 +126,13 @@ export function OracleConsole() { const [cancelled, setCancelled] = useState(false); const [result, setResult] = useState<{ type: 'error' | 'ok'; msg: string; code?: string } | null>(null); - const daemonStatus = useMemo(() => { - if (lastDaemonActivityTs == null) return { active: false, label: t('oracle.daemonNoData') }; + const SCHEDULER_INTERVAL_MIN = 15; + const nextRunLabel = useMemo(() => { + if (lastDaemonActivityTs == null) return null; const nowSec = Math.floor(Date.now() / 1000); - const diffMin = Math.floor((nowSec - lastDaemonActivityTs) / 60); - if (diffMin > 30) return { active: false, label: t('oracle.daemonInactive') }; - return { active: true, label: t('oracle.daemonLastActive', { minutes: diffMin }) }; - }, [lastDaemonActivityTs, t]); + const minsLeft = Math.ceil((lastDaemonActivityTs + SCHEDULER_INTERVAL_MIN * 60 - nowSec) / 60); + return minsLeft > 0 ? `(${minsLeft}분 후)` : '(잠시 후)'; + }, [lastDaemonActivityTs]); const handleRun = async () => { if (contractId === 0) { toast(t('toast.selectContract'), 'w'); return; } @@ -208,41 +173,18 @@ export function OracleConsole() { {t('oracle.title')} - {mode === 'onchain' ? t('oracle.modeOnchain') : t('oracle.modeSwitchboard')} + {t('oracle.tagManual')} - {/* Oracle control mode selector */} - - setOracleMode('manual')} - > - {t('oracle.tabManual')} - - setOracleMode('trackA')} - > - {t('oracle.tabTrackA')} - - setOracleMode('trackB')} - > - {t('oracle.tabTrackB')} - - - - {/* ── Manual ── */} - {oracleMode === 'manual' && ( - <> - + + + + {t('oracle.manualNote')} + {mode === 'onchain' && nextRunLabel && <> {nextRunLabel}} + + + + {t('oracle.targetContract')} - - )} - - {/* ── Track A ── */} - {oracleMode === 'trackA' && ( - <> - {mode === 'onchain' ? ( - <> - - - - {t('oracle.daemonBadge')} -  ·  - {daemonStatus.label} - - - - {t('oracle.trackAMonitorHintPlain')} - - - ) : ( - - 🤖 - {t('oracle.trackASimTitle')} - {t('oracle.trackASimDesc')} - - )} - - )} - - {/* ── Track B ── */} - {oracleMode === 'trackB' && } ); diff --git a/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx b/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx index 8484d6e..6198817 100644 --- a/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx +++ b/frontend/src/components/tabs/tab-oracle/PolicyMonitorTable.tsx @@ -81,7 +81,8 @@ const SettleBtn = styled.button` const STATUS_COLOR: Record = { active: '#14F195', - claimed: '#9945FF', + claimed: '#F59E0B', + paid: '#9945FF', noClaim: '#94A3B8', expired: '#64748B', settled: '#22C55E', @@ -89,7 +90,8 @@ const STATUS_COLOR: Record = { const STATUS_ICON: Record = { active: '⏳', - claimed: '✅', + claimed: '⚠', + paid: '✅', noClaim: '──', expired: '⏰', settled: '💸', diff --git a/frontend/src/components/tabs/tab-oracle/TrackBPanel.tsx b/frontend/src/components/tabs/tab-oracle/TrackBPanel.tsx deleted file mode 100644 index 48091a9..0000000 --- a/frontend/src/components/tabs/tab-oracle/TrackBPanel.tsx +++ /dev/null @@ -1,714 +0,0 @@ -import { useState, useMemo, Fragment } from 'react'; -import styled from '@emotion/styled'; -import { PublicKey } from '@solana/web3.js'; -import { BN } from '@coral-xyz/anchor'; -import { useTranslation } from 'react-i18next'; -import { Button, Divider, FormGroup, FormLabel, FormInput, Tag, useToast } from '@/components/common'; -import { useProtocolStore } from '@/store/useProtocolStore'; -import { useProgram } from '@/hooks/useProgram'; -import { useCreatePolicy } from '@/hooks/useCreatePolicy'; -import { useOpenUnderwriting } from '@/hooks/useOpenUnderwriting'; -import { useActivatePolicy } from '@/hooks/useActivatePolicy'; -import { useAcceptShare } from '@/hooks/useAcceptShare'; -import { useRejectShare } from '@/hooks/useRejectShare'; -import { useExpirePolicy } from '@/hooks/useExpirePolicy'; -import { useRefundAfterExpiry } from '@/hooks/useRefundAfterExpiry'; -import { useRegisterPolicyholder } from '@/hooks/useRegisterPolicyholder'; -import { useTrackBSettle } from '@/hooks/useTrackBSettle'; -import { useUnderwriting } from '@/hooks/useUnderwriting'; -import { useCheckOracle } from '@/hooks/useCheckOracle'; -import { - PolicyState, - UnderwritingStatus, - ParticipantStatus, - POLICY_STATE_LABELS, - UNDERWRITING_STATUS_LABELS, - PARTICIPANT_STATUS_LABELS, -} from '@/lib/idl/open_parametric'; -import type { PolicyholderEntryInput } from '@/lib/idl/open_parametric'; -import { CURRENCY_MINT } from '@/lib/constants'; - -/* ── Styled ── */ - -const Section = styled.div` - margin-bottom: 16px; -`; - -const SectionLabel = styled.div` - font-size: 10px; - font-weight: 700; - color: ${p => p.theme.colors.sub}; - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 8px; - margin-top: 4px; -`; - -const DaemonBadge = styled.div<{ active: boolean }>` - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - border-radius: 8px; - font-size: 12px; - font-weight: 600; - margin-bottom: 14px; - border: 1px solid ${p => p.active ? 'var(--success)' : 'var(--warning, #F59E0B)'}; - background: ${p => p.active ? 'rgba(34,197,94,.06)' : 'rgba(245,158,11,.06)'}; - color: ${p => p.active ? 'var(--success)' : 'var(--warning, #F59E0B)'}; -`; - -const DaemonDot = styled.span<{ active: boolean }>` - width: 8px; - height: 8px; - flex-shrink: 0; - border-radius: 50%; - background: ${p => p.active ? 'var(--success)' : 'var(--warning, #F59E0B)'}; - box-shadow: ${p => p.active ? '0 0 6px rgba(34,197,94,0.6)' : 'none'}; -`; - -const MiniTable = styled.table` - width: 100%; - border-collapse: collapse; - th { - padding: 4px 8px; - text-align: left; - font-size: 9px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: ${p => p.theme.colors.sub}; - border-bottom: 1px solid ${p => p.theme.colors.border}; - white-space: nowrap; - position: sticky; - top: 0; - z-index: 1; - background: ${p => p.theme.colors.surface1}; - } - td { - padding: 5px 8px; - border-bottom: 1px solid ${p => p.theme.colors.border}; - font-size: 11px; - } - tr:last-child td { border-bottom: none; } - tr:hover td { background: ${p => p.theme.colors.surface2}; } -`; - -const Mono = styled.span` - font-family: 'DM Mono', monospace; - font-size: 10px; -`; - -const StatusBadge = styled.span<{ clr: string }>` - display: inline-flex; - align-items: center; - gap: 3px; - padding: 1px 5px; - border-radius: 4px; - font-size: 9px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - font-family: 'DM Mono', monospace; - background: ${p => p.clr}1a; - color: ${p => p.clr}; - border: 1px solid ${p => p.clr}44; -`; - -const ActionBtn = styled.button<{ variant?: 'primary' | 'danger' | 'default' }>` - font-size: 10px; - font-weight: 600; - padding: 3px 9px; - border-radius: 5px; - border: 1px solid ${p => - p.variant === 'danger' ? 'var(--danger)' : - p.variant === 'primary' ? p.theme.colors.primary : - p.theme.colors.border}; - background: ${p => - p.variant === 'danger' ? 'rgba(239,68,68,0.08)' : - p.variant === 'primary' ? 'rgba(153,69,255,0.08)' : - 'transparent'}; - color: ${p => - p.variant === 'danger' ? 'var(--danger)' : - p.variant === 'primary' ? p.theme.colors.primary : - p.theme.colors.text}; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: all 0.15s; - &:hover { opacity: 0.8; } - &:disabled { opacity: 0.4; cursor: not-allowed; } -`; - -const ExpandRow = styled.tr` - td { - padding: 8px 12px !important; - background: ${p => p.theme.colors.surface2}; - } -`; - -const ParticipantRow = styled.div` - display: flex; - align-items: center; - gap: 8px; - padding: 4px 0; - font-size: 11px; - border-bottom: 1px solid ${p => p.theme.colors.border}; - &:last-child { border-bottom: none; } -`; - -const FormRow = styled.div` - display: flex; - gap: 8px; - align-items: flex-end; - margin-bottom: 8px; -`; - -const SmallInput = styled(FormInput)` - font-size: 11px; - padding: 5px 8px; - font-family: 'DM Mono', monospace; - color-scheme: dark; -`; - -const STATE_COLORS: Record = { - [PolicyState.Draft]: '#94A3B8', - [PolicyState.Open]: '#F59E0B', - [PolicyState.Funded]: '#38BDF8', - [PolicyState.Active]: '#14F195', - [PolicyState.Claimable]: '#9945FF', - [PolicyState.Approved]: '#9945FF', - [PolicyState.Settled]: '#22C55E', - [PolicyState.Expired]: '#64748B', -}; - -/* ── Subcomponents ── */ - -function UWDetail({ policyPubkey }: { policyPubkey: PublicKey }) { - const { t } = useTranslation(); - const { toast } = useToast(); - const { wallet } = useProgram(); - const { account: uw, loading: uwLoading } = useUnderwriting(policyPubkey); - const { acceptShare, loading: acceptLoading } = useAcceptShare(); - const { rejectShare, loading: rejectLoading } = useRejectShare(); - const [depositAmount, setDepositAmount] = useState(''); - - if (uwLoading || !uw) return Loading...; - - const walletKey = wallet?.publicKey?.toBase58(); - - return ( -
-
- {t('oracle.trackBUWStatus')} - - {UNDERWRITING_STATUS_LABELS[uw.status] || String(uw.status)} - -
- {t('oracle.trackBParticipantList')} - {uw.participants.map((p, i) => { - const isMe = walletKey === p.insurer.toBase58(); - const isPending = p.status === ParticipantStatus.Pending; - return ( - - - {p.insurer.toBase58().slice(0, 8)}… {isMe && '(me)'} - - {p.ratioBps} bps - - {PARTICIPANT_STATUS_LABELS[p.status] || String(p.status)} - - {isMe && isPending && uw.status === UnderwritingStatus.Open && ( - <> - setDepositAmount(e.target.value)} - style={{ width: 100 }} - /> - { - const res = await acceptShare(policyPubkey, i, new BN(depositAmount)); - if (res.success) toast(t('oracle.trackBAcceptDone'), 's'); - else toast(res.error || '', 'd'); - }} - > - {t('oracle.trackBAcceptShare')} - - { - const res = await rejectShare(policyPubkey, i); - if (res.success) toast(t('oracle.trackBRejectDone'), 's'); - else toast(res.error || '', 'd'); - }} - > - {t('oracle.trackBRejectShare')} - - - )} - - ); - })} -
- ); -} - -/* ── Main Panel ── */ - -export function TrackBPanel() { - const { t } = useTranslation(); - const { toast } = useToast(); - const { wallet } = useProgram(); - const { trackBPolicies, trackBClaims, lastDaemonActivityTs } = useProtocolStore(); - - // Hooks - const { createPolicy, loading: createLoading } = useCreatePolicy(); - const { openUnderwriting, loading: openUWLoading } = useOpenUnderwriting(); - const { activatePolicy, loading: activateLoading } = useActivatePolicy(); - const { expirePolicy, loading: expireLoading } = useExpirePolicy(); - const { refundAfterExpiry, loading: refundLoading } = useRefundAfterExpiry(); - const { registerPolicyholder, loading: registerLoading } = useRegisterPolicyholder(); - const { approveClaim, settleClaim, loading: settleLoading } = useTrackBSettle(); - const { checkOracle, loading: oracleCheckLoading } = useCheckOracle(); - - // UI state - const [expandedKey, setExpandedKey] = useState(null); - const [showCreateForm, setShowCreateForm] = useState(false); - const [actionLoading, setActionLoading] = useState(null); - - // Create policy form state - const [cpId, setCpId] = useState('1'); - const [cpRoute, setCpRoute] = useState(''); - const [cpFlightNo, setCpFlightNo] = useState(''); - const [cpDepDate, setCpDepDate] = useState(''); - const [cpPayout, setCpPayout] = useState(''); - const [cpOracleFeed, setCpOracleFeed] = useState(''); - const [cpActiveFrom, setCpActiveFrom] = useState(''); - const [cpActiveTo, setCpActiveTo] = useState(''); - const [cpReinsurer, setCpReinsurer] = useState(''); - const [cpCededBps, setCpCededBps] = useState('0'); - const [cpReinsBps, setCpReinsBps] = useState('0'); - const [cpMint, setCpMint] = useState(CURRENCY_MINT.toBase58()); - const [cpParticipants, setCpParticipants] = useState<{ insurer: string; ratioBps: string }[]>([ - { insurer: '', ratioBps: '10000' }, - ]); - - // Register policyholder form - const [showRegister, setShowRegister] = useState(null); - const [regRef, setRegRef] = useState(''); - const [regPolicyId, setRegPolicyId] = useState(''); - const [regFlightNo, setRegFlightNo] = useState(''); - const [regDepDate, setRegDepDate] = useState(''); - const [regPassengers, setRegPassengers] = useState('1'); - const [regPremium, setRegPremium] = useState(''); - const [regCoverage, setRegCoverage] = useState(''); - - // Oracle trigger form - const [oraclePolicyKey, setOraclePolicyKey] = useState(''); - - const daemonStatus = useMemo(() => { - if (lastDaemonActivityTs == null) return { active: false, label: t('oracle.daemonNoData') }; - const nowSec = Math.floor(Date.now() / 1000); - const diffMin = Math.floor((nowSec - lastDaemonActivityTs) / 60); - if (diffMin > 30) return { active: false, label: t('oracle.daemonInactive') }; - return { active: true, label: t('oracle.daemonLastActive', { minutes: diffMin }) }; - }, [lastDaemonActivityTs, t]); - - const ratioSum = cpParticipants.reduce((s, p) => s + (parseInt(p.ratioBps) || 0), 0); - - const handleCreatePolicy = async () => { - if (!wallet) return; - try { - const mint = new PublicKey(cpMint); - const params = { - policyId: new BN(cpId), - route: cpRoute, - flightNo: cpFlightNo, - departureDate: new BN(Math.floor(new Date(cpDepDate).getTime() / 1000)), - delayThresholdMin: 120, - payoutAmount: new BN(cpPayout), - oracleFeed: new PublicKey(cpOracleFeed), - activeFrom: new BN(Math.floor(new Date(cpActiveFrom).getTime() / 1000)), - activeTo: new BN(Math.floor(new Date(cpActiveTo).getTime() / 1000)), - participants: cpParticipants.map(p => ({ - insurer: new PublicKey(p.insurer), - ratioBps: parseInt(p.ratioBps), - })), - }; - const res = await createPolicy(params, mint); - if (res.success) { toast(t('oracle.trackBCreated'), 's'); setShowCreateForm(false); } - else toast(res.error || '', 'd'); - } catch (err) { - toast(err instanceof Error ? err.message : String(err), 'd'); - } - }; - - const handleRegisterPH = async (policyPK: string) => { - try { - const entry: PolicyholderEntryInput = { - externalRef: regRef, - policyId: new BN(regPolicyId), - flightNo: regFlightNo, - departureDate: new BN(Math.floor(new Date(regDepDate).getTime() / 1000)), - passengerCount: parseInt(regPassengers), - premiumPaid: new BN(regPremium), - coverageAmount: new BN(regCoverage), - }; - const res = await registerPolicyholder(new PublicKey(policyPK), entry); - if (res.success) { toast(t('oracle.trackBRegisterPHDone'), 's'); setShowRegister(null); } - else toast(res.error || '', 'd'); - } catch (err) { - toast(err instanceof Error ? err.message : String(err), 'd'); - } - }; - - return ( - <> - {/* Daemon badge */} - - - - {t('oracle.trackBDaemonBadge')} -  · {daemonStatus.label} - - - - {/* Policy monitor table */} - {t('oracle.trackBMonitor')} - {trackBPolicies.length === 0 ? ( -
- {t('oracle.trackBNoPolicies')} -
- ) : ( -
- - - - # - {t('oracle.th.flight')} - {t('oracle.th.status')} - - - - - {trackBPolicies.map(p => { - const pKey = p.publicKey.toBase58(); - const claim = trackBClaims.find(c => c.account.policy.toBase58() === pKey); - const stateNum = p.account.state; - const clr = STATE_COLORS[stateNum] || '#94A3B8'; - const stateLabel = POLICY_STATE_LABELS[stateNum] || String(stateNum); - const isExpanded = expandedKey === pKey; - const nowSec = Math.floor(Date.now() / 1000); - const canExpire = stateNum === PolicyState.Active && p.account.activeTo.toNumber() < nowSec; - - return ( - - setExpandedKey(isExpanded ? null : pKey)}> - #{p.account.policyId.toNumber()} - {p.account.flightNo || pKey.slice(0, 8)} - {stateLabel} - - {/* Draft → Open UW */} - {stateNum === PolicyState.Draft && ( - { e.stopPropagation(); setActionLoading(`uw-${pKey}`); - const res = await openUnderwriting(p.publicKey); - setActionLoading(null); - if (res.success) toast(t('oracle.trackBOpenUWDone'), 's'); - else toast(res.error || '', 'd'); - }}> - {actionLoading === `uw-${pKey}` ? '…' : t('oracle.trackBOpenUW')} - - )} - {/* Funded → Activate */} - {stateNum === PolicyState.Funded && ( - { e.stopPropagation(); setActionLoading(`act-${pKey}`); - const res = await activatePolicy(p.publicKey); - setActionLoading(null); - if (res.success) toast(t('oracle.trackBActivateDone'), 's'); - else toast(res.error || '', 'd'); - }}> - {actionLoading === `act-${pKey}` ? '…' : t('oracle.trackBActivate')} - - )} - {/* Active + expired time → Expire */} - {canExpire && ( - { e.stopPropagation(); setActionLoading(`exp-${pKey}`); - const res = await expirePolicy(p.publicKey); - setActionLoading(null); - if (res.success) toast(t('oracle.trackBExpireDone'), 's'); - else toast(res.error || '', 'd'); - }}> - {actionLoading === `exp-${pKey}` ? '…' : t('oracle.trackBExpire')} - - )} - {/* Claimable → Approve */} - {stateNum === PolicyState.Claimable && claim && ( - { e.stopPropagation(); setActionLoading(`apr-${pKey}`); - const res = await approveClaim(p.publicKey, claim.publicKey); - setActionLoading(null); - if (res.success) toast(t('oracle.trackBApproved'), 's'); - else toast(res.error || '', 'd'); - }}> - {actionLoading === `apr-${pKey}` ? '…' : t('oracle.trackBApproveBtn')} - - )} - {/* Approved → Settle */} - {stateNum === PolicyState.Approved && claim && ( - { e.stopPropagation(); setActionLoading(`stl-${pKey}`); - const res = await settleClaim(p.publicKey, claim.publicKey); - setActionLoading(null); - if (res.success) toast(t('oracle.trackBSettled'), 's'); - else toast(res.error || '', 'd'); - }}> - {actionLoading === `stl-${pKey}` ? '…' : t('oracle.trackBSettleBtn')} - - )} - {/* Expired → Refund */} - {stateNum === PolicyState.Expired && ( - { e.stopPropagation(); setActionLoading(`ref-${pKey}`); - const res = await refundAfterExpiry(p.publicKey, 0); - setActionLoading(null); - if (res.success) toast(t('oracle.trackBRefundDone'), 's'); - else toast(res.error || '', 'd'); - }}> - {actionLoading === `ref-${pKey}` ? '…' : t('oracle.trackBRefund')} - - )} - {/* Register PH */} - { e.stopPropagation(); setShowRegister(showRegister === pKey ? null : pKey); }}> - {t('oracle.trackBRegisterPH')} - - - - {/* Expanded: UW detail */} - {isExpanded && ( - - - - - - )} - {/* Register PH form */} - {showRegister === pKey && ( - - - {t('oracle.trackBRegisterPH')} - - setRegRef(e.target.value)} style={{ flex: 1 }} /> - setRegPolicyId(e.target.value)} style={{ width: 80 }} /> - - - setRegFlightNo(e.target.value)} style={{ flex: 1 }} /> - setRegDepDate(e.target.value)} style={{ flex: 1 }} /> - - - setRegPassengers(e.target.value)} style={{ width: 80 }} /> - setRegPremium(e.target.value)} style={{ flex: 1 }} /> - setRegCoverage(e.target.value)} style={{ flex: 1 }} /> - - handleRegisterPH(pKey)}> - {registerLoading ? '…' : t('oracle.trackBRegisterPH')} - - - - )} - - ); - })} - - -
- )} - - - - {/* Policy creation form */} -
-
- {t('oracle.trackBCreatePolicy')} - setShowCreateForm(!showCreateForm)}> - {showCreateForm ? '−' : '+'} - -
- {showCreateForm && ( -
- - - {t('oracle.trackBPolicyId')} - setCpId(e.target.value)} /> - - - {t('oracle.trackBRoute')} - setCpRoute(e.target.value)} placeholder="ICN-NRT" /> - - - - - {t('oracle.trackBFlightNo')} - setCpFlightNo(e.target.value)} placeholder="KE001" /> - - - {t('oracle.trackBPayoutAmount')} - setCpPayout(e.target.value)} /> - - - - - {t('oracle.trackBDepartureDate')} - setCpDepDate(e.target.value)} /> - - - - {t('oracle.trackBActiveFrom')} - setCpActiveFrom(e.target.value)} /> - - - {t('oracle.trackBActiveTo')} - setCpActiveTo(e.target.value)} /> - - - {t('oracle.trackBOracleFeed')} - setCpOracleFeed(e.target.value)} placeholder="Pubkey..." /> - - - {t('oracle.trackBCurrencyMint')} - setCpMint(e.target.value)} /> - - - {/* Reinsurer */} - {t('oracle.trackBReinsurer')} - - {t('oracle.trackBReinsurerAddr')} - setCpReinsurer(e.target.value)} placeholder="Pubkey..." /> - - - - {t('oracle.trackBCededBps')} - setCpCededBps(e.target.value)} placeholder="0" /> - - - {t('oracle.trackBReinsBps')} - setCpReinsBps(e.target.value)} placeholder="0" /> - - - - {/* Participants */} - {t('oracle.trackBParticipants')} - {cpParticipants.map((p, i) => ( - - { - const val = e.target.value; - setCpParticipants(prev => prev.map((item, j) => - j === i ? { insurer: val, ratioBps: item.ratioBps } : item, - )); - }} - style={{ flex: 3 }} - /> - { - const val = e.target.value; - setCpParticipants(prev => prev.map((item, j) => - j === i ? { insurer: item.insurer, ratioBps: val } : item, - )); - }} - style={{ width: 80 }} - /> - {cpParticipants.length > 1 && ( - setCpParticipants(cpParticipants.filter((_, j) => j !== i))}> - {t('oracle.trackBRemoveParticipant')} - - )} - - ))} -
- setCpParticipants([...cpParticipants, { insurer: '', ratioBps: '' }])}> - {t('oracle.trackBAddParticipant')} - - - {t('oracle.trackBRatioSum', { sum: ratioSum })} - -
- - -
- )} -
- - - - {/* 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 ef7b8b7..0d31c0f 100644 --- a/frontend/src/hooks/useFlightPolicies.ts +++ b/frontend/src/hooks/useFlightPolicies.ts @@ -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/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 80b7a2a..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; } @@ -164,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; @@ -241,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 }; @@ -284,8 +267,6 @@ export const useProtocolStore = create()(persist((set, get) => ({ lastTxSignature: null, masterPolicies: [], lastDaemonActivityTs: null, - trackBPolicies: [], - trackBClaims: [], setMode: (m) => { set({ mode: m }); @@ -767,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 } @@ -866,9 +847,6 @@ export const useProtocolStore = create()(persist((set, get) => ({ }); }, - syncTrackBPoliciesFromChain: (policies, claims) => { - set({ trackBPolicies: policies, trackBClaims: claims }); - }, }), { name: 'riskmesh-protocol', partialize: (state) => { From e89c071adc8e468bc2c0e956bc25f96acff09b50 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:20:26 +0900 Subject: [PATCH 5/8] fix: frontend > css error resolve --- frontend/src/components/tabs/tab-contract/StateMachine.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tabs/tab-contract/StateMachine.tsx b/frontend/src/components/tabs/tab-contract/StateMachine.tsx index d7bb55d..85b4768 100644 --- a/frontend/src/components/tabs/tab-contract/StateMachine.tsx +++ b/frontend/src/components/tabs/tab-contract/StateMachine.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled'; -import { keyframes } from '@emotion/react'; +import { keyframes, css } from '@emotion/react'; import { Card, CardHeader, CardTitle } from '@/components/common'; import { useProtocolStore, POLICY_STATES, POLICY_STATE_ICONS } from '@/store/useProtocolStore'; @@ -45,8 +45,8 @@ const StateCircle = styled.div<{ state?: CircleState }>` transition: all 0.5s; ${p => p.state === 'done' && `border-color:var(--success);background:rgba(34,197,94,.1);color:var(--success);box-shadow:0 0 10px rgba(34,197,94,.3);`} - ${p => p.state === 'cur' && `border-color:var(--primary);background:rgba(153,69,255,.15);color:var(--primary);box-shadow:0 0 16px rgba(153,69,255,.22);animation:${pulsePrimary} 2s infinite;`} - ${p => p.state === 'claimable' && `border-color:var(--warning);background:rgba(245,158,11,.15);color:var(--warning);animation:${pulseWarning} 1.5s infinite;`} + ${p => p.state === 'cur' && css`border-color:var(--primary);background:rgba(153,69,255,.15);color:var(--primary);box-shadow:0 0 16px rgba(153,69,255,.22);animation:${pulsePrimary} 2s infinite;`} + ${p => p.state === 'claimable' && css`border-color:var(--warning);background:rgba(245,158,11,.15);color:var(--warning);animation:${pulseWarning} 1.5s infinite;`} ${p => p.state === 'settled' && `border-color:var(--accent);background:rgba(20,241,149,.1);color:var(--accent);box-shadow:0 0 14px rgba(20,241,149,.22);`} `; From 52ebf86c3baadbfdc1c7f5ab1c093a95a8b192d3 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:38:18 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feature:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20AP?= =?UTF-8?q?I(web.rs)=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20API=20spec=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/cache.rs | 120 --------- backend/src/web.rs | 228 ----------------- docs/API_SPECIFICATION.md | 506 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 506 insertions(+), 348 deletions(-) delete mode 100644 backend/src/cache.rs delete mode 100644 backend/src/web.rs create mode 100644 docs/API_SPECIFICATION.md 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/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/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) +} +``` From 02c27e5c3b5cd9d85150fe66731a1f0e0e81eb6b Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:52:34 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feature:=20=EC=88=98=EB=8F=99=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(=EC=99=B8=EB=B6=80=20api=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=20x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contract/scripts/manual-create-flight.ts | 134 ++++++++++++++ contract/scripts/manual-list.ts | 96 ++++++++++ contract/scripts/manual-resolve.ts | 87 ++++++++++ contract/scripts/manual-settle.ts | 149 ++++++++++++++++ docs/MANUAL_SCRIPTS_GUIDE.md | 212 +++++++++++++++++++++++ 5 files changed, 678 insertions(+) create mode 100644 contract/scripts/manual-create-flight.ts create mode 100644 contract/scripts/manual-list.ts create mode 100644 contract/scripts/manual-resolve.ts create mode 100644 contract/scripts/manual-settle.ts create mode 100644 docs/MANUAL_SCRIPTS_GUIDE.md 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/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) ✓ +``` From e9241edf0193a51d65b46e0cb55f81dba620b937 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:59:22 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BACKEND_API_SPEC.md | 515 ------------------------- docs/MASTER_POLICY_REDESIGN_PLAN_KO.md | 244 ------------ docs/emotion-migration-handoff.md | 101 ----- 3 files changed, 860 deletions(-) delete mode 100644 docs/BACKEND_API_SPEC.md delete mode 100644 docs/MASTER_POLICY_REDESIGN_PLAN_KO.md delete mode 100644 docs/emotion-migration-handoff.md diff --git a/docs/BACKEND_API_SPEC.md b/docs/BACKEND_API_SPEC.md deleted file mode 100644 index 6675a18..0000000 --- a/docs/BACKEND_API_SPEC.md +++ /dev/null @@ -1,515 +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` - -현재 상태 그대로 유지. - -**curl 예시:** -```bash -curl http://localhost:3000/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 -``` - -**curl 예시:** -```bash -curl http://localhost:3000/api/master-policies - -curl "http://localhost:3000/api/master-policies?leader=GNPnwyRCCvo8wLEPwJEmzEjrqyhSXeyXvTbYibieHpYM" -``` - -**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 -``` - -**curl 예시:** -```bash -curl http://localhost:3000/api/flight-policies - -curl "http://localhost:3000/api/flight-policies?master=c6DFe9oViEFYKPyasoCM8eiYggx9TZ2e7qH6UTr55mr" - -curl "http://localhost:3000/api/flight-policies?master=c6DFe9oViEFYKPyasoCM8eiYggx9TZ2e7qH6UTr55mr&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 계정 주소 | - -**curl 예시:** -```bash -curl http://localhost:3000/api/master-policies/3yGp... -``` - -**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 계정 주소 | - -**curl 예시:** -```bash -curl http://localhost:3000/api/flight-policies/9zAb... -``` - -**Response:** 위 목록 응답의 단일 `FlightPolicyInfo` 객체. - ---- - -### `GET /api/master-policies/:master_policy_pubkey/flight-policies` - -특정 MasterPolicy에 속한 FlightPolicy 목록 조회. - -**Path Parameters:** - -| 파라미터 | 타입 | 설명 | -|---|---|---| -| `master_policy_pubkey` | `string (base58)` | MasterPolicy 계정 주소 | - -**curl 예시:** -```bash -curl "http://localhost:3000/api/master-policies/3yGp.../flight-policies" -``` - -**Response:** -```json -{ - "program_id": "ETEEEssGKAAQEGwz3ggDcy9vzPAPtBjtb2KocdyLBMjh", - "master_policy_pubkey": "3yGp...", - "count": 2, - "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 - } - ] -} -``` - -**Error:** -```json -{ "error": "account not found" } // 404 -{ "error": "failed to fetch: ..." } // 500 -``` - ---- - -### `POST /api/master-policies/:master_policy_pubkey/flight-policies` - -특정 MasterPolicy 아래에 새로운 FlightPolicy를 생성. - -**Path Parameters:** - -| 파라미터 | 타입 | 설명 | -|---|---|---| -| `master_policy_pubkey` | `string (base58)` | FlightPolicy를 생성할 대상 MasterPolicy 계정 주소 | - -**Request Body:** -```json -{ - "subscriber_ref": "USR-001", - "flight_no": "KE001", - "route": "ICN-NRT", - "departure_ts": 1710500000 -} -``` - -**curl 예시:** -```bash -curl -X POST "http://localhost:3000/api/master-policies/3yGp.../flight-policies" \ - -H "Content-Type: application/json" \ - -d '{ - "subscriber_ref": "USR-001", - "flight_no": "KE001", - "route": "ICN-NRT", - "departure_ts": 1710500000 - }' -``` - -**Response:** -```json -{ - "program_id": "ETEEEssGKAAQEGwz3ggDcy9vzPAPtBjtb2KocdyLBMjh", - "master_policy_pubkey": "3yGp...", - "child_policy_id": 1, - "flight_policy_pubkey": "9zAb...", - "tx_signature": "5YkK..." -} -``` - -**Error:** -```json -{ "error": "master_policy_pubkey 주소 파싱 실패: ..." } // 400 -{ "error": "MasterPolicy가 Active 상태가 아닙니다: status=..." } // 500 -{ "error": "현재 서버 키는 이 MasterPolicy의 leader/operator 권한이 없습니다" } // 500 -{ "error": "subscriber_ref, flight_no, route는 비어 있을 수 없습니다" } // 500 -``` - ---- - -### `GET /api/events` ⭐ (핵심 신규 엔드포인트) - -**Server-Sent Events** 스트림. 백엔드가 Solana 계정 변경을 감지하면 연결된 프론트에 즉시 푸시. - -**Query Parameters:** - -| 파라미터 | 타입 | 필수 | 설명 | -|---|---|---|---| -| `master` | `string (base58 pubkey)` | 선택 | 이 master에 속한 FlightPolicy 변경 이벤트만 수신. 생략 시 전체 수신. | - -**curl 예시:** -```bash -curl -N http://localhost:3000/api/events - -curl -N "http://localhost:3000/api/events?master=3yGp..." -``` - -**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/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` -- **현상**: 토글이 정적 `
`로만 구현. 키보드/스크린리더 접근 불가 -- **권장**: `