From 1e30ef38c1811c721633e3732e0764084b7acd83 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:01:50 +0900 Subject: [PATCH 01/24] Define official master agreement naming and Step 3 dashboard The new spec fixes two UX gaps at once: contracts need an official, durable name, and Step 3 needs to become an operational readiness dashboard instead of repeating participant confirmation. The design chooses on-chain storage for the master agreement name because backend persistence is not durable enough for the user's requirement. It also scopes Step 3 to a leader/operator snapshot dashboard, keeping time-series analytics out of the first implementation slice. Constraint: Master agreement names must survive backend loss and remain the official shared identifier Rejected: Backend-only metadata storage | name would disappear if backend state is lost Rejected: Existing-contract backfill | not required for this first rollout Confidence: high Scope-risk: moderate Reversibility: clean Directive: Treat the contract field as the single source of truth for master agreement names; do not reintroduce backend-owned naming Tested: Spec self-review for placeholders, ambiguity, and scope Not-tested: Implementation feasibility against exact Anchor account sizing and migration details --- ...aster-agreement-naming-and-step3-design.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-master-agreement-naming-and-step3-design.md 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의 보험료/보험금 스냅샷 계산 기준이다. 이 값이 온체인 원시 상태만으로 충분하지 않다면, 백엔드 집계 규칙을 별도 구현 계획에서 명시해야 한다. From 99b093da7ed88b941267921a8f2b221137ed3fde Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:53:49 +0900 Subject: [PATCH 02/24] Store validated master agreement names on creation Persist a trimmed master agreement name at creation time and reject blank or overlong values before the account is initialized. The account space now reserves an explicit UTF-8 budget for the stored name. Constraint: Follow the task's TDD sequence with focused contract tests first Rejected: Accept raw input without trimming | would persist avoidable whitespace differences on-chain Confidence: high Scope-risk: narrow Directive: Keep create-time name validation aligned with MASTER_AGREEMENT_NAME_MAX_LEN if the limit changes Tested: cargo test create_master_agreement_test Tested: cargo test --- .../programs/open_parametric/src/constants.rs | 4 ++-- .../instructions/create_master_agreement.rs | 12 ++++++++++ .../create_master_agreement_test.rs | 23 ++++++++++++++++++- .../programs/open_parametric/src/state.rs | 2 ++ 4 files changed, 38 insertions(+), 3 deletions(-) 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..dbb742b0 100644 --- a/contract/programs/open_parametric/src/instructions/create_master_agreement.rs +++ b/contract/programs/open_parametric/src/instructions/create_master_agreement.rs @@ -37,6 +37,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 +74,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(); @@ -116,6 +118,16 @@ pub fn handler( Ok(()) } +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()) +} + pub(crate) fn validate_create_master_inputs( leader_share_bps: u16, participants: &[MasterParticipantInit], 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..6d2466af 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,9 @@ 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::{ + normalize_master_agreement_name, validate_create_master_inputs, validate_master_participants, +}; #[test] fn master_participants_require_10000_bps_with_separate_leader_share() { @@ -179,3 +181,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/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, From 2c730a067a8bb391a95c5c2be2011146682977e7 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:12:22 +0900 Subject: [PATCH 03/24] Allow leader and operator to rename master agreements Shared master agreement name normalization is reused for the new rename path, and the frontend IDL artifacts are synced to expose the instruction and account shape. Constraint: Keep scope limited to Task 2 instruction, tests, and regenerated IDL artifacts Rejected: Duplicating name normalization in the rename instruction | would fork a shared invariant Confidence: high Scope-risk: narrow Directive: Keep rename authorization limited to leader/operator unless the master agreement role model changes Tested: cargo test update_master_agreement_name_test; anchor build Not-tested: yarn test src/lib/__tests__/programEnv.test.ts (blocked by Yarn 1 vs packageManager yarn@4.1.0/Corepack requirement) --- .../open_parametric/src/instructions/mod.rs | 5 +++ .../update_master_agreement_name.rs | 35 ++++++++++++++++++ .../update_master_agreement_name_test.rs | 36 ++++++++++++++++++ contract/programs/open_parametric/src/lib.rs | 7 ++++ frontend/src/lib/idl/open_parametric.json | 37 +++++++++++++++++++ frontend/src/lib/idl/open_parametric.ts | 1 + 6 files changed, 121 insertions(+) create mode 100644 contract/programs/open_parametric/src/instructions/update_master_agreement_name.rs create mode 100644 contract/programs/open_parametric/src/instructions/update_master_agreement_name_test.rs diff --git a/contract/programs/open_parametric/src/instructions/mod.rs b/contract/programs/open_parametric/src/instructions/mod.rs index 46efd950..bf6e4626 100644 --- a/contract/programs/open_parametric/src/instructions/mod.rs +++ b/contract/programs/open_parametric/src/instructions/mod.rs @@ -9,6 +9,7 @@ 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 +32,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 +55,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..0085abc0 --- /dev/null +++ b/contract/programs/open_parametric/src/instructions/update_master_agreement_name.rs @@ -0,0 +1,35 @@ +use anchor_lang::prelude::*; + +use crate::errors::OpenParamError; +use crate::state::*; + +use super::create_master_agreement::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 fn handler(ctx: Context, name: String) -> Result<()> { + let normalized_name = normalize_master_agreement_name(&name)?; + let master = &mut ctx.accounts.master_agreement; + + assert_can_rename_master_agreement(master.leader, master.operator, ctx.accounts.signer.key())?; + master.name = normalized_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..cc7ad773 --- /dev/null +++ b/contract/programs/open_parametric/src/instructions/update_master_agreement_name_test.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::Pubkey; + +use crate::errors::OpenParamError; + +use super::update_master_agreement_name::assert_can_rename_master_agreement; + +#[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))); +} 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/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..eaa60001 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; From 6d28561b2942b32e334d1d8a69fad027bdfef6c3 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:42:38 +0900 Subject: [PATCH 04/24] Sync the exported TS IDL with master agreement rename metadata The regenerated JSON already included the rename instruction and added name fields, but the hand-maintained OpenParametric type block was still stale. Constraint: Keep the fix limited to the stale frontend TS IDL export Rejected: Regenerate or rewrite the full TS helper file | broader than the review item and would mix unrelated drift Confidence: high Scope-risk: narrow Directive: When JSON IDL is refreshed, update both helper interfaces and the exported OpenParametric type block together Tested: rg -n 'updateMasterAgreementName|"name": "name"' frontend/src/lib/idl/open_parametric.ts frontend/src/lib/idl/open_parametric.json; npx tsc --noEmit -p tsconfig.json Not-tested: Full frontend test suite --- frontend/src/lib/idl/open_parametric.ts | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/frontend/src/lib/idl/open_parametric.ts b/frontend/src/lib/idl/open_parametric.ts index eaa60001..0e1f25e0 100644 --- a/frontend/src/lib/idl/open_parametric.ts +++ b/frontend/src/lib/idl/open_parametric.ts @@ -715,6 +715,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": [ @@ -875,6 +904,10 @@ export type OpenParametric = { "name": "masterId", "type": "u64" }, + { + "name": "name", + "type": "string" + }, { "name": "coverageStartTs", "type": "i64" @@ -1065,6 +1098,10 @@ export type OpenParametric = { "name": "masterId", "type": "u64" }, + { + "name": "name", + "type": "string" + }, { "name": "leader", "type": "pubkey" From 33b18e2588b2088c85e53fe728b14831e81a5a9c Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:55:25 +0900 Subject: [PATCH 05/24] Keep master agreement names consistent across create and rename paths This follow-up closes the Task 2 review gaps by sharing name normalization between create and rename, extending rename-path tests to cover trimming and rejection cases, and making the frontend TS helper surface require the same name field the JSON IDL already defines. Constraint: Keep the fix limited to Task 2 follow-up review items without regenerating unrelated frontend artifacts Rejected: Leave normalization in create_master_agreement.rs | keeps a shared invariant hidden behind one instruction boundary Rejected: Update only the TS helper type without the typed hook consumer | would leave the frontend surface internally inconsistent Confidence: high Scope-risk: narrow Directive: If master agreement name rules change, update the shared helper and both create/rename tests together Tested: cargo test update_master_agreement_name_test; cargo test create_master_agreement_test; cargo test; npx tsc --noEmit -p tsconfig.json; rg -n 'export interface CreateMasterAgreementParams|name: string;|name: updateMasterAgreementName|name: name' frontend/src/lib/idl/open_parametric.ts frontend/src/lib/idl/open_parametric.json frontend/src/hooks/useCreateMasterAgreement.ts Not-tested: Interactive frontend create-master flow --- .../instructions/create_master_agreement.rs | 12 +-- .../create_master_agreement_test.rs | 5 +- .../src/instructions/master_agreement_name.rs | 12 +++ .../open_parametric/src/instructions/mod.rs | 1 + .../update_master_agreement_name.rs | 19 +++-- .../update_master_agreement_name_test.rs | 78 ++++++++++++++++++- .../src/hooks/useCreateMasterAgreement.ts | 2 + frontend/src/lib/idl/open_parametric.ts | 1 + 8 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 contract/programs/open_parametric/src/instructions/master_agreement_name.rs 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 dbb742b0..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> { @@ -118,16 +120,6 @@ pub fn handler( Ok(()) } -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()) -} - pub(crate) fn validate_create_master_inputs( leader_share_bps: u16, participants: &[MasterParticipantInit], 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 6d2466af..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,8 +4,9 @@ use crate::constants::MAX_MASTER_PARTICIPANTS; use crate::errors::OpenParamError; use crate::state::MasterParticipantInit; -use super::create_master_agreement::{ - normalize_master_agreement_name, 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] 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 bf6e4626..78cc3200 100644 --- a/contract/programs/open_parametric/src/instructions/mod.rs +++ b/contract/programs/open_parametric/src/instructions/mod.rs @@ -5,6 +5,7 @@ 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; 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 index 0085abc0..d90d7ecb 100644 --- a/contract/programs/open_parametric/src/instructions/update_master_agreement_name.rs +++ b/contract/programs/open_parametric/src/instructions/update_master_agreement_name.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::*; use crate::errors::OpenParamError; use crate::state::*; -use super::create_master_agreement::normalize_master_agreement_name; +use super::master_agreement_name::normalize_master_agreement_name; #[derive(Accounts)] pub struct UpdateMasterAgreementName<'info> { @@ -24,12 +24,19 @@ pub(crate) fn assert_can_rename_master_agreement( Ok(()) } -pub fn handler(ctx: Context, name: String) -> Result<()> { - let normalized_name = normalize_master_agreement_name(&name)?; - let master = &mut ctx.accounts.master_agreement; +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)?; - assert_can_rename_master_agreement(master.leader, master.operator, ctx.accounts.signer.key())?; - master.name = normalized_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 index cc7ad773..3057db32 100644 --- 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 @@ -1,8 +1,44 @@ use anchor_lang::prelude::Pubkey; use crate::errors::OpenParamError; +use crate::state::MasterAgreement; -use super::update_master_agreement_name::assert_can_rename_master_agreement; +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() { @@ -34,3 +70,43 @@ fn participant_cannot_rename() { 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/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/lib/idl/open_parametric.ts b/frontend/src/lib/idl/open_parametric.ts index 0e1f25e0..145a1f34 100644 --- a/frontend/src/lib/idl/open_parametric.ts +++ b/frontend/src/lib/idl/open_parametric.ts @@ -140,6 +140,7 @@ export interface MasterParticipantInit { export interface CreateMasterAgreementParams { masterId: BN; + name: string; coverageStartTs: BN; coverageEndTs: BN; premiumPerPolicy: BN; From fe9732b6d5d035684cf6cd024e4d81c21c5e30ed Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:08:41 +0900 Subject: [PATCH 06/24] Surface master agreement names from on-chain accounts Parse the on-chain master agreement name into backend responses and carry it through the frontend agreement summary/account mappers so downstream UI work can consume it. The backend test fixture now serializes the inserted string field, and related backend test constructors include placeholder names so the requested parser test command still compiles after the struct shape change. Constraint: The on-chain account layout inserts `name` immediately after `master_id`, so parser offsets had to shift without touching unrelated fields Constraint: Existing backend unit tests construct `MasterAgreementInfo` directly, so they needed the new field for the requested cargo test command to pass Rejected: Limit changes to the five requested files only | `cargo test program_accounts` failed to compile due to direct struct initializers in existing backend tests Confidence: high Scope-risk: narrow Directive: Keep backend/frontend field ordering aligned with the on-chain account layout when adding future master agreement fields Tested: `cd backend && cargo test program_accounts` Tested: Frontend TypeScript diagnostics clean for modified hooks/store and `src/store/__tests__/pure-functions.test.ts` via `npx tsc --noEmit --project frontend/tsconfig.json` Not-tested: `cd frontend && yarn test src/store/__tests__/pure-functions.test.ts` blocked by Yarn/Corepack workspace setup and lockfile state --- backend/src/api/service/tests.rs | 1 + backend/src/db/tests.rs | 1 + backend/src/events/tests.rs | 1 + backend/src/oracle/program_accounts/mod.rs | 3 +++ backend/src/oracle/program_accounts/tests.rs | 2 ++ frontend/src/hooks/useMasterAgreementAccount.ts | 2 ++ frontend/src/hooks/useMasterAgreements.ts | 2 ++ frontend/src/store/useProtocolStore.ts | 1 + 8 files changed, 13 insertions(+) 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..72113e16 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, @@ -135,6 +136,7 @@ fn parse_master_agreement(pubkey: &Pubkey, data: &[u8]) -> Result 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); @@ -115,6 +116,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()); diff --git a/frontend/src/hooks/useMasterAgreementAccount.ts b/frontend/src/hooks/useMasterAgreementAccount.ts index 11ba31c6..d2459406 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<{ @@ -54,6 +55,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), diff --git a/frontend/src/hooks/useMasterAgreements.ts b/frontend/src/hooks/useMasterAgreements.ts index df52b56f..07c18efe 100644 --- a/frontend/src/hooks/useMasterAgreements.ts +++ b/frontend/src/hooks/useMasterAgreements.ts @@ -6,6 +6,7 @@ import { BACKEND_URL } from '@/lib/constants'; interface BackendMasterAgreementItem { pubkey: string; master_id: number; + name: string; leader: string; reinsurer: string; status: number; @@ -44,6 +45,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, diff --git a/frontend/src/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index b3d59c62..2aaee58e 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -88,6 +88,7 @@ export type ProtocolMode = 'simulation' | 'onchain'; export interface MasterAgreementSummary { pda: string; masterId: string; + name: string; status: number; statusLabel: string; coverageEndTs: number; From b7fb7833ff7cff748f17f5fc8cdfdee575aab232 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:23:31 +0900 Subject: [PATCH 07/24] Add official master agreement naming to the workbench Keep the workbench and dropdown aligned with the on-chain master agreement name so leaders and operators can create, review, and rename agreements without falling back to opaque identifiers. Constraint: The live STEP1 on-chain creation flow still batches raw instructions, so the create params had to stay inline while becoming typed and name-complete Rejected: Route creation through useCreateMasterAgreement | it only exposes an rpc path and cannot compose the existing multi-transaction setup flow Directive: Keep the on-chain create params and rename hook aligned with the IDL name field; do not drop the name/oracleFeed fields when the setup flow changes Confidence: high Scope-risk: narrow Tested: Dropdown rendering test via ./node_modules/.bin/vitest run src/components/layout/__tests__/MasterAgreementDropdown.test.tsx src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx; frontend typecheck via ./node_modules/.bin/tsc -b --pretty false Not-tested: Live wallet-backed create and rename transactions against a running backend/indexer --- .../layout/MasterAgreementDropdown.tsx | 2 +- .../MasterAgreementDropdown.test.tsx | 59 +++++++++++++ .../MasterAgreementNameEditor.tsx | 87 +++++++++++++++++++ .../MasterAgreementReviewPanel.tsx | 13 +++ .../tab-contract/MasterAgreementWorkbench.tsx | 15 +++- .../tabs/tab-contract/MasterContractSetup.tsx | 52 +++++++---- .../src/hooks/useUpdateMasterAgreementName.ts | 45 ++++++++++ frontend/src/i18n/locales/en.ts | 7 ++ frontend/src/i18n/locales/ko.ts | 7 ++ 9 files changed, 269 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx create mode 100644 frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx create mode 100644 frontend/src/hooks/useUpdateMasterAgreementName.ts diff --git a/frontend/src/components/layout/MasterAgreementDropdown.tsx b/frontend/src/components/layout/MasterAgreementDropdown.tsx index f5e066cd..22984529 100644 --- a/frontend/src/components/layout/MasterAgreementDropdown.tsx +++ b/frontend/src/components/layout/MasterAgreementDropdown.tsx @@ -86,7 +86,7 @@ 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..f7eed226 --- /dev/null +++ b/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx @@ -0,0 +1,59 @@ +import '@testing-library/jest-dom/vitest'; +import { ThemeProvider } from '@emotion/react'; +import { render, screen } 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(); + }); +}); 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..19df62e9 --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx @@ -0,0 +1,87 @@ +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 { 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 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(''); + + useEffect(() => { + setDraftName(account?.name ?? ''); + }, [account?.name]); + + if (!masterAgreementKey) { + return null; + } + + const normalizedCurrentName = 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; + } + + await Promise.allSettled([refetchAccount(), refetchPolicies()]); + toast(t('master.nameSaved'), 's'); + }; + + 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..f0d5c351 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx @@ -1,7 +1,10 @@ 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 { formatNum, useProtocolStore } from '@/store/useProtocolStore'; export type MasterAgreementReviewStep = 'basic' | 'participants' | 'activate'; @@ -122,6 +125,11 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas reinsurerConfirmed: reinsurer.confirmed, masterActive, }); + const masterAgreementKey = useMemo( + () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), + [masterAgreementPDA], + ); + const { account } = useMasterAgreementAccount(masterAgreementKey); return ( @@ -133,6 +141,11 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas + + {t('master.review.name')} + {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..01df7c5e 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx @@ -5,6 +5,7 @@ import { useShallow } from 'zustand/react/shallow'; import { Tag } from '@/components/common'; import { useProtocolStore } from '@/store/useProtocolStore'; import { MasterContractSetup } from './MasterContractSetup'; +import { MasterAgreementNameEditor } from './MasterAgreementNameEditor'; import { MasterAgreementReviewPanel, type MasterAgreementReviewStep } from './MasterAgreementReviewPanel'; import { ParticipantConfirm } from './ParticipantConfirm'; @@ -179,6 +180,10 @@ 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) { return 'activate'; @@ -229,13 +234,16 @@ function StepContent({ 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 +294,11 @@ export function MasterAgreementWorkbench() { + {canEditName && ( + + + + )} diff --git a/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx b/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx index 54fd7ffb..efb3c221 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'; @@ -111,6 +111,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 +146,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 +237,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 +261,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, @@ -369,6 +378,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')} => { + 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..26d2439f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -65,6 +65,12 @@ 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.noNameFallback': 'No official name yet', 'master.premiumPerContract': 'Premium per Policy (USDC)', 'master.payoutByTier': 'Payout by Delay Tier', 'master.setTermsBtn': '📄 Set Terms & Rate', @@ -82,6 +88,7 @@ const en = { 'master.step.participants': 'Participants Confirm', 'master.step.activate': 'Activate Pool', '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', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index c9a9ecee..6b8a09d6 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -65,6 +65,12 @@ const ko = { 'master.coverageEnd': '담보 기간 종료', 'master.coverageType': '담보 항목', 'master.coverageTypeValue': '항공기 출발 지연 보험', + 'master.name': '공식 계약명', + 'master.namePlaceholder': '마스터 계약명을 입력하세요', + 'master.nameRequired': '마스터 계약 생성 또는 이름 변경 전에 공식 계약명을 입력하세요', + 'master.nameSave': '이름 저장', + 'master.nameSaved': '마스터 계약명이 업데이트되었습니다', + 'master.noNameFallback': '아직 공식 계약명이 없습니다', 'master.premiumPerContract': '건당 보험료 (USDC)', 'master.payoutByTier': '지연 구간별 보험금', 'master.setTermsBtn': '📄 약관 세팅 & 요율 산정', @@ -82,6 +88,7 @@ const ko = { 'master.step.participants': '참여사 승인', 'master.step.activate': '풀 활성화', 'master.review.title': '검토 패널', + 'master.review.name': '공식 계약명', 'master.review.coverage': '보장 기간', 'master.review.premium': '계약당 보험료', 'master.review.maxPayout': '최대 지급액', From 72437068d404b521a5595e5e09e10e229ad3abc7 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:55:53 +0900 Subject: [PATCH 08/24] Restore operator role flow for master agreement selection The workbench rename UI was gated on operator access, but the fetched agreement summaries never surfaced operator ownership, so selecting an operator-owned agreement could not set the store role correctly. Constraint: Operator access must continue to respect existing wallet-role precedence instead of overriding leader or participant ownership Rejected: Make the workbench bypass store role and inspect account ownership directly | it would hide the broken summary-selection path instead of fixing it Directive: Keep backend summary role detection and dropdown selection role sync in step when new roles are added Confidence: high Scope-risk: narrow Tested: ./node_modules/.bin/vitest run src/components/layout/__tests__/MasterAgreementDropdown.test.tsx src/hooks/__tests__/useMasterAgreements.test.ts; ./node_modules/.bin/tsc -b --pretty false Not-tested: Live backend response with a real operator-owned agreement in a connected browser session --- .../layout/MasterAgreementDropdown.tsx | 3 +- .../MasterAgreementDropdown.test.tsx | 26 +++++++++- .../__tests__/useMasterAgreements.test.ts | 49 +++++++++++++++++++ frontend/src/hooks/useMasterAgreements.ts | 2 + frontend/src/store/useProtocolStore.ts | 2 +- 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 frontend/src/hooks/__tests__/useMasterAgreements.test.ts diff --git a/frontend/src/components/layout/MasterAgreementDropdown.tsx b/frontend/src/components/layout/MasterAgreementDropdown.tsx index 22984529..80b59316 100644 --- a/frontend/src/components/layout/MasterAgreementDropdown.tsx +++ b/frontend/src/components/layout/MasterAgreementDropdown.tsx @@ -37,10 +37,11 @@ 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() { diff --git a/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx b/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx index f7eed226..66527807 100644 --- a/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx +++ b/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { ThemeProvider } from '@emotion/react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { darkTheme } from '@/styles/theme'; import { useProtocolStore } from '@/store/useProtocolStore'; @@ -56,4 +56,28 @@ describe('MasterAgreementDropdown', () => { expect(await screen.findByRole('option', { name: /대한-뉴욕 2026 리더 공동계약/ })).toBeInTheDocument(); }); + + 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/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/useMasterAgreements.ts b/frontend/src/hooks/useMasterAgreements.ts index 07c18efe..56f5aea5 100644 --- a/frontend/src/hooks/useMasterAgreements.ts +++ b/frontend/src/hooks/useMasterAgreements.ts @@ -8,6 +8,7 @@ interface BackendMasterAgreementItem { master_id: number; name: string; leader: string; + operator: string; reinsurer: string; status: number; status_label: string; @@ -17,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'; diff --git a/frontend/src/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index 2aaee58e..7657bb0c 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -92,7 +92,7 @@ export interface MasterAgreementSummary { status: number; statusLabel: string; coverageEndTs: number; - myRole?: 'leader' | 'participant' | 'rein'; + myRole?: 'leader' | 'participant' | 'rein' | 'operator'; } export interface PoolHistEntry { From 4f5189b3972996b29008da3b00ea8c8e98475c13 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:13:28 +0900 Subject: [PATCH 09/24] Keep official names visible before backend refresh catches up The workbench could briefly fall back to stale or placeholder naming after create and rename because visible state depended on list/account refetch timing. This adds a narrow selected-name store surface so the active agreement keeps its official name immediately while reconciliation runs in the background. Constraint: The optimistic name must not be overwritten by the stale policy list during the same selection cycle Rejected: Wait for list/account refetch before updating the UI | it preserves the timing gap the review flagged Directive: Treat selectedMasterAgreementName as the immediate UI source for the active agreement only; refresh paths should reconcile it, not block on it Confidence: high Scope-risk: narrow Tested: ./node_modules/.bin/vitest run src/components/layout/__tests__/MasterAgreementDropdown.test.tsx src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx; ./node_modules/.bin/vitest run src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx; ./node_modules/.bin/tsc -b --pretty false Not-tested: Live wallet-backed create/rename with delayed backend index updates in a browser session --- .../layout/MasterAgreementDropdown.tsx | 12 ++- .../MasterAgreementDropdown.test.tsx | 16 +++ .../MasterAgreementNameEditor.tsx | 15 ++- .../MasterAgreementReviewPanel.tsx | 4 +- .../tabs/tab-contract/MasterContractSetup.tsx | 3 + .../MasterAgreementNameEditor.test.tsx | 102 ++++++++++++++++++ frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/ko.ts | 1 + frontend/src/store/useProtocolStore.ts | 13 ++- 9 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx diff --git a/frontend/src/components/layout/MasterAgreementDropdown.tsx b/frontend/src/components/layout/MasterAgreementDropdown.tsx index 80b59316..f7bb49e5 100644 --- a/frontend/src/components/layout/MasterAgreementDropdown.tsx +++ b/frontend/src/components/layout/MasterAgreementDropdown.tsx @@ -47,8 +47,10 @@ const ROLE_LABEL: Record<'leader' | 'participant' | 'rein' | 'operator', string> 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(); @@ -58,7 +60,10 @@ export function MasterAgreementDropdown() { 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 (found?.name?.trim() && !selectedMasterAgreementName?.trim()) { + setSelectedMasterAgreementName(found.name.trim()); + } + }, [masterAgreementPDA, policies, selectedMasterAgreementName]); // eslint-disable-line react-hooks/exhaustive-deps // Refetch when a newly created policy isn't in the list yet useEffect(() => { @@ -75,6 +80,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); } }; @@ -87,12 +93,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 index 66527807..9c29c706 100644 --- a/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx +++ b/frontend/src/components/layout/__tests__/MasterAgreementDropdown.test.tsx @@ -57,6 +57,22 @@ describe('MasterAgreementDropdown', () => { 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('syncs the selected operator role into the protocol store', () => { mockUseMasterAgreements.mockReturnValue({ loading: false, diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx index 19df62e9..3016ea3c 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx @@ -17,6 +17,8 @@ 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], @@ -27,14 +29,14 @@ export function MasterAgreementNameEditor() { const [draftName, setDraftName] = useState(''); useEffect(() => { - setDraftName(account?.name ?? ''); - }, [account?.name]); + setDraftName(selectedMasterAgreementName ?? account?.name ?? ''); + }, [account?.name, selectedMasterAgreementName]); if (!masterAgreementKey) { return null; } - const normalizedCurrentName = account?.name?.trim() ?? ''; + const normalizedCurrentName = selectedMasterAgreementName?.trim() || account?.name?.trim() || ''; const normalizedDraftName = draftName.trim(); const handleSave = async () => { @@ -53,8 +55,11 @@ export function MasterAgreementNameEditor() { return; } - await Promise.allSettled([refetchAccount(), refetchPolicies()]); - toast(t('master.nameSaved'), 's'); + setSelectedMasterAgreementName(normalizedDraftName); + + const refreshResults = await Promise.allSettled([refetchAccount(), refetchPolicies()]); + const refreshSucceeded = refreshResults.some((refreshResult) => refreshResult.status === 'fulfilled'); + toast(refreshSucceeded ? t('master.nameSaved') : t('master.nameSavedLocalWarning'), refreshSucceeded ? 's' : 'w'); }; return ( diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx index f0d5c351..eb06f608 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx @@ -92,6 +92,7 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas processStep, masterActive, masterAgreementPDA, + selectedMasterAgreementName, } = useProtocolStore(useShallow(state => ({ mode: state.mode, coverageStart: state.coverageStart, @@ -104,6 +105,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); @@ -143,7 +145,7 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas {t('master.review.name')} - {account?.name?.trim() || t('master.noNameFallback')} + {account?.name?.trim() || selectedMasterAgreementName?.trim() || t('master.noNameFallback')} diff --git a/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx b/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx index efb3c221..abfa7f07 100644 --- a/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterContractSetup.tsx @@ -100,6 +100,7 @@ export function MasterContractSetup({ onTermsSet }: MasterContractSetupProps) { setTerms, onChainSetTerms, setMasterAgreementPDA, + setSelectedMasterAgreementName, setCoverage, setCollateralClaimCount, } = store; @@ -328,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, 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..c4f4de7c --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx @@ -0,0 +1,102 @@ +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 both refreshes fail', async () => { + mockRefetchAccount.mockRejectedValue(new Error('account refresh failed')); + mockRefetchPolicies.mockRejectedValue(new Error('policies refresh failed')); + + 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.nameSavedLocalWarning', 'w'); + }); + + expect(mockToast).not.toHaveBeenCalledWith('master.nameSaved', 's'); + }); +}); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 26d2439f..56fcb514 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -70,6 +70,7 @@ const en = { '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', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 6b8a09d6..1b020b53 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -70,6 +70,7 @@ const ko = { 'master.nameRequired': '마스터 계약 생성 또는 이름 변경 전에 공식 계약명을 입력하세요', 'master.nameSave': '이름 저장', 'master.nameSaved': '마스터 계약명이 업데이트되었습니다', + 'master.nameSavedLocalWarning': '마스터 계약명은 로컬에 반영되었지만 새로고침 동기화는 아직 지연되고 있습니다', 'master.noNameFallback': '아직 공식 계약명이 없습니다', 'master.premiumPerContract': '건당 보험료 (USDC)', 'master.payoutByTier': '지연 구간별 보험금', diff --git a/frontend/src/store/useProtocolStore.ts b/frontend/src/store/useProtocolStore.ts index 7657bb0c..dca015e1 100644 --- a/frontend/src/store/useProtocolStore.ts +++ b/frontend/src/store/useProtocolStore.ts @@ -258,6 +258,7 @@ interface ProtocolState { // On-chain state poolRefreshKey: number; masterAgreementPDA: string | null; + selectedMasterAgreementName: string | null; lastTxSignature: string | null; masterAgreements: MasterAgreementSummary[]; lastDaemonActivityTs: number | null; @@ -287,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; @@ -345,6 +348,7 @@ export const useProtocolStore = create()(persist((set, get) => ({ // On-chain state poolRefreshKey: 0, masterAgreementPDA: null, + selectedMasterAgreementName: null, lastTxSignature: null, masterAgreements: [], lastDaemonActivityTs: null, @@ -640,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) => { @@ -657,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', '체인에서 상태 조회 중...'); } }, @@ -672,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 }), @@ -875,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'); @@ -956,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, }); }, From a766320a80251948207ef3fa4cc809a53aafcc75 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:25:16 +0900 Subject: [PATCH 10/24] Prefer optimistic names across review and reconciliation UI The review panel could still show a stale backend name even after the dropdown and editor had fresher local state, and the rename flow collapsed reconciliation into a single success state. This aligns the review surface with the optimistic selected name and makes refresh failures explicit while keeping the successful local update visible. Constraint: Keep the fix minimal and limited to precedence/toast semantics rather than reworking the refresh architecture Rejected: Wait for account refresh before showing any success state | it reintroduces the stale-name lag already fixed elsewhere in Task 4 Directive: When optimistic selected-name state exists for the active agreement, prefer it over lagging account data in read surfaces Confidence: high Scope-risk: narrow Tested: ./node_modules/.bin/vitest run src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx src/components/tabs/tab-contract/__tests__/MasterAgreementReviewPanel.test.tsx; ./node_modules/.bin/tsc -b --pretty false Not-tested: Live browser session with delayed account refresh after rename --- .../MasterAgreementNameEditor.tsx | 7 +- .../MasterAgreementReviewPanel.tsx | 2 +- .../MasterAgreementNameEditor.test.tsx | 5 +- .../MasterAgreementReviewPanel.test.tsx | 68 +++++++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementReviewPanel.test.tsx diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx index 3016ea3c..486ccbc4 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx @@ -58,8 +58,11 @@ export function MasterAgreementNameEditor() { setSelectedMasterAgreementName(normalizedDraftName); const refreshResults = await Promise.allSettled([refetchAccount(), refetchPolicies()]); - const refreshSucceeded = refreshResults.some((refreshResult) => refreshResult.status === 'fulfilled'); - toast(refreshSucceeded ? t('master.nameSaved') : t('master.nameSavedLocalWarning'), refreshSucceeded ? 's' : 'w'); + const refreshFailed = refreshResults.some((refreshResult) => refreshResult.status === 'rejected'); + toast(t('master.nameSaved'), 's'); + if (refreshFailed) { + toast(t('master.nameSavedLocalWarning'), 'w'); + } }; return ( diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx index eb06f608..03823f57 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementReviewPanel.tsx @@ -145,7 +145,7 @@ export function MasterAgreementReviewPanel({ selectedStep }: { selectedStep: Mas {t('master.review.name')} - {account?.name?.trim() || selectedMasterAgreementName?.trim() || t('master.noNameFallback')} + {selectedMasterAgreementName?.trim() || account?.name?.trim() || t('master.noNameFallback')} diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx index c4f4de7c..c6c6bd94 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx @@ -76,7 +76,7 @@ describe('MasterAgreementNameEditor', () => { }); }); - it('updates the visible selected name immediately and warns when both refreshes fail', async () => { + it('updates the visible selected name immediately and warns when refresh reconciliation fails', async () => { mockRefetchAccount.mockRejectedValue(new Error('account refresh failed')); mockRefetchPolicies.mockRejectedValue(new Error('policies refresh failed')); @@ -94,9 +94,8 @@ describe('MasterAgreementNameEditor', () => { 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'); }); - - expect(mockToast).not.toHaveBeenCalledWith('master.nameSaved', 's'); }); }); 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..cfadfd22 --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementReviewPanel.test.tsx @@ -0,0 +1,68 @@ +import '@testing-library/jest-dom/vitest'; +import { ThemeProvider } from '@emotion/react'; +import { render, screen } 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(); + }); +}); From f1d04eeac6e84d79631b7d09b0c2f36f22055d54 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:33:30 +0900 Subject: [PATCH 11/24] Return explicit refresh results for master agreement reconciliation The rename warning branch relied on rejected promises, but both master-agreement refresh hooks swallowed failures and resolved normally. This changes those refetch surfaces to return explicit booleans so the editor can distinguish successful reconciliation from local-only optimistic updates. Constraint: Preserve existing state updates and call patterns while adding a real success/failure signal Rejected: Infer failure from Promise.allSettled shapes in the editor | the hooks resolve on failure today, so that signal is false confidence Directive: Any UI that needs refresh-health semantics from these hooks should use their boolean return values rather than settlement shape Confidence: high Scope-risk: narrow Tested: ./node_modules/.bin/vitest run src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx src/components/tabs/tab-contract/__tests__/MasterAgreementReviewPanel.test.tsx; ./node_modules/.bin/tsc -b --pretty false Not-tested: Live browser session with backend fetch failures during rename reconciliation --- .../tabs/tab-contract/MasterAgreementNameEditor.tsx | 8 +++++--- .../__tests__/MasterAgreementNameEditor.test.tsx | 4 ++-- frontend/src/hooks/useMasterAgreementAccount.ts | 10 ++++++---- frontend/src/hooks/useMasterAgreements.ts | 8 +++++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx index 486ccbc4..a5d0f430 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementNameEditor.tsx @@ -57,10 +57,12 @@ export function MasterAgreementNameEditor() { setSelectedMasterAgreementName(normalizedDraftName); - const refreshResults = await Promise.allSettled([refetchAccount(), refetchPolicies()]); - const refreshFailed = refreshResults.some((refreshResult) => refreshResult.status === 'rejected'); + const [accountRefreshOk, policyRefreshOk] = await Promise.all([ + refetchAccount(), + refetchPolicies(), + ]); toast(t('master.nameSaved'), 's'); - if (refreshFailed) { + if (!accountRefreshOk || !policyRefreshOk) { toast(t('master.nameSavedLocalWarning'), 'w'); } }; diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx index c6c6bd94..a3fa7f75 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterAgreementNameEditor.test.tsx @@ -77,8 +77,8 @@ describe('MasterAgreementNameEditor', () => { }); it('updates the visible selected name immediately and warns when refresh reconciliation fails', async () => { - mockRefetchAccount.mockRejectedValue(new Error('account refresh failed')); - mockRefetchPolicies.mockRejectedValue(new Error('policies refresh failed')); + mockRefetchAccount.mockResolvedValue(false); + mockRefetchPolicies.mockResolvedValue(true); renderSubject(); diff --git a/frontend/src/hooks/useMasterAgreementAccount.ts b/frontend/src/hooks/useMasterAgreementAccount.ts index d2459406..da3ea414 100644 --- a/frontend/src/hooks/useMasterAgreementAccount.ts +++ b/frontend/src/hooks/useMasterAgreementAccount.ts @@ -101,11 +101,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); @@ -116,17 +116,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); @@ -141,7 +143,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/useMasterAgreements.ts b/frontend/src/hooks/useMasterAgreements.ts index 56f5aea5..35171137 100644 --- a/frontend/src/hooks/useMasterAgreements.ts +++ b/frontend/src/hooks/useMasterAgreements.ts @@ -30,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); @@ -56,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 }; From 84052185d66eda507c3ca7e84893d01711a7d3ba Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:50:03 +0900 Subject: [PATCH 12/24] Turn Step 3 into a master activation dashboard Replace the duplicated STEP3 confirmation surface with a leader/operator snapshot that shows activation readiness, collateral coverage, and live money flow in one place. Constraint: Existing Yarn workspace resolution is broken in this worktree, so verification used direct local vitest/tsc binaries Rejected: Reuse ParticipantConfirm for STEP3 | it kept the activation surface as a duplicate of STEP2 instead of a snapshot dashboard Confidence: high Scope-risk: narrow Directive: Keep STEP3 focused on current activation readiness; do not expand it into historical analytics without a separate task Tested: ./node_modules/.bin/vitest run src/hooks/__tests__/useMasterAgreementSnapshot.test.ts src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx Tested: ./node_modules/.bin/tsc -b --pretty false Not-tested: yarn test in this worktree remains blocked by lockfile/workspace resolution --- .../tabs/shared/PoolHealthVisual.tsx | 4 + .../MasterActivationDashboard.tsx | 223 ++++++++++++++++++ .../tab-contract/MasterAgreementWorkbench.tsx | 5 + .../useMasterAgreementSnapshot.test.ts | 113 +++++++++ .../src/hooks/useMasterAgreementSnapshot.ts | 86 +++++++ frontend/src/hooks/usePoolCollateralStatus.ts | 35 ++- frontend/src/i18n/locales/en.ts | 8 + frontend/src/i18n/locales/ko.ts | 8 + 8 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx create mode 100644 frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts create mode 100644 frontend/src/hooks/useMasterAgreementSnapshot.ts 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..02cb502c --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx @@ -0,0 +1,223 @@ +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 { Card, CardBody, CardHeader, CardTitle, Tag } from '@/components/common'; +import { PoolHealthVisual } from '@/components/tabs/shared/PoolHealthVisual'; +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)'; + }}; +`; + +function formatUsdc(value: number | null | undefined): string { + return value == null ? '—' : `${formatNum(value, 2)} USDC`; +} + +export function MasterActivationDashboard() { + const { t } = useTranslation(); + const { masterAgreementPDA, selectedMasterAgreementName } = useProtocolStore(useShallow((state) => ({ + masterAgreementPDA: state.masterAgreementPDA, + selectedMasterAgreementName: state.selectedMasterAgreementName, + }))); + const masterAgreementKey = useMemo( + () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), + [masterAgreementPDA], + ); + const { snapshot, status, activePartyId, masterData, loading, error } = useMasterAgreementSnapshot(masterAgreementKey); + + const agreementName = + snapshot?.agreementName || + selectedMasterAgreementName?.trim() || + masterData?.name?.trim() || + t('master.noNameFallback'); + const readinessTag = snapshot?.aggregateReady ? t('pool.healthAggregateReady') : t('pool.healthAggregateActionNeeded'); + const readinessVariant = snapshot?.aggregateReady ? 'accent' : 'warning'; + const emptyMessage = loading ? t('master.loading') : error || t('master.step3.empty'); + + 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?.blockerLabels.length ? ( + + {t('pool.healthAggregateActionNeeded')}: {snapshot.blockerLabels.join(', ')} + + ) : null} + + {!snapshot && {emptyMessage}} + + + + {status ? ( + + ) : ( + + + {t('pool.healthTitle')} + + + {emptyMessage} + + + )} + + + + {t('master.step3.moneyTitle')} + + + {snapshot ? ( + + + {t('master.step3.totalPremiumInflow')} + {formatUsdc(snapshot.totalPremiumInflow)} + + + {t('master.step3.totalClaimOutflow')} + {formatUsdc(snapshot.totalClaimOutflow)} + + + {t('master.step3.netBalance')} + = 0 ? 'accent' : 'danger'}> + {formatUsdc(snapshot.netBalance)} + + + + ) : ( + {emptyMessage} + )} + + + + ); +} diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx index 01df7c5e..0a51d827 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx @@ -5,6 +5,7 @@ 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'; @@ -229,6 +230,10 @@ function StepContent({ return ; } + if (step === 'activate') { + return ; + } + return ; } diff --git a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts new file mode 100644 index 00000000..34399f70 --- /dev/null +++ b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts @@ -0,0 +1,113 @@ +import { PublicKey } from '@solana/web3.js'; +import { describe, expect, test } 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 type { FlightPolicyWithKey } from '../useFlightPolicies'; +import { buildMasterAgreementSnapshot } from '../useMasterAgreementSnapshot'; + +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', + }, + ], + }; +} + +describe('buildMasterAgreementSnapshot', () => { + 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'); + }); +}); diff --git a/frontend/src/hooks/useMasterAgreementSnapshot.ts b/frontend/src/hooks/useMasterAgreementSnapshot.ts new file mode 100644 index 00000000..6ec89f8a --- /dev/null +++ b/frontend/src/hooks/useMasterAgreementSnapshot.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import type { CollateralStatus } from '@/lib/collateral'; +import type { MasterAgreementAccount } from '@/lib/idl/open_parametric'; +import { useFlightPolicies, type FlightPolicyWithKey } from './useFlightPolicies'; +import { useMasterAgreementAccount } 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; +} + +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 useMasterAgreementSnapshot(masterPda: PublicKey | null) { + const { account: masterData, loading: masterLoading, error: masterError } = useMasterAgreementAccount(masterPda); + const { status, activePartyId } = usePoolCollateralStatus(masterPda); + const { policies, loading: policiesLoading, error: policiesError } = useFlightPolicies(masterPda); + + const snapshot = useMemo( + () => buildMasterAgreementSnapshot(masterData, policies, status), + [masterData, policies, status], + ); + + return { + snapshot, + status, + activePartyId, + masterData, + loading: masterLoading || policiesLoading, + error: masterError ?? policiesError, + }; +} diff --git a/frontend/src/hooks/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index 1dbe800e..1b0ee252 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -1,7 +1,9 @@ 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 { useProtocolStore } from '@/store/useProtocolStore'; import { useMasterAgreementAccount } from './useMasterAgreementAccount'; import { useProgram } from './useProgram'; @@ -25,6 +27,16 @@ function rawMicroUsdcToNumber(amount: { toString(): string }): number { return Number(amount.toString()) / 1_000_000; } +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; +} + async function readTokenBalance( connection: ReturnType['connection'], tokenAccount: PublicKey | null | undefined, @@ -70,6 +82,13 @@ export function usePoolCollateralStatus( ): UsePoolCollateralStatusResult { const { connection, wallet } = useProgram(); const { account: masterData } = useMasterAgreementAccount(masterPDA); + const { displayNamesByWallet, participants: storedParticipants, reinsurer: storedReinsurer } = useProtocolStore( + useShallow((state) => ({ + displayNamesByWallet: state.displayNamesByWallet, + participants: state.participants, + reinsurer: state.reinsurer, + })), + ); const [balances, setBalances] = useState(null); useEffect(() => { @@ -137,13 +156,23 @@ export function usePoolCollateralStatus( }, 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, })), reinsurer: masterData.reinsurer ? { - label: 'Reinsurer', + label: resolvePartyLabel( + masterData.reinsurer, + 'Reinsurer', + displayNamesByWallet, + storedReinsurer.name, + ), confirmed: masterData.reinsurerConfirmed, balance: safeBalances.reinsurer, } : undefined, @@ -151,7 +180,7 @@ export function usePoolCollateralStatus( } catch { return null; } - }, [balances, masterData]); + }, [balances, displayNamesByWallet, masterData, storedParticipants, storedReinsurer.name]); return { status, activePartyId, masterData }; } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 56fcb514..11595482 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -88,6 +88,14 @@ 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', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 1b020b53..abc572c0 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -88,6 +88,14 @@ 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': '보장 기간', From 9d1518c9c5e5998652c58c3fa908c29dccd9697c Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:57:07 +0900 Subject: [PATCH 13/24] Route leader pool label through agreement naming state Use the selected or parsed master agreement name for the leader row in pool collateral status instead of always falling back to a generic label. Constraint: Keep the fix inside the existing pool-collateral label path without redesigning store-backed naming Rejected: Introduce a broader party-label abstraction | unnecessary scope for a single missing leader path Confidence: high Scope-risk: narrow Directive: Preserve the current fallback order for leader labels unless a dedicated leader-display source is added later Tested: ./node_modules/.bin/vitest run src/hooks/__tests__/useMasterAgreementSnapshot.test.ts src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx Tested: ./node_modules/.bin/tsc -b --pretty false --- .../useMasterAgreementSnapshot.test.ts | 7 +++++++ frontend/src/hooks/usePoolCollateralStatus.ts | 20 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts index 34399f70..efe23561 100644 --- a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts +++ b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } 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 { resolveLeaderLabel } from '../usePoolCollateralStatus'; import type { FlightPolicyWithKey } from '../useFlightPolicies'; import { buildMasterAgreementSnapshot } from '../useMasterAgreementSnapshot'; @@ -97,6 +98,12 @@ function makeCollateralStatus(): CollateralStatus { } 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('derives premium inflow, claim outflow, net balance, and blockers', () => { const snapshot = buildMasterAgreementSnapshot( makeMasterAgreement(), diff --git a/frontend/src/hooks/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index 1b0ee252..c3002547 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -27,6 +27,14 @@ function rawMicroUsdcToNumber(amount: { toString(): string }): number { return Number(amount.toString()) / 1_000_000; } +export function resolveLeaderLabel( + selectedMasterAgreementName: string | null | undefined, + masterAgreementName: string | null | undefined, + fallbackLabel: string, +): string { + return selectedMasterAgreementName?.trim() || masterAgreementName?.trim() || fallbackLabel; +} + function resolvePartyLabel( wallet: PublicKey | null | undefined, fallbackLabel: string, @@ -82,11 +90,17 @@ export function usePoolCollateralStatus( ): UsePoolCollateralStatusResult { const { connection, wallet } = useProgram(); const { account: masterData } = useMasterAgreementAccount(masterPDA); - const { displayNamesByWallet, participants: storedParticipants, reinsurer: storedReinsurer } = useProtocolStore( + const { + displayNamesByWallet, + participants: storedParticipants, + reinsurer: storedReinsurer, + selectedMasterAgreementName, + } = useProtocolStore( useShallow((state) => ({ displayNamesByWallet: state.displayNamesByWallet, participants: state.participants, reinsurer: state.reinsurer, + selectedMasterAgreementName: state.selectedMasterAgreementName, })), ); const [balances, setBalances] = useState(null); @@ -150,7 +164,7 @@ export function usePoolCollateralStatus( reinsurerEffectiveBps: masterData.reinsurerEffectiveBps, leaderShareBps: masterData.leaderShareBps, leader: { - label: 'Leader', + label: resolveLeaderLabel(selectedMasterAgreementName, masterData.name, 'Leader'), confirmed: true, balance: safeBalances.leader, }, @@ -180,7 +194,7 @@ export function usePoolCollateralStatus( } catch { return null; } - }, [balances, displayNamesByWallet, masterData, storedParticipants, storedReinsurer.name]); + }, [balances, displayNamesByWallet, masterData, selectedMasterAgreementName, storedParticipants, storedReinsurer.name]); return { status, activePartyId, masterData }; } From 26db9d0d7dd48a4d2681415eb7e363e889dc106e Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:15:46 +0900 Subject: [PATCH 14/24] Restore Step 3 activation control and guard unresolved money snapshots The activation review step became non-actionable after the dashboard swap, and unresolved policy fetches were rendering misleading zero-value money metrics. This shares the activation path across the participant and dashboard surfaces, exposes policy fetch state for the dashboard, and adds focused regressions for the step swap and loading/error handling. Constraint: Keep the Task 5 fix pass scoped to the existing activation and snapshot surfaces Rejected: Re-embed ParticipantConfirm inside the activation step | would duplicate the participant checklist UI instead of sharing the activation behavior Rejected: Null out the full snapshot during policy loading/errors | would hide valid collateral KPIs alongside the money snapshot regression Confidence: high Scope-risk: narrow Directive: Keep activation business logic shared between participant confirmation and activation dashboard surfaces Tested: vitest run src/hooks/__tests__/useMasterAgreementSnapshot.test.ts src/components/tabs/tab-contract/__tests__/MasterAgreementWorkbench.test.tsx src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx Tested: ./node_modules/.bin/tsc -b --pretty false (from frontend/) Tested: lsp_diagnostics_directory frontend --- .../MasterActivationDashboard.tsx | 35 +++- .../tabs/tab-contract/ParticipantConfirm.tsx | 54 ++---- .../MasterActivationDashboard.test.tsx | 155 ++++++++++++++++++ .../MasterAgreementWorkbench.test.tsx | 18 ++ .../useMasterAgreementSnapshot.test.ts | 79 ++++++++- .../src/hooks/useMasterAgreementActivation.ts | 97 +++++++++++ .../src/hooks/useMasterAgreementSnapshot.ts | 4 + frontend/src/hooks/usePoolCollateralStatus.ts | 2 +- 8 files changed, 393 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx create mode 100644 frontend/src/hooks/useMasterAgreementActivation.ts diff --git a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx index 02cb502c..3b071a4b 100644 --- a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx @@ -3,8 +3,9 @@ import { PublicKey } from '@solana/web3.js'; import styled from '@emotion/styled'; import { useTranslation } from 'react-i18next'; import { useShallow } from 'zustand/react/shallow'; -import { Card, CardBody, CardHeader, CardTitle, Tag } from '@/components/common'; +import { Button, Card, CardBody, CardHeader, CardTitle, Tag } from '@/components/common'; import { PoolHealthVisual } from '@/components/tabs/shared/PoolHealthVisual'; +import { useMasterAgreementActivation } from '@/hooks/useMasterAgreementActivation'; import { useMasterAgreementSnapshot } from '@/hooks/useMasterAgreementSnapshot'; import { formatNum, useProtocolStore } from '@/store/useProtocolStore'; @@ -111,21 +112,29 @@ const MoneyValue = styled.span<{ tone?: 'default' | 'danger' | 'accent' }>` }}; `; +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, selectedMasterAgreementName } = useProtocolStore(useShallow((state) => ({ + 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 { snapshot, status, activePartyId, masterData, loading, error } = useMasterAgreementSnapshot(masterAgreementKey); + const { snapshot, status, activePartyId, masterData, loading, error, policyStatus } = useMasterAgreementSnapshot(masterAgreementKey); + const { activateLoading, canActivate, handleActivate } = useMasterAgreementActivation(); const agreementName = snapshot?.agreementName || @@ -135,6 +144,8 @@ export function MasterActivationDashboard() { const readinessTag = snapshot?.aggregateReady ? t('pool.healthAggregateReady') : t('pool.healthAggregateActionNeeded'); const readinessVariant = snapshot?.aggregateReady ? 'accent' : 'warning'; const emptyMessage = loading ? t('master.loading') : error || t('master.step3.empty'); + const moneyMessage = policyStatus === 'loading' ? t('master.loading') : policyStatus === 'error' ? error || t('master.step3.empty') : emptyMessage; + const showMoneySnapshot = policyStatus === 'ready' && !!snapshot; return ( @@ -175,6 +186,20 @@ export function MasterActivationDashboard() { ) : null} {!snapshot && {emptyMessage}} + + {!masterActive && ( + + + + )} @@ -196,7 +221,7 @@ export function MasterActivationDashboard() { {t('master.step3.moneyTitle')} - {snapshot ? ( + {showMoneySnapshot ? ( {t('master.step3.totalPremiumInflow')} @@ -214,7 +239,7 @@ export function MasterActivationDashboard() { ) : ( - {emptyMessage} + {moneyMessage} )} diff --git a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx index a5e836f6..504a830f 100644 --- a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx +++ b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx @@ -1,13 +1,10 @@ import styled from '@emotion/styled'; -import { PublicKey } from '@solana/web3.js'; -import { useMemo } from 'react'; import { Card, CardHeader, CardTitle, CardBody, Button, Tag } from '@/components/common'; import { useProtocolStore, PARTICIPANT_COLORS, REINSURER_COLOR } from '@/store/useProtocolStore'; import { useShallow } from 'zustand/shallow'; import { useToast } from '@/components/common'; import { useTranslation } from 'react-i18next'; -import { useActivateMaster } from '@/hooks/useActivateMaster'; -import { useMasterAgreementAccount } from '@/hooks/useMasterAgreementAccount'; +import { useMasterAgreementActivation } from '@/hooks/useMasterAgreementActivation'; const ParticipantRow = styled.div<{ confirmed?: boolean }>` background: var(--card2); @@ -45,27 +42,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 { activateLoading, canActivate, handleActivate } = useMasterAgreementActivation({ onActivated }); - 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,30 +68,6 @@ 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'); - onActivated?.(); - }; - return ( @@ -154,7 +119,14 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { )} )} - 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..fe27757d --- /dev/null +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx @@ -0,0 +1,155 @@ +import '@testing-library/jest-dom/vitest'; +import { ThemeProvider } from '@emotion/react'; +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(); + +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/useActivateMaster', () => ({ + useActivateMaster: () => ({ + activateMaster: vi.fn(), + loading: false, + }), +})); + +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, + }; +} + +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, + }); + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: makeSnapshot(), + status: null, + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: null, + policyStatus: 'ready', + }); +}); + +describe('MasterActivationDashboard', () => { + test('keeps the activation step actionable when activation is pending', () => { + renderSubject(); + + const activateButton = screen.getByRole('button', { name: 'confirm.activateBtn' }); + expect(activateButton).toBeEnabled(); + + fireEvent.click(activateButton); + + expect(useProtocolStore.getState().masterActive).toBe(true); + expect(useProtocolStore.getState().processStep).toBe(5); + }); + + test('suppresses zero-valued money rows while policy data is still loading', () => { + mockUseMasterAgreementSnapshot.mockReturnValue({ + snapshot: makeSnapshot(), + status: null, + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: null, + policyStatus: 'loading', + }); + + renderSubject(); + + expect(screen.getByText('master.loading')).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: null, + activePartyId: 'leader', + masterData: { name: 'Master 2026' }, + loading: false, + error: 'policy fetch failed', + policyStatus: 'error', + }); + + renderSubject(); + + expect(screen.getAllByText('policy fetch failed')).toHaveLength(2); + expect(screen.queryByText('0.00 USDC')).not.toBeInTheDocument(); + }); +}); 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..4b5f35e9 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,20 @@ 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('opens the activation dashboard directly once confirmations reach process step 4', () => { + useProtocolStore.setState({ + processStep: 4, + masterActive: false, + }); + + renderSubject(); + + expect(screen.queryByText('Mock participant step')).not.toBeInTheDocument(); + expect(screen.getByText('Mock activation dashboard')).toBeInTheDocument(); expect(screen.getByTestId('selected-step')).toHaveTextContent('activate'); }, 15000); }); diff --git a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts index efe23561..31abc54d 100644 --- a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts +++ b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts @@ -1,11 +1,32 @@ -import { PublicKey } from '@solana/web3.js'; -import { describe, expect, test } from 'vitest'; +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 { resolveLeaderLabel } from '../usePoolCollateralStatus'; +import { resolveLeaderLabel, resolvePartyLabel } from '../usePoolCollateralStatus'; import type { FlightPolicyWithKey } from '../useFlightPolicies'; -import { buildMasterAgreementSnapshot } from '../useMasterAgreementSnapshot'; +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 { @@ -97,6 +118,25 @@ function makeCollateralStatus(): CollateralStatus { }; } +beforeEach(() => { + vi.clearAllMocks(); + mockUseMasterAgreementAccount.mockReturnValue({ + account: makeMasterAgreement(), + loading: false, + error: null, + }); + mockUsePoolCollateralStatus.mockReturnValue({ + status: makeCollateralStatus(), + activePartyId: 'leader', + masterData: makeMasterAgreement(), + }); + 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'); @@ -104,6 +144,16 @@ describe('buildMasterAgreementSnapshot', () => { 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('derives premium inflow, claim outflow, net balance, and blockers', () => { const snapshot = buildMasterAgreementSnapshot( makeMasterAgreement(), @@ -117,4 +167,25 @@ describe('buildMasterAgreementSnapshot', () => { 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'); + + mockUseFlightPolicies.mockReturnValueOnce({ + policies: [], + loading: false, + error: 'policy fetch failed', + }); + const errorResult = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); + expect(errorResult.result.current.policyStatus).toBe('error'); + + const readyResult = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); + expect(readyResult.result.current.policyStatus).toBe('ready'); + }); }); diff --git a/frontend/src/hooks/useMasterAgreementActivation.ts b/frontend/src/hooks/useMasterAgreementActivation.ts new file mode 100644 index 00000000..2765c25b --- /dev/null +++ b/frontend/src/hooks/useMasterAgreementActivation.ts @@ -0,0 +1,97 @@ +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 { useProtocolStore } from '@/store/useProtocolStore'; +import { useActivateMaster } from './useActivateMaster'; +import { useMasterAgreementAccount } from './useMasterAgreementAccount'; + +interface UseMasterAgreementActivationOptions { + onActivated?: () => void; +} + +export function useMasterAgreementActivation(options: UseMasterAgreementActivationOptions = {}) { + const { onActivated } = 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 masterAgreementKey = useMemo( + () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), + [masterAgreementPDA], + ); + const { account: masterAccount } = useMasterAgreementAccount(masterAgreementKey); + + const allParticipantsConfirmed = participants.every((participant) => participant.confirmed); + const reinOk = !reinsurer.enabled || reinsurer.confirmed; + const allConfirmed = allParticipantsConfirmed && reinOk; + const canActivate = allConfirmed && !masterActive && (role === 'leader' || role === 'operator'); + + 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 (!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'); + onActivated?.(); + }; + + return { + activateLoading, + allConfirmed, + canActivate, + handleActivate, + }; +} diff --git a/frontend/src/hooks/useMasterAgreementSnapshot.ts b/frontend/src/hooks/useMasterAgreementSnapshot.ts index 6ec89f8a..205aace4 100644 --- a/frontend/src/hooks/useMasterAgreementSnapshot.ts +++ b/frontend/src/hooks/useMasterAgreementSnapshot.ts @@ -22,6 +22,8 @@ export interface MasterAgreementSnapshot { aggregateReady: boolean; } +export type MasterAgreementPolicyStatus = 'loading' | 'error' | 'ready'; + function rawMicroUsdcToDisplay(amount: { toString(): string }): number { return Number(amount.toString()) / MICRO_USDC_FACTOR; } @@ -69,6 +71,7 @@ export function useMasterAgreementSnapshot(masterPda: PublicKey | null) { const { account: masterData, loading: masterLoading, error: masterError } = useMasterAgreementAccount(masterPda); const { status, activePartyId } = usePoolCollateralStatus(masterPda); const { policies, loading: policiesLoading, error: policiesError } = useFlightPolicies(masterPda); + const policyStatus: MasterAgreementPolicyStatus = policiesLoading ? 'loading' : policiesError ? 'error' : 'ready'; const snapshot = useMemo( () => buildMasterAgreementSnapshot(masterData, policies, status), @@ -82,5 +85,6 @@ export function useMasterAgreementSnapshot(masterPda: PublicKey | null) { masterData, loading: masterLoading || policiesLoading, error: masterError ?? policiesError, + policyStatus, }; } diff --git a/frontend/src/hooks/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index c3002547..638eac2a 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -35,7 +35,7 @@ export function resolveLeaderLabel( return selectedMasterAgreementName?.trim() || masterAgreementName?.trim() || fallbackLabel; } -function resolvePartyLabel( +export function resolvePartyLabel( wallet: PublicKey | null | undefined, fallbackLabel: string, displayNamesByWallet: Record, From 15fe4bb9b7235f265457939bd8dc13795879ea42 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:34:16 +0900 Subject: [PATCH 15/24] Keep Step 3 actionable when live balances are unresolved Simulation mode now derives its Step 3 dashboard from local agreement terms and pool state, while on-chain mode withholds collateral readiness until token balances are actually loaded. This preserves the dashboard handoff in simulation without routing users back to confirmation semantics, and it removes the synthetic zero-balance path that was producing false deficits during unresolved balance fetches. Constraint: Task 5 scope is limited to Step 3 dashboard correctness and directly related tests Rejected: Send simulation users back to the confirmation panel | breaks the Step 3 dashboard product intent Rejected: Keep zero-balance placeholders until fetch completes | shows false Action Needed and deficit states Confidence: high Scope-risk: narrow Directive: Do not treat missing collateral balances as authoritative funded data without an explicit resolved state Tested: vitest run src/components/tabs/tab-contract/__tests__ src/hooks/__tests__/useMasterAgreementSnapshot.test.ts; tsc --noEmit --pretty false -p tsconfig.json; lsp diagnostics on modified files; lsp_diagnostics_directory frontend --- .../MasterActivationDashboard.tsx | 10 +- .../MasterActivationDashboard.test.tsx | 18 ++ .../useMasterAgreementSnapshot.test.ts | 63 ++++++ .../src/hooks/useMasterAgreementSnapshot.ts | 69 ++++++- frontend/src/hooks/usePoolCollateralStatus.ts | 181 ++++++++++++++++-- 5 files changed, 311 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx index 3b071a4b..b674942d 100644 --- a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx @@ -141,8 +141,12 @@ export function MasterActivationDashboard() { selectedMasterAgreementName?.trim() || masterData?.name?.trim() || t('master.noNameFallback'); - const readinessTag = snapshot?.aggregateReady ? t('pool.healthAggregateReady') : t('pool.healthAggregateActionNeeded'); - const readinessVariant = snapshot?.aggregateReady ? 'accent' : 'warning'; + const readinessTag = loading + ? t('master.loading') + : snapshot?.aggregateReady + ? t('pool.healthAggregateReady') + : t('pool.healthAggregateActionNeeded'); + const readinessVariant = loading ? 'subtle' : snapshot?.aggregateReady ? 'accent' : 'warning'; const emptyMessage = loading ? t('master.loading') : error || t('master.step3.empty'); const moneyMessage = policyStatus === 'loading' ? t('master.loading') : policyStatus === 'error' ? error || t('master.step3.empty') : emptyMessage; const showMoneySnapshot = policyStatus === 'ready' && !!snapshot; @@ -179,7 +183,7 @@ export function MasterActivationDashboard() { - {snapshot?.blockerLabels.length ? ( + {snapshot && !loading && snapshot.blockerLabels.length ? ( {t('pool.healthAggregateActionNeeded')}: {snapshot.blockerLabels.join(', ')} diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx index fe27757d..0a255357 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx @@ -152,4 +152,22 @@ describe('MasterActivationDashboard', () => { expect(screen.getAllByText('policy fetch failed')).toHaveLength(2); 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', + }); + + renderSubject(); + + expect(screen.getAllByText('master.loading').length).toBeGreaterThan(0); + expect(screen.queryByText('pool.healthAggregateActionNeeded')).not.toBeInTheDocument(); + expect(screen.queryByText('5.00 USDC')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts index 31abc54d..eac55d1e 100644 --- a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts +++ b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts @@ -4,6 +4,7 @@ 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 { resolveLeaderLabel, resolvePartyLabel } from '../usePoolCollateralStatus'; import type { FlightPolicyWithKey } from '../useFlightPolicies'; import { buildMasterAgreementSnapshot, useMasterAgreementSnapshot } from '../useMasterAgreementSnapshot'; @@ -120,6 +121,10 @@ function makeCollateralStatus(): CollateralStatus { beforeEach(() => { vi.clearAllMocks(); + useProtocolStore.getState().resetAll(); + useProtocolStore.setState({ + mode: 'onchain', + }); mockUseMasterAgreementAccount.mockReturnValue({ account: makeMasterAgreement(), loading: false, @@ -129,6 +134,8 @@ beforeEach(() => { status: makeCollateralStatus(), activePartyId: 'leader', masterData: makeMasterAgreement(), + loading: false, + error: null, }); mockUseFlightPolicies.mockReturnValue({ policies: [], @@ -188,4 +195,60 @@ describe('buildMasterAgreementSnapshot', () => { const readyResult = renderHook(() => useMasterAgreementSnapshot(PublicKey.default)); expect(readyResult.result.current.policyStatus).toBe('ready'); }); + + 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(); + }); }); diff --git a/frontend/src/hooks/useMasterAgreementSnapshot.ts b/frontend/src/hooks/useMasterAgreementSnapshot.ts index 205aace4..c8e02df9 100644 --- a/frontend/src/hooks/useMasterAgreementSnapshot.ts +++ b/frontend/src/hooks/useMasterAgreementSnapshot.ts @@ -1,7 +1,9 @@ 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 } from './useMasterAgreementAccount'; import { usePoolCollateralStatus } from './usePoolCollateralStatus'; @@ -67,15 +69,70 @@ export function buildMasterAgreementSnapshot( }; } +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) { + const { mode, selectedMasterAgreementName, totalPremium, totalClaim } = useProtocolStore( + useShallow((state) => ({ + mode: state.mode, + selectedMasterAgreementName: state.selectedMasterAgreementName, + totalPremium: state.totalPremium, + totalClaim: state.totalClaim, + })), + ); const { account: masterData, loading: masterLoading, error: masterError } = useMasterAgreementAccount(masterPda); - const { status, activePartyId } = usePoolCollateralStatus(masterPda); + const { status, activePartyId, loading: collateralLoading, error: collateralError } = usePoolCollateralStatus(masterPda); const { policies, loading: policiesLoading, error: policiesError } = useFlightPolicies(masterPda); - const policyStatus: MasterAgreementPolicyStatus = policiesLoading ? 'loading' : policiesError ? 'error' : 'ready'; + const policyStatus: MasterAgreementPolicyStatus = + mode === 'simulation' ? 'ready' : policiesLoading ? 'loading' : policiesError ? 'error' : 'ready'; const snapshot = useMemo( - () => buildMasterAgreementSnapshot(masterData, policies, status), - [masterData, policies, status], + () => ( + mode === 'simulation' + ? buildSimulationMasterAgreementSnapshot({ + agreementName: selectedMasterAgreementName?.trim() || '', + totalPremiumInflow: totalPremium, + totalClaimOutflow: totalClaim, + collateralStatus: status, + }) + : buildMasterAgreementSnapshot(masterData, policies, status) + ), + [masterData, mode, policies, selectedMasterAgreementName, status, totalClaim, totalPremium], ); return { @@ -83,8 +140,8 @@ export function useMasterAgreementSnapshot(masterPda: PublicKey | null) { status, activePartyId, masterData, - loading: masterLoading || policiesLoading, - error: masterError ?? policiesError, + loading: mode === 'simulation' ? false : masterLoading || collateralLoading || policiesLoading, + error: mode === 'simulation' ? null : masterError ?? collateralError ?? policiesError, policyStatus, }; } diff --git a/frontend/src/hooks/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index 638eac2a..95ba9c61 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -17,6 +17,8 @@ interface UsePoolCollateralStatusResult { status: CollateralStatus | null; activePartyId?: string; masterData: MasterAgreementAccount | null; + loading: boolean; + error: string | null; } function participantId(index: number): string { @@ -27,6 +29,10 @@ function rawMicroUsdcToNumber(amount: { toString(): string }): number { return Number(amount.toString()) / 1_000_000; } +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, @@ -84,46 +90,100 @@ 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, ): UsePoolCollateralStatusResult { const { connection, wallet } = useProgram(); - const { account: masterData } = useMasterAgreementAccount(masterPDA); + const { account: masterData, loading: masterLoading, error: masterError } = useMasterAgreementAccount(masterPDA); 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)); + } } } @@ -132,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({ @@ -166,7 +285,7 @@ export function usePoolCollateralStatus( leader: { label: resolveLeaderLabel(selectedMasterAgreementName, masterData.name, 'Leader'), confirmed: true, - balance: safeBalances.leader, + balance: balances.leader, }, participants: masterData.participants.map((participant, index) => ({ id: participantId(index), @@ -178,7 +297,7 @@ export function usePoolCollateralStatus( ), shareBps: participant.shareBps, confirmed: participant.confirmed, - balance: safeBalances.participants[index] ?? 0, + balance: balances.participants[index] ?? 0, })), reinsurer: masterData.reinsurer ? { label: resolvePartyLabel( @@ -188,13 +307,33 @@ export function usePoolCollateralStatus( storedReinsurer.name, ), confirmed: masterData.reinsurerConfirmed, - balance: safeBalances.reinsurer, + balance: balances.reinsurer, } : undefined, }); } catch { return null; } - }, [balances, displayNamesByWallet, masterData, selectedMasterAgreementName, storedParticipants, storedReinsurer.name]); + }, [ + 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, + error: mode === 'simulation' ? null : masterError ?? balancesError, + }; } From 0976399e31886bfa452aec2f21e5fe44d1ed0cb3 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:51:59 +0900 Subject: [PATCH 16/24] Prevent Step 3 from treating missing live data as authoritative deficits Task 5 still conflated policy fetch latency with readiness state, and token balance RPC failures could collapse into zero balances that rendered false blockers. This pass keeps readiness KPIs active while policy data loads, isolates policy-specific money-panel failures, and leaves collateral readiness unresolved when live balance reads fail. Constraint: Scope limited to the Task 5 Step 3 dashboard hook/component path and directly related regressions Rejected: Preserve a single loading/error channel for readiness and policies | it still hides ready collateral state behind policy fetch latency Rejected: Continue coercing token balance RPC failures to zero | it produces synthetic underfunded states from transport failures Confidence: high Scope-risk: narrow Directive: Do not fold policy loading/error back into readiness state without a separate unresolved UI path for missing collateral data Tested: ./node_modules/.bin/vitest run src/components/tabs/tab-contract/__tests__ src/hooks/__tests__/useMasterAgreementSnapshot.test.ts (from frontend) Tested: ./node_modules/.bin/tsc --noEmit --pretty false -p tsconfig.json (from frontend) Tested: lsp_diagnostics on modified frontend files; lsp_diagnostics_directory frontend Not-tested: Live Solana RPC integration behavior beyond mocked/unit coverage --- .../MasterActivationDashboard.tsx | 12 +++- .../MasterActivationDashboard.test.tsx | 62 +++++++++++++++++-- .../useMasterAgreementSnapshot.test.ts | 35 ++++++++++- .../src/hooks/useMasterAgreementSnapshot.ts | 7 ++- frontend/src/hooks/usePoolCollateralStatus.ts | 10 +-- 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx index b674942d..d68c1df7 100644 --- a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx @@ -133,7 +133,7 @@ export function MasterActivationDashboard() { () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), [masterAgreementPDA], ); - const { snapshot, status, activePartyId, masterData, loading, error, policyStatus } = useMasterAgreementSnapshot(masterAgreementKey); + const { snapshot, status, activePartyId, masterData, loading, error, policyStatus, policyError } = useMasterAgreementSnapshot(masterAgreementKey); const { activateLoading, canActivate, handleActivate } = useMasterAgreementActivation(); const agreementName = @@ -143,12 +143,18 @@ export function MasterActivationDashboard() { t('master.noNameFallback'); const readinessTag = loading ? t('master.loading') + : !snapshot + ? t('master.step3.empty') : snapshot?.aggregateReady ? t('pool.healthAggregateReady') : t('pool.healthAggregateActionNeeded'); - const readinessVariant = loading ? 'subtle' : snapshot?.aggregateReady ? 'accent' : 'warning'; + const readinessVariant = loading || !snapshot ? 'subtle' : snapshot.aggregateReady ? 'accent' : 'warning'; const emptyMessage = loading ? t('master.loading') : error || t('master.step3.empty'); - const moneyMessage = policyStatus === 'loading' ? t('master.loading') : policyStatus === 'error' ? error || t('master.step3.empty') : emptyMessage; + const moneyMessage = policyStatus === 'loading' + ? t('master.loading') + : policyStatus === 'error' + ? policyError || t('master.step3.empty') + : emptyMessage; const showMoneySnapshot = policyStatus === 'ready' && !!snapshot; return ( diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx index 0a255357..780b4656 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx @@ -70,6 +70,32 @@ function makeSnapshot() { }; } +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(); @@ -97,12 +123,13 @@ beforeEach(() => { }); mockUseMasterAgreementSnapshot.mockReturnValue({ snapshot: makeSnapshot(), - status: null, + status: makeStatus(), activePartyId: 'leader', masterData: { name: 'Master 2026' }, loading: false, error: null, policyStatus: 'ready', + policyError: null, }); }); @@ -122,34 +149,39 @@ describe('MasterActivationDashboard', () => { test('suppresses zero-valued money rows while policy data is still loading', () => { mockUseMasterAgreementSnapshot.mockReturnValue({ snapshot: makeSnapshot(), - status: null, + 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: null, + status: makeStatus(), activePartyId: 'leader', masterData: { name: 'Master 2026' }, loading: false, - error: 'policy fetch failed', + error: null, policyStatus: 'error', + policyError: 'policy fetch failed', }); renderSubject(); - expect(screen.getAllByText('policy fetch failed')).toHaveLength(2); + expect(screen.getByText('policy fetch failed')).toBeInTheDocument(); + expect(screen.getByText('pool.healthAggregateActionNeeded')).toBeInTheDocument(); expect(screen.queryByText('0.00 USDC')).not.toBeInTheDocument(); }); @@ -162,6 +194,7 @@ describe('MasterActivationDashboard', () => { loading: true, error: null, policyStatus: 'ready', + policyError: null, }); renderSubject(); @@ -170,4 +203,23 @@ describe('MasterActivationDashboard', () => { expect(screen.queryByText('pool.healthAggregateActionNeeded')).not.toBeInTheDocument(); expect(screen.queryByText('5.00 USDC')).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/hooks/__tests__/useMasterAgreementSnapshot.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts index eac55d1e..08bb8e51 100644 --- a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts +++ b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts @@ -5,7 +5,7 @@ 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 { resolveLeaderLabel, resolvePartyLabel } from '../usePoolCollateralStatus'; +import { readTokenBalance, resolveLeaderLabel, resolvePartyLabel } from '../usePoolCollateralStatus'; import type { FlightPolicyWithKey } from '../useFlightPolicies'; import { buildMasterAgreementSnapshot, useMasterAgreementSnapshot } from '../useMasterAgreementSnapshot'; @@ -161,6 +161,14 @@ describe('buildMasterAgreementSnapshot', () => { 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(), @@ -183,6 +191,10 @@ describe('buildMasterAgreementSnapshot', () => { }); 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: [], @@ -191,9 +203,13 @@ describe('buildMasterAgreementSnapshot', () => { }); 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('builds a simulation snapshot from local store state without requiring an on-chain account', () => { @@ -251,4 +267,21 @@ describe('buildMasterAgreementSnapshot', () => { 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/useMasterAgreementSnapshot.ts b/frontend/src/hooks/useMasterAgreementSnapshot.ts index c8e02df9..ede68e09 100644 --- a/frontend/src/hooks/useMasterAgreementSnapshot.ts +++ b/frontend/src/hooks/useMasterAgreementSnapshot.ts @@ -120,6 +120,8 @@ export function useMasterAgreementSnapshot(masterPda: PublicKey | null) { 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( () => ( @@ -140,8 +142,9 @@ export function useMasterAgreementSnapshot(masterPda: PublicKey | null) { status, activePartyId, masterData, - loading: mode === 'simulation' ? false : masterLoading || collateralLoading || policiesLoading, - error: mode === 'simulation' ? null : masterError ?? collateralError ?? policiesError, + loading: readinessLoading, + error: readinessError, policyStatus, + policyError: mode === 'simulation' ? null : policiesError, }; } diff --git a/frontend/src/hooks/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index 95ba9c61..628d6fc0 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -51,7 +51,7 @@ export function resolvePartyLabel( return walletLabel?.trim() || storedLabel?.trim() || fallbackLabel; } -async function readTokenBalance( +export async function readTokenBalance( connection: ReturnType['connection'], tokenAccount: PublicKey | null | undefined, ): Promise { @@ -59,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( From c10294055471ec8c637154348695049118bc5765 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:11:32 +0900 Subject: [PATCH 17/24] Keep Step 3 as the only master activation surface Step 2 now stops at confirmation state and only advances into the Step 3 dashboard when approvals are complete. The dashboard also treats the live-balance handoff as loading so on-chain users do not briefly see an empty activation snapshot before balance reads begin. Constraint: Task 5 fix pass must preserve simulation and on-chain activation behavior once Step 3 loads Rejected: Leave activation wired in ParticipantConfirm | bypasses Step 3 ownership and guide target Confidence: high Scope-risk: narrow Directive: Keep \ attached to MasterActivationDashboard unless Step ownership changes again Tested: frontend vitest run src/components/tabs/tab-contract/__tests__ src/hooks/__tests__/useMasterAgreementSnapshot.test.ts Tested: frontend tsc -b --pretty false Tested: frontend lsp_diagnostics_directory + modified-file diagnostics Not-tested: Manual browser walkthrough --- .../MasterActivationDashboard.tsx | 10 +- .../tabs/tab-contract/ParticipantConfirm.tsx | 23 +++-- .../MasterActivationDashboard.test.tsx | 19 ++++ .../__tests__/ParticipantConfirm.test.tsx | 98 ++++++------------- frontend/src/hooks/usePoolCollateralStatus.ts | 4 +- 5 files changed, 68 insertions(+), 86 deletions(-) diff --git a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx index d68c1df7..2e712e1a 100644 --- a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx @@ -135,21 +135,22 @@ export function MasterActivationDashboard() { ); const { snapshot, status, activePartyId, masterData, loading, error, policyStatus, policyError } = useMasterAgreementSnapshot(masterAgreementKey); const { activateLoading, canActivate, handleActivate } = useMasterAgreementActivation(); + const readinessLoading = loading || (!!masterData && !snapshot && !error); const agreementName = snapshot?.agreementName || selectedMasterAgreementName?.trim() || masterData?.name?.trim() || t('master.noNameFallback'); - const readinessTag = loading + const readinessTag = readinessLoading ? t('master.loading') : !snapshot ? t('master.step3.empty') : snapshot?.aggregateReady ? t('pool.healthAggregateReady') : t('pool.healthAggregateActionNeeded'); - const readinessVariant = loading || !snapshot ? 'subtle' : snapshot.aggregateReady ? 'accent' : 'warning'; - const emptyMessage = loading ? t('master.loading') : error || t('master.step3.empty'); + 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' @@ -189,7 +190,7 @@ export function MasterActivationDashboard() { - {snapshot && !loading && snapshot.blockerLabels.length ? ( + {snapshot && !readinessLoading && snapshot.blockerLabels.length ? ( {t('pool.healthAggregateActionNeeded')}: {snapshot.blockerLabels.join(', ')} @@ -205,6 +206,7 @@ export function MasterActivationDashboard() { onClick={() => void handleActivate()} disabled={!canActivate || activateLoading} data-testid="master-activation-cta" + data-guide="activate-btn" > {activateLoading ? 'Sending TX...' : t('confirm.activateBtn')} diff --git a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx index 504a830f..24012790 100644 --- a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx +++ b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx @@ -4,7 +4,6 @@ import { useProtocolStore, PARTICIPANT_COLORS, REINSURER_COLOR } from '@/store/u import { useShallow } from 'zustand/shallow'; import { useToast } from '@/components/common'; import { useTranslation } from 'react-i18next'; -import { useMasterAgreementActivation } from '@/hooks/useMasterAgreementActivation'; const ParticipantRow = styled.div<{ confirmed?: boolean }>` background: var(--card2); @@ -50,7 +49,6 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { ); const { toast } = useToast(); const { t } = useTranslation(); - const { activateLoading, canActivate, handleActivate } = useMasterAgreementActivation({ onActivated }); const allParticipantsConfirmed = participants.every((p) => p.confirmed); const reinOk = !reinsurer.enabled || reinsurer.confirmed; @@ -119,16 +117,17 @@ 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 index 780b4656..f2a6387b 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx @@ -139,6 +139,7 @@ describe('MasterActivationDashboard', () => { const activateButton = screen.getByRole('button', { name: 'confirm.activateBtn' }); expect(activateButton).toBeEnabled(); + expect(activateButton).toHaveAttribute('data-guide', 'activate-btn'); fireEvent.click(activateButton); @@ -204,6 +205,24 @@ describe('MasterActivationDashboard', () => { 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, 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..653f3083 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx @@ -1,6 +1,6 @@ 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'; @@ -8,7 +8,6 @@ import { useProtocolStore } from '@/store/useProtocolStore'; const mockToast = vi.fn(); const mockActivateMasterOnChain = vi.fn(); -const mockUseMasterAgreementAccount = vi.fn(); vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next'); @@ -35,19 +34,10 @@ vi.mock('@/hooks/useActivateMaster', () => ({ }), })); -vi.mock('@/hooks/useMasterAgreementAccount', () => ({ - useMasterAgreementAccount: (...args: unknown[]) => mockUseMasterAgreementAccount(...args), -})); - -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 +48,51 @@ 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('advances to the Step 3 dashboard when all confirmations are ready without activating on-chain', async () => { + const onActivated = vi.fn(); + + renderParticipantConfirm(onActivated); - fireEvent.click(screen.getByRole('button', { name: 'confirm.activateBtn' })); + const transitionButton = screen.getByRole('button', { name: 'master.step.activate' }); + expect(transitionButton).toBeEnabled(); + expect(transitionButton).not.toHaveAttribute('data-guide', 'activate-btn'); + + fireEvent.click(transitionButton); await waitFor(() => { - expect(mockActivateMasterOnChain).toHaveBeenCalledWith({ - masterAgreement, - leaderPoolToken: leaderPoolWallet, - reinsurerPoolToken: reinsurerPoolWallet, - participantPoolTokens: participantPoolWallets, - }); + expect(onActivated).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: 'master.step.activate' }); + 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/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index 628d6fc0..dd016323 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -329,7 +329,9 @@ export function usePoolCollateralStatus( status, activePartyId, masterData, - loading: mode === 'simulation' ? false : masterLoading || balancesLoading, + loading: mode === 'simulation' + ? false + : masterLoading || balancesLoading || (!!masterData && !balances && !balancesError), error: mode === 'simulation' ? null : masterError ?? balancesError, }; } From f03e2fa3c2eb8959d991453534356e6b73078bcc Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:32:05 +0900 Subject: [PATCH 18/24] Keep activation guidance and CTA readiness aligned with the Step 3 flow The guided tour still targeted the old single-step activation path, and the new dashboard CTA could be clicked before the on-chain account fetch finished. This patch adds a dedicated tour stop for the Step 2 transition, shifts the downstream step wiring by one slot, and disables on-chain activation until the required account data is present. Constraint: Keep scope limited to Task 5 activation flow guidance and readiness checks Rejected: Reuse snapshot data for activation wallets | snapshot path does not expose the full account payload needed for on-chain activation Confidence: high Scope-risk: narrow Directive: If the activation flow changes again, update both GUIDE_STEPS sequencing and GuideTour auto-advance/manual-step mappings together Tested: frontend vitest run src/components/tabs/tab-contract/__tests__ src/hooks/__tests__/useMasterAgreementSnapshot.test.ts Tested: frontend tsc --noEmit --pretty false -p tsconfig.json Tested: lsp_diagnostics_directory frontend Not-tested: Interactive browser walkthrough of the guide overlay --- frontend/src/components/guide/GuideTour.tsx | 15 ++++---- frontend/src/components/guide/guideSteps.ts | 17 +++++----- .../tabs/tab-contract/ParticipantConfirm.tsx | 1 + .../MasterActivationDashboard.test.tsx | 16 +++++++++ .../__tests__/ParticipantConfirm.test.tsx | 8 ++++- .../src/hooks/useMasterAgreementActivation.ts | 3 +- frontend/src/i18n/locales/en.ts | 34 ++++++++++--------- frontend/src/i18n/locales/ko.ts | 34 ++++++++++--------- 8 files changed, 79 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/guide/GuideTour.tsx b/frontend/src/components/guide/GuideTour.tsx index 08a83a7e..497e6f13 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 ── */ @@ -300,11 +300,12 @@ export function GuideTour({ activeTab }: Props) { 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 +316,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/tabs/tab-contract/ParticipantConfirm.tsx b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx index 24012790..a945fdf8 100644 --- a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx +++ b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx @@ -124,6 +124,7 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { onClick={onActivated} disabled={!allConfirmed} style={{ marginTop: 4 }} + data-guide="activate-transition-btn" > {t('master.step.activate')} diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx index f2a6387b..5c43c6ba 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx @@ -147,6 +147,22 @@ describe('MasterActivationDashboard', () => { 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, + }); + + 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(), 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 653f3083..8dcfb454 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx @@ -5,6 +5,7 @@ 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(); @@ -59,6 +60,11 @@ beforeEach(() => { }); describe('ParticipantConfirm', () => { + 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(); @@ -66,7 +72,7 @@ describe('ParticipantConfirm', () => { const transitionButton = screen.getByRole('button', { name: 'master.step.activate' }); expect(transitionButton).toBeEnabled(); - expect(transitionButton).not.toHaveAttribute('data-guide', 'activate-btn'); + expect(transitionButton).toHaveAttribute('data-guide', 'activate-transition-btn'); fireEvent.click(transitionButton); diff --git a/frontend/src/hooks/useMasterAgreementActivation.ts b/frontend/src/hooks/useMasterAgreementActivation.ts index 2765c25b..aa594a90 100644 --- a/frontend/src/hooks/useMasterAgreementActivation.ts +++ b/frontend/src/hooks/useMasterAgreementActivation.ts @@ -46,7 +46,8 @@ export function useMasterAgreementActivation(options: UseMasterAgreementActivati const allParticipantsConfirmed = participants.every((participant) => participant.confirmed); const reinOk = !reinsurer.enabled || reinsurer.confirmed; const allConfirmed = allParticipantsConfirmed && reinOk; - const canActivate = allConfirmed && !masterActive && (role === 'leader' || role === 'operator'); + const hasActivationAccountData = mode === 'simulation' || (!!masterAgreementKey && !!masterAccount); + const canActivate = allConfirmed && !masterActive && (role === 'leader' || role === 'operator') && hasActivationAccountData; const handleActivate = async () => { if (mode === 'simulation') { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 11595482..46c9dff5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -543,22 +543,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 abc572c0..0a41d6a5 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -543,22 +543,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': '참여사 포탈', From e8a355975725d49322c857b3ac2386f88e1513f3 Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:49:27 +0900 Subject: [PATCH 19/24] Prevent duplicate master-agreement subscriptions on activation dashboard Task 5's activation path was fetching the same master-agreement account through the dashboard, snapshot, and collateral hooks. This change centralizes the account fetch in the dashboard, threads the resolved master state into the dependent hooks, and gives the Step 2 transition button its own label so it is distinct from the real Step 3 activation CTA. Constraint: Keep the fix local to the Task 5 activation dashboard path and directly related tests/i18n Rejected: Introduce a broader shared context/provider for master agreement data | too broad for a fix pass Rejected: Leave snapshot and collateral hooks to fetch the same PDA independently | preserves duplicate backend fetch and SSE chains Confidence: high Scope-risk: narrow Tested: ./node_modules/.bin/vitest run src/components/tabs/tab-contract/__tests__ src/hooks/__tests__/useMasterAgreementSnapshot.test.ts Tested: ./node_modules/.bin/tsc -b --pretty false Tested: lsp_diagnostics_directory frontend and per-file diagnostics on modified files --- .../MasterActivationDashboard.tsx | 17 +++++-- .../tabs/tab-contract/ParticipantConfirm.tsx | 2 +- .../MasterActivationDashboard.test.tsx | 51 +++++++++++++++++-- .../__tests__/ParticipantConfirm.test.tsx | 4 +- .../useMasterAgreementSnapshot.test.ts | 20 ++++++++ .../src/hooks/useMasterAgreementAccount.ts | 6 +++ .../src/hooks/useMasterAgreementActivation.ts | 17 +++---- .../src/hooks/useMasterAgreementSnapshot.ts | 18 +++++-- frontend/src/hooks/usePoolCollateralStatus.ts | 8 ++- frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/ko.ts | 1 + 11 files changed, 118 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx index 2e712e1a..b4c5aae0 100644 --- a/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterActivationDashboard.tsx @@ -5,6 +5,7 @@ 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'; @@ -133,14 +134,22 @@ export function MasterActivationDashboard() { () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), [masterAgreementPDA], ); - const { snapshot, status, activePartyId, masterData, loading, error, policyStatus, policyError } = useMasterAgreementSnapshot(masterAgreementKey); - const { activateLoading, canActivate, handleActivate } = useMasterAgreementActivation(); - const readinessLoading = loading || (!!masterData && !snapshot && !error); + 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() || - masterData?.name?.trim() || + snapshotMasterData?.name?.trim() || t('master.noNameFallback'); const readinessTag = readinessLoading ? t('master.loading') diff --git a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx index a945fdf8..6e9d3056 100644 --- a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx +++ b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx @@ -126,7 +126,7 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { style={{ marginTop: 4 }} data-guide="activate-transition-btn" > - {t('master.step.activate')} + {t('confirm.activateTransitionBtn')} )} diff --git a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx index 5c43c6ba..dfc2a693 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/MasterActivationDashboard.test.tsx @@ -1,5 +1,6 @@ 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'; @@ -8,6 +9,7 @@ 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'); @@ -39,11 +41,8 @@ vi.mock('@/hooks/useMasterAgreementAccount', () => ({ useMasterAgreementAccount: (...args: unknown[]) => mockUseMasterAgreementAccount(...args), })); -vi.mock('@/hooks/useActivateMaster', () => ({ - useActivateMaster: () => ({ - activateMaster: vi.fn(), - loading: false, - }), +vi.mock('@/hooks/useMasterAgreementActivation', () => ({ + useMasterAgreementActivation: (...args: unknown[]) => mockUseMasterAgreementActivation(...args), })); function renderSubject() { @@ -121,6 +120,13 @@ beforeEach(() => { loading: false, error: null, }); + mockUseMasterAgreementActivation.mockImplementation(() => ({ + activateLoading: false, + canActivate: true, + handleActivate: vi.fn(() => { + useProtocolStore.getState().activateMaster(); + }), + })); mockUseMasterAgreementSnapshot.mockReturnValue({ snapshot: makeSnapshot(), status: makeStatus(), @@ -134,6 +140,36 @@ beforeEach(() => { }); 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(); @@ -157,6 +193,11 @@ describe('MasterActivationDashboard', () => { loading: true, error: null, }); + mockUseMasterAgreementActivation.mockReturnValue({ + activateLoading: false, + canActivate: false, + handleActivate: vi.fn(), + }); renderSubject(); 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 8dcfb454..59ff90a9 100644 --- a/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx +++ b/frontend/src/components/tabs/tab-contract/__tests__/ParticipantConfirm.test.tsx @@ -70,7 +70,7 @@ describe('ParticipantConfirm', () => { renderParticipantConfirm(onActivated); - const transitionButton = screen.getByRole('button', { name: 'master.step.activate' }); + const transitionButton = screen.getByRole('button', { name: 'confirm.activateTransitionBtn' }); expect(transitionButton).toBeEnabled(); expect(transitionButton).toHaveAttribute('data-guide', 'activate-transition-btn'); @@ -93,7 +93,7 @@ describe('ParticipantConfirm', () => { renderParticipantConfirm(onActivated); - const transitionButton = screen.getByRole('button', { name: 'master.step.activate' }); + const transitionButton = screen.getByRole('button', { name: 'confirm.activateTransitionBtn' }); expect(transitionButton).toBeDisabled(); fireEvent.click(transitionButton); diff --git a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts index 08bb8e51..b24765c7 100644 --- a/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts +++ b/frontend/src/hooks/__tests__/useMasterAgreementSnapshot.test.ts @@ -212,6 +212,26 @@ describe('buildMasterAgreementSnapshot', () => { 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', diff --git a/frontend/src/hooks/useMasterAgreementAccount.ts b/frontend/src/hooks/useMasterAgreementAccount.ts index da3ea414..218a73f4 100644 --- a/frontend/src/hooks/useMasterAgreementAccount.ts +++ b/frontend/src/hooks/useMasterAgreementAccount.ts @@ -46,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) => diff --git a/frontend/src/hooks/useMasterAgreementActivation.ts b/frontend/src/hooks/useMasterAgreementActivation.ts index aa594a90..7f6ce68b 100644 --- a/frontend/src/hooks/useMasterAgreementActivation.ts +++ b/frontend/src/hooks/useMasterAgreementActivation.ts @@ -3,16 +3,16 @@ 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 { useMasterAgreementAccount } from './useMasterAgreementAccount'; -interface UseMasterAgreementActivationOptions { +interface UseMasterAgreementActivationOptions extends Partial { onActivated?: () => void; } export function useMasterAgreementActivation(options: UseMasterAgreementActivationOptions = {}) { - const { onActivated } = options; + const { onActivated, masterData } = options; const { mode, role, @@ -41,12 +41,11 @@ export function useMasterAgreementActivation(options: UseMasterAgreementActivati () => (masterAgreementPDA ? new PublicKey(masterAgreementPDA) : null), [masterAgreementPDA], ); - const { account: masterAccount } = useMasterAgreementAccount(masterAgreementKey); const allParticipantsConfirmed = participants.every((participant) => participant.confirmed); const reinOk = !reinsurer.enabled || reinsurer.confirmed; const allConfirmed = allParticipantsConfirmed && reinOk; - const hasActivationAccountData = mode === 'simulation' || (!!masterAgreementKey && !!masterAccount); + const hasActivationAccountData = mode === 'simulation' || (!!masterAgreementKey && !!masterData); const canActivate = allConfirmed && !masterActive && (role === 'leader' || role === 'operator') && hasActivationAccountData; const handleActivate = async () => { @@ -67,16 +66,16 @@ export function useMasterAgreementActivation(options: UseMasterAgreementActivati return; } - if (!masterAccount) { + if (!masterData) { 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), + leaderPoolToken: masterData.leaderPoolWallet, + reinsurerPoolToken: masterData.reinsurerPoolWallet ?? masterData.leaderPoolWallet, + participantPoolTokens: masterData.participants.map((participant) => participant.poolWallet), }); if (!result.success) { diff --git a/frontend/src/hooks/useMasterAgreementSnapshot.ts b/frontend/src/hooks/useMasterAgreementSnapshot.ts index ede68e09..519feacc 100644 --- a/frontend/src/hooks/useMasterAgreementSnapshot.ts +++ b/frontend/src/hooks/useMasterAgreementSnapshot.ts @@ -5,7 +5,7 @@ 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 } from './useMasterAgreementAccount'; +import { useMasterAgreementAccount, type SharedMasterAgreementAccountState } from './useMasterAgreementAccount'; import { usePoolCollateralStatus } from './usePoolCollateralStatus'; const MICRO_USDC_FACTOR = 1_000_000; @@ -106,7 +106,10 @@ export function buildSimulationMasterAgreementSnapshot({ }; } -export function useMasterAgreementSnapshot(masterPda: PublicKey | null) { +export function useMasterAgreementSnapshot( + masterPda: PublicKey | null, + sharedMasterState?: SharedMasterAgreementAccountState, +) { const { mode, selectedMasterAgreementName, totalPremium, totalClaim } = useProtocolStore( useShallow((state) => ({ mode: state.mode, @@ -115,8 +118,15 @@ export function useMasterAgreementSnapshot(masterPda: PublicKey | null) { totalClaim: state.totalClaim, })), ); - const { account: masterData, loading: masterLoading, error: masterError } = useMasterAgreementAccount(masterPda); - const { status, activePartyId, loading: collateralLoading, error: collateralError } = usePoolCollateralStatus(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 { 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'; diff --git a/frontend/src/hooks/usePoolCollateralStatus.ts b/frontend/src/hooks/usePoolCollateralStatus.ts index dd016323..c2735df9 100644 --- a/frontend/src/hooks/usePoolCollateralStatus.ts +++ b/frontend/src/hooks/usePoolCollateralStatus.ts @@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow'; import type { MasterAgreementAccount } from '@/lib/idl/open_parametric'; import { buildCollateralStatus, type CollateralStatus } from '@/lib/collateral'; import { useProtocolStore } from '@/store/useProtocolStore'; -import { useMasterAgreementAccount } from './useMasterAgreementAccount'; +import { useMasterAgreementAccount, type SharedMasterAgreementAccountState } from './useMasterAgreementAccount'; import { useProgram } from './useProgram'; interface PoolCollateralBalances { @@ -105,9 +105,13 @@ function getSimulationActivePartyId( export function usePoolCollateralStatus( masterPDA: PublicKey | null, activeWallet?: PublicKey | null, + sharedMasterState?: SharedMasterAgreementAccountState, ): UsePoolCollateralStatusResult { const { connection, wallet } = useProgram(); - const { account: masterData, loading: masterLoading, error: masterError } = 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, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 46c9dff5..9f28aae2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -169,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', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 0a41d6a5..e96c3d13 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -169,6 +169,7 @@ const ko = { 'confirm.shareInfo': '지분: {{share}}% (원수사 내)', 'confirm.reinInfo': '원수사 보험료·보험금의 50% 재보험 인수', 'confirm.btn': '✓ 컨펌', + 'confirm.activateTransitionBtn': '활성화 대시보드로 이동', 'confirm.activateBtn': '⚡ 마스터 계약 활성화', 'confirm.portalGuide': '각 참여사는 Portal 페이지에서 자체 지갑으로 컨펌해주세요', From 42cedc81e0f57eb72a0f84bda3fd773a459511fc Mon Sep 17 00:00:00 2001 From: MYONG JAEWI <78201530+Jaymyong66@users.noreply.github.com> Date: Mon, 27 Apr 2026 05:02:31 +0900 Subject: [PATCH 20/24] Preserve the Step 2 to Step 3 activation boundary The workbench was still treating confirmation readiness as equivalent to a user-entered activation transition, which hid the transition CTA and let the guide advance on the wrong signal. Constraint: Keep scope limited to the Task 5 Step 3 transition and directly related regression tests Rejected: Broader workbench step-state refactor | unnecessary for the verified regression surface Directive: Do not auto-select the activation dashboard from readiness state alone; only activation or an explicit user transition should cross that boundary Confidence: high Scope-risk: narrow Tested: vitest run src/components/tabs/tab-contract/__tests__ src/hooks/__tests__/useMasterAgreementSnapshot.test.ts; tsc -b --pretty false; lsp_diagnostics_directory frontend --- frontend/src/components/guide/GuideTour.tsx | 1 - .../tabs/tab-contract/MasterAgreementWorkbench.tsx | 2 +- .../tabs/tab-contract/ParticipantConfirm.tsx | 12 +++++++++++- .../__tests__/MasterAgreementWorkbench.test.tsx | 9 +++++++-- .../__tests__/ParticipantConfirm.test.tsx | 9 +++++++++ 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/guide/GuideTour.tsx b/frontend/src/components/guide/GuideTour.tsx index 497e6f13..3f5d3ff4 100644 --- a/frontend/src/components/guide/GuideTour.tsx +++ b/frontend/src/components/guide/GuideTour.tsx @@ -299,7 +299,6 @@ 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 = masterActive; break; case 11: ok = activeTab === 'tab-feed'; break; case 13: ok = activeTab === 'tab-oracle'; break; diff --git a/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx b/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx index 0a51d827..04d9fe83 100644 --- a/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx +++ b/frontend/src/components/tabs/tab-contract/MasterAgreementWorkbench.tsx @@ -186,7 +186,7 @@ const EditorWrap = styled.div` `; function getRecommendedStep(processStep: number, masterActive: boolean): MasterAgreementReviewStep { - if (masterActive || processStep >= 4) { + if (masterActive || processStep >= 5) { return 'activate'; } diff --git a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx index 6e9d3056..4658b862 100644 --- a/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx +++ b/frontend/src/components/tabs/tab-contract/ParticipantConfirm.tsx @@ -4,6 +4,8 @@ import { useProtocolStore, PARTICIPANT_COLORS, REINSURER_COLOR } from '@/store/u import { useShallow } from 'zustand/shallow'; import { useToast } from '@/components/common'; import { useTranslation } from 'react-i18next'; +import { GUIDE_STEPS } from '@/components/guide/guideSteps'; +import { useGuideTour } from '@/components/guide/useGuideTour'; const ParticipantRow = styled.div<{ confirmed?: boolean }>` background: var(--card2); @@ -49,6 +51,7 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { ); const { toast } = useToast(); const { t } = useTranslation(); + const { currentStep, nextStep } = useGuideTour(); const allParticipantsConfirmed = participants.every((p) => p.confirmed); const reinOk = !reinsurer.enabled || reinsurer.confirmed; @@ -66,6 +69,13 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) { toast(t('toast.confirmDone', { role: t('role.reinShort') }), 's'); }; + const handleActivateTransition = () => { + onActivated?.(); + if (currentStep !== null && GUIDE_STEPS[currentStep]?.target === 'activate-transition-btn') { + nextStep(); + } + }; + return ( @@ -121,7 +131,7 @@ export function ParticipantConfirm({ onActivated }: ParticipantConfirmProps) {