diff --git a/backend/src/api/service/tests.rs b/backend/src/api/service/tests.rs index 024a12f7..acdbe709 100644 --- a/backend/src/api/service/tests.rs +++ b/backend/src/api/service/tests.rs @@ -104,6 +104,7 @@ fn master_agreement( MasterAgreementInfo { pubkey: pubkey.to_string(), master_id: 1, + name: "test master agreement".to_string(), leader: leader.to_string(), operator: leader.to_string(), currency_mint: Pubkey::new_unique().to_string(), diff --git a/backend/src/db/tests.rs b/backend/src/db/tests.rs index f25c335b..5e27f799 100644 --- a/backend/src/db/tests.rs +++ b/backend/src/db/tests.rs @@ -78,6 +78,7 @@ fn master_agreement(pubkey: &str, premium_per_policy: u64) -> MasterAgreementInf MasterAgreementInfo { pubkey: pubkey.to_string(), master_id: 1, + name: "test master agreement".to_string(), leader: "leader-1".to_string(), operator: "leader-1".to_string(), currency_mint: Pubkey::new_unique().to_string(), diff --git a/backend/src/events/tests.rs b/backend/src/events/tests.rs index ae1fe378..868faad9 100644 --- a/backend/src/events/tests.rs +++ b/backend/src/events/tests.rs @@ -9,6 +9,7 @@ fn master_agreement(pubkey: &str, status: u8) -> MasterAgreementInfo { MasterAgreementInfo { pubkey: pubkey.to_string(), master_id: 1, + name: "test master agreement".to_string(), leader: "leader-1".to_string(), operator: "leader-1".to_string(), currency_mint: Pubkey::new_unique().to_string(), diff --git a/backend/src/oracle/program_accounts/mod.rs b/backend/src/oracle/program_accounts/mod.rs index f9ef8f4c..684d3004 100644 --- a/backend/src/oracle/program_accounts/mod.rs +++ b/backend/src/oracle/program_accounts/mod.rs @@ -23,6 +23,7 @@ pub struct MasterAgreementParticipantInfo { pub struct MasterAgreementInfo { pub pubkey: String, pub master_id: u64, + pub name: String, pub leader: String, pub operator: String, pub currency_mint: String, @@ -132,9 +133,45 @@ fn scan_accounts( } fn parse_master_agreement(pubkey: &Pubkey, data: &[u8]) -> Result { + match parse_master_agreement_with_name(pubkey, data) { + Ok(info) => Ok(info), + Err(name_layout_err) => { + let legacy = parse_master_agreement_legacy(pubkey, data); + match legacy { + Ok(info) => { + tracing::info!( + "[program_accounts] legacy MasterAgreement 레이아웃으로 파싱 성공 {pubkey}" + ); + Ok(info) + } + Err(legacy_err) => Err(name_layout_err) + .with_context(|| format!("legacy 레이아웃 파싱도 실패: {legacy_err}")), + } + } + } +} + +fn parse_master_agreement_with_name(pubkey: &Pubkey, data: &[u8]) -> Result { let mut offset = 8usize; let master_id = read_u64(data, &mut offset)?; + let name = read_string(data, &mut offset)?; + parse_master_agreement_from_offset(pubkey, data, offset, master_id, name) +} + +fn parse_master_agreement_legacy(pubkey: &Pubkey, data: &[u8]) -> Result { + let mut offset = 8usize; + let master_id = read_u64(data, &mut offset)?; + parse_master_agreement_from_offset(pubkey, data, offset, master_id, String::new()) +} + +fn parse_master_agreement_from_offset( + pubkey: &Pubkey, + data: &[u8], + mut offset: usize, + master_id: u64, + name: String, +) -> Result { let leader = read_pubkey(data, &mut offset)?; let operator = read_pubkey(data, &mut offset)?; let currency_mint = read_pubkey(data, &mut offset)?; @@ -164,6 +201,7 @@ fn parse_master_agreement(pubkey: &Pubkey, data: &[u8]) -> Result (Pubkey, Vec, Vec) { let mut data = anchor_account_discriminator("MasterAgreement").to_vec(); data.extend_from_slice(&7u64.to_le_bytes()); + push_string(&mut data, "대한-뉴욕 2026 리더 공동계약"); push_pubkey(&mut data, &leader); push_pubkey(&mut data, &operator); push_pubkey(&mut data, ¤cy_mint); @@ -81,6 +82,77 @@ fn build_master_agreement_bytes() -> (Pubkey, Vec, Vec) { ) } +fn build_legacy_master_agreement_bytes() -> (Pubkey, Vec, Vec) { + let pubkey = Pubkey::new_unique(); + let leader = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + let currency_mint = Pubkey::new_unique(); + let reinsurer = Pubkey::new_unique(); + let reinsurer_pool_wallet = Pubkey::new_unique(); + let reinsurer_deposit_wallet = Pubkey::new_unique(); + let leader_pool_wallet = Pubkey::new_unique(); + let leader_deposit_wallet = Pubkey::new_unique(); + let participant_insurer = Pubkey::new_unique(); + let participant_pool_wallet = Pubkey::new_unique(); + let participant_deposit_wallet = Pubkey::new_unique(); + let oracle_feed = Pubkey::new_unique(); + + let mut data = anchor_account_discriminator("MasterAgreement").to_vec(); + data.extend_from_slice(&7u64.to_le_bytes()); + push_pubkey(&mut data, &leader); + push_pubkey(&mut data, &operator); + push_pubkey(&mut data, ¤cy_mint); + data.extend_from_slice(&100i64.to_le_bytes()); + data.extend_from_slice(&200i64.to_le_bytes()); + data.extend_from_slice(&1_000u64.to_le_bytes()); + data.extend_from_slice(&100u64.to_le_bytes()); + data.extend_from_slice(&200u64.to_le_bytes()); + data.extend_from_slice(&300u64.to_le_bytes()); + data.extend_from_slice(&400u64.to_le_bytes()); + data.extend_from_slice(&5_000u16.to_le_bytes()); + data.extend_from_slice(&1_100u16.to_le_bytes()); + data.extend_from_slice(&220u16.to_le_bytes()); + data.extend_from_slice(&880u16.to_le_bytes()); + data.push(1); + push_pubkey(&mut data, &reinsurer); + data.push(1); + data.push(1); + push_pubkey(&mut data, &reinsurer_pool_wallet); + data.push(1); + push_pubkey(&mut data, &reinsurer_deposit_wallet); + push_pubkey(&mut data, &leader_pool_wallet); + push_pubkey(&mut data, &leader_deposit_wallet); + data.extend_from_slice(&1u32.to_le_bytes()); + push_pubkey(&mut data, &participant_insurer); + data.extend_from_slice(&5_000u16.to_le_bytes()); + data.push(1); + push_pubkey(&mut data, &participant_pool_wallet); + push_pubkey(&mut data, &participant_deposit_wallet); + push_pubkey(&mut data, &oracle_feed); + data.push(2); + data.extend_from_slice(&777i64.to_le_bytes()); + data.push(254); + + ( + pubkey, + data, + vec![ + leader, + operator, + currency_mint, + reinsurer, + reinsurer_pool_wallet, + reinsurer_deposit_wallet, + leader_pool_wallet, + leader_deposit_wallet, + participant_insurer, + participant_pool_wallet, + participant_deposit_wallet, + oracle_feed, + ], + ) +} + fn build_flight_policy_bytes(status: u8) -> (Pubkey, Vec, Pubkey, Pubkey) { let pubkey = Pubkey::new_unique(); let master = Pubkey::new_unique(); @@ -115,6 +187,7 @@ fn parse_master_agreement_parses_full_account_data() { assert_eq!(agreement.pubkey, pubkey.to_string()); assert_eq!(agreement.master_id, 7); + assert_eq!(agreement.name, "대한-뉴욕 2026 리더 공동계약"); assert_eq!(agreement.leader, keys[0].to_string()); assert_eq!(agreement.operator, keys[1].to_string()); assert_eq!(agreement.currency_mint, keys[2].to_string()); @@ -146,6 +219,23 @@ fn parse_master_agreement_parses_full_account_data() { assert_eq!(agreement.created_at, 777); } +#[test] +fn parse_master_agreement_parses_legacy_account_data_without_name() { + let (pubkey, data, keys) = build_legacy_master_agreement_bytes(); + + let agreement = parse_master_agreement(&pubkey, &data).unwrap(); + + assert_eq!(agreement.pubkey, pubkey.to_string()); + assert_eq!(agreement.master_id, 7); + assert_eq!(agreement.name, ""); + assert_eq!(agreement.leader, keys[0].to_string()); + assert_eq!(agreement.operator, keys[1].to_string()); + assert_eq!(agreement.currency_mint, keys[2].to_string()); + assert_eq!(agreement.participants.len(), 1); + assert_eq!(agreement.oracle_feed, keys[11].to_string()); + assert_eq!(agreement.status, 2); +} + #[test] fn parse_flight_policy_parses_full_account_data() { let (pubkey, data, master, creator) = build_flight_policy_bytes(4); @@ -188,5 +278,11 @@ fn parse_master_agreement_fails_on_truncated_data() { let error = parse_master_agreement(&pubkey, &data).unwrap_err(); - assert!(error.to_string().contains("읽기 실패") || error.to_string().contains("범위 초과")); + let message = error.to_string(); + assert!( + message.contains("읽기 실패") + || message.contains("범위 초과") + || message.contains("legacy 레이아웃 파싱도 실패"), + "unexpected error: {message}" + ); } diff --git a/contract/programs/open_parametric/src/constants.rs b/contract/programs/open_parametric/src/constants.rs index 56fec1c4..53a0390b 100644 --- a/contract/programs/open_parametric/src/constants.rs +++ b/contract/programs/open_parametric/src/constants.rs @@ -4,8 +4,8 @@ pub const ORACLE_MAX_STALENESS_SLOTS: u64 = 150; // approx 60-90s depending on c pub const MAX_ROUTE_LEN: usize = 16; pub const MAX_FLIGHT_NO_LEN: usize = 16; pub const MAX_MASTER_PARTICIPANTS: usize = 5; +pub const MASTER_AGREEMENT_NAME_MAX_LEN: usize = 40; pub const MAX_SUBSCRIBER_REF_LEN: usize = 64; -// oracle_feed(32 bytes) 추가됐지만 4096 버퍼로 충분. -pub const MASTER_POLICY_SPACE: usize = 4096; +pub const MASTER_POLICY_SPACE: usize = 8 + 1024 + 4 + MASTER_AGREEMENT_NAME_MAX_LEN * 4; pub const FLIGHT_POLICY_SPACE: usize = 1024; diff --git a/contract/programs/open_parametric/src/instructions/create_master_agreement.rs b/contract/programs/open_parametric/src/instructions/create_master_agreement.rs index ac34697e..69aa7620 100644 --- a/contract/programs/open_parametric/src/instructions/create_master_agreement.rs +++ b/contract/programs/open_parametric/src/instructions/create_master_agreement.rs @@ -6,6 +6,8 @@ use crate::errors::OpenParamError; use crate::math::effective_reinsurer_bps; use crate::state::*; +use super::master_agreement_name::normalize_master_agreement_name; + #[derive(Accounts)] #[instruction(params: CreateMasterAgreementParams)] pub struct CreateMasterAgreement<'info> { @@ -37,6 +39,7 @@ pub fn handler( ctx: Context, params: CreateMasterAgreementParams, ) -> Result<()> { + let normalized_name = normalize_master_agreement_name(¶ms.name)?; let master = &mut ctx.accounts.master_agreement; let has_reinsurer = params.ceded_ratio_bps > 0; @@ -73,6 +76,7 @@ pub fn handler( effective_reinsurer_bps(params.ceded_ratio_bps, params.reins_commission_bps)?; master.master_id = params.master_id; + master.name = normalized_name; master.leader = ctx.accounts.leader.key(); master.operator = ctx.accounts.operator.key(); master.currency_mint = ctx.accounts.currency_mint.key(); diff --git a/contract/programs/open_parametric/src/instructions/create_master_agreement_test.rs b/contract/programs/open_parametric/src/instructions/create_master_agreement_test.rs index f8c85f69..f86cb9ed 100644 --- a/contract/programs/open_parametric/src/instructions/create_master_agreement_test.rs +++ b/contract/programs/open_parametric/src/instructions/create_master_agreement_test.rs @@ -4,7 +4,10 @@ use crate::constants::MAX_MASTER_PARTICIPANTS; use crate::errors::OpenParamError; use crate::state::MasterParticipantInit; -use super::create_master_agreement::{validate_create_master_inputs, validate_master_participants}; +use super::{ + create_master_agreement::{validate_create_master_inputs, validate_master_participants}, + master_agreement_name::normalize_master_agreement_name, +}; #[test] fn master_participants_require_10000_bps_with_separate_leader_share() { @@ -179,3 +182,22 @@ fn accepts_collateral_claim_count_between_1_and_100() { assert!(result.is_ok()); } + +#[test] +fn accepts_trimmed_name_within_40_chars() { + let normalized = normalize_master_agreement_name(" 2026 인천-뉴욕 공동계약 ").unwrap(); + assert_eq!(normalized, "2026 인천-뉴욕 공동계약"); +} + +#[test] +fn rejects_blank_master_agreement_name() { + let error = normalize_master_agreement_name(" ").unwrap_err(); + assert!(matches!(error, OpenParamError::InvalidInput)); +} + +#[test] +fn rejects_master_agreement_name_longer_than_40_chars() { + let error = + normalize_master_agreement_name("12345678901234567890123456789012345678901").unwrap_err(); + assert!(matches!(error, OpenParamError::InvalidInput)); +} diff --git a/contract/programs/open_parametric/src/instructions/master_agreement_name.rs b/contract/programs/open_parametric/src/instructions/master_agreement_name.rs new file mode 100644 index 00000000..615d2116 --- /dev/null +++ b/contract/programs/open_parametric/src/instructions/master_agreement_name.rs @@ -0,0 +1,12 @@ +use crate::constants::MASTER_AGREEMENT_NAME_MAX_LEN; +use crate::errors::OpenParamError; + +pub(crate) fn normalize_master_agreement_name( + name: &str, +) -> std::result::Result { + let normalized = name.trim(); + if normalized.is_empty() || normalized.chars().count() > MASTER_AGREEMENT_NAME_MAX_LEN { + return Err(OpenParamError::InvalidInput); + } + Ok(normalized.to_string()) +} diff --git a/contract/programs/open_parametric/src/instructions/mod.rs b/contract/programs/open_parametric/src/instructions/mod.rs index 46efd950..78cc3200 100644 --- a/contract/programs/open_parametric/src/instructions/mod.rs +++ b/contract/programs/open_parametric/src/instructions/mod.rs @@ -5,10 +5,12 @@ pub mod confirm_master; pub mod create_flight_policy_from_master; pub mod create_master_agreement; pub mod fund_pool; +pub(crate) mod master_agreement_name; pub mod register_participant_wallets; pub mod resolve_flight_delay; pub mod settle_flight_claim; pub mod settle_flight_no_claim; +pub mod update_master_agreement_name; // 인스트럭션별 단위 테스트 모듈 #[cfg(test)] @@ -31,6 +33,8 @@ mod resolve_flight_delay_test; mod settle_flight_claim_test; #[cfg(test)] mod settle_flight_no_claim_test; +#[cfg(test)] +mod update_master_agreement_name_test; #[allow(ambiguous_glob_reexports)] pub use activate_master::*; @@ -52,3 +56,5 @@ pub use resolve_flight_delay::*; pub use settle_flight_claim::*; #[allow(ambiguous_glob_reexports)] pub use settle_flight_no_claim::*; +#[allow(ambiguous_glob_reexports)] +pub use update_master_agreement_name::*; diff --git a/contract/programs/open_parametric/src/instructions/update_master_agreement_name.rs b/contract/programs/open_parametric/src/instructions/update_master_agreement_name.rs new file mode 100644 index 00000000..d90d7ecb --- /dev/null +++ b/contract/programs/open_parametric/src/instructions/update_master_agreement_name.rs @@ -0,0 +1,42 @@ +use anchor_lang::prelude::*; + +use crate::errors::OpenParamError; +use crate::state::*; + +use super::master_agreement_name::normalize_master_agreement_name; + +#[derive(Accounts)] +pub struct UpdateMasterAgreementName<'info> { + pub signer: Signer<'info>, + #[account(mut)] + pub master_agreement: Account<'info, MasterAgreement>, +} + +pub(crate) fn assert_can_rename_master_agreement( + leader: Pubkey, + operator: Pubkey, + signer: Pubkey, +) -> std::result::Result<(), OpenParamError> { + if signer != leader && signer != operator { + return Err(OpenParamError::Unauthorized); + } + + Ok(()) +} + +pub(crate) fn apply_master_agreement_name_update( + master: &mut MasterAgreement, + signer: Pubkey, + name: &str, +) -> std::result::Result<(), OpenParamError> { + assert_can_rename_master_agreement(master.leader, master.operator, signer)?; + master.name = normalize_master_agreement_name(name)?; + + Ok(()) +} + +pub fn handler(ctx: Context, name: String) -> Result<()> { + let master = &mut ctx.accounts.master_agreement; + apply_master_agreement_name_update(master, ctx.accounts.signer.key(), &name)?; + Ok(()) +} diff --git a/contract/programs/open_parametric/src/instructions/update_master_agreement_name_test.rs b/contract/programs/open_parametric/src/instructions/update_master_agreement_name_test.rs new file mode 100644 index 00000000..1d56c8db --- /dev/null +++ b/contract/programs/open_parametric/src/instructions/update_master_agreement_name_test.rs @@ -0,0 +1,113 @@ +use anchor_lang::prelude::Pubkey; + +use crate::errors::OpenParamError; +use crate::state::MasterAgreement; + +use super::update_master_agreement_name::{ + apply_master_agreement_name_update, assert_can_rename_master_agreement, +}; + +fn master_agreement_with_name(name: &str, leader: Pubkey, operator: Pubkey) -> MasterAgreement { + MasterAgreement { + master_id: 1, + name: name.to_string(), + leader, + operator, + currency_mint: Pubkey::default(), + coverage_start_ts: 0, + coverage_end_ts: 0, + premium_per_policy: 0, + payout_delay_2h: 0, + payout_delay_3h: 0, + payout_delay_4to5h: 0, + payout_delay_6h_or_cancelled: 0, + leader_share_bps: 0, + ceded_ratio_bps: 0, + reins_commission_bps: 0, + reinsurer_effective_bps: 0, + reinsurer: None, + reinsurer_confirmed: false, + reinsurer_pool_wallet: None, + reinsurer_deposit_wallet: None, + leader_pool_wallet: Pubkey::default(), + leader_deposit_wallet: Pubkey::default(), + participants: vec![], + oracle_feed: Pubkey::default(), + status: 0, + created_at: 0, + bump: 0, + collateral_claim_count: 0, + } +} + +#[test] +fn leader_can_rename() { + let leader = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + + let result = assert_can_rename_master_agreement(leader, operator, leader); + + assert!(result.is_ok()); +} + +#[test] +fn operator_can_rename() { + let leader = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + + let result = assert_can_rename_master_agreement(leader, operator, operator); + + assert!(result.is_ok()); +} + +#[test] +fn participant_cannot_rename() { + let leader = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + let participant = Pubkey::new_unique(); + + let result = assert_can_rename_master_agreement(leader, operator, participant); + + assert!(matches!(result, Err(OpenParamError::Unauthorized))); +} + +#[test] +fn normalized_success_writes_trimmed_value() { + let leader = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + let mut master = master_agreement_with_name("existing", leader, operator); + + let result = + apply_master_agreement_name_update(&mut master, leader, " 2026 인천-뉴욕 공동계약 "); + + assert!(result.is_ok()); + assert_eq!(master.name, "2026 인천-뉴욕 공동계약"); +} + +#[test] +fn blank_rename_is_rejected() { + let leader = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + let mut master = master_agreement_with_name("existing", leader, operator); + + let result = apply_master_agreement_name_update(&mut master, leader, " "); + + assert!(matches!(result, Err(OpenParamError::InvalidInput))); + assert_eq!(master.name, "existing"); +} + +#[test] +fn overlong_rename_is_rejected() { + let leader = Pubkey::new_unique(); + let operator = Pubkey::new_unique(); + let mut master = master_agreement_with_name("existing", leader, operator); + + let result = apply_master_agreement_name_update( + &mut master, + operator, + "12345678901234567890123456789012345678901", + ); + + assert!(matches!(result, Err(OpenParamError::InvalidInput))); + assert_eq!(master.name, "existing"); +} diff --git a/contract/programs/open_parametric/src/lib.rs b/contract/programs/open_parametric/src/lib.rs index f4ac9e16..52754773 100644 --- a/contract/programs/open_parametric/src/lib.rs +++ b/contract/programs/open_parametric/src/lib.rs @@ -24,6 +24,13 @@ pub mod open_parametric { instructions::create_master_agreement::handler(ctx, params) } + pub fn update_master_agreement_name( + ctx: Context, + name: String, + ) -> Result<()> { + instructions::update_master_agreement_name::handler(ctx, name) + } + pub fn register_participant_wallets(ctx: Context) -> Result<()> { instructions::register_participant_wallets::handler(ctx) } diff --git a/contract/programs/open_parametric/src/state.rs b/contract/programs/open_parametric/src/state.rs index 332ab11b..f8e3d405 100644 --- a/contract/programs/open_parametric/src/state.rs +++ b/contract/programs/open_parametric/src/state.rs @@ -46,6 +46,7 @@ pub struct MasterParticipant { #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct CreateMasterAgreementParams { pub master_id: u64, + pub name: String, pub coverage_start_ts: i64, pub coverage_end_ts: i64, pub premium_per_policy: u64, @@ -75,6 +76,7 @@ pub struct CreateFlightPolicyParams { #[account] pub struct MasterAgreement { pub master_id: u64, + pub name: String, pub leader: Pubkey, pub operator: Pubkey, pub currency_mint: Pubkey, diff --git a/docs/superpowers/specs/2026-04-26-master-agreement-naming-and-step3-design.md b/docs/superpowers/specs/2026-04-26-master-agreement-naming-and-step3-design.md new file mode 100644 index 00000000..fa2f987b --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-master-agreement-naming-and-step3-design.md @@ -0,0 +1,277 @@ +# Master Agreement Naming And Step 3 Dashboard Design + +## 목적 + +이번 변경의 목적은 두 가지다. + +첫째, `Master Agreement`를 주소가 아니라 사람이 읽을 수 있는 공식 이름으로 식별할 수 있게 한다. 이 이름은 계약 생성 시 입력할 수 있어야 하고, 생성 이후에도 수정할 수 있어야 한다. + +둘째, 현재 `STEP3 풀 활성화` 화면을 `STEP2 참여자 확인`의 반복이 아니라, 리더사와 운영자가 마스터 계약의 풀 상태와 활성화 readiness를 한눈에 확인하는 운영 대시보드로 바꾼다. + +## 확정 요구사항 + +- 마스터 계약 이름은 공식 이름이다. +- 이름은 생성 시 입력 가능해야 한다. +- 이름은 생성 후 수정 가능해야 한다. +- 수정 권한은 `leader`와 `operator`에게만 있다. +- 이름은 중복 허용이다. +- 이름은 현재 값만 유지하면 된다. 이력 저장은 필요 없다. +- 이름 길이는 최대 40자 수준의 자유 텍스트로 제한한다. +- 기존 계약 마이그레이션은 하지 않는다. +- 이름 지원은 새로 생성되는 계약부터 적용한다. +- STEP3의 주 사용자는 리더사와 운영자다. +- STEP3는 우선 현재 시점의 스냅샷만 보여주면 된다. 시계열 분석은 이번 범위가 아니다. + +## 최종 방향 + +이름의 authoritative source는 백엔드가 아니라 온체인 `MasterAgreement` 계정이다. + +백엔드는 이름을 별도 저장하지 않고, 온체인에서 읽은 값을 API 응답으로 전달하는 계층 역할만 수행한다. 이렇게 하면 백엔드 저장소가 초기화되거나 교체돼도 계약의 공식 이름은 유지된다. + +STEP3는 `활성화 버튼이 있는 마지막 단계`가 아니라, `활성화 직전과 직후의 풀 상태를 판단하는 운영 대시보드`로 재구성한다. 핵심 질문은 세 가지다. + +1. 지금 이 마스터 계약은 활성화 가능한가 +2. 어느 역할의 풀이 부족한가 +3. 현재 기준으로 보험료와 보험금이 얼마나 반영되어 있는가 + +## 데이터 모델 변경 + +`MasterAgreement` 계정에 이름 필드를 추가한다. + +권장 형태: + +- `name: String` + +제약: + +- 최대 40자 +- 빈 문자열 불가 +- 앞뒤 공백은 저장 전 정규화한다 + +이번 변경은 새 계약에만 적용하므로, 기존 이름 없는 계약을 억지로 읽기 위한 복잡한 호환 계층은 두지 않는다. 대신 새 스키마가 적용된 계약만 이름을 가진다는 점을 API와 프론트에서 명확히 다룬다. + +## 컨트랙트 변경 + +### 1. 생성 instruction 확장 + +`create_master_agreement` 입력 파라미터에 이름을 추가한다. + +효과: + +- 새 계약은 생성 시 공식 이름이 반드시 포함된다. +- 생성 직후부터 드롭다운과 상세 조회에서 이름을 표시할 수 있다. + +검증: + +- 이름 누락 불가 +- 40자 초과 불가 +- 빈 문자열 또는 공백만 있는 값 불가 + +### 2. 이름 수정 instruction 추가 + +별도 instruction을 추가한다. + +권장 이름: + +- `update_master_agreement_name` + +권한: + +- `leader` 또는 `operator`만 호출 가능 +- 참여사와 재보험사는 수정 불가 + +동작: + +- 새 이름 검증 후 `MasterAgreement.name`만 갱신한다 +- 이름 변경 이력은 남기지 않는다 + +## 백엔드/API 방향 + +백엔드는 이름의 저장소가 아니라 전달 계층이다. + +필요한 변경: + +- 온체인 `MasterAgreement` 파싱 구조에 `name` 포함 +- 단건 조회 API 응답에 `name` 포함 +- 목록 조회 API 응답에 `name` 포함 +- 트리/관련 목록 응답에서도 같은 이름을 재사용 가능하게 정렬 + +이름을 별도 SQLite 문서로 저장하는 API는 추가하지 않는다. 기존 `display-names` 패턴은 참여사/재보험사 별칭 저장에는 남아 있을 수 있지만, 마스터 계약 공식 이름에는 사용하지 않는다. + +## 프론트엔드 변경 + +### 1. 마스터 계약 생성 폼 + +`STEP1 기본 설정`에 마스터 계약 이름 입력 필드를 추가한다. + +요구사항: + +- 필수 입력 +- 최대 40자 +- 생성 시점에 온체인 instruction으로 전달 +- 유효성 에러는 입력 필드 근처에서 즉시 보이게 한다 + +### 2. 마스터 계약 선택 UI + +현재 드롭다운은 `PDA 앞 8자리 + 상태 + 역할 + 종료일` 중심이다. + +변경 후에는 이름을 우선 노출한다. + +권장 표시 형식: + +- `계약명 · 상태 · 역할 · 종료일` +- 보조 텍스트 또는 툴팁으로 PDA 유지 + +이름이 같을 수 있으므로, 상태/역할/종료일/PDA 일부는 계속 함께 보여줘야 한다. + +### 3. 상세 조회 및 보조 패널 + +`useMasterAgreements`, `useMasterAgreementAccount`, 리뷰 패널, STEP3 헤더 등 마스터 계약을 표시하는 모든 위치는 온체인 이름을 기준으로 표시한다. + +## STEP3 대시보드 설계 + +### 역할 + +STEP3는 리더사와 운영자 중심의 운영 화면이다. 참여사와 재보험사에게 동일한 수준의 관제 경험을 제공하는 것은 이번 범위가 아니다. + +### 목표 + +STEP3는 다음을 한 화면에서 판단하게 해야 한다. + +- 활성화 가능 여부 +- 부족 담보가 있는 역할 +- 각 역할의 현재 풀 상태 +- 현재 기준 자금 스냅샷 + +### 화면 구조 + +#### 상단 요약 바 + +포함 항목: + +- 마스터 계약명 +- 마스터 계약 PDA +- 현재 상태 +- 커버리지 기간 +- 총 필요 담보액 +- 총 현재 풀 잔액 +- 활성화 가능 여부 + +#### 핵심 시각화 영역 + +기존 코드베이스의 `PoolHealthVisual`을 재사용 또는 확장한다. + +표시 대상: + +- 리더사 +- 참여사들 +- 재보험사 + +각 역할마다 다음을 보여준다. + +- 현재 잔액 +- 필요 금액 +- 부족분 +- 충족률 +- 확인 상태 + +그래프의 목적은 미세한 시계열 분석이 아니라, `누가 준비되었고 누가 부족한지`를 즉시 드러내는 것이다. + +#### 운영 액션 영역 + +다음을 명시한다. + +- 아직 확인이 필요한 역할 +- 추가 자금 투입이 필요한 역할 +- 활성화 차단 사유 +- 활성화 버튼 + +활성화 버튼은 단순 비활성 상태만 주지 말고, 왜 막혔는지 주변 텍스트에서 이해 가능해야 한다. + +#### 자금 스냅샷 영역 + +이번 범위에서는 차트보다 숫자 요약이 우선이다. + +포함 항목: + +- 누적 보험료 유입 +- 누적 보험금 유출 +- 현재 순잔액 + +이는 `현재 시점의 운영 판단`을 위한 숫자다. 상세 시계열이나 기간 비교는 이번 스펙에 포함하지 않는다. + +#### 역할별 상세 표 + +표 형태로 최소 다음 열을 제공한다. + +- 역할 +- 상태 +- share +- 현재 잔액 +- 필요 금액 +- 부족분 + +## 데이터 흐름 + +### 이름 데이터 + +1. 사용자가 STEP1에서 이름 입력 +2. 프론트가 `create_master_agreement`에 이름 전달 +3. 컨트랙트가 이름 검증 후 계정에 저장 +4. 백엔드가 온체인에서 이름을 읽어 API 응답에 포함 +5. 프론트 목록/상세/STEP3가 같은 이름을 표시 + +수정 흐름은 다음과 같다. + +1. 리더사 또는 운영자가 이름 수정 요청 +2. 프론트가 `update_master_agreement_name` 호출 +3. 컨트랙트가 권한 검증 후 이름 갱신 +4. 백엔드/프론트 조회 경로가 갱신된 이름을 반영 + +### STEP3 데이터 + +STEP3는 두 종류의 데이터를 조합한다. + +- 온체인 마스터 계약 데이터: 이름, 역할, share, 지갑, 상태 +- 백엔드 계산/집계 데이터: 풀 잔액 스냅샷, 보험료/보험금 합계, 활성화 readiness 보조 정보 + +이름은 반드시 온체인을 기준으로 하고, STEP3 집계값은 운영 편의를 위한 읽기 모델로 백엔드가 계산해 제공해도 된다. + +## 비범위 + +이번 변경에서 다음은 하지 않는다. + +- 기존 마스터 계약에 이름 일괄 부여 +- 이름 변경 이력 저장 +- 참여사/재보험사용 별도 STEP3 대시보드 +- 보험료/보험금 시계열 차트 +- 신규 외부 의존성 추가 + +## 검증 기준 + +### 컨트랙트 + +- 새 마스터 계약 생성 시 이름이 저장된다 +- 40자 초과 이름은 거부된다 +- 빈 문자열 또는 공백 이름은 거부된다 +- `leader`와 `operator`는 이름 수정 가능하다 +- 참여사/재보험사는 이름 수정 불가다 + +### 백엔드 + +- 마스터 계약 단건 조회 응답에 `name`이 포함된다 +- 마스터 계약 목록 조회 응답에 `name`이 포함된다 +- 새 이름 수정 후 조회 응답이 갱신된 값을 반영한다 + +### 프론트엔드 + +- STEP1에서 이름 입력 없이는 생성이 진행되지 않는다 +- 드롭다운에서 이름이 주소보다 우선 표시된다 +- STEP3 헤더에서 계약 이름이 보인다 +- STEP3에서 역할별 풀 상태와 활성화 차단 사유가 한 화면에 보인다 + +## 남는 리스크 + +가장 큰 리스크는 온체인 계정 스키마 변경 범위다. 새 필드 추가는 instruction, 계정 직렬화, 테스트, IDL, 백엔드 파서, 프론트 타입 전반에 영향을 준다. 하지만 이번 요구사항에서 이름의 내구성과 공용성은 핵심이므로, 이 비용은 정당화된다. + +또 하나의 리스크는 STEP3의 보험료/보험금 스냅샷 계산 기준이다. 이 값이 온체인 원시 상태만으로 충분하지 않다면, 백엔드 집계 규칙을 별도 구현 계획에서 명시해야 한다. diff --git a/frontend/src/components/guide/GuideTour.tsx b/frontend/src/components/guide/GuideTour.tsx index 08a83a7e..3f5d3ff4 100644 --- a/frontend/src/components/guide/GuideTour.tsx +++ b/frontend/src/components/guide/GuideTour.tsx @@ -212,7 +212,7 @@ function getTooltipStyle(rect: DOMRect, position: string): React.CSSProperties { } /* Steps that need a manual "Next" button (auto-advance not reliable) */ -const MANUAL_STEPS = new Set([11, 13]); +const MANUAL_STEPS = new Set([12, 14]); /* ── Component ── */ @@ -299,12 +299,12 @@ export function GuideTour({ activeTab }: Props) { case 6: ok = !reinsurer.enabled || role === 'rein'; break; case 7: ok = !reinsurer.enabled || reinsurer.confirmed; break; case 8: ok = role === 'leader'; break; - case 9: ok = masterActive; break; - case 10: ok = activeTab === 'tab-feed'; break; - case 12: ok = activeTab === 'tab-oracle'; break; - case 14: ok = claims.length > claimsAtStart.current; break; - case 15: ok = claims.some(c => c.status === 'settled'); break; - case 16: ok = activeTab === 'tab-settlement'; break; + case 10: ok = masterActive; break; + case 11: ok = activeTab === 'tab-feed'; break; + case 13: ok = activeTab === 'tab-oracle'; break; + case 15: ok = claims.length > claimsAtStart.current; break; + case 16: ok = claims.some(c => c.status === 'settled'); break; + case 17: ok = activeTab === 'tab-settlement'; break; } if (ok) { const timer = setTimeout(nextStep, 350); @@ -315,7 +315,7 @@ export function GuideTour({ activeTab }: Props) { /* ── Step 13: DOM event listener for select change ── */ useEffect(() => { - if (currentStep === null || GUIDE_STEPS[currentStep]!.step !== 13) return; + if (currentStep === null || GUIDE_STEPS[currentStep]!.step !== 14) return; const el = document.querySelector('[data-guide="select-contract"]') as HTMLSelectElement; if (!el) return; const handler = () => { diff --git a/frontend/src/components/guide/guideSteps.ts b/frontend/src/components/guide/guideSteps.ts index 1d85f69a..480512e6 100644 --- a/frontend/src/components/guide/guideSteps.ts +++ b/frontend/src/components/guide/guideSteps.ts @@ -15,14 +15,15 @@ export const GUIDE_STEPS: GuideStep[] = [ { step: 6, target: 'role-select', titleKey: 'guide.step6.title', descKey: 'guide.step6.desc', position: 'bottom' }, { step: 7, target: 'confirm-rein', titleKey: 'guide.step7.title', descKey: 'guide.step7.desc', position: 'right' }, { step: 8, target: 'role-select', titleKey: 'guide.step8.title', descKey: 'guide.step8.desc', position: 'bottom' }, - { step: 9, target: 'activate-btn', titleKey: 'guide.step9.title', descKey: 'guide.step9.desc', position: 'right' }, - { step: 10, target: 'tab-feed', titleKey: 'guide.step10.title', descKey: 'guide.step10.desc', position: 'bottom' }, - { step: 11, target: 'create-contract-btn', titleKey: 'guide.step11.title', descKey: 'guide.step11.desc', position: 'right' }, - { step: 12, target: 'tab-oracle', titleKey: 'guide.step12.title', descKey: 'guide.step12.desc', position: 'bottom' }, - { step: 13, target: 'select-contract', titleKey: 'guide.step13.title', descKey: 'guide.step13.desc', position: 'right' }, - { step: 14, target: 'resolve-btn', titleKey: 'guide.step14.title', descKey: 'guide.step14.desc', position: 'right' }, - { step: 15, target: 'settle-btn', titleKey: 'guide.step15.title', descKey: 'guide.step15.desc', position: 'right' }, - { step: 16, target: 'tab-settlement', titleKey: 'guide.step16.title', descKey: 'guide.step16.desc', position: 'bottom' }, + { step: 9, target: 'activate-transition-btn', titleKey: 'guide.step9.title', descKey: 'guide.step9.desc', position: 'right' }, + { step: 10, target: 'activate-btn', titleKey: 'guide.step10.title', descKey: 'guide.step10.desc', position: 'right' }, + { step: 11, target: 'tab-feed', titleKey: 'guide.step11.title', descKey: 'guide.step11.desc', position: 'bottom' }, + { step: 12, target: 'create-contract-btn', titleKey: 'guide.step12.title', descKey: 'guide.step12.desc', position: 'right' }, + { step: 13, target: 'tab-oracle', titleKey: 'guide.step13.title', descKey: 'guide.step13.desc', position: 'bottom' }, + { step: 14, target: 'select-contract', titleKey: 'guide.step14.title', descKey: 'guide.step14.desc', position: 'right' }, + { step: 15, target: 'resolve-btn', titleKey: 'guide.step15.title', descKey: 'guide.step15.desc', position: 'right' }, + { step: 16, target: 'settle-btn', titleKey: 'guide.step16.title', descKey: 'guide.step16.desc', position: 'right' }, + { step: 17, target: 'tab-settlement', titleKey: 'guide.step17.title', descKey: 'guide.step17.desc', position: 'bottom' }, ]; export const TOTAL_STEPS = GUIDE_STEPS.length; diff --git a/frontend/src/components/layout/MasterAgreementDropdown.tsx b/frontend/src/components/layout/MasterAgreementDropdown.tsx index f5e066cd..a1cf316a 100644 --- a/frontend/src/components/layout/MasterAgreementDropdown.tsx +++ b/frontend/src/components/layout/MasterAgreementDropdown.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useProtocolStore } from '@/store/useProtocolStore'; import { useMasterAgreements } from '@/hooks/useMasterAgreements'; import { useProgram } from '@/hooks/useProgram'; +import { useSyncedSelectedMasterAgreementName } from '@/hooks/useSyncedSelectedMasterAgreementName'; import { MasterAgreementStatus } from '@/lib/idl/open_parametric'; const SelectBase = styled.select` @@ -37,27 +38,35 @@ function formatDate(ts: number): string { }); } -const ROLE_LABEL: Record<'leader' | 'participant' | 'rein', string> = { +const ROLE_LABEL: Record<'leader' | 'participant' | 'rein' | 'operator', string> = { leader: '리더사', participant: '참여사', rein: '재보험사', + operator: 'Operator', }; export function MasterAgreementDropdown() { const mode = useProtocolStore(s => s.mode); const masterAgreementPDA = useProtocolStore(s => s.masterAgreementPDA); + const selectedMasterAgreementName = useProtocolStore(s => s.selectedMasterAgreementName); const selectMasterAgreement = useProtocolStore(s => s.selectMasterAgreement); const setRole = useProtocolStore(s => s.setRole); + const setSelectedMasterAgreementName = useProtocolStore(s => s.setSelectedMasterAgreementName); const { t } = useTranslation(); const { connected } = useProgram(); const { policies, loading, refetch } = useMasterAgreements(); + const selectedPolicy = masterAgreementPDA + ? policies.find((policy) => policy.pda === masterAgreementPDA) + : undefined; + + useSyncedSelectedMasterAgreementName(selectedPolicy?.name); // Sync detected role to store when selected policy changes or list updates useEffect(() => { - if (!masterAgreementPDA) return; - const found = policies.find(p => p.pda === masterAgreementPDA); - if (found?.myRole) setRole(found.myRole); - }, [masterAgreementPDA, policies]); // eslint-disable-line react-hooks/exhaustive-deps + if (selectedPolicy?.myRole) { + setRole(selectedPolicy.myRole); + } + }, [selectedPolicy, setRole]); // Refetch when a newly created policy isn't in the list yet useEffect(() => { @@ -74,6 +83,7 @@ export function MasterAgreementDropdown() { if (pda) { const found = policies.find(p => p.pda === pda); if (found?.myRole) setRole(found.myRole); + setSelectedMasterAgreementName(found?.name?.trim() || null); } }; @@ -86,12 +96,12 @@ export function MasterAgreementDropdown() { )} {policies.map(p => ( ))} {masterAgreementPDA && !policies.some(p => p.pda === masterAgreementPDA) && ( )} diff --git a/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx b/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx new file mode 100644 index 00000000..81500775 --- /dev/null +++ b/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx @@ -0,0 +1,136 @@ +import '@testing-library/jest-dom/vitest'; +import { ThemeProvider } from '@emotion/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { darkTheme } from '@/styles/theme'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { MasterAgreementDropdown } from '../MasterAgreementDropdown'; + +const mockUseMasterAgreements = vi.fn(); + +vi.mock('@/hooks/useProgram', () => ({ + useProgram: () => ({ + connected: true, + }), +})); + +vi.mock('@/hooks/useMasterAgreements', () => ({ + useMasterAgreements: () => mockUseMasterAgreements(), +})); + +function renderSubject() { + return render( + + + , + ); +} + +describe('MasterAgreementDropdown', () => { + beforeEach(() => { + useProtocolStore.getState().resetAll(); + useProtocolStore.setState({ + mode: 'onchain', + masterAgreementPDA: null, + }); + + mockUseMasterAgreements.mockReturnValue({ + loading: false, + refetch: vi.fn(), + policies: [ + { + pda: '8Fj2kP9aFake', + name: '대한-뉴욕 2026 리더 공동계약', + masterId: '1710000000', + status: 2, + statusLabel: 'Active', + coverageEndTs: 1770000000, + myRole: 'leader', + }, + ], + }); + }); + + it('renders the official name before the fallback identifiers', async () => { + renderSubject(); + + expect(await screen.findByRole('option', { name: /대한-뉴욕 2026 리더 공동계약/ })).toBeInTheDocument(); + }); + + it('keeps the optimistic selected name visible while the backend list catches up', async () => { + useProtocolStore.setState({ + masterAgreementPDA: 'FreshCreate1111111111111111111111111111111', + selectedMasterAgreementName: '즉시 반영된 신규 공동계약명', + }); + mockUseMasterAgreements.mockReturnValue({ + loading: false, + refetch: vi.fn(), + policies: [], + }); + + renderSubject(); + + expect(await screen.findByRole('option', { name: /즉시 반영된 신규 공동계약명/ })).toBeInTheDocument(); + }); + + it('resyncs the selected agreement name when the backend list publishes a newer authoritative rename', async () => { + const syncedPolicy = { + pda: '8Fj2kP9aFake', + name: '초기 체인 공동계약명', + masterId: '1710000000', + status: 2, + statusLabel: 'Active', + coverageEndTs: 1770000000, + myRole: 'leader' as const, + }; + useProtocolStore.setState({ + masterAgreementPDA: syncedPolicy.pda, + selectedMasterAgreementName: '로컬 낙관적 이름', + }); + mockUseMasterAgreements.mockReturnValue({ + loading: false, + refetch: vi.fn(), + policies: [syncedPolicy], + }); + + const view = renderSubject(); + + expect(await screen.findByRole('option', { name: /로컬 낙관적 이름/ })).toBeInTheDocument(); + + syncedPolicy.name = '백엔드 확정 공동계약명'; + view.rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /백엔드 확정 공동계약명/ })).toBeInTheDocument(); + }); + expect(useProtocolStore.getState().selectedMasterAgreementName).toBe('백엔드 확정 공동계약명'); + }); + + it('syncs the selected operator role into the protocol store', () => { + mockUseMasterAgreements.mockReturnValue({ + loading: false, + refetch: vi.fn(), + policies: [ + { + pda: '9Op3r4t0rFake', + name: '오퍼레이터 전용 공동계약', + masterId: '1710000001', + status: 2, + statusLabel: 'Active', + coverageEndTs: 1770000001, + myRole: 'operator', + }, + ], + }); + + renderSubject(); + + fireEvent.change(screen.getByRole('combobox'), { target: { value: '9Op3r4t0rFake' } }); + + expect(useProtocolStore.getState().role).toBe('operator'); + }); +}); diff --git a/frontend/src/components/tabs/shared/PoolHealthVisual.tsx b/frontend/src/components/tabs/shared/PoolHealthVisual.tsx index 7004694d..e1390364 100644 --- a/frontend/src/components/tabs/shared/PoolHealthVisual.tsx +++ b/frontend/src/components/tabs/shared/PoolHealthVisual.tsx @@ -53,6 +53,10 @@ function getRoleLabel(party: CollateralPartyStatus, t: Translate): string { } function getPartyDisplayName(party: CollateralPartyStatus, t: Translate): string { + if (party.label.trim()) { + return party.label; + } + if (party.role === 'leader') return t('pool.healthRoleLeader'); if (party.role === 'reinsurer') return t('pool.healthRoleReinsurer'); diff --git a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx new file mode 100644 index 00000000..b4c5aae0 --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx @@ -0,0 +1,269 @@ +import { useMemo } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import styled from '@emotion/styled'; +import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; +import { Button, Card, CardBody, CardHeader, CardTitle, Tag } from '@/components/common'; +import { PoolHealthVisual } from '@/components/tabs/shared/PoolHealthVisual'; +import { useMasterAgreementAccount } from '@/hooks/useMasterAgreementAccount'; +import { useMasterAgreementActivation } from '@/hooks/useMasterAgreementActivation'; +import { useMasterAgreementSnapshot } from '@/hooks/useMasterAgreementSnapshot'; +import { formatNum, useProtocolStore } from '@/store/useProtocolStore'; + +const DashboardStack = styled.div` + display: grid; + gap: 16px; +`; + +const KpiGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +`; + +const KpiCard = styled.div` + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card2); + display: grid; + gap: 6px; +`; + +const KpiLabel = styled.span` + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sub); +`; + +const KpiValue = styled.span<{ tone?: 'default' | 'danger' | 'accent' | 'sub' }>` + font-family: 'DM Mono', monospace; + font-size: 14px; + font-weight: 700; + color: ${({ tone }) => { + if (tone === 'danger') return 'var(--danger)'; + if (tone === 'accent') return 'var(--accent)'; + if (tone === 'sub') return 'var(--sub)'; + return 'var(--text)'; + }}; +`; + +const AgreementName = styled.p` + margin: 0 0 14px; + color: var(--text); + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; +`; + +const EmptyState = styled.div` + padding: 16px; + border-radius: 12px; + border: 1px dashed var(--border); + color: var(--sub); + font-size: 13px; + text-align: center; +`; + +const BlockerNote = styled.div` + margin-top: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.08); + color: var(--text); + font-size: 12px; + line-height: 1.5; +`; + +const MoneyStack = styled.div` + display: grid; + gap: 10px; +`; + +const MoneyRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(15, 23, 42, 0.42); +`; + +const MoneyLabel = styled.span` + color: var(--sub); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +`; + +const MoneyValue = styled.span<{ tone?: 'default' | 'danger' | 'accent' }>` + font-family: 'DM Mono', monospace; + font-size: 13px; + font-weight: 700; + color: ${({ tone }) => { + if (tone === 'danger') return 'var(--danger)'; + if (tone === 'accent') return 'var(--accent)'; + return 'var(--text)'; + }}; +`; + +const ActionStack = styled.div` + display: grid; + gap: 10px; + margin-top: 16px; +`; + +function formatUsdc(value: number | null | undefined): string { + return value == null ? '—' : `${formatNum(value, 2)} USDC`; +} + +export function MasterActivationDashboard() { + const { t } = useTranslation(); + const { masterAgreementPDA, masterActive, selectedMasterAgreementName } = useProtocolStore(useShallow((state) => ({ + masterAgreementPDA: state.masterAgreementPDA, + masterActive: state.masterActive, + selectedMasterAgreementName: state.selectedMasterAgreementName, + }))); + const masterAgreementKey = useMemo( + () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), + [masterAgreementPDA], + ); + const { account: masterData, loading: masterLoading, error: masterError } = useMasterAgreementAccount(masterAgreementKey); + const { snapshot, status, activePartyId, masterData: snapshotMasterData, loading, error, policyStatus, policyError } = useMasterAgreementSnapshot( + masterAgreementKey, + { masterData, masterLoading, masterError }, + ); + const { activateLoading, canActivate, handleActivate } = useMasterAgreementActivation({ + masterData, + masterLoading, + masterError, + }); + const readinessLoading = loading || (!!snapshotMasterData && !snapshot && !error); + + const agreementName = + snapshot?.agreementName || + selectedMasterAgreementName?.trim() || + snapshotMasterData?.name?.trim() || + t('master.noNameFallback'); + const readinessTag = readinessLoading + ? t('master.loading') + : !snapshot + ? t('master.step3.empty') + : snapshot?.aggregateReady + ? t('pool.healthAggregateReady') + : t('pool.healthAggregateActionNeeded'); + const readinessVariant = readinessLoading || !snapshot ? 'subtle' : snapshot.aggregateReady ? 'accent' : 'warning'; + const emptyMessage = readinessLoading ? t('master.loading') : error || t('master.step3.empty'); + const moneyMessage = policyStatus === 'loading' + ? t('master.loading') + : policyStatus === 'error' + ? policyError || t('master.step3.empty') + : emptyMessage; + const showMoneySnapshot = policyStatus === 'ready' && !!snapshot; + + return ( + + + + {t('master.step.activate')} + {readinessTag} + + + {agreementName} + + + {t('master.step3.totalRequired')} + {formatUsdc(snapshot?.totalRequired)} + + + {t('master.step3.totalFunded')} + {formatUsdc(snapshot?.totalFunded)} + + + {t('master.step3.totalDeficit')} + + {formatUsdc(snapshot?.totalDeficit)} + + + + {t('pool.healthTotal')} + + {snapshot ? `${formatNum(snapshot.readinessPct, 1)}%` : '—'} + + + + + {snapshot && !readinessLoading && snapshot.blockerLabels.length ? ( + + {t('pool.healthAggregateActionNeeded')}: {snapshot.blockerLabels.join(', ')} + + ) : null} + + {!snapshot && {emptyMessage}} + + {!masterActive && ( + + + + )} + + + + {status ? ( + + ) : ( + + + {t('pool.healthTitle')} + + + {emptyMessage} + + + )} + + + + {t('master.step3.moneyTitle')} + + + {showMoneySnapshot ? ( + + + {t('master.step3.totalPremiumInflow')} + {formatUsdc(snapshot.totalPremiumInflow)} + + + {t('master.step3.totalClaimOutflow')} + {formatUsdc(snapshot.totalClaimOutflow)} + + + {t('master.step3.netBalance')} + = 0 ? 'accent' : 'danger'}> + {formatUsdc(snapshot.netBalance)} + + + + ) : ( + {moneyMessage} + )} + + + + ); +} diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx new file mode 100644 index 00000000..43023ffd --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx @@ -0,0 +1,100 @@ +import styled from '@emotion/styled'; +import { useEffect, useMemo, useState } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { useTranslation } from 'react-i18next'; +import { Card, CardBody, CardHeader, CardTitle, Button, FormGroup, FormInput, FormLabel, useToast } from '@/components/common'; +import { useMasterAgreementAccount } from '@/hooks/useMasterAgreementAccount'; +import { useMasterAgreements } from '@/hooks/useMasterAgreements'; +import { useSyncedSelectedMasterAgreementName } from '@/hooks/useSyncedSelectedMasterAgreementName'; +import { useUpdateMasterAgreementName } from '@/hooks/useUpdateMasterAgreementName'; +import { useProtocolStore } from '@/store/useProtocolStore'; + +const Actions = styled.div` + display: flex; + justify-content: flex-end; +`; + +export function MasterAgreementNameEditor() { + const { t } = useTranslation(); + const { toast } = useToast(); + const masterAgreementPDA = useProtocolStore(s => s.masterAgreementPDA); + const selectedMasterAgreementName = useProtocolStore(s => s.selectedMasterAgreementName); + const setSelectedMasterAgreementName = useProtocolStore(s => s.setSelectedMasterAgreementName); + const masterAgreementKey = useMemo( + () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), + [masterAgreementPDA], + ); + const { account, refetch: refetchAccount } = useMasterAgreementAccount(masterAgreementKey); + const { refetch: refetchPolicies } = useMasterAgreements(); + const { updateMasterAgreementName, loading } = useUpdateMasterAgreementName(); + const [draftName, setDraftName] = useState(''); + + useSyncedSelectedMasterAgreementName(account?.name); + + useEffect(() => { + setDraftName(selectedMasterAgreementName ?? account?.name ?? ''); + }, [account?.name, selectedMasterAgreementName]); + + if (!masterAgreementKey) { + return null; + } + + const normalizedCurrentName = selectedMasterAgreementName?.trim() || account?.name?.trim() || ''; + const normalizedDraftName = draftName.trim(); + + const handleSave = async () => { + if (!normalizedDraftName) { + toast(t('master.nameRequired'), 'd'); + return; + } + + const result = await updateMasterAgreementName({ + masterAgreement: masterAgreementKey, + name: normalizedDraftName, + }); + + if (!result.success) { + toast(result.error || 'Failed to update master agreement name', 'd'); + return; + } + + setSelectedMasterAgreementName(normalizedDraftName); + + const [accountRefreshOk, policyRefreshOk] = await Promise.all([ + refetchAccount(), + refetchPolicies(), + ]); + toast(t('master.nameSaved'), 's'); + if (!accountRefreshOk || !policyRefreshOk) { + toast(t('master.nameSavedLocalWarning'), 'w'); + } + }; + + return ( + + + {t('master.name')} + + + + {t('master.name')} + setDraftName(e.target.value)} + placeholder={t('master.namePlaceholder')} + /> + + + + + + + ); +} diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx index ec772896..456fe58d 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx @@ -1,7 +1,11 @@ import styled from '@emotion/styled'; +import { useMemo } from 'react'; +import { PublicKey } from '@solana/web3.js'; import { useTranslation } from 'react-i18next'; import { useShallow } from 'zustand/react/shallow'; import { Card, CardBody, CardHeader, CardTitle, Tag } from '@/components/common'; +import { useMasterAgreementAccount } from '@/hooks/useMasterAgreementAccount'; +import { useSyncedSelectedMasterAgreementName } from '@/hooks/useSyncedSelectedMasterAgreementName'; import { formatNum, useProtocolStore } from '@/store/useProtocolStore'; export type MasterAgreementReviewStep = 'basic' | 'participants' | 'activate'; @@ -89,6 +93,7 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas processStep, masterActive, masterAgreementPDA, + selectedMasterAgreementName, } = useProtocolStore(useShallow(state => ({ mode: state.mode, coverageStart: state.coverageStart, @@ -101,6 +106,7 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas processStep: state.processStep, masterActive: state.masterActive, masterAgreementPDA: state.masterAgreementPDA, + selectedMasterAgreementName: state.selectedMasterAgreementName, }))); const shareTotal = leaderShare + participants.reduce((sum, participant) => sum + participant.share, 0); @@ -122,6 +128,13 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas reinsurerConfirmed: reinsurer.confirmed, masterActive, }); + const masterAgreementKey = useMemo( + () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), + [masterAgreementPDA], + ); + const { account } = useMasterAgreementAccount(masterAgreementKey); + + useSyncedSelectedMasterAgreementName(account?.name); return ( @@ -133,6 +146,11 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas + + {t('master.review.name')} + {selectedMasterAgreementName?.trim() || account?.name?.trim() || t('master.noNameFallback')} + + {t('master.review.coverage')} {`${coverageStart} - ${coverageEnd}`} diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx index 6376bdf8..0171bac0 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx @@ -5,6 +5,8 @@ import { useShallow } from 'zustand/react/shallow'; import { Tag } from '@/components/common'; import { useProtocolStore } from '@/store/useProtocolStore'; import { MasterContractSetup } from './MasterContractSetup'; +import { MasterActivationDashboard } from './MasterActivationDashboard'; +import { MasterAgreementNameEditor } from './MasterAgreementNameEditor'; import { MasterAgreementReviewPanel, type MasterAgreementReviewStep } from './MasterAgreementReviewPanel'; import { ParticipantConfirm } from './ParticipantConfirm'; @@ -179,8 +181,12 @@ const WorkArea = styled.div` min-height: 0; `; +const EditorWrap = styled.div` + margin-bottom: 16px; +`; + function getRecommendedStep(processStep: number, masterActive: boolean): MasterAgreementReviewStep { - if (masterActive || processStep >= 4) { + if (masterActive || processStep >= 5) { return 'activate'; } @@ -224,18 +230,25 @@ function StepContent({ return ; } + if (step === 'activate') { + return ; + } + return ; } export function MasterAgreementWorkbench() { const { t } = useTranslation(); - const { processStep, masterActive } = useProtocolStore(useShallow(state => ({ + const { processStep, masterActive, role, masterAgreementPDA } = useProtocolStore(useShallow(state => ({ processStep: state.processStep, masterActive: state.masterActive, + role: state.role, + masterAgreementPDA: state.masterAgreementPDA, }))); const [activeStep, setActiveStep] = useState(() => getRecommendedStep(processStep, masterActive)); const handleTermsSet = () => setActiveStep('participants'); const handleActivated = () => setActiveStep('activate'); + const canEditName = !!masterAgreementPDA && (role === 'leader' || role === 'operator'); useEffect(() => { setActiveStep(getRecommendedStep(processStep, masterActive)); @@ -286,6 +299,11 @@ export function MasterAgreementWorkbench() { + {canEditName && activeStep === 'basic' && processStep >= 1 && ( + + + + )} diff --git a/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx b/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx index 54fd7ffb..abfa7f07 100644 --- a/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx @@ -11,7 +11,7 @@ import { useProgram } from '@/hooks/useProgram'; import { getMasterAgreementPDA } from '@/lib/pda'; import { CURRENCY_MINT } from '@/lib/constants'; import { setPoolWallet } from '@/lib/demo-keypairs'; -import { ConfirmRole } from '@/lib/idl/open_parametric'; +import { ConfirmRole, type CreateMasterAgreementParams } from '@/lib/idl/open_parametric'; import { putMasterAgreementDisplayNames } from '@/services/insurerApi'; import { ParticipationStructure } from './ParticipationStructure'; @@ -100,6 +100,7 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { setTerms, onChainSetTerms, setMasterAgreementPDA, + setSelectedMasterAgreementName, setCoverage, setCollateralClaimCount, } = store; @@ -111,6 +112,7 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { const [coverageStart, setCoverageStart] = useState(store.coverageStart); const [coverageEnd, setCoverageEnd] = useState(store.coverageEnd); + const [masterAgreementName, setMasterAgreementName] = useState(''); const [premium, setPremium] = useState(store.premiumPerPolicy); const [payout2h, setPayout2h] = useState(store.payoutTiers.delay2h); const [payout3h, setPayout3h] = useState(store.payoutTiers.delay3h); @@ -145,6 +147,11 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { toast('Please connect your wallet first', 'd'); return; } + const normalizedName = masterAgreementName.trim(); + if (!normalizedName) { + toast(t('master.nameRequired'), 'd'); + return; + } const total = leaderShare + participants.reduce((s, p) => s + p.share, 0); if (total !== 100) { toast(t('store.shareSumError'), 'd'); @@ -231,6 +238,23 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { insurer: participantPubkeys[i]!, shareBps: p.share * 100, })); + const createParams: CreateMasterAgreementParams = { + masterId: masterIdBN, + name: normalizedName, + coverageStartTs: new BN(Math.floor(new Date(coverageStart).getTime() / 1000)), + coverageEndTs: new BN(Math.floor(new Date(coverageEnd).getTime() / 1000)), + premiumPerPolicy: new BN(premium * 1_000_000), + payoutDelay2H: new BN(payout2h * 1_000_000), + payoutDelay3H: new BN(payout3h * 1_000_000), + payoutDelay4To5H: new BN(payout4to5h * 1_000_000), + payoutDelay6HOrCancelled: new BN(payout6h * 1_000_000), + collateralClaimCount: localCollateralClaimCount, + leaderShareBps: leaderShare * 100, + cededRatioBps: reinsurer.enabled ? 5000 : 0, + reinsCommissionBps: reinsurer.enabled ? 1000 : 0, + participants: instructionParticipants, + oracleFeed: PublicKey.default, + }; const reinsurerKey = reinsurerPubkey ?? leaderKey; // fallback if no reinsurer const reinsurerDepositWallet = reinsurerPubkey @@ -238,21 +262,7 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { : leaderATA; const createMasterIx = await prog.methods - .createMasterAgreement({ - masterId: masterIdBN, - coverageStartTs: new BN(Math.floor(new Date(coverageStart).getTime() / 1000)), - coverageEndTs: new BN(Math.floor(new Date(coverageEnd).getTime() / 1000)), - premiumPerPolicy: new BN(premium * 1_000_000), - payoutDelay2H: new BN(payout2h * 1_000_000), - payoutDelay3H: new BN(payout3h * 1_000_000), - payoutDelay4To5H: new BN(payout4to5h * 1_000_000), - payoutDelay6HOrCancelled: new BN(payout6h * 1_000_000), - collateralClaimCount: localCollateralClaimCount, - leaderShareBps: leaderShare * 100, - cededRatioBps: reinsurer.enabled ? 5000 : 0, - reinsCommissionBps: reinsurer.enabled ? 1000 : 0, - participants: instructionParticipants, - }) + .createMasterAgreement(createParams) .accounts({ leader: leaderKey, operator: operatorKey, @@ -319,7 +329,9 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { // ── Update store ── setMasterAgreementPDA(masterAgreementPDA.toBase58()); + setSelectedMasterAgreementName(normalizedName); onChainSetTerms(sig, { + masterAgreementName: normalizedName, cededRatioBps: reinsurer.enabled ? 5000 : 0, reinsCommissionBps: reinsurer.enabled ? 1000 : 0, premium, @@ -369,6 +381,17 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { {masterActive ? t('common.active') : t('common.inactive')} + + {t('master.name')} + setMasterAgreementName(e.target.value)} + placeholder={t('master.namePlaceholder')} + readOnly={locked} + required={mode === 'onchain'} + style={{ opacity: locked ? 0.6 : 1 }} + /> + {t('master.coverageStart')} ` background: var(--card2); @@ -45,27 +43,19 @@ type ParticipantConfirmProps = { }; export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { - const { mode, role, participants, reinsurer, masterActive, masterAgreementPDA, confirmParticipant, confirmReinsurer, activateMaster, onChainActivate } = useProtocolStore( + const { mode, participants, reinsurer, confirmParticipant, confirmReinsurer } = useProtocolStore( useShallow(s => ({ - mode: s.mode, role: s.role, participants: s.participants, reinsurer: s.reinsurer, - masterActive: s.masterActive, masterAgreementPDA: s.masterAgreementPDA, + mode: s.mode, participants: s.participants, reinsurer: s.reinsurer, confirmParticipant: s.confirmParticipant, confirmReinsurer: s.confirmReinsurer, - activateMaster: s.activateMaster, onChainActivate: s.onChainActivate, })), ); const { toast } = useToast(); const { t } = useTranslation(); - const { activateMaster: activateMasterOnChain, loading: activateLoading } = useActivateMaster(); - const masterAgreementKey = useMemo( - () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), - [masterAgreementPDA], - ); - const { account: masterAccount } = useMasterAgreementAccount(masterAgreementKey); + const { currentStep, nextStep } = useGuideTour(); - const allParticipantsConfirmed = participants.every(p => p.confirmed); + const allParticipantsConfirmed = participants.every((p) => p.confirmed); const reinOk = !reinsurer.enabled || reinsurer.confirmed; const allConfirmed = allParticipantsConfirmed && reinOk; - const canActivate = allConfirmed && !masterActive && (role === 'leader' || role === 'operator'); const handleSimConfirmParticipant = (id: string) => { confirmParticipant(id); @@ -79,28 +69,11 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { toast(t('toast.confirmDone', { role: t('role.reinShort') }), 's'); }; - const handleActivate = async () => { - if (mode === 'simulation') { - const result = activateMaster(); - if (!result.ok) { toast(result.msg!, 'd'); return; } - toast(t('toast.masterActivated'), 's'); - onActivated?.(); - return; - } - - // On-chain - if (!masterAgreementKey) { toast('No master agreement PDA', 'd'); return; } - if (!masterAccount) { toast('Master agreement account not loaded', 'd'); return; } - const result = await activateMasterOnChain({ - masterAgreement: masterAgreementKey, - leaderPoolToken: masterAccount.leaderPoolWallet, - reinsurerPoolToken: masterAccount.reinsurerPoolWallet ?? masterAccount.leaderPoolWallet, - participantPoolTokens: masterAccount.participants.map((participant) => participant.poolWallet), - }); - if (!result.success) { toast(`TX failed: ${result.error}`, 'd'); return; } - onChainActivate(result.signature, masterAgreementKey.toBase58()); - toast(t('toast.masterActivated') + ` TX: ${result.signature.slice(0, 8)}...`, 's'); + const handleActivateTransition = () => { onActivated?.(); + if (currentStep !== null && GUIDE_STEPS[currentStep]?.target === 'activate-transition-btn') { + nextStep(); + } }; return ( @@ -154,9 +127,18 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { )} )} - + {onActivated && ( + + )} ); diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx new file mode 100644 index 00000000..dfc2a693 --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx @@ -0,0 +1,301 @@ +import '@testing-library/jest-dom/vitest'; +import { ThemeProvider } from '@emotion/react'; +import { PublicKey } from '@solana/web3.js'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { darkTheme } from '@/styles/theme'; +import { MasterActivationDashboard } from '../MasterActivationDashboard'; + +const mockUseMasterAgreementSnapshot = vi.fn(); +const mockUseMasterAgreementAccount = vi.fn(); +const mockUseMasterAgreementActivation = vi.fn(); + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + }; +}); + +vi.mock('@/components/common', async () => { + const actual = await vi.importActual('@/components/common'); + return { + ...actual, + useToast: () => ({ toast: vi.fn() }), + }; +}); + +vi.mock('@/components/tabs/shared/PoolHealthVisual', () => ({ + PoolHealthVisual: () =>
, +})); + +vi.mock('@/hooks/useMasterAgreementSnapshot', () => ({ + useMasterAgreementSnapshot: (...args: unknown[]) => mockUseMasterAgreementSnapshot(...args), +})); + +vi.mock('@/hooks/useMasterAgreementAccount', () => ({ + useMasterAgreementAccount: (...args: unknown[]) => mockUseMasterAgreementAccount(...args), +})); + +vi.mock('@/hooks/useMasterAgreementActivation', () => ({ + useMasterAgreementActivation: (...args: unknown[]) => mockUseMasterAgreementActivation(...args), +})); + +function renderSubject() { + return render( + + + , + ); +} + +function makeSnapshot() { + return { + agreementName: 'Master 2026', + totalPremiumInflow: 0, + totalClaimOutflow: 0, + netBalance: 0, + totalRequired: 15, + totalFunded: 10, + totalDeficit: 5, + readinessPct: 66.7, + blockers: ['leader'], + blockerLabels: ['Leader'], + aggregateReady: false, + }; +} + +function makeStatus() { + return { + totalRequired: 15, + totalFunded: 10, + totalDeficit: 5, + totalSurplus: 0, + totalHealthPct: 66.7, + aggregateReady: false, + parties: [ + { + id: 'leader', + label: 'Leader', + role: 'leader', + shareBps: 5000, + required: 10, + balance: 5, + deficit: 5, + surplus: 0, + fundedPct: 50, + confirmed: true, + state: 'underfunded', + }, + ], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + useProtocolStore.getState().resetAll(); + useProtocolStore.setState({ + mode: 'simulation', + role: 'leader', + masterActive: false, + processStep: 4, + selectedMasterAgreementName: 'Master 2026', + participants: [ + { id: 'p1', name: 'Participant 1', share: 30, address: 'wallet-1', confirmed: true }, + { id: 'p2', name: 'Participant 2', share: 20, address: 'wallet-2', confirmed: true }, + ], + reinsurer: { + enabled: true, + name: 'Korean Re', + address: 'wallet-re', + confirmed: true, + }, + }); + mockUseMasterAgreementAccount.mockReturnValue({ + account: null, + loading: false, + error: null, + }); + mockUseMasterAgreementActivation.mockImplementation(() => ({ + activateLoading: false, + canActivate: true, + handleActivate: vi.fn(() => { + useProtocolStore.getState().activateMaster(); + }), + })); + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: makeSnapshot(), + status: makeStatus(), + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: null, + policyStatus: 'ready', + policyError: null, + }); +}); + +describe('MasterActivationDashboard', () => { + test('threads shared master account state into the activation snapshot and CTA hooks', () => { + const sharedMasterAccount = { name: 'Master 2026' }; + useProtocolStore.setState({ + mode: 'onchain', + masterAgreementPDA: '11111111111111111111111111111111', + }); + mockUseMasterAgreementAccount.mockReturnValue({ + account: sharedMasterAccount, + loading: true, + error: 'master lag', + }); + + renderSubject(); + + const [snapshotMasterKey, snapshotState] = mockUseMasterAgreementSnapshot.mock.calls[0]; + expect(snapshotMasterKey).toBeInstanceOf(PublicKey); + expect((snapshotMasterKey as PublicKey).toBase58()).toBe('11111111111111111111111111111111'); + expect(snapshotState).toEqual({ + masterData: sharedMasterAccount, + masterLoading: true, + masterError: 'master lag', + }); + + expect(mockUseMasterAgreementActivation).toHaveBeenCalledWith({ + masterData: sharedMasterAccount, + masterLoading: true, + masterError: 'master lag', + }); + }); + + test('keeps the activation step actionable when activation is pending', () => { + renderSubject(); + + const activateButton = screen.getByRole('button', { name: 'confirm.activateBtn' }); + expect(activateButton).toBeEnabled(); + expect(activateButton).toHaveAttribute('data-guide', 'activate-btn'); + + fireEvent.click(activateButton); + + expect(useProtocolStore.getState().masterActive).toBe(true); + expect(useProtocolStore.getState().processStep).toBe(5); + }); + + test('disables the activation CTA in on-chain mode until the master account is loaded', () => { + useProtocolStore.setState({ + mode: 'onchain', + masterAgreementPDA: '11111111111111111111111111111111', + }); + mockUseMasterAgreementAccount.mockReturnValue({ + account: null, + loading: true, + error: null, + }); + mockUseMasterAgreementActivation.mockReturnValue({ + activateLoading: false, + canActivate: false, + handleActivate: vi.fn(), + }); + + renderSubject(); + + expect(screen.getByRole('button', { name: 'confirm.activateBtn' })).toBeDisabled(); + }); + + test('suppresses zero-valued money rows while policy data is still loading', () => { + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: makeSnapshot(), + status: makeStatus(), + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: null, + policyStatus: 'loading', + policyError: null, + }); + + renderSubject(); + + expect(screen.getByText('master.loading')).toBeInTheDocument(); + expect(screen.getByText('pool.healthAggregateActionNeeded')).toBeInTheDocument(); + expect(screen.getByText('15.00 USDC')).toBeInTheDocument(); + expect(screen.queryByText('0.00 USDC')).not.toBeInTheDocument(); + }); + + test('replaces misleading money rows with the policy error state when policy data is unavailable', () => { + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: makeSnapshot(), + status: makeStatus(), + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: null, + policyStatus: 'error', + policyError: 'policy fetch failed', + }); + + renderSubject(); + + expect(screen.getByText('policy fetch failed')).toBeInTheDocument(); + expect(screen.getByText('pool.healthAggregateActionNeeded')).toBeInTheDocument(); + expect(screen.queryByText('0.00 USDC')).not.toBeInTheDocument(); + }); + + test('shows a loading state instead of false deficits while collateral balances are unresolved', () => { + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: null, + status: null, + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: true, + error: null, + policyStatus: 'ready', + policyError: null, + }); + + renderSubject(); + + expect(screen.getAllByText('master.loading').length).toBeGreaterThan(0); + expect(screen.queryByText('pool.healthAggregateActionNeeded')).not.toBeInTheDocument(); + expect(screen.queryByText('5.00 USDC')).not.toBeInTheDocument(); + }); + + test('keeps the dashboard in a loading state during the live-balance handoff before the snapshot resolves', () => { + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: null, + status: null, + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: null, + policyStatus: 'ready', + policyError: null, + }); + + renderSubject(); + + expect(screen.getAllByText('master.loading').length).toBeGreaterThan(0); + expect(screen.queryByText('master.step3.empty')).not.toBeInTheDocument(); + }); + + test('shows an unresolved state instead of synthetic deficits when balance reads fail', () => { + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: null, + status: null, + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: 'balance read failed', + policyStatus: 'ready', + policyError: null, + }); + + renderSubject(); + + expect(screen.getAllByText('balance read failed').length).toBeGreaterThan(0); + expect(screen.queryByText('pool.healthAggregateActionNeeded')).not.toBeInTheDocument(); + expect(screen.queryByText('5.00 USDC')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx new file mode 100644 index 00000000..71c97570 --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx @@ -0,0 +1,130 @@ +import '@testing-library/jest-dom/vitest'; +import { ThemeProvider } from '@emotion/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { darkTheme } from '@/styles/theme'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { MasterAgreementNameEditor } from '../MasterAgreementNameEditor'; + +const mockToast = vi.fn(); +const mockRefetchAccount = vi.fn(); +const mockRefetchPolicies = vi.fn(); +const mockUpdateMasterAgreementName = vi.fn(); +const mockUseMasterAgreementAccount = vi.fn(); + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + }; +}); + +vi.mock('@/components/common', async () => { + const actual = await vi.importActual('@/components/common'); + return { + ...actual, + useToast: () => ({ toast: mockToast }), + }; +}); + +vi.mock('@/hooks/useMasterAgreementAccount', () => ({ + useMasterAgreementAccount: (...args: unknown[]) => mockUseMasterAgreementAccount(...args), +})); + +vi.mock('@/hooks/useMasterAgreements', () => ({ + useMasterAgreements: () => ({ + refetch: mockRefetchPolicies, + }), +})); + +vi.mock('@/hooks/useUpdateMasterAgreementName', () => ({ + useUpdateMasterAgreementName: () => ({ + updateMasterAgreementName: mockUpdateMasterAgreementName, + loading: false, + }), +})); + +function renderSubject() { + return render( + + + , + ); +} + +describe('MasterAgreementNameEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + useProtocolStore.getState().resetAll(); + useProtocolStore.setState({ + mode: 'onchain', + role: 'leader', + masterAgreementPDA: '11111111111111111111111111111111', + }); + mockUseMasterAgreementAccount.mockReturnValue({ + account: { + name: 'Old Agreement Name', + }, + refetch: mockRefetchAccount, + }); + mockUpdateMasterAgreementName.mockResolvedValue({ + success: true, + signature: 'rename-sig', + }); + }); + + it('updates the visible selected name immediately and warns when refresh reconciliation fails', async () => { + mockRefetchAccount.mockResolvedValue(false); + mockRefetchPolicies.mockResolvedValue(true); + + renderSubject(); + + fireEvent.change(screen.getByPlaceholderText('master.namePlaceholder'), { + target: { value: 'New Agreement Name' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'master.nameSave' })); + + await waitFor(() => { + expect(mockUpdateMasterAgreementName).toHaveBeenCalled(); + }); + + expect((useProtocolStore.getState() as unknown as { selectedMasterAgreementName?: string | null }).selectedMasterAgreementName).toBe('New Agreement Name'); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('master.nameSaved', 's'); + expect(mockToast).toHaveBeenCalledWith('master.nameSavedLocalWarning', 'w'); + }); + }); + + it('resyncs the optimistic selected name when the fetched account publishes a newer authoritative rename', async () => { + let authoritativeName = 'Old Agreement Name'; + useProtocolStore.setState({ + selectedMasterAgreementName: 'Optimistic Rename', + }); + mockUseMasterAgreementAccount.mockImplementation(() => ({ + account: { + name: authoritativeName, + }, + refetch: mockRefetchAccount, + })); + + const view = renderSubject(); + + expect(screen.getByDisplayValue('Optimistic Rename')).toBeInTheDocument(); + + authoritativeName = 'Authoritative Synced Rename'; + view.rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByDisplayValue('Authoritative Synced Rename')).toBeInTheDocument(); + }); + expect(useProtocolStore.getState().selectedMasterAgreementName).toBe('Authoritative Synced Rename'); + }); +}); diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementReviewPanel.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementReviewPanel.test.tsx new file mode 100644 index 00000000..5006d66a --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementReviewPanel.test.tsx @@ -0,0 +1,93 @@ +import '@testing-library/jest-dom/vitest'; +import { ThemeProvider } from '@emotion/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { darkTheme } from '@/styles/theme'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { MasterAgreementReviewPanel } from '../MasterAgreementReviewPanel'; + +const mockUseMasterAgreementAccount = vi.fn(); + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params?.count != null) return `${key}:${params.count}`; + return key; + }, + }), + }; +}); + +vi.mock('@/hooks/useMasterAgreementAccount', () => ({ + useMasterAgreementAccount: (...args: unknown[]) => mockUseMasterAgreementAccount(...args), +})); + +function renderSubject() { + return render( + + + , + ); +} + +describe('MasterAgreementReviewPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + useProtocolStore.getState().resetAll(); + useProtocolStore.setState({ + mode: 'onchain', + role: 'leader', + masterAgreementPDA: '11111111111111111111111111111111', + selectedMasterAgreementName: 'Fresh Optimistic Agreement Name', + coverageStart: '2026-01-01', + coverageEnd: '2026-12-31', + premiumPerPolicy: 3, + payoutTiers: { delay2h: 5, delay3h: 8, delay4to5h: 12, delay6hOrCancelled: 15 }, + leaderShare: 50, + participants: [{ id: 'p1', name: 'Participant 1', share: 50, address: 'wallet-1', confirmed: false }], + reinsurer: { enabled: false, address: '', confirmed: false }, + processStep: 1, + masterActive: false, + }); + mockUseMasterAgreementAccount.mockReturnValue({ + account: { + name: 'Stale Backend Agreement Name', + }, + }); + }); + + it('prefers the fresher optimistic selected name over a stale account name', () => { + renderSubject(); + + expect(screen.getByText('Fresh Optimistic Agreement Name')).toBeInTheDocument(); + expect(screen.queryByText('Stale Backend Agreement Name')).not.toBeInTheDocument(); + }); + + it('resyncs to a newer authoritative account name after the backend refreshes', async () => { + let authoritativeName = 'Stale Backend Agreement Name'; + mockUseMasterAgreementAccount.mockImplementation(() => ({ + account: { + name: authoritativeName, + }, + })); + + const view = renderSubject(); + + expect(screen.getByText('Fresh Optimistic Agreement Name')).toBeInTheDocument(); + + authoritativeName = 'Authoritative Backend Agreement Name'; + view.rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Authoritative Backend Agreement Name')).toBeInTheDocument(); + }); + expect(useProtocolStore.getState().selectedMasterAgreementName).toBe('Authoritative Backend Agreement Name'); + }); +}); diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx index fd600c29..c819840e 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx @@ -17,6 +17,10 @@ vi.mock('../ParticipantConfirm', () => ({ ), })); +vi.mock('../MasterActivationDashboard', () => ({ + MasterActivationDashboard: () =>
Mock activation dashboard
, +})); + vi.mock('../MasterAgreementReviewPanel', () => ({ MasterAgreementReviewPanel: ({ selectedStep }: { selectedStep: string }) => ( @@ -108,6 +112,25 @@ describe('MasterAgreementWorkbench', () => { fireEvent.click(screen.getByRole('button', { name: 'Mock activate' })); + expect(screen.getByText('Mock activation dashboard')).toBeInTheDocument(); + expect(screen.getByTestId('selected-step')).toHaveTextContent('activate'); + }, 15000); + + test('keeps the participant step selected until the Step 3 transition is used', () => { + useProtocolStore.setState({ + processStep: 4, + masterActive: false, + }); + + renderSubject(); + + expect(screen.getByText('Mock participant step')).toBeInTheDocument(); + expect(screen.queryByText('Mock activation dashboard')).not.toBeInTheDocument(); + expect(screen.getByTestId('selected-step')).toHaveTextContent('participants'); + + fireEvent.click(screen.getByRole('button', { name: 'Mock activate' })); + + expect(screen.getByText('Mock activation dashboard')).toBeInTheDocument(); expect(screen.getByTestId('selected-step')).toHaveTextContent('activate'); }, 15000); }); diff --git a/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx index 760ffdf9..ab9765d1 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx @@ -1,14 +1,15 @@ import { ThemeProvider } from '@emotion/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { Keypair, PublicKey } from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ParticipantConfirm } from '../ParticipantConfirm'; import { darkTheme } from '@/styles/theme'; import { useProtocolStore } from '@/store/useProtocolStore'; +import { GUIDE_STEPS } from '@/components/guide/guideSteps'; const mockToast = vi.fn(); const mockActivateMasterOnChain = vi.fn(); -const mockUseMasterAgreementAccount = vi.fn(); +const mockNextStep = vi.fn(); vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next'); @@ -35,19 +36,17 @@ vi.mock('@/hooks/useActivateMaster', () => ({ }), })); -vi.mock('@/hooks/useMasterAgreementAccount', () => ({ - useMasterAgreementAccount: (...args: unknown[]) => mockUseMasterAgreementAccount(...args), +vi.mock('@/components/guide/useGuideTour', () => ({ + useGuideTour: () => ({ + currentStep: 8, + nextStep: mockNextStep, + }), })); -const masterAgreement = Keypair.generate().publicKey; -const leaderPoolWallet = Keypair.generate().publicKey; -const reinsurerPoolWallet = Keypair.generate().publicKey; -const participantPoolWallets = [Keypair.generate().publicKey, Keypair.generate().publicKey]; - -function renderParticipantConfirm() { +function renderParticipantConfirm(onActivated = vi.fn()) { return render( - + , ); } @@ -58,81 +57,57 @@ beforeEach(() => { useProtocolStore.setState({ mode: 'onchain', role: 'leader', - masterAgreementPDA: masterAgreement.toBase58(), masterActive: false, participants: [ - { id: 'p1', name: 'Participant 1', share: 30, address: participantPoolWallets[0].toBase58(), confirmed: true }, - { id: 'p2', name: 'Participant 2', share: 20, address: participantPoolWallets[1].toBase58(), confirmed: true }, + { id: 'p1', name: 'Participant 1', share: 30, address: 'wallet-1', confirmed: true }, + { id: 'p2', name: 'Participant 2', share: 20, address: 'wallet-2', confirmed: true }, ], reinsurer: { enabled: true, address: PublicKey.default.toBase58(), confirmed: true }, }); - mockUseMasterAgreementAccount.mockReturnValue({ - account: { - leaderPoolWallet, - reinsurerPoolWallet, - participants: participantPoolWallets.map((poolWallet, index) => ({ - insurer: Keypair.generate().publicKey, - shareBps: index === 0 ? 3000 : 2000, - confirmed: true, - poolWallet, - depositWallet: Keypair.generate().publicKey, - })), - }, - loading: false, - error: null, - refetch: vi.fn(), - }); mockActivateMasterOnChain.mockResolvedValue({ success: true, signature: 'activate-sig' }); }); describe('ParticipantConfirm', () => { - it('passes leader, reinsurer, and participant pool accounts to on-chain activation', async () => { - renderParticipantConfirm(); + it('routes the guide tour through the Step 2 transition before the Step 3 activation CTA', () => { + expect(GUIDE_STEPS.find((step) => step.step === 9)?.target).toBe('activate-transition-btn'); + expect(GUIDE_STEPS.find((step) => step.step === 10)?.target).toBe('activate-btn'); + }); + + it('advances to the Step 3 dashboard when all confirmations are ready without activating on-chain', async () => { + const onActivated = vi.fn(); - fireEvent.click(screen.getByRole('button', { name: 'confirm.activateBtn' })); + renderParticipantConfirm(onActivated); + + const transitionButton = screen.getByRole('button', { name: 'confirm.activateTransitionBtn' }); + expect(transitionButton).toBeEnabled(); + expect(transitionButton).toHaveAttribute('data-guide', 'activate-transition-btn'); + + fireEvent.click(transitionButton); await waitFor(() => { - expect(mockActivateMasterOnChain).toHaveBeenCalledWith({ - masterAgreement, - leaderPoolToken: leaderPoolWallet, - reinsurerPoolToken: reinsurerPoolWallet, - participantPoolTokens: participantPoolWallets, - }); + expect(onActivated).toHaveBeenCalledTimes(1); }); + expect(mockNextStep).toHaveBeenCalledTimes(1); + expect(mockActivateMasterOnChain).not.toHaveBeenCalled(); }); - it('uses leader pool as placeholder when the master has no reinsurer pool', async () => { + it('keeps Step 3 unavailable until every confirmation is complete', () => { useProtocolStore.setState({ - reinsurer: { enabled: false, address: '', confirmed: true }, - }); - mockUseMasterAgreementAccount.mockReturnValue({ - account: { - leaderPoolWallet, - reinsurerPoolWallet: null, - participants: participantPoolWallets.map((poolWallet, index) => ({ - insurer: Keypair.generate().publicKey, - shareBps: index === 0 ? 3000 : 2000, - confirmed: true, - poolWallet, - depositWallet: Keypair.generate().publicKey, - })), - }, - loading: false, - error: null, - refetch: vi.fn(), + participants: [ + { id: 'p1', name: 'Participant 1', share: 30, address: 'wallet-1', confirmed: true }, + { id: 'p2', name: 'Participant 2', share: 20, address: 'wallet-2', confirmed: false }, + ], }); + const onActivated = vi.fn(); - renderParticipantConfirm(); + renderParticipantConfirm(onActivated); - fireEvent.click(screen.getByRole('button', { name: 'confirm.activateBtn' })); + const transitionButton = screen.getByRole('button', { name: 'confirm.activateTransitionBtn' }); + expect(transitionButton).toBeDisabled(); - await waitFor(() => { - expect(mockActivateMasterOnChain).toHaveBeenCalledWith({ - masterAgreement, - leaderPoolToken: leaderPoolWallet, - reinsurerPoolToken: leaderPoolWallet, - participantPoolTokens: participantPoolWallets, - }); - }); + fireEvent.click(transitionButton); + + expect(onActivated).not.toHaveBeenCalled(); + expect(mockActivateMasterOnChain).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/hooks/__tests__/useMasterAgreementActivation.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementActivation.test.ts new file mode 100644 index 00000000..7fffbadc --- /dev/null +++ b/frontend/src/hooks/__tests__/useMasterAgreementActivation.test.ts @@ -0,0 +1,118 @@ +import { renderHook } from '@testing-library/react'; +import { PublicKey } from '@solana/web3.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { useMasterAgreementActivation } from '../useMasterAgreementActivation'; + +const mockUseProgram = vi.fn(); +const mockUseActivateMaster = vi.fn(); + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + }; +}); + +vi.mock('@/components/common', async () => { + const actual = await vi.importActual('@/components/common'); + return { + ...actual, + useToast: () => ({ toast: vi.fn() }), + }; +}); + +vi.mock('../useProgram', () => ({ + useProgram: () => mockUseProgram(), +})); + +vi.mock('../useActivateMaster', () => ({ + useActivateMaster: () => mockUseActivateMaster(), +})); + +function makeWallet(seed: number) { + return new PublicKey(new Uint8Array(32).fill(seed)); +} + +function makeMasterData(operator: PublicKey) { + return { + operator, + leaderPoolWallet: makeWallet(21), + reinsurerPoolWallet: makeWallet(22), + participants: [ + { + poolWallet: makeWallet(23), + }, + ], + }; +} + +describe('useMasterAgreementActivation', () => { + beforeEach(() => { + vi.clearAllMocks(); + useProtocolStore.getState().resetAll(); + useProtocolStore.setState({ + mode: 'onchain', + role: 'leader', + masterAgreementPDA: makeWallet(11).toBase58(), + masterActive: false, + participants: [ + { id: 'p1', name: 'Participant 1', share: 50, address: 'wallet-1', confirmed: true }, + ], + reinsurer: { + enabled: false, + address: '', + confirmed: false, + }, + }); + mockUseActivateMaster.mockReturnValue({ + activateMaster: vi.fn(), + loading: false, + }); + }); + + it('disables the on-chain activation CTA when the connected wallet is not the configured operator', () => { + const operatorWallet = makeWallet(31); + const leaderWallet = makeWallet(32); + mockUseProgram.mockReturnValue({ + wallet: { publicKey: leaderWallet }, + }); + + const { result } = renderHook(() => useMasterAgreementActivation({ + masterData: makeMasterData(operatorWallet) as never, + })); + + expect(result.current.canActivate).toBe(false); + }); + + it('enables on-chain activation from the operator wallet even when the local role is stale', () => { + const operatorWallet = makeWallet(41); + mockUseProgram.mockReturnValue({ + wallet: { publicKey: operatorWallet }, + }); + + const { result } = renderHook(() => useMasterAgreementActivation({ + masterData: makeMasterData(operatorWallet) as never, + })); + + expect(result.current.canActivate).toBe(true); + }); + + it('preserves simulation activation behavior for leader-driven previews', () => { + useProtocolStore.setState({ + mode: 'simulation', + role: 'leader', + masterAgreementPDA: null, + }); + mockUseProgram.mockReturnValue({ + wallet: { publicKey: makeWallet(51) }, + }); + + const { result } = renderHook(() => useMasterAgreementActivation()); + + expect(result.current.canActivate).toBe(true); + }); +}); diff --git a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts new file mode 100644 index 00000000..b24765c7 --- /dev/null +++ b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts @@ -0,0 +1,307 @@ +import { Keypair, PublicKey } from '@solana/web3.js'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { BN } from '@coral-xyz/anchor'; +import type { CollateralStatus } from '@/lib/collateral'; +import type { FlightPolicyAccount, MasterAgreementAccount } from '@/lib/idl/open_parametric'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { readTokenBalance, resolveLeaderLabel, resolvePartyLabel } from '../usePoolCollateralStatus'; +import type { FlightPolicyWithKey } from '../useFlightPolicies'; +import { buildMasterAgreementSnapshot, useMasterAgreementSnapshot } from '../useMasterAgreementSnapshot'; + +const mockUseFlightPolicies = vi.fn(); +const mockUseMasterAgreementAccount = vi.fn(); +const mockUsePoolCollateralStatus = vi.fn(); + +vi.mock('../useFlightPolicies', () => ({ + useFlightPolicies: (...args: unknown[]) => mockUseFlightPolicies(...args), +})); + +vi.mock('../useMasterAgreementAccount', () => ({ + useMasterAgreementAccount: (...args: unknown[]) => mockUseMasterAgreementAccount(...args), +})); + +vi.mock('../usePoolCollateralStatus', async () => { + const actual = await vi.importActual('../usePoolCollateralStatus'); + return { + ...actual, + usePoolCollateralStatus: (...args: unknown[]) => mockUsePoolCollateralStatus(...args), + }; +}); + +function fakeBn(value: number): BN { + return { + toNumber: () => value, + toString: () => String(value), + } as unknown as BN; +} + +function makeMasterAgreement(): MasterAgreementAccount { + return { + masterId: fakeBn(1), + name: 'Korea-US Flight Delay Facility', + leader: PublicKey.default, + operator: PublicKey.default, + currencyMint: PublicKey.default, + coverageStartTs: fakeBn(0), + coverageEndTs: fakeBn(0), + premiumPerPolicy: fakeBn(3_000_000), + payoutDelay2H: fakeBn(5_000_000), + payoutDelay3H: fakeBn(8_000_000), + payoutDelay4To5H: fakeBn(12_000_000), + payoutDelay6HOrCancelled: fakeBn(15_000_000), + leaderShareBps: 5000, + cededRatioBps: 5000, + reinsCommissionBps: 1000, + reinsurerEffectiveBps: 4500, + reinsurer: null, + reinsurerConfirmed: false, + reinsurerPoolWallet: null, + reinsurerDepositWallet: null, + leaderPoolWallet: PublicKey.default, + leaderDepositWallet: PublicKey.default, + participants: [], + oracleFeed: PublicKey.default, + status: 1, + createdAt: fakeBn(0), + bump: 0, + collateralClaimCount: 10, + }; +} + +function makePolicy(id: number, premiumPaidRaw: number, payoutAmountRaw: number): FlightPolicyWithKey { + return { + publicKey: PublicKey.default, + account: { + childPolicyId: fakeBn(id), + master: PublicKey.default, + creator: PublicKey.default, + subscriberRef: `SUB-${id}`, + flightNo: 'KE081', + route: 'ICN-JFK', + departureTs: fakeBn(0), + premiumPaid: fakeBn(premiumPaidRaw), + delayMinutes: 0, + cancelled: false, + payoutAmount: fakeBn(payoutAmountRaw), + status: 0, + premiumDistributed: false, + createdAt: fakeBn(0), + updatedAt: fakeBn(0), + bump: 0, + } as FlightPolicyAccount, + }; +} + +function makeCollateralStatus(): CollateralStatus { + return { + totalRequired: 15, + totalFunded: 10, + totalDeficit: 5, + totalSurplus: 0, + totalHealthPct: 66.7, + aggregateReady: false, + parties: [ + { + id: 'leader', + label: 'Leader', + role: 'leader', + shareBps: 5000, + required: 10, + balance: 5, + deficit: 5, + surplus: 0, + fundedPct: 50, + confirmed: true, + state: 'underfunded', + }, + ], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + useProtocolStore.getState().resetAll(); + useProtocolStore.setState({ + mode: 'onchain', + }); + mockUseMasterAgreementAccount.mockReturnValue({ + account: makeMasterAgreement(), + loading: false, + error: null, + }); + mockUsePoolCollateralStatus.mockReturnValue({ + status: makeCollateralStatus(), + activePartyId: 'leader', + masterData: makeMasterAgreement(), + loading: false, + error: null, + }); + mockUseFlightPolicies.mockReturnValue({ + policies: [], + loading: false, + error: null, + }); +}); + +describe('buildMasterAgreementSnapshot', () => { + test('prefers selected agreement name for the leader-facing label path', () => { + expect(resolveLeaderLabel(' Fresh Named Agreement ', 'Chain Name', 'Leader')).toBe('Fresh Named Agreement'); + expect(resolveLeaderLabel('', 'Chain Name', 'Leader')).toBe('Chain Name'); + expect(resolveLeaderLabel(null, ' ', 'Leader')).toBe('Leader'); + }); + + test('prefers wallet display names, then stored labels, then fallbacks for participants and reinsurers', () => { + const wallet = Keypair.generate().publicKey; + + expect( + resolvePartyLabel(wallet, 'Participant 1', { [wallet.toBase58()]: ' Hana Fire ' }, 'Stored Participant'), + ).toBe('Hana Fire'); + expect(resolvePartyLabel(wallet, 'Reinsurer', {}, ' Korean Re ')).toBe('Korean Re'); + expect(resolvePartyLabel(null, 'Participant 2', {}, ' ')).toBe('Participant 2'); + }); + + test('throws on live token balance read failure instead of returning a synthetic zero', async () => { + const connection = { + getTokenAccountBalance: vi.fn().mockRejectedValue(new Error('rpc unavailable')), + } as unknown as Parameters[0]; + + await expect(readTokenBalance(connection, Keypair.generate().publicKey)).rejects.toThrow('rpc unavailable'); + }); + + test('derives premium inflow, claim outflow, net balance, and blockers', () => { + const snapshot = buildMasterAgreementSnapshot( + makeMasterAgreement(), + [makePolicy(1, 3_000_000, 0), makePolicy(2, 3_000_000, 8_000_000)], + makeCollateralStatus(), + ); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.totalPremiumInflow).toBe(6); + expect(snapshot?.totalClaimOutflow).toBe(8); + expect(snapshot?.netBalance).toBe(-2); + expect(snapshot?.blockers).toContain('leader'); + }); + + test('exposes loading, error, and ready policy states separately from snapshot totals', () => { + mockUseFlightPolicies.mockReturnValueOnce({ + policies: [], + loading: true, + error: null, + }); + const loadingResult = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); + expect(loadingResult.result.current.policyStatus).toBe('loading'); + expect(loadingResult.result.current.loading).toBe(false); + expect(loadingResult.result.current.error).toBeNull(); + expect(loadingResult.result.current.policyError).toBeNull(); + expect(loadingResult.result.current.snapshot).not.toBeNull(); + + mockUseFlightPolicies.mockReturnValueOnce({ + policies: [], + loading: false, + error: 'policy fetch failed', + }); + const errorResult = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); + expect(errorResult.result.current.policyStatus).toBe('error'); + expect(errorResult.result.current.error).toBeNull(); + expect(errorResult.result.current.policyError).toBe('policy fetch failed'); + expect(errorResult.result.current.snapshot).not.toBeNull(); + + const readyResult = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); + expect(readyResult.result.current.policyStatus).toBe('ready'); + expect(readyResult.result.current.policyError).toBeNull(); + }); + + test('reuses injected master account state instead of opening another master-account fetch chain', () => { + const injectedMaster = makeMasterAgreement(); + + const result = renderHook(() => useMasterAgreementSnapshot(PublicKey.default, { + masterData: injectedMaster, + masterLoading: true, + masterError: 'master lag', + })); + + expect(mockUseMasterAgreementAccount).toHaveBeenCalledWith(null); + expect(mockUsePoolCollateralStatus).toHaveBeenCalledWith(PublicKey.default, undefined, { + masterData: injectedMaster, + masterLoading: true, + masterError: 'master lag', + }); + expect(result.result.current.masterData).toBe(injectedMaster); + expect(result.result.current.loading).toBe(true); + expect(result.result.current.error).toBe('master lag'); + }); + + test('builds a simulation snapshot from local store state without requiring an on-chain account', () => { + useProtocolStore.setState({ + mode: 'simulation', + selectedMasterAgreementName: 'Simulation Facility', + totalPremium: 24, + totalClaim: 5, + }); + mockUseMasterAgreementAccount.mockReturnValueOnce({ + account: null, + loading: false, + error: null, + }); + mockUsePoolCollateralStatus.mockReturnValueOnce({ + status: makeCollateralStatus(), + activePartyId: 'leader', + masterData: null, + loading: false, + error: null, + }); + mockUseFlightPolicies.mockReturnValueOnce({ + policies: [], + loading: false, + error: null, + }); + + const result = renderHook(() => useMasterAgreementSnapshot(null)); + + expect(result.result.current.snapshot).toMatchObject({ + agreementName: 'Simulation Facility', + totalPremiumInflow: 24, + totalClaimOutflow: 5, + netBalance: 19, + totalRequired: 15, + totalFunded: 10, + totalDeficit: 5, + }); + expect(result.result.current.policyStatus).toBe('ready'); + expect(result.result.current.loading).toBe(false); + }); + + test('keeps the snapshot unresolved while collateral balances are still loading', () => { + mockUsePoolCollateralStatus.mockReturnValueOnce({ + status: null, + activePartyId: 'leader', + masterData: makeMasterAgreement(), + loading: true, + error: null, + }); + + const result = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); + + expect(result.result.current.snapshot).toBeNull(); + expect(result.result.current.loading).toBe(true); + expect(result.result.current.error).toBeNull(); + }); + + test('keeps the snapshot unresolved when live balance reads fail so deficits are not synthesized from zeroes', () => { + mockUsePoolCollateralStatus.mockReturnValueOnce({ + status: null, + activePartyId: 'leader', + masterData: makeMasterAgreement(), + loading: false, + error: 'balance read failed', + }); + + const result = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); + + expect(result.result.current.snapshot).toBeNull(); + expect(result.result.current.loading).toBe(false); + expect(result.result.current.error).toBe('balance read failed'); + expect(result.result.current.policyError).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useMasterAgreements.test.ts b/frontend/src/hooks/__tests__/useMasterAgreements.test.ts new file mode 100644 index 00000000..e1fb1730 --- /dev/null +++ b/frontend/src/hooks/__tests__/useMasterAgreements.test.ts @@ -0,0 +1,49 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { PublicKey } from '@solana/web3.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMasterAgreements } from '../useMasterAgreements'; + +const mockUseWallet = vi.fn(); + +vi.mock('@solana/wallet-adapter-react', () => ({ + useWallet: () => mockUseWallet(), +})); + +describe('useMasterAgreements', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps operator wallet ownership to the operator role', async () => { + const operator = new PublicKey('11111111111111111111111111111111'); + mockUseWallet.mockReturnValue({ publicKey: operator }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + master_agreements: [ + { + pubkey: '8Fj2kP9aFake', + master_id: 1710000000, + name: '대한-뉴욕 2026 리더 공동계약', + leader: 'Leader1111111111111111111111111111111111111', + operator: operator.toBase58(), + reinsurer: 'Rein111111111111111111111111111111111111111', + status: 2, + status_label: 'Active', + coverage_end_ts: 1770000000, + participants: [], + }, + ], + }), + })); + + const { result } = renderHook(() => useMasterAgreements()); + + await waitFor(() => { + expect(result.current.policies).toHaveLength(1); + }); + + expect(result.current.policies[0]?.myRole).toBe('operator'); + }); +}); diff --git a/frontend/src/hooks/useCreateMasterAgreement.ts b/frontend/src/hooks/useCreateMasterAgreement.ts index a82fd71b..b878267d 100644 --- a/frontend/src/hooks/useCreateMasterAgreement.ts +++ b/frontend/src/hooks/useCreateMasterAgreement.ts @@ -22,6 +22,7 @@ interface CurrentPayoutDelayFields { export interface CreateMasterAgreementInput extends LegacyPayoutDelayFields, CurrentPayoutDelayFields { masterId: number; + name: string; coverageStartTs: number; // unix seconds coverageEndTs: number; premiumPerPolicy: number; // in token base units @@ -80,6 +81,7 @@ export function useCreateMasterAgreement() { const params: CreateMasterAgreementParams = { masterId: masterIdBN, + name: input.name, coverageStartTs: new BN(input.coverageStartTs), coverageEndTs: new BN(input.coverageEndTs), premiumPerPolicy: new BN(input.premiumPerPolicy), diff --git a/frontend/src/hooks/useMasterAgreementAccount.ts b/frontend/src/hooks/useMasterAgreementAccount.ts index 11ba31c6..218a73f4 100644 --- a/frontend/src/hooks/useMasterAgreementAccount.ts +++ b/frontend/src/hooks/useMasterAgreementAccount.ts @@ -12,6 +12,7 @@ const fakeBN = (n: number) => ({ interface BackendMasterAgreement { pubkey: string; master_id: number; + name: string; status: number; collateral_claim_count?: number; participants: Array<{ @@ -45,6 +46,12 @@ interface BackendMasterAgreement { status_label: string; } +export interface SharedMasterAgreementAccountState { + masterData: MasterAgreementAccount | null; + masterLoading: boolean; + masterError: string | null; +} + function toMasterAgreementAccount(data: BackendMasterAgreement): MasterAgreementAccount { const SYSTEM_PROGRAM = '11111111111111111111111111111111'; const safePubkey = (s: string | undefined | null) => @@ -54,6 +61,7 @@ function toMasterAgreementAccount(data: BackendMasterAgreement): MasterAgreement return { masterId: fakeBN(data.master_id) as unknown as import('@coral-xyz/anchor').BN, + name: data.name, leader: safePubkey(data.leader), operator: safePubkey(data.operator), currencyMint: safePubkey(data.currency_mint), @@ -99,11 +107,11 @@ export function useMasterAgreementAccount(masterAgreementPDA: PublicKey | null) const pdaRef = useRef(pdaKey); pdaRef.current = pdaKey; - const fetchAccount = useCallback(async () => { + const fetchAccount = useCallback(async (): Promise => { const pda = pdaRef.current; if (!pda) { setAccount(null); - return; + return false; } setLoading(true); @@ -114,17 +122,19 @@ export function useMasterAgreementAccount(masterAgreementPDA: PublicKey | null) if (pdaRef.current === pda) { setAccount(null); } - return; + return false; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: BackendMasterAgreement = await res.json(); if (pdaRef.current === pda) { setAccount(toMasterAgreementAccount(data)); } + return true; } catch (err: unknown) { if (pdaRef.current === pda) { setError(err instanceof Error ? err.message : String(err)); } + return false; } finally { if (pdaRef.current === pda) { setLoading(false); @@ -139,7 +149,7 @@ export function useMasterAgreementAccount(masterAgreementPDA: PublicKey | null) // Initial fetch useEffect(() => { - fetchAccount(); + void fetchAccount(); }, [fetchAccount]); // SSE subscription for real-time updates diff --git a/frontend/src/hooks/useMasterAgreementActivation.ts b/frontend/src/hooks/useMasterAgreementActivation.ts new file mode 100644 index 00000000..16cddc4a --- /dev/null +++ b/frontend/src/hooks/useMasterAgreementActivation.ts @@ -0,0 +1,106 @@ +import { useMemo } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/shallow'; +import { useToast } from '@/components/common'; +import type { SharedMasterAgreementAccountState } from './useMasterAgreementAccount'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { useActivateMaster } from './useActivateMaster'; +import { useProgram } from './useProgram'; + +interface UseMasterAgreementActivationOptions extends Partial { + onActivated?: () => void; +} + +export function useMasterAgreementActivation(options: UseMasterAgreementActivationOptions = {}) { + const { onActivated, masterData } = options; + const { + mode, + role, + participants, + reinsurer, + masterActive, + masterAgreementPDA, + activateMaster, + onChainActivate, + } = useProtocolStore( + useShallow((state) => ({ + mode: state.mode, + role: state.role, + participants: state.participants, + reinsurer: state.reinsurer, + masterActive: state.masterActive, + masterAgreementPDA: state.masterAgreementPDA, + activateMaster: state.activateMaster, + onChainActivate: state.onChainActivate, + })), + ); + const { toast } = useToast(); + const { t } = useTranslation(); + const { activateMaster: activateMasterOnChain, loading: activateLoading } = useActivateMaster(); + const { wallet } = useProgram(); + const masterAgreementKey = useMemo( + () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), + [masterAgreementPDA], + ); + + const allParticipantsConfirmed = participants.every((participant) => participant.confirmed); + const reinOk = !reinsurer.enabled || reinsurer.confirmed; + const allConfirmed = allParticipantsConfirmed && reinOk; + const hasActivationAccountData = mode === 'simulation' || (!!masterAgreementKey && !!masterData); + const hasActivationAuthority = mode === 'simulation' + ? role === 'leader' || role === 'operator' + : !!masterData && !!wallet?.publicKey && masterData.operator.equals(wallet.publicKey); + const canActivate = allConfirmed && !masterActive && hasActivationAuthority && hasActivationAccountData; + + const handleActivate = async () => { + if (mode === 'simulation') { + const result = activateMaster(); + if (!result.ok) { + toast(result.msg!, 'd'); + return; + } + + toast(t('toast.masterActivated'), 's'); + onActivated?.(); + return; + } + + if (!masterAgreementKey) { + toast('No master agreement PDA', 'd'); + return; + } + + if (!masterData) { + toast('Master agreement account not loaded', 'd'); + return; + } + + if (!wallet?.publicKey || !masterData.operator.equals(wallet.publicKey)) { + return; + } + + const result = await activateMasterOnChain({ + masterAgreement: masterAgreementKey, + leaderPoolToken: masterData.leaderPoolWallet, + reinsurerPoolToken: masterData.reinsurerPoolWallet ?? masterData.leaderPoolWallet, + participantPoolTokens: masterData.participants.map((participant) => participant.poolWallet), + }); + + if (!result.success) { + toast(`TX failed: ${result.error}`, 'd'); + return; + } + + onChainActivate(result.signature, masterAgreementKey.toBase58()); + toast(`${t('toast.masterActivated')} TX: ${result.signature.slice(0, 8)}...`, 's'); + onActivated?.(); + }; + + return { + activateLoading, + allConfirmed, + canActivate, + handleActivate, + }; +} diff --git a/frontend/src/hooks/useMasterAgreementSnapshot.ts b/frontend/src/hooks/useMasterAgreementSnapshot.ts new file mode 100644 index 00000000..519feacc --- /dev/null +++ b/frontend/src/hooks/useMasterAgreementSnapshot.ts @@ -0,0 +1,160 @@ +import { useMemo } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { useShallow } from 'zustand/react/shallow'; +import type { CollateralStatus } from '@/lib/collateral'; +import type { MasterAgreementAccount } from '@/lib/idl/open_parametric'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { useFlightPolicies, type FlightPolicyWithKey } from './useFlightPolicies'; +import { useMasterAgreementAccount, type SharedMasterAgreementAccountState } from './useMasterAgreementAccount'; +import { usePoolCollateralStatus } from './usePoolCollateralStatus'; + +const MICRO_USDC_FACTOR = 1_000_000; + +export interface MasterAgreementSnapshot { + agreementName: string; + totalPremiumInflow: number; + totalClaimOutflow: number; + netBalance: number; + totalRequired: number; + totalFunded: number; + totalDeficit: number; + readinessPct: number; + blockers: string[]; + blockerLabels: string[]; + aggregateReady: boolean; +} + +export type MasterAgreementPolicyStatus = 'loading' | 'error' | 'ready'; + +function rawMicroUsdcToDisplay(amount: { toString(): string }): number { + return Number(amount.toString()) / MICRO_USDC_FACTOR; +} + +export function buildMasterAgreementSnapshot( + master: MasterAgreementAccount | null, + policies: FlightPolicyWithKey[], + collateralStatus: CollateralStatus | null, +): MasterAgreementSnapshot | null { + if (!master || !collateralStatus) { + return null; + } + + const totalPremiumInflow = policies.reduce( + (sum, policy) => sum + rawMicroUsdcToDisplay(policy.account.premiumPaid), + 0, + ); + const totalClaimOutflow = policies.reduce( + (sum, policy) => sum + rawMicroUsdcToDisplay(policy.account.payoutAmount), + 0, + ); + const blockers = collateralStatus.parties + .filter((party) => party.state !== 'ready') + .map((party) => party.id); + const blockerLabels = collateralStatus.parties + .filter((party) => party.state !== 'ready') + .map((party) => party.label); + + return { + agreementName: master.name?.trim() ?? '', + totalPremiumInflow, + totalClaimOutflow, + netBalance: totalPremiumInflow - totalClaimOutflow, + totalRequired: collateralStatus.totalRequired, + totalFunded: collateralStatus.totalFunded, + totalDeficit: collateralStatus.totalDeficit, + readinessPct: collateralStatus.totalHealthPct, + blockers, + blockerLabels, + aggregateReady: collateralStatus.aggregateReady, + }; +} + +export function buildSimulationMasterAgreementSnapshot({ + agreementName, + totalPremiumInflow, + totalClaimOutflow, + collateralStatus, +}: { + agreementName: string; + totalPremiumInflow: number; + totalClaimOutflow: number; + collateralStatus: CollateralStatus | null; +}): MasterAgreementSnapshot | null { + if (!collateralStatus) { + return null; + } + + const blockers = collateralStatus.parties + .filter((party) => party.state !== 'ready') + .map((party) => party.id); + const blockerLabels = collateralStatus.parties + .filter((party) => party.state !== 'ready') + .map((party) => party.label); + + return { + agreementName, + totalPremiumInflow, + totalClaimOutflow, + netBalance: totalPremiumInflow - totalClaimOutflow, + totalRequired: collateralStatus.totalRequired, + totalFunded: collateralStatus.totalFunded, + totalDeficit: collateralStatus.totalDeficit, + readinessPct: collateralStatus.totalHealthPct, + blockers, + blockerLabels, + aggregateReady: collateralStatus.aggregateReady, + }; +} + +export function useMasterAgreementSnapshot( + masterPda: PublicKey | null, + sharedMasterState?: SharedMasterAgreementAccountState, +) { + const { mode, selectedMasterAgreementName, totalPremium, totalClaim } = useProtocolStore( + useShallow((state) => ({ + mode: state.mode, + selectedMasterAgreementName: state.selectedMasterAgreementName, + totalPremium: state.totalPremium, + totalClaim: state.totalClaim, + })), + ); + const resolvedMasterState = useMasterAgreementAccount(sharedMasterState ? null : masterPda); + const masterData = sharedMasterState?.masterData ?? resolvedMasterState.account; + const masterLoading = sharedMasterState?.masterLoading ?? resolvedMasterState.loading; + const masterError = sharedMasterState?.masterError ?? resolvedMasterState.error; + const { status, activePartyId, loading: collateralLoading, error: collateralError } = usePoolCollateralStatus( + masterPda, + undefined, + { masterData, masterLoading, masterError }, + ); + const { policies, loading: policiesLoading, error: policiesError } = useFlightPolicies(masterPda); + const policyStatus: MasterAgreementPolicyStatus = + mode === 'simulation' ? 'ready' : policiesLoading ? 'loading' : policiesError ? 'error' : 'ready'; + const readinessLoading = mode === 'simulation' ? false : masterLoading || collateralLoading; + const readinessError = mode === 'simulation' ? null : masterError ?? collateralError; + + const snapshot = useMemo( + () => ( + mode === 'simulation' + ? buildSimulationMasterAgreementSnapshot({ + agreementName: selectedMasterAgreementName?.trim() || '', + totalPremiumInflow: totalPremium, + totalClaimOutflow: totalClaim, + collateralStatus: status, + }) + : buildMasterAgreementSnapshot(masterData, policies, status) + ), + [masterData, mode, policies, selectedMasterAgreementName, status, totalClaim, totalPremium], + ); + + return { + snapshot, + status, + activePartyId, + masterData, + loading: readinessLoading, + error: readinessError, + policyStatus, + policyError: mode === 'simulation' ? null : policiesError, + }; +} diff --git a/frontend/src/hooks/useMasterAgreements.ts b/frontend/src/hooks/useMasterAgreements.ts index df52b56f..35171137 100644 --- a/frontend/src/hooks/useMasterAgreements.ts +++ b/frontend/src/hooks/useMasterAgreements.ts @@ -6,7 +6,9 @@ import { BACKEND_URL } from '@/lib/constants'; interface BackendMasterAgreementItem { pubkey: string; master_id: number; + name: string; leader: string; + operator: string; reinsurer: string; status: number; status_label: string; @@ -16,6 +18,7 @@ interface BackendMasterAgreementItem { function detectRole(m: BackendMasterAgreementItem, wallet: string): MasterAgreementSummary['myRole'] { if (m.leader === wallet) return 'leader'; + if (m.operator === wallet) return 'operator'; if (m.reinsurer === wallet) return 'rein'; const nonLeaders = m.participants.filter(p => p.insurer !== m.leader); if (nonLeaders.some(p => p.insurer === wallet)) return 'participant'; @@ -27,10 +30,10 @@ export function useMasterAgreements() { const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); - const fetchPolicies = useCallback(async () => { + const fetchPolicies = useCallback(async (): Promise => { if (!publicKey) { setPolicies([]); - return; + return false; } setLoading(true); @@ -44,6 +47,7 @@ export function useMasterAgreements() { const mapped: MasterAgreementSummary[] = json.master_agreements.map((m) => ({ pda: m.pubkey, masterId: String(m.master_id), + name: m.name, status: m.status, statusLabel: m.status_label, coverageEndTs: m.coverage_end_ts, @@ -52,15 +56,17 @@ export function useMasterAgreements() { mapped.sort((a, b) => Number(b.masterId) - Number(a.masterId)); setPolicies(mapped); + return true; } catch { setPolicies([]); + return false; } finally { setLoading(false); } }, [publicKey]); useEffect(() => { - fetchPolicies(); + void fetchPolicies(); }, [fetchPolicies]); return { policies, loading, refetch: fetchPolicies }; diff --git a/frontend/src/hooks/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index 1dbe800e..c2735df9 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; import { PublicKey } from '@solana/web3.js'; +import { useShallow } from 'zustand/react/shallow'; import type { MasterAgreementAccount } from '@/lib/idl/open_parametric'; import { buildCollateralStatus, type CollateralStatus } from '@/lib/collateral'; -import { useMasterAgreementAccount } from './useMasterAgreementAccount'; +import { useProtocolStore } from '@/store/useProtocolStore'; +import { useMasterAgreementAccount, type SharedMasterAgreementAccountState } from './useMasterAgreementAccount'; import { useProgram } from './useProgram'; interface PoolCollateralBalances { @@ -15,6 +17,8 @@ interface UsePoolCollateralStatusResult { status: CollateralStatus | null; activePartyId?: string; masterData: MasterAgreementAccount | null; + loading: boolean; + error: string | null; } function participantId(index: number): string { @@ -25,7 +29,29 @@ function rawMicroUsdcToNumber(amount: { toString(): string }): number { return Number(amount.toString()) / 1_000_000; } -async function readTokenBalance( +function toEffectiveReinsurerBps(cededRatioBps: number, reinsCommissionBps: number): number { + return Math.max(0, Math.min(10_000, Math.round((cededRatioBps * (10_000 - reinsCommissionBps)) / 10_000))); +} + +export function resolveLeaderLabel( + selectedMasterAgreementName: string | null | undefined, + masterAgreementName: string | null | undefined, + fallbackLabel: string, +): string { + return selectedMasterAgreementName?.trim() || masterAgreementName?.trim() || fallbackLabel; +} + +export function resolvePartyLabel( + wallet: PublicKey | null | undefined, + fallbackLabel: string, + displayNamesByWallet: Record, + storedLabel?: string, +): string { + const walletLabel = wallet ? displayNamesByWallet[wallet.toBase58()] : undefined; + return walletLabel?.trim() || storedLabel?.trim() || fallbackLabel; +} + +export async function readTokenBalance( connection: ReturnType['connection'], tokenAccount: PublicKey | null | undefined, ): Promise { @@ -33,12 +59,8 @@ async function readTokenBalance( return 0; } - try { - const balance = await connection.getTokenAccountBalance(tokenAccount); - return rawMicroUsdcToNumber({ toString: () => balance.value.amount }); - } catch { - return 0; - } + const balance = await connection.getTokenAccountBalance(tokenAccount); + return rawMicroUsdcToNumber({ toString: () => balance.value.amount }); } function getActivePartyId( @@ -64,33 +86,104 @@ function getActivePartyId( return participantIndex >= 0 ? participantId(participantIndex) : undefined; } +function getSimulationActivePartyId( + role: ReturnType['role'], + participantCount: number, + reinsurerEnabled: boolean, +): string | undefined { + if (role === 'rein') { + return reinsurerEnabled ? 'reinsurer' : 'leader'; + } + + if (role === 'participant') { + return participantCount > 0 ? participantId(0) : undefined; + } + + return 'leader'; +} + export function usePoolCollateralStatus( masterPDA: PublicKey | null, activeWallet?: PublicKey | null, + sharedMasterState?: SharedMasterAgreementAccountState, ): UsePoolCollateralStatusResult { const { connection, wallet } = useProgram(); - const { account: masterData } = useMasterAgreementAccount(masterPDA); + const resolvedMasterState = useMasterAgreementAccount(sharedMasterState ? null : masterPDA); + const masterData = sharedMasterState?.masterData ?? resolvedMasterState.account; + const masterLoading = sharedMasterState?.masterLoading ?? resolvedMasterState.loading; + const masterError = sharedMasterState?.masterError ?? resolvedMasterState.error; + const { + mode, + role, + displayNamesByWallet, + leaderShare, + collateralClaimCount, + payoutTiers, + cededRatioBps, + reinsCommissionBps, + poolBalance, + participants: storedParticipants, + reinsurer: storedReinsurer, + selectedMasterAgreementName, + } = useProtocolStore( + useShallow((state) => ({ + mode: state.mode, + role: state.role, + displayNamesByWallet: state.displayNamesByWallet, + leaderShare: state.leaderShare, + collateralClaimCount: state.collateralClaimCount, + payoutTiers: state.payoutTiers, + cededRatioBps: state.cededRatioBps, + reinsCommissionBps: state.reinsCommissionBps, + poolBalance: state.poolBalance, + participants: state.participants, + reinsurer: state.reinsurer, + selectedMasterAgreementName: state.selectedMasterAgreementName, + })), + ); const [balances, setBalances] = useState(null); + const [balancesLoading, setBalancesLoading] = useState(false); + const [balancesError, setBalancesError] = useState(null); useEffect(() => { + if (mode === 'simulation') { + setBalances(null); + setBalancesLoading(false); + setBalancesError(null); + return; + } + let cancelled = false; async function loadBalances() { if (!masterData) { setBalances(null); + setBalancesLoading(false); + setBalancesError(null); return; } setBalances(null); + setBalancesLoading(true); + setBalancesError(null); - const [leader, reinsurer, participants] = await Promise.all([ - readTokenBalance(connection, masterData.leaderPoolWallet), - readTokenBalance(connection, masterData.reinsurerPoolWallet), - Promise.all(masterData.participants.map((participant) => readTokenBalance(connection, participant.poolWallet))), - ]); + try { + const [leader, reinsurer, participants] = await Promise.all([ + readTokenBalance(connection, masterData.leaderPoolWallet), + readTokenBalance(connection, masterData.reinsurerPoolWallet), + Promise.all(masterData.participants.map((participant) => readTokenBalance(connection, participant.poolWallet))), + ]); - if (!cancelled) { - setBalances({ leader, reinsurer, participants }); + if (!cancelled) { + setBalances({ leader, reinsurer, participants }); + setBalancesLoading(false); + } + } catch (error) { + if (!cancelled) { + setBalances(null); + setBalancesLoading(false); + setBalancesError(error instanceof Error ? error.message : String(error)); + } } } @@ -99,25 +192,84 @@ export function usePoolCollateralStatus( return () => { cancelled = true; }; - }, [connection, masterData]); + }, [connection, masterData, mode]); const resolvedActiveWallet = activeWallet ?? wallet?.publicKey ?? null; const activePartyId = useMemo( - () => getActivePartyId(masterData, resolvedActiveWallet), - [masterData, resolvedActiveWallet], + () => ( + mode === 'simulation' + ? getSimulationActivePartyId(role, storedParticipants.length, storedReinsurer.enabled) + : getActivePartyId(masterData, resolvedActiveWallet) + ), + [masterData, mode, resolvedActiveWallet, role, storedParticipants.length, storedReinsurer.enabled], ); const status = useMemo(() => { + if (mode === 'simulation') { + const simulationInput = { + payoutTiers, + collateralClaimCount, + reinsurerEffectiveBps: storedReinsurer.enabled + ? toEffectiveReinsurerBps(cededRatioBps, reinsCommissionBps) + : 0, + leaderShareBps: leaderShare * 100, + leader: { + label: resolveLeaderLabel(selectedMasterAgreementName, null, 'Leader'), + confirmed: true, + balance: 0, + }, + participants: storedParticipants.map((participant, index) => ({ + id: participantId(index), + label: participant.name?.trim() || `Participant ${index + 1}`, + shareBps: participant.share * 100, + confirmed: participant.confirmed, + balance: 0, + })), + reinsurer: storedReinsurer.enabled ? { + label: storedReinsurer.name?.trim() || 'Reinsurer', + confirmed: storedReinsurer.confirmed, + balance: 0, + } : undefined, + }; + + try { + const baselineStatus = buildCollateralStatus(simulationInput); + const totalFunded = Math.max(0, poolBalance); + const balanceByPartyId = new Map( + baselineStatus.parties.map((party) => [ + party.id, + baselineStatus.totalRequired > 0 ? (totalFunded * party.required) / baselineStatus.totalRequired : 0, + ]), + ); + + return buildCollateralStatus({ + ...simulationInput, + leader: { + ...simulationInput.leader, + balance: balanceByPartyId.get('leader') ?? 0, + }, + participants: simulationInput.participants.map((participant) => ({ + ...participant, + balance: balanceByPartyId.get(participant.id) ?? 0, + })), + reinsurer: simulationInput.reinsurer ? { + ...simulationInput.reinsurer, + balance: balanceByPartyId.get('reinsurer') ?? 0, + } : undefined, + }); + } catch { + return null; + } + } + if (!masterData) { return null; } - const safeBalances = balances ?? { - leader: 0, - reinsurer: 0, - participants: masterData.participants.map(() => 0), - }; + if (!balances) { + return null; + } try { return buildCollateralStatus({ @@ -131,27 +283,59 @@ export function usePoolCollateralStatus( reinsurerEffectiveBps: masterData.reinsurerEffectiveBps, leaderShareBps: masterData.leaderShareBps, leader: { - label: 'Leader', + label: resolveLeaderLabel(selectedMasterAgreementName, masterData.name, 'Leader'), confirmed: true, - balance: safeBalances.leader, + balance: balances.leader, }, participants: masterData.participants.map((participant, index) => ({ id: participantId(index), - label: `Participant ${index + 1}`, + label: resolvePartyLabel( + participant.insurer, + `Participant ${index + 1}`, + displayNamesByWallet, + storedParticipants[index]?.name, + ), shareBps: participant.shareBps, confirmed: participant.confirmed, - balance: safeBalances.participants[index] ?? 0, + balance: balances.participants[index] ?? 0, })), reinsurer: masterData.reinsurer ? { - label: 'Reinsurer', + label: resolvePartyLabel( + masterData.reinsurer, + 'Reinsurer', + displayNamesByWallet, + storedReinsurer.name, + ), confirmed: masterData.reinsurerConfirmed, - balance: safeBalances.reinsurer, + balance: balances.reinsurer, } : undefined, }); } catch { return null; } - }, [balances, masterData]); + }, [ + balances, + cededRatioBps, + collateralClaimCount, + displayNamesByWallet, + leaderShare, + masterData, + mode, + payoutTiers, + poolBalance, + reinsCommissionBps, + selectedMasterAgreementName, + storedParticipants, + storedReinsurer, + ]); - return { status, activePartyId, masterData }; + return { + status, + activePartyId, + masterData, + loading: mode === 'simulation' + ? false + : masterLoading || balancesLoading || (!!masterData && !balances && !balancesError), + error: mode === 'simulation' ? null : masterError ?? balancesError, + }; } diff --git a/frontend/src/hooks/useSyncedSelectedMasterAgreementName.ts b/frontend/src/hooks/useSyncedSelectedMasterAgreementName.ts new file mode 100644 index 00000000..45a8dac4 --- /dev/null +++ b/frontend/src/hooks/useSyncedSelectedMasterAgreementName.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef } from 'react'; +import { useProtocolStore } from '@/store/useProtocolStore'; + +function normalizeName(name: string | null | undefined): string | null { + const trimmed = name?.trim(); + return trimmed ? trimmed : null; +} + +export function useSyncedSelectedMasterAgreementName(authoritativeName: string | null | undefined) { + const selectedMasterAgreementName = useProtocolStore((state) => state.selectedMasterAgreementName); + const setSelectedMasterAgreementName = useProtocolStore((state) => state.setSelectedMasterAgreementName); + const normalizedAuthoritativeName = normalizeName(authoritativeName); + const previousAuthoritativeNameRef = useRef(normalizedAuthoritativeName); + + useEffect(() => { + const previousAuthoritativeName = previousAuthoritativeNameRef.current; + previousAuthoritativeNameRef.current = normalizedAuthoritativeName; + + if (!normalizedAuthoritativeName) { + return; + } + + if (!selectedMasterAgreementName?.trim()) { + setSelectedMasterAgreementName(normalizedAuthoritativeName); + return; + } + + if ( + previousAuthoritativeName && + previousAuthoritativeName !== normalizedAuthoritativeName && + selectedMasterAgreementName.trim() !== normalizedAuthoritativeName + ) { + setSelectedMasterAgreementName(normalizedAuthoritativeName); + } + }, [normalizedAuthoritativeName, selectedMasterAgreementName, setSelectedMasterAgreementName]); +} diff --git a/frontend/src/hooks/useUpdateMasterAgreementName.ts b/frontend/src/hooks/useUpdateMasterAgreementName.ts new file mode 100644 index 00000000..e0abdccb --- /dev/null +++ b/frontend/src/hooks/useUpdateMasterAgreementName.ts @@ -0,0 +1,45 @@ +import { useCallback, useState } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { useProgram } from './useProgram'; +import { sendTx, type TxResult } from '@/lib/tx'; + +export interface UpdateMasterAgreementNameInput { + masterAgreement: PublicKey; + name: string; +} + +export function useUpdateMasterAgreementName() { + const { program, provider, wallet } = useProgram(); + const [loading, setLoading] = useState(false); + + const updateMasterAgreementName = useCallback( + async (input: UpdateMasterAgreementNameInput): Promise => { + if (!program || !provider || !wallet) { + return { signature: '', success: false, error: 'Wallet not connected' }; + } + + setLoading(true); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prog = program as any; + return await sendTx(provider, () => + prog.methods + .updateMasterAgreementName(input.name) + .accounts({ + signer: wallet.publicKey, + masterAgreement: input.masterAgreement, + }) + .rpc(), + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { signature: '', success: false, error: message }; + } finally { + setLoading(false); + } + }, + [program, provider, wallet], + ); + + return { updateMasterAgreementName, loading }; +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c02aeaf8..9f28aae2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -65,6 +65,13 @@ const en = { 'master.coverageEnd': 'Coverage End', 'master.coverageType': 'Coverage Type', 'master.coverageTypeValue': 'Flight Departure Delay Insurance', + 'master.name': 'Official Name', + 'master.namePlaceholder': 'Enter the master agreement name', + 'master.nameRequired': 'Enter an official name before creating or renaming the master agreement', + 'master.nameSave': 'Save Name', + 'master.nameSaved': 'Master agreement name updated', + 'master.nameSavedLocalWarning': 'Master agreement name updated locally; refresh is still catching up', + 'master.noNameFallback': 'No official name yet', 'master.premiumPerContract': 'Premium per Policy (USDC)', 'master.payoutByTier': 'Payout by Delay Tier', 'master.setTermsBtn': '📄 Set Terms & Rate', @@ -81,7 +88,16 @@ const en = { 'master.step.basic': 'Contract Setup', 'master.step.participants': 'Participants Confirm', 'master.step.activate': 'Activate Pool', + 'master.step3.empty': 'No activation snapshot is available yet.', + 'master.step3.moneyTitle': 'Money Snapshot', + 'master.step3.totalRequired': 'Total Required', + 'master.step3.totalFunded': 'Total Funded', + 'master.step3.totalDeficit': 'Total Deficit', + 'master.step3.totalPremiumInflow': 'Premium Inflow', + 'master.step3.totalClaimOutflow': 'Claim Outflow', + 'master.step3.netBalance': 'Net Balance', 'master.review.title': 'Review Panel', + 'master.review.name': 'Official Name', 'master.review.coverage': 'Coverage Period', 'master.review.premium': 'Premium Per Policy', 'master.review.maxPayout': 'Maximum Payout', @@ -153,6 +169,7 @@ const en = { 'confirm.shareInfo': 'Share: {{share}}% (Primary Ins.)', 'confirm.reinInfo': 'Primary Ins. Premium 50% reinsurance acceptance', 'confirm.btn': '✓ Confirm', + 'confirm.activateTransitionBtn': 'Continue to Activation Dashboard', 'confirm.activateBtn': '⚡ Master Agreement Activation', 'confirm.portalGuide': 'Each participant must confirm via the Portal page with their own wallet', @@ -527,22 +544,24 @@ const en = { 'guide.step7.desc': 'Click the confirm button for Reinsurer.', 'guide.step8.title': 'Step 8: Switch to Leader', 'guide.step8.desc': 'Change your role back to "Leader".', - 'guide.step9.title': 'Step 9: Activate Master Agreement', - 'guide.step9.desc': 'Click the master agreement activation button.', - 'guide.step10.title': 'Step 10: Live Policy Feed', - 'guide.step10.desc': 'Click the "Live Policy Feed" tab.', - 'guide.step11.title': 'Step 11: Create Policy', - 'guide.step11.desc': 'Create a policy by clicking this button.', - 'guide.step12.title': 'Step 12: Oracle & Claims', - 'guide.step12.desc': 'Navigate to the "Oracle & Claims" tab.', - 'guide.step13.title': 'Step 13: Select Policy', - 'guide.step13.desc': 'Select a target policy from the dropdown.', - 'guide.step14.title': 'Step 14: Process Flight Delay', - 'guide.step14.desc': 'Click the flight delay processing button.', - 'guide.step15.title': 'Step 15: Settle Claims', - 'guide.step15.desc': 'Click the claim settlement button.', - 'guide.step16.title': 'Step 16: Settlement Status', - 'guide.step16.desc': 'View the results in the "Settlement" tab.', + 'guide.step9.title': 'Step 9: Open Activation Step', + 'guide.step9.desc': 'Move from confirmations into the activation dashboard.', + 'guide.step10.title': 'Step 10: Activate Master Agreement', + 'guide.step10.desc': 'Click the master agreement activation button.', + 'guide.step11.title': 'Step 11: Live Policy Feed', + 'guide.step11.desc': 'Click the "Live Policy Feed" tab.', + 'guide.step12.title': 'Step 12: Create Policy', + 'guide.step12.desc': 'Create a policy by clicking this button.', + 'guide.step13.title': 'Step 13: Oracle & Claims', + 'guide.step13.desc': 'Navigate to the "Oracle & Claims" tab.', + 'guide.step14.title': 'Step 14: Select Policy', + 'guide.step14.desc': 'Select a target policy from the dropdown.', + 'guide.step15.title': 'Step 15: Process Flight Delay', + 'guide.step15.desc': 'Click the flight delay processing button.', + 'guide.step16.title': 'Step 16: Settle Claims', + 'guide.step16.desc': 'Click the claim settlement button.', + 'guide.step17.title': 'Step 17: Settlement Status', + 'guide.step17.desc': 'View the results in the "Settlement" tab.', // === Portal === 'portal.title': 'Participant Portal', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index c9a9ecee..e96c3d13 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -65,6 +65,13 @@ const ko = { 'master.coverageEnd': '담보 기간 종료', 'master.coverageType': '담보 항목', 'master.coverageTypeValue': '항공기 출발 지연 보험', + 'master.name': '공식 계약명', + 'master.namePlaceholder': '마스터 계약명을 입력하세요', + 'master.nameRequired': '마스터 계약 생성 또는 이름 변경 전에 공식 계약명을 입력하세요', + 'master.nameSave': '이름 저장', + 'master.nameSaved': '마스터 계약명이 업데이트되었습니다', + 'master.nameSavedLocalWarning': '마스터 계약명은 로컬에 반영되었지만 새로고침 동기화는 아직 지연되고 있습니다', + 'master.noNameFallback': '아직 공식 계약명이 없습니다', 'master.premiumPerContract': '건당 보험료 (USDC)', 'master.payoutByTier': '지연 구간별 보험금', 'master.setTermsBtn': '📄 약관 세팅 & 요율 산정', @@ -81,7 +88,16 @@ const ko = { 'master.step.basic': '기본 계약 설정', 'master.step.participants': '참여사 승인', 'master.step.activate': '풀 활성화', + 'master.step3.empty': '아직 활성화 스냅샷을 표시할 데이터가 없습니다.', + 'master.step3.moneyTitle': '자금 스냅샷', + 'master.step3.totalRequired': '총 필요액', + 'master.step3.totalFunded': '총 납부액', + 'master.step3.totalDeficit': '총 부족분', + 'master.step3.totalPremiumInflow': '보험료 유입', + 'master.step3.totalClaimOutflow': '보험금 유출', + 'master.step3.netBalance': '순잔액', 'master.review.title': '검토 패널', + 'master.review.name': '공식 계약명', 'master.review.coverage': '보장 기간', 'master.review.premium': '계약당 보험료', 'master.review.maxPayout': '최대 지급액', @@ -153,6 +169,7 @@ const ko = { 'confirm.shareInfo': '지분: {{share}}% (원수사 내)', 'confirm.reinInfo': '원수사 보험료·보험금의 50% 재보험 인수', 'confirm.btn': '✓ 컨펌', + 'confirm.activateTransitionBtn': '활성화 대시보드로 이동', 'confirm.activateBtn': '⚡ 마스터 계약 활성화', 'confirm.portalGuide': '각 참여사는 Portal 페이지에서 자체 지갑으로 컨펌해주세요', @@ -527,22 +544,24 @@ const ko = { 'guide.step7.desc': '재보험사의 컨펌 버튼을 클릭하세요.', 'guide.step8.title': 'Step 8: 리더사로 전환', 'guide.step8.desc': '역할을 "리더사"로 다시 변경하세요.', - 'guide.step9.title': 'Step 9: 마스터계약 활성화', - 'guide.step9.desc': '마스터 계약 활성화 버튼을 클릭하세요.', - 'guide.step10.title': 'Step 10: 실시간 보험 피드', - 'guide.step10.desc': '"실시간 보험 피드" 탭을 클릭하세요.', - 'guide.step11.title': 'Step 11: 보험 가입', - 'guide.step11.desc': '보험 가입 버튼을 클릭합니다.', - 'guide.step12.title': 'Step 12: 오라클 & 클레임', - 'guide.step12.desc': '"오라클 & 클레임" 탭으로 이동하세요.', - 'guide.step13.title': 'Step 13: 대상 보험 선택', - 'guide.step13.desc': '대상 보험을 선택하세요.', - 'guide.step14.title': 'Step 14: 항공편 지연 처리', - 'guide.step14.desc': '항공편 지연 처리 버튼을 클릭하세요.', - 'guide.step15.title': 'Step 15: 보험금 정산', - 'guide.step15.desc': '보험금 정산 버튼을 클릭하세요.', - 'guide.step16.title': 'Step 16: 정산 현황', - 'guide.step16.desc': '"정산 현황" 탭에서 결과를 확인하세요.', + 'guide.step9.title': 'Step 9: 활성화 단계 열기', + 'guide.step9.desc': '컨펌 단계에서 활성화 대시보드로 이동하세요.', + 'guide.step10.title': 'Step 10: 마스터계약 활성화', + 'guide.step10.desc': '마스터 계약 활성화 버튼을 클릭하세요.', + 'guide.step11.title': 'Step 11: 실시간 보험 피드', + 'guide.step11.desc': '"실시간 보험 피드" 탭을 클릭하세요.', + 'guide.step12.title': 'Step 12: 보험 가입', + 'guide.step12.desc': '보험 가입 버튼을 클릭합니다.', + 'guide.step13.title': 'Step 13: 오라클 & 클레임', + 'guide.step13.desc': '"오라클 & 클레임" 탭으로 이동하세요.', + 'guide.step14.title': 'Step 14: 대상 보험 선택', + 'guide.step14.desc': '대상 보험을 선택하세요.', + 'guide.step15.title': 'Step 15: 항공편 지연 처리', + 'guide.step15.desc': '항공편 지연 처리 버튼을 클릭하세요.', + 'guide.step16.title': 'Step 16: 보험금 정산', + 'guide.step16.desc': '보험금 정산 버튼을 클릭하세요.', + 'guide.step17.title': 'Step 17: 정산 현황', + 'guide.step17.desc': '"정산 현황" 탭에서 결과를 확인하세요.', // === Portal === 'portal.title': '참여사 포탈', diff --git a/frontend/src/lib/idl/open_parametric.json b/frontend/src/lib/idl/open_parametric.json index c481b906..c628b185 100644 --- a/frontend/src/lib/idl/open_parametric.json +++ b/frontend/src/lib/idl/open_parametric.json @@ -505,6 +505,35 @@ } ], "args": [] + }, + { + "name": "update_master_agreement_name", + "discriminator": [ + 214, + 111, + 112, + 115, + 193, + 69, + 193, + 59 + ], + "accounts": [ + { + "name": "signer", + "signer": true + }, + { + "name": "master_agreement", + "writable": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + } + ] } ], "accounts": [ @@ -665,6 +694,10 @@ "name": "master_id", "type": "u64" }, + { + "name": "name", + "type": "string" + }, { "name": "coverage_start_ts", "type": "i64" @@ -807,6 +840,10 @@ "name": "master_id", "type": "u64" }, + { + "name": "name", + "type": "string" + }, { "name": "leader", "type": "pubkey" diff --git a/frontend/src/lib/idl/open_parametric.ts b/frontend/src/lib/idl/open_parametric.ts index 27a6e912..145a1f34 100644 --- a/frontend/src/lib/idl/open_parametric.ts +++ b/frontend/src/lib/idl/open_parametric.ts @@ -83,6 +83,7 @@ export interface MasterParticipant { /** Mirrors the runtime Anchor JS camelCase account shape. */ export interface MasterAgreementAccount { masterId: BN; + name: string; leader: PublicKey; operator: PublicKey; currencyMint: PublicKey; @@ -139,6 +140,7 @@ export interface MasterParticipantInit { export interface CreateMasterAgreementParams { masterId: BN; + name: string; coverageStartTs: BN; coverageEndTs: BN; premiumPerPolicy: BN; @@ -714,6 +716,35 @@ export type OpenParametric = { } ], "args": [] + }, + { + "name": "updateMasterAgreementName", + "discriminator": [ + 214, + 111, + 112, + 115, + 193, + 69, + 193, + 59 + ], + "accounts": [ + { + "name": "signer", + "signer": true + }, + { + "name": "masterAgreement", + "writable": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + } + ] } ], "accounts": [ @@ -874,6 +905,10 @@ export type OpenParametric = { "name": "masterId", "type": "u64" }, + { + "name": "name", + "type": "string" + }, { "name": "coverageStartTs", "type": "i64" @@ -1064,6 +1099,10 @@ export type OpenParametric = { "name": "masterId", "type": "u64" }, + { + "name": "name", + "type": "string" + }, { "name": "leader", "type": "pubkey" diff --git a/frontend/src/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index b3d59c62..dca015e1 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -88,10 +88,11 @@ export type ProtocolMode = 'simulation' | 'onchain'; export interface MasterAgreementSummary { pda: string; masterId: string; + name: string; status: number; statusLabel: string; coverageEndTs: number; - myRole?: 'leader' | 'participant' | 'rein'; + myRole?: 'leader' | 'participant' | 'rein' | 'operator'; } export interface PoolHistEntry { @@ -257,6 +258,7 @@ interface ProtocolState { // On-chain state poolRefreshKey: number; masterAgreementPDA: string | null; + selectedMasterAgreementName: string | null; lastTxSignature: string | null; masterAgreements: MasterAgreementSummary[]; lastDaemonActivityTs: number | null; @@ -286,9 +288,11 @@ interface ProtocolState { addLog: (msg: string, color: string, instruction: string, detail?: string, txSignature?: string) => void; applyMasterAgreementDisplayNames: (payload: MasterAgreementDisplayNames) => void; setMasterAgreementPDA: (pda: string | null) => void; + setSelectedMasterAgreementName: (name: string | null) => void; setMasterAgreements: (list: MasterAgreementSummary[]) => void; selectMasterAgreement: (pda: string | null) => void; onChainSetTerms: (txSignature: string, opts?: { + masterAgreementName?: string; cededRatioBps?: number; reinsCommissionBps?: number; premium?: number; @@ -344,6 +348,7 @@ export const useProtocolStore = create()(persist((set, get) => ({ // On-chain state poolRefreshKey: 0, masterAgreementPDA: null, + selectedMasterAgreementName: null, lastTxSignature: null, masterAgreements: [], lastDaemonActivityTs: null, @@ -639,6 +644,8 @@ export const useProtocolStore = create()(persist((set, get) => ({ setMasterAgreementPDA: (pda) => set({ masterAgreementPDA: pda }), + setSelectedMasterAgreementName: (name) => set({ selectedMasterAgreementName: name?.trim() || null }), + setMasterAgreements: (list) => set({ masterAgreements: list }), selectMasterAgreement: (pda) => { @@ -656,10 +663,10 @@ export const useProtocolStore = create()(persist((set, get) => ({ ...getDefaultMasterTerms(), }; if (pda === null) { - set({ masterAgreementPDA: null, displayNamesByWallet: {}, ...resetMirror }); + set({ masterAgreementPDA: null, selectedMasterAgreementName: null, displayNamesByWallet: {}, ...resetMirror }); get().addLog('새 마스터계약 생성 모드', '#94A3B8', 'select_master'); } else { - set({ masterAgreementPDA: pda, displayNamesByWallet: {}, ...resetMirror }); + set({ masterAgreementPDA: pda, selectedMasterAgreementName: null, displayNamesByWallet: {}, ...resetMirror }); get().addLog(`마스터계약 전환: ${pda.slice(0, 8)}...`, '#9945FF', 'select_master', '체인에서 상태 조회 중...'); } }, @@ -671,6 +678,7 @@ export const useProtocolStore = create()(persist((set, get) => ({ set({ processStep: 1, policyStateIdx: 0, + ...(opts?.masterAgreementName != null && { selectedMasterAgreementName: opts.masterAgreementName.trim() || null }), ...(opts?.cededRatioBps != null && { cededRatioBps: opts.cededRatioBps }), ...(opts?.reinsCommissionBps != null && { reinsCommissionBps: opts.reinsCommissionBps }), ...(opts?.premium != null && { premiumPerPolicy: opts.premium }), @@ -874,6 +882,7 @@ export const useProtocolStore = create()(persist((set, get) => ({ logs: [], logIdCounter: 0, masterAgreementPDA: null, lastTxSignature: null, kpiSnapshot: null, + selectedMasterAgreementName: null, displayNamesByWallet: {}, }); get().addLog(i18n.t('store.resetMsg'), '#9945FF', 'system_init'); @@ -955,6 +964,7 @@ export const useProtocolStore = create()(persist((set, get) => ({ }), ...(data.coverageStartTs && { coverageStart: toDateStr(data.coverageStartTs) }), ...(data.coverageEndTs && { coverageEnd: toDateStr(data.coverageEndTs) }), + selectedMasterAgreementName: data.name?.trim() || null, policyStateIdx: isActive ? 3 : processStep > 0 ? 0 : -1, }); },