결정 기록
📌 이 문서를 읽는 법
카드가 결정의 유일한 집이다. 여기(그리고 17-changelog의 날짜 항목)에 적고, 다른 곳에서는 다시 서술하지 말고 링크만 건다. 대체된 결정은 절대 지우지 않는다. 카드를 유지한 채 "⚠️ Superseded by …" 배너가 붙으므로 그 결정을 만든 논리를 계속 읽을 수 있다.
카드마다 배지가 자기 상태를 담고 있으니 문서 전체에 붙은 단서가 아니라 카드의 배지를 믿을 것. v3.0 차원 모델이 앞선 BD/PD 항목 여럿을 해소한다. v3.0 결정 참조.
비즈니스·제품·법무 정렬을 위한 열린 항목이다. 카드를 눌러 펼치면 선택지 전체를 볼 수 있다.
결정 색인
v3 결정 카드 152개 전부이고 최신순이다. 카드는 번호순이 아니라 시기별 묶음으로 문서에 놓이므로, 스크롤하지 말고 이 색인을 쓸 것. 대체된 카드도 항목을 유지한다. 결정은 제거되지 않고 배너 표시만 붙는다.
색인 열기 (카드 152개)
| 카드 | 결정 |
|---|---|
| v3-157 | claimRedemptionFallback 제거는 함수 정리가 아니라 무권한 이탈 보장의 철회다 |
| v3-156 | 회수된 번호이고 의도적으로 비어 있다. 2026-09-03에 Issuer SPV 롤 재설계로 등록됐다가(f361d976) 같은 날 회수됐다(97181db9). 재기획 중이다. 🔴 재사용하지 않는다. 등록 커밋이 이미 push돼서 원격 이력과 배포된 사이트 양쪽에 v3-156이 그 뜻으로 남아 있고, 같은 번호에 두 번째 뜻이 붙으면 이력을 뒤지는 사람이 어느 쪽인지 알 수 없다. 이 문서의 첫 결번이고, 채워야 할 누락이 아니라 의도된 결번이다. |
| v3-155 | 리저브 몫이 외부 지갑으로 나가고, 그래서 reserve_bps가 보유 비율에서 라우팅 비율로 바뀐다 |
| v3-154 | 풀이 갖는 요율 필드는 발생 모드가 정한다. 둘 다 가진 풀은 한 질문에 답이 둘이기 때문이다 |
| v3-153 | 이자는 납입 자본에 붙으므로 상각은 NAV를 움직이고 청구액은 움직이지 않는다 |
| v3-152 | 청산은 가격을 다시 계산하지 않는다. 리저브가 풀 밖으로 나간 뒤에는 나눌 만한 것이 남지 않기 때문이다 |
| v3-151 | 재투자를 모든 화면에서 뺀다. 경로를 실제로 닫는 것은 사라진 버튼이 아니라 컬럼 기본값이다 |
| v3-150 | 회차별 정산 상한은 제품 표면에서 빠지고 컨트랙트에 남는다 |
| v3-149 | 즉시 상환은 되물릴 수 없고, 그것을 허용하던 온체인 분기는 그냥 호출자를 잃는다 |
| v3-148 | 보유분이 어떻게 빠져나가는지는 축들로 유도하고 저장하지 않는다. 그 판단에는 축이 전부 필요하다 |
| v3-147 | 만기 후 쿠폰 날짜는 상환 회차 자신의 날짜이고, 온체인 약속이 예측이 아니라 드디어 기록된다 |
| v3-146 | 락업의 세 날짜는 생성 시점의 결정 하나다. 그래서 모집 마감일이 편집 가능한 필드이기를 그치고 개방형 풀에는 락업이 없다 |
| v3-145 | 기간은 모집 마감부터 센다. 그래서 W9 상한과 늦은 투자자 결함이 함께 사라진다 |
| v3-144 | 락업은 모집 마감부터 센다. 풀이 clone이라 두 기준이 동시에 살아 있다 |
| v3-143 | 에스크로된 LP도 계속 발생시키고, 발생 기준이 이미 남의 이름이 붙은 집합이기를 그친다 |
| v3-141 | 모집 마감이 자기 컬럼을 갖는다. end_date가 "청약이 끝났다"와 "기간이 끝났다"에 숫자 하나로 답하고 있었다 |
| v3-142 | 닫힌 모집을 다시 열 수 있고 체인에도 알려야 했다. 화면은 2주 반 동안 그것을 약속하고 있었다 |
| v3-140 | AUTO가 지켜보던 유일한 것을 끄고 그 일을 아무에게도 넘기지 않았다. 그래서 리마인더가 아니라 스위치를 뺀다 |
| v3-139 | 펀딩일 엔드포인트가 주기 번호를 받고, 풀 행은 여전히 접수 중인 주기만 대변하며, 체인이 양쪽 이웃을 검사한다 |
| v3-138 | 키 분리로 새 풀이 전부 서명할 수 없게 됐고, 정지 역할은 회수하기 전에 넘긴다 |
| v3-137 | 한 주기에 남은 펀딩은 그 주기에 남고, 없는 정리 로직은 아무도 내리지 않은 컨트랙트 결정이다 |
| v3-136 | 저장된 규칙이 만든 날짜는 사람에게 보여 주는 것 말고는 아무 데도 쓸 수 없다 |
| v3-135 | 펀딩일 리마인더는 몇 달 전에 고른 설정이 아니라 계획의 상태로 켜지고 꺼진다 |
| v3-134 | 라이터 둘이 같은 목록을 반대 순서로 체인에 올렸고, 그 이유는 서로 같은 이유가 아니다 |
| v3-133 | 배포가 모든 주기의 날짜를 쓴다. 쓰이지 않은 주기는 자신 있는 오답으로 읽히고 정산마다 굳어지기 때문이다 |
| v3-132 | 상환 계획은 날짜 목록으로 저장하고, 그것을 만든 규칙은 사람이 같은 목록을 두 번 만들 수 있게만 남긴다 |
| v3-131 | 만기 뒤 여러 회차로 갚는 풀은 체인이 이미 허용하는 조합이고, 그것을 거부하는 것은 위저드 가드뿐이다 |
| v3-130 | 락업 앵커는 추론이 아니라 이벤트로 내보내고, 재투자도 입금과 똑같이 앵커된다 |
| v3-129 | 폰에서는 글자가 작아지고 모니터에서는 열이 늘어난다. 큰 화면은 더 큰 내용이 아니라 더 많은 내용을 얻는다 |
| v3-128 | hold-back을 뺀다. 아무도 당기지 않는 기능도 그것을 지고 다니는 모든 산식에 비용을 물린다 |
| v3-127 | SBT가 신원의 기준이므로, 민팅이 그 절반을 지어내서도 안 되고 로그인이 나머지 절반을 뭉개서도 안 된다 |
| v3-126 | 삭제된 테이블의 기본값이 살아남았다. 상환 뷰가 PENDING_RESERVE를 뒤집어 갖고 있었고 "보류 중인가"를 NULL 안전하게 물을 방법이 없었다 |
| v3-125 | 포트폴리오는 원금을 가격 매기고 원금은 액면이 상한이다. 포지션 변화는 수익이 아니라 상각이다 |
| v3-124 | 시간 기준 셋을 떼어 놓는다. 주기는 "N일마다", 한 달은 30일, 수익은 달력이다 |
| v3-123 | Yield 리뷰는 가격이 매겨진 집합 전체를 상환 러너로 서명하고, 정산이 아니라 입금을 센다 |
| v3-122 | 잔액은 append-only 원장을 접어 만든다. money 테이블 넷은 사라지고 legacy는 남는다 |
| v3-121 | Yield 화면은 "누구를 쫓아야 하나"와 "누가 받았나"에 답한다. 이미 있던 기록으로 |
| v3-120 | 수익 기간은 늦기 일주일 전부터 조치 대상이 되고, 어느 쪽인지를 말한다 |
| v3-119 | 펀딩되는 단위가 주기이므로 엔드포인트가 받는 단위도 주기다 |
| v3-118 | 04-pool-models에 색인과 단계적 노출을 넣는다. 잘라 내는 것은 없다 |
| v3-117 | 거버넌스 타임락에 실행 기한 알림이 붙고, 제3자 실행이 이제 DB에 닿는다 |
| v3-116 | 07-redemption은 동작만 남기고 발굴 기록을 뺀다. "미구현" 콜아웃 셋이 낡아 있었다 |
| v3-115 | 05-investment-lifecycle은 투자자의 여정이다. 동작은 그 주인에게 속한다 |
| v3-114 | 04-pool-models는 개념 명세이지 보관소가 아니다. 발굴 기록은 주인에게 옮긴다 |
| v3-113 | 백엔드 문서 정보 구조: 축 하나, 그룹 열 개 |
| v3-112 | hold-back은 잠자는 레버이고 "그 90%"는 설정된 잔여분이다 |
| v3-111 | 재투자는 입금과 마찬가지로 effectiveNav로 가격을 매긴다 |
| v3-110 | ARCHIVED는 lifecycle 상태였던 적이 없고 CLOSED에는 들어갈 길이 없다 |
| v3-109 | 리저브는 손실 계층이 아니다. NAV 분모는 totalSupply다 |
| v3-108 | 알림 재구축: 두 일을 하던 행 하나가 event → notification → delivery가 된다 |
| v3-107 | 앵커 기반 회차 일정은 온체인에 절대 설치되지 않는다. 펀딩일 출처는 체인 읽기가 아니라 컬럼이다 |
| v3-106 | 트랜치 상각은 ADMIN의 행위다. IMPAIRED는 온체인에만 있고, 지워진 트랜치만 부실이다 |
| v3-105 | 부분 체결된 취소는 먼저 청구해야 한다. 펀딩일 배지는 체인이 아니라 DB를 읽는다 |
| v3-104 | 도래했으나 기록되지 않은 수익 기간은 프런트엔드 조인이 아니라 읽기 모델 행이다. 어드민 Yield 목록에 include_due가 붙는다 |
| v3-103 | 알림 문구의 기준은 시트가 아니라 코드다. 레지스트리를 56개 키로 맞췄고 전달은 여전히 발송 워커에서 멈춰 있다 |
| v3-102 | distributeYield와 withdrawFees가 settleYield 하나가 된다. 반쯤 정산된 기간은 이제 표현 불가능하다 |
| v3-101 | 금액의 축은 주석이 아니라 타입이다. 컨트랙트는 그대로 두고, 살아 있던 1e12 버그 둘을 고쳤다 |
| v3-100 | 회차 엔진: 항아리가 스칼라가 됐다. v3-93의 열린 항목 여섯을 닫았고 살아 있는 회계 누수 하나를 찾았다 |
| v3-99 | 상환 출구 게이트. "reject" 대신 Return position이고, ADMIN 전용이며 사유가 필수다 |
| v3-98 | KYB(법인 온보딩) 폐기. 플랫폼은 개인만 온보딩한다 |
| v3-97 | 동결 이정표를 알린다. 날짜를 미리 알리고 재개 안내도 두 번 보내며, 중복 제거는 동결 주기로 키잉한다 |
| v3-96 | 수익을 기록하는 것이 곧 분배다. 복구는 상태별로 범위가 잡히고, distribute tx_hash는 마무리 전에 저장한다 |
| v3-95 | 상태 배너 문구는 정확성 표면이다. 기준 표와 규칙 다섯, 그리고 지어낸 투자자 드로어 제거 |
| v3-94 | pause는 수동 전용이고 연체는 절대 자동 pause하지 않는다. 자동 pause를 하려면 pause 출처 기록이 먼저다 |
| v3-93 | 회차 요청 창은 하드 게이트다. 펀딩일은 주기마다 어드민이 입력하고, 취소 창은 요청 창과 같다(v3-91 수정) |
| v3-92 | 상태 플래그 자동 해제는 온체인 pause도 함께 풀어야 한다(v3-78 수정) |
| v3-91 | 회차 상환 엔진 재설계: 달력 앵커 일정 + 수요 동결 + 정산 에스크로(이중 커밋 수정) + 요청 시 수익 정지 |
| v3-90 | Joob 펀드 데이터 의미: NPL은 DPD 90, 상각은 약 180 DPD, 펀드 가치는 현금 수취 기준, 갱신은 EOD 09:00 |
| v3-89 | 상환 용어: OPEN_ENDED가 표준이다("revolving"은 UX 별칭). 개방형 풀에는 조기 이탈 패널티가 없다 |
| v3-88 | 어드민 상환 단계 위저드: 일관성과 설정 가드 (D1~D9) |
| v3-87 | 풀 상세 정보 구조: Controls 탭은 동작만, NAV 추이는 Overview 차트로, 거버넌스·lifecycle 이력은 전역 Audit Log로 |
| v3-86 | Audit Log 재정의: 감사 우선 기준 + 행위자 스냅샷 + 쓰기 범위 확대 + Audit와 Activity 분리 |
| v3-85 | 조기 이탈 패널티의 목적지는 풀 리저브가 아니라 fund_wallet이다(모든 패널티 타입) |
| v3-84 | YIELD_BASED 패널티 재도입(락업 풀 전용, 해제는 첫 수익 분배 이후). v3-79의 폐기를 뒤집는다 |
| v3-83 | 락업은 패널티 타입과 독립이다(NO_EARLY와 lockup=0의 결합 제거) |
| v3-82 | 파트너 펀딩이 PENDING_RESERVE 상환을 자동 정산한다(어드민 승인 게이트 제거) |
| v3-81 | 생성 검증과 REVOLVING·APY 표시: 만기 > 락업, REVOLVING 발행 예외, 연 단위 APY |
| v3-80 | 규칙 7번 개정: 어드민 생성·수정 입력은 percent를 받는다(단방향 무손실 → bps) |
| v3-79 | 조기 이탈 패널티를 PRINCIPAL_BASED로(YIELD_BASED 폐기) + 부분 상환 정책 |
| v3-78 | 풀 상태 플래그: 자동 해제로 배타성 확보, 그리고 wind-down은 freeze를 켜지 않는다 |
| v3-77 | 디자인 시스템을 공유 패키지 하나로 통합(web과 admin의 기준) |
| v3-76 | 상환 플로우 정비: 리저브로 게이팅되는 즉시 정산 + 락업 하드 차단 |
| v3-75 | KYB(법인) 온보딩 정책: 관할에 맞추고 UBO는 20% 이상 수집, 판단은 관할별로 |
| v3-74 | 적격투자자 게이팅(미국 밖 Reg S): investor_tier → investor_status + eligibility_mode, 백엔드에서 강제 |
| v3-73 | FE는 비율 설정을 percent로 표시한다. 저장·입력·API·컨트랙트는 bps 그대로다(단방향 표시 포맷터) |
| v3-72 | 펀드 데이터 DPD/NPL 모델: NPL은 장부상 연체(선행 신호), 누적 손실은 실현 상각(NAV에 반영). 동적 버킷과 리스크 배지 신호 |
| v3-71 | 필드 거버넌스 표를 컨트랙트와 대조해 정정. 온체인 재분류와 열린 타임락·법무 질문 |
| v3-70 | money-path 용어 통일(자본 축과 수수료 축). 새 표준 문서 23-money-path |
| v3-69 | 수수료 구조: 수취자 셋(Aset / 펀드 / 운용). 풀 관리 수수료는 분배 시점에 fund_fee_wallet으로 간다 |
| v3-68 | NAV 안전장치(서킷 브레이커·이탈 상한·신선도)는 운영용이다. 투자자 UI는 없다 |
| v3-67 | 회차 상환 게이팅 상한의 기준은 TVL이 아니라 totalDeposited다(문서·주석 정정) |
| v3-66 | NO_EARLY는 락업 없음을 뜻한다(lockup_days = 0) |
| v3-65 | Fund Data 근거 확보: total_subscribed, 펀드 수준 서비스 제공자, NPL과 상각 연체 |
| v3-64 | 재투자는 같은 풀에만 한다(다른 풀로 재투자 불가) |
| v3-63 | 리저브 회복은 홀더별이 아니라 풀 단위다 |
| v3-62 | 숫자·USD·날짜 표시 관례 |
| v3-61 | 투자자별 상한(max_investment)은 숨긴다. 컨트랙트 필드는 잠자는 채로 둔다 |
| v3-60 | 국가 코드의 표준 형식은 ISO alpha-3다 |
| v3-59 | DPD 연체 버킷을 일반화한다(고정 30/60/90 → 관할이 정하는 동적 버킷) |
| v3-58 | 허가 없는 LP 전송: 화이트리스트 게이트 제거(검증은 가치 경계에서 강제한다) |
| v3-57 | Pool Updates(WO-6) RBAC 정식화: 오퍼레이터 옵트인, 소프트 삭제 보존, MATERIAL_EVENT 개정 감사 |
| v3-56 | 전액 LP 민팅: 리저브 분리가 더 이상 투자자의 청구권을 희석하지 않는다 |
| v3-55 | 파트너 펀드 데이터 지표를 자산군 중립 이름으로 변경(total_rni/total_npl → realized_income/cumulative_impairment) |
| v3-54 | FM 지갑 서명 보강: fund_wallet 노출 + 클라이언트 서명 강제 + 바인딩된 지갑 표시 (W8/W9) |
| v3-53 | FM 지갑 서명: depositYield와 fundRedemption을 클라이언트에서 서명 (B3 + 2단계 분리) |
| v3-52 | 투자자 표시 이름은 KYC 법적 이름이다(수정 불가) + SumSub 백필 |
| v3-51 | 수익 정합기: claimable_yield는 온체인 pendingYield다(미청구 + 적립) |
| v3-50 | 트랜치 손실 워터폴: BE 기능을 지금 만들되 손실만 다루고, 생성 플로우는 방식 A |
| v3-49 | FM 수익 분배 도래와 연체 알림(스케줄러 생산자) |
| v3-48 | BE와 FE 간극 점검: 펀드 멤버 수정과 NAV 제안 배선, retry-lp-mint 제거 |
| v3-47 | 어드민 사용자 관리: 이름 수정과 삭제를 모든 role에서 접근 가능하게(권한은 삭제 규칙이 정한다) |
| v3-46 | 상환 모델(즉시 대 회차)은 게이팅 구조의 선택이고 만기와 직교한다 |
| v3-45 | 투자자 이메일 등록과 인증(알림 이메일 채널) |
| v3-44 | 알림 시스템: 코드 문구 레지스트리 + Direction-B 이메일 템플릿 + payload jsonb 스키마 |
| v3-43 | 풀 필드 편집 가능성: 단계별로 다르다(DRAFT 대 ACTIVE). capacity는 올리기만, min_investment는 잠금 |
| v3-42 | 펀드 매니저의 활동 접근: 펀드 범위의 운영 뷰. 컴플라이언스 Audit Log는 어드민 전용으로 남는다 |
| v3-41 | 감사 로그는 두 스트림을 합친 피드다(사람의 행위 + 온체인 미러). 보관 5년 |
| v3-40 | 미리보기(마케팅·쇼케이스) 풀 등급과 관심 등록 CTA |
| v3-39 | 트랜치 first-loss는 v1에서 오프체인(Lambda) 신뢰 모델이다. 온체인 워터폴은 미룬다 |
| v3-38 | 회차 기간은 풀 생성 시에만 정한다. 운영 중 변경은 v2 타임락 거버넌스로 미룬다 |
| v3-37 | 콜드 role에 Gnosis Safe 멀티시그 도입(Admin 3-of-5 + 별도 Pauser 2-of-3) |
| v3-36 | 펀드별 통계 드릴다운과 부족분 KPI |
| v3-35 | collateral_type enum 삭제(v3-07 마무리) |
| v3-34 | FM_ACCEPTED 제거 확정(v3-04 후속) |
| v3-33 | 관리형(임베디드) 지갑 허용, 단 "Aset은 키 조각을 전혀 갖지 않는다"는 제약 아래 |
| v3-32 | updateNAV의 범위 제한을 코어에 두고 role 관리는 콜드 키와 타임락으로(멀티시그 없이) |
| v3-31 | 이탈권 폴백: 허가 없이 타임락으로 상환을 청구할 수 있게 |
| v3-30 | 표적 모듈화: KYC만 업그레이드 가능한 프록시, 코어는 불변 |
| v3-29 | DB와 온체인 필드 동기화 및 PATCH 강제 |
| v3-28 | 비상 동결의 이탈권: 기간 제한과 상환 폴백(옵션 D) |
| v3-27 | 비수탁 키 거버넌스: money-path는 업그레이드 불가, 주변부는 타임락 |
| v3-26 | 모든 풀에 fund_id 필수(Aset 수탁 직접 풀 없음) |
| v3-25 | 풀 리스크 등급 배지(Low/Med/High), 매출채권 우선 |
| v3-20 | 수익은 수동 청구만(AUTO 분배와 yield_trigger 폐기) |
| v3-19 | KYC 유효기간과 만료 정책(만료는 입금을 막고 상환은 막지 않는다) |
| v3-18 | 최소한의 온체인 설정 + KYC 컴플라이언스는 온체인에서 강제 |
| v3-17 | VASP 간 KYC 전략(제공자가 같으면 SumSub 재사용 KYC, 아니면 빠른 재KYC) |
| v3-16 | 리저브 소모를 updateNAV에 통합(원자적. 손실 흡수라는 틀은 R8이 대체) |
| v3-15 | PARTNER_REPORTED 폐기. NAV는 언제나 Aset이 처리한다 |
| v3-14 | 연결된 단일 풀들로 트랜치를 구성(v3-09 대체) |
| v3-13 | Joob DPD API로 NAV 상각 자동화(OJK 기준) |
| v3-12 | 청산을 NAV 메커니즘과 통합하고 IMPAIRED 상태를 추가 |
| v3-11 | PlatformEscrow를 PlatformPool에 흡수(컨트랙트 5개 → 4개) |
| v3-10 | 풀별 KYC 게이팅(레벨 + 관할 화이트리스트) |
| v3-09 | 트랜치 구조를 차원으로 |
| v3-08 | NAV 데이터 출처 차원 |
| v3-07 | collateral_type enum을 자유 텍스트와 비율로 |
| v3-06 | 비상 청산(60+30일 타임락) |
| v3-05 | 2단계 풀 정지(soft + emergency) |
| v3-04 | FM_ACCEPTED를 PENDING_RESERVE로 대체 |
| v3-03 | Receipt NFT 제거. 입금은 원자적이다 |
| v3-02 | LP는 언제나 Aset이 민팅한다(PLATFORM_ISSUED만) |
| v3-01 | pool_type 바이너리를 24개 차원으로 대체 |
더 오래된 BD/PD 항목(비즈니스·제품·법무)은 문서 아래쪽 비즈니스 결정에 있다.
v3.0 결정
v3.0 차원 모델 재설계 과정에서 내린 주요 결정이다. 각각이 앞선 BD/PD를 대체하거나 확장한다.
날짜에 대해: 아래 모든 결정은 자기
Date:줄을 갖는다(결정한 날짜다). 새 결정에는 반드시 넣어야 한다. 시간순의 기준은 changelog(17-changelog)다. (#v3-0-decisions-2026-05-29섹션 앵커는 기존 링크가 깨지지 않게만 유지한다.)
v3-01 — pool_type 바이너리를 24개 차원으로 대체 ✅ Decided
v3-01 — 풀 설정 모델
Date: 2026-05-29
결정: pool_type enum(AS_POOL/FUND_POOL)을 직교하는 차원 약 24개로 대체한다.
이유: Joob과 LINE BK를 온보딩하면서 바이너리가 너무 뻣뻣하다는 것이 드러났다. 파트너마다 리저브 비율, NAV 출처, 트랜치 구조, KYC 요건, fund_wallet 소유가 서로 다르게 조합된다. 바이너리는 없는 묶음을 강요했다.
결과: 모든 파트너에게 같은 Pool 컨트랙트, 같은 LP 토큰, 같은 흐름이다. 동작은 설정이 정한다.
참고: Pool Models, DB Schema
v3-02 — LP는 언제나 Aset이 민팅한다 (PLATFORM_ISSUED만) ✅ Decided
v3-02 — LP 발행
Date: 2026-05-29
결정: Aset의 PlatformLPToken이 투자자 포지션의 유일한 기준이다. FUND_ISSUED 모드는 폐기한다.
이유: 파트너의 기술 수준과 무관하게 정확한 포트폴리오 데이터, 상환 추적, 수익 분배를 보장할 수 있다.
🔴 정정(2026-08-05). 이 줄은 예전에 "파트너에게 API나 웹훅으로 통지한다"로 끝났다. 그런 알림은 존재하지 않는다. apps/infra, apps/web, apps/admin-web에서 aset-lp-mint나 partner_endpoint를 grep하면 0건이고, 입금 시 파트너 엔드포인트로 실행되는 것도 없다. 펀드 매니저는 대신 플랫폼을 읽는다. GET /deposits는 펀드 범위이고 deposit_confirmed_ops의 대상에 fundManagers가 들어 있다. 파트너로 밀어 주는 개념은 대기 중이 아니라 폐기됐다. 파트너에게 가능하다거나 곧 된다고 말하지 말 것. 전체 기록: v3-115 · 17-changelog.
v3-03 — Receipt NFT 제거. 입금은 원자적이다 ✅ Decided
v3-03 — Receipt NFT 없음
Date: 2026-05-29
결정: PlatformReceiptNFT 컨트랙트를 제거한다. 입금과 LP 민팅, fund_wallet 분배가 한 트랜잭션에서 일어난다.
이유: Receipt NFT는 비동기 LP 발행(FUND_ISSUED)을 잇기 위해 존재했다. Aset이 언제나 LP를 원자적으로 민팅하면 그 다리가 필요 없다. 온체인 상태가 단순해지고 D+7 환불이라는 예외 상황이 사라진다.
v3-04 — FM_ACCEPTED를 PENDING_RESERVE로 대체 ✅ Decided
v3-04 — 상환의 파트너 조율
Date: 2026-05-29
결정: 상환 상태 FM_ACCEPTED를 PENDING_RESERVE로 대체한다. 어드민 승인은 한 단계이고, 파트너는 부족분을 Pool.fundRedemption()으로 보낸다.
이유: FM_ACCEPTED는 상환 흐름에 파트너의 공식 결재가 있다는 인상을 줬다. 실제로 의미 있는 파트너의 행동은 풀 리저브가 부족할 때 돈을 보내는 것뿐이고, 그것은 워크플로 단계가 아니라 상태다.
범위 참고(회차):
PENDING_RESERVE는 즉시 상환 풀 전용이다. 회차 풀(redemption_epoch_days > 0, 회차 상환)은 기약 없는 대기 상태를 pro-rata 부분 체결과 이월로 대체한다. FM_ACCEPTED 제거는 v3-34에서 확정됐다.
참고: Redemption, Status Machines, 회차 상환
v3-05 — 2단계 풀 정지 (soft + emergency) ✅ Decided
v3-05 — 풀 정지의 단계
Date: 2026-05-29
결정: is_paused와 별개로 is_emergency_frozen 플래그를 둔다. soft pause는 입금만 막고, 비상 동결은 상환을 포함한 모든 활동을 막는다.
이유: 원래의 is_paused는 짐이 너무 많았다. 어드민은 어떨 때는 투자자를 가두지 않고 입금만 멈추고 싶었고, 어떨 때는 보안 사고로 전부 멈추고 싶었다. 나누면 의도가 분명해진다.
v3-06 — 비상 청산 (60+30일 타임락) ✅ Decided
v3-06 — 청산 복구
Date: 2026-05-29
결정: PlatformPool에 proposeWindDown()과 executeWindDown()을 넣는다. 60일 무응답 트리거 + 30일 타임락으로 청산까지 총 90일이다. 실행 후 LP 홀더는 풀 리저브에서 pro-rata로 청구한다.
중대한 한계: 온체인에서 회수 가능한 것은 **풀 리저브(보통 10%)**뿐이다. fund_wallet에 있는 90%는 파트너가 통제하고, 그것을 되찾으려면 오프체인 법적 조치가 필요하다.
이유: 90일은 전통 금융의 "90일 연체" 부도 트리거와 맞는다. 30일 타임락은 파트너에게 마지막 응답 기회를 주고 실수로 인한 청산을 막는다.
v3-07 — collateral_type enum을 자유 텍스트와 비율로 ✅ Decided
v3-07 — 담보 서술
Date: 2026-05-29
결정: collateral_type enum(FULLY_COLLATERALIZED / PARTIALLY_COLLATERALIZED / UNSECURED)을 collateral_description(TEXT)과 선택적 collateral_ratio(NUMERIC)로 대체한다.
이유: 현실의 담보 구조는 세 칸에 들어가지 않는다. 자유 텍스트와 비율이면 뉘앙스를 담을 수 있다(예: "송장 금융 매출채권, 법적 담보권으로 150% 초과담보").
참고: Writedown & NAV, DB Schema
v3-08 — NAV 데이터 출처 차원 🔄 Superseded by v3-15
v3-08 — NAV 책임 (2026-06-02 대체됨)
Date: 2026-05-29
원래 결정(2026-05-29): nav_data_source 차원을 두고 값 둘을 갖는다. ASET_INTERNAL(Aset이 NAV를 계산)이나 PARTNER_REPORTED(파트너가 NAV를 밀어 준다)다.
v3-15가 대체함: 순수 미러링(PARTNER_REPORTED)은 폐기했다. 모든 풀은 언제나 Aset 오라클을 거치고, 오라클이 파트너 API에서 원본 데이터를 받아 Aset의 산식을 적용한다. 처리 경로가 하나면 신뢰 모델이 단순해지고 트랜치 워터폴을 강제할 수 있다(v3-14).
참고: 현재 모델은 v3-15.
v3-18 — 최소한의 온체인 설정 + KYC 컴플라이언스는 온체인에서 강제 ✅ Decided
v3-18 — 온체인과 오프체인 데이터의 경계
Date: 2026-06-02
결정: 최소 온체인 설정(Maple 방식)을 채택하되 예외가 하나 있다. KYC 컴플라이언스 필드는 온체인에서 강제한다(그 부분만 Centrifuge 방식).
온체인 (PlatformPool — 정산과 컴플라이언스):
navPerToken,reserveBalance— 정산 상태lockupDays,maturityDate— 상환 게이팅penaltyRateBps,penaltyType,flatFeeAmount— 조기 이탈 패널티(RedemptionConfig안에 있고, 컨트랙트가requestRedemption에서 온체인으로 계산·적용한다). v3-18 초안의 오프체인 분류를 정정한 것이다(아래 ⚠️ 참조)paused,isEmergencyFrozen— 어드민 정지 플래그reserveBps—deposit()이 10/90으로 나눌 때 쓴다(basis point)fundWallet— 90% 릴리스 목적지kycContract— PlatformKYCSoulbound 참조kycLevelRequired— 입금 시 KYC와 KYB를 강제한다(v3-10 + v3-18)kycJurisdictionWhitelist— 입금 시 지역 제한을 강제한다(v3-10 + v3-18)
오프체인 (DB만):
apy_rate,apy_disclosure,description,collateral_description— 표시·마케팅min_investment,capacity— 소프트 한도(앱 계층에서 강제)penalty_type,penalty_rate,penalty_fee_amount— DB 미러 전용(패널티의 기준은 온체인RedemptionConfig이고 컨트랙트가 지급액을 계산한다). 위 온체인 목록 참조tranche_group_id,tranche_role— Lambda가 워터폴을 적용한다(v3-14)maturity_model,lifecycle_status(IMPAIRED 포함) — Lambda가 상태 전이를 강제한다external_provider,external_fund_id,partner_id— 연동 메타데이터investor_count,total_yield_distributed— 이벤트에서 유도start_date,end_date— 배포·만기 타임스탬프
절대 온체인에 두지 않는 것 (프라이버시·PII):
- 투자자 이름, 이메일, 국가(DB와 SumSub에 보관)
- KYC 서류(SumSub만)
- 차주 단위 대출 데이터
- 파트너 NDA와 내부 조건
왜 최소한으로 하고 KYC만 예외인가:
- 우리 모델은 허가형(KYC로 게이팅되는 플랫폼)이지 순수 DeFi가 아니다. 투자자는 이미 플랫폼을 신뢰한다
- 컨트랙트 표면이 작을수록 감사가 빠르고 업그레이드가 쉬우며 배포가 싸다
- KYC 레벨과 관할은 규제상 유일한 절대선이다. 온체인에서 강제하지 않으면 능숙한 사용자가 우리 앱을 우회해 잘못된 풀(KYB 전용, 지역 제한)에 입금할 수 있고 그것은 VASP 규제 위반이다
- 그 밖의 모든 필드는 Lambda가 강제할 수 있다(v3-15의 NAV와 v3-14의 워터폴에서 이미 Lambda를 신뢰한다). 패널티가 예외이고, 온체인
RedemptionConfig에서 계산·적용한다(기준은 온체인)
받아들이는 신뢰 가정 (Lambda의 권한):
- 패널티와 지급액은 온체인에서 계산한다(
grossAmount − penaltyAmount, 컨트랙트가 기준). Lambda 신뢰 대상이 아니다 - Lambda가 트랜치 워터폴을 올바르게 적용한다
- Lambda가 온체인 호출을 허용하기 전에 IMPAIRED와 lifecycle 게이팅을 강제한다
- Lambda가 컨트랙트 직접 우회 시도를 감시한다. 어드민은 SBT를 회수하거나 풀을 정지할 수 있다
Lambda 오류나 침해에 대한 완화책:
- 모든 NAV 갱신에 24시간 타임락(창 안에서 어드민이 취소할 수 있다)
- 모든 fund_wallet·리저브·KYC 변경에 7일 타임락
- 청산에 30일 타임락
- 중요 기능에 멀티시그 어드민 role
⚠️ v3-18 정정(패널티 기준): 초안은
penalty_*를 오프체인(Lambda가 지급액 계산)으로 분류했으나,RedemptionConfig컨트랙트(penaltyRateBps/penaltyType/flatFeeAmount)가 패널티를 온체인에 저장·계산·적용하므로 패널티 계산의 기준은 온체인으로 확정했다. DBpenalty_*컬럼은 표시용 미러다. (관련 버그: YIELD_BASED가 bps를 무시하고 수익 전액을 몰수했다. 비율을 적용하도록 고쳤다. SECURITY_REVIEW P-1.)
⚠️ 회차 풀에서 v3-18의 시점: v3-18은 패널티와 NAV를 요청 시점에 고정한다. 이는 즉시 상환 풀에만 해당한다. 회차 풀(회차 상환)은 NAV와 패널티를 정산 시점에 계산하고(forward pricing)
nav_at_request는 NULL이다.
참고: Smart Contracts, v3-10(풀별 KYC), v3-14, v3-15, v3-16
v3-16 — 리저브 소모를 updateNAV에 통합 (원자적. 손실 흡수라는 틀은 R8이 대체) ⚠️ Superseded in part — v3-109
v3-16 — 리저브 소모 인터페이스
⚠️ 일부가 R8(v3-109)로 대체됐다. 아래의 전제, 즉 손실 흡수가 리저브를 줄인다는 것은 폐기됐다. 리저브는 투자자 입금에서 떼어 낸 것이므로 이미 NAV가 가격 매기는 청구권 안에 있다. 손실에 대해 그것을 차감하면 이중 계산이었다. 리저브는 상환 유동성 버퍼일 뿐이고, 백엔드는 모든 호출에서
reserveConsumed = 0을 넘긴다(NO_RESERVE_CONSUMED, 반영됨). 여전히 유효한 것은 원자성 논거(하나의 tx, 하나의 이벤트, 하나의 타임락 큐)와 ABI다. 두 번째 인자는 그대로이고 값만 0으로 고정된다. 이력용으로 남긴다. 이 카드는 그 인자가 왜 있는지를 읽는 곳이지 그것이 무엇을 하는지를 읽는 곳이 아니다.
Date: 2026-06-02
결정: 손실 흡수로 인한 리저브 감소를 별도 함수로 노출하지 않고 updateNAV()에 통합한다. Lambda가 원자적 호출 하나로 NAV를 갱신하고 리저브를 소모한다.
새 시그니처:
function updateNAV(uint256 newNav, uint256 reserveConsumed)
external onlyRole(ORACLE_ROLE)
{
if (reserveConsumed > 0) {
require(reserveBalance >= reserveConsumed, "Insufficient reserve");
reserveBalance -= reserveConsumed;
}
if (newNav < navPerToken) {
// 24h timelock for decreases (existing v3.0 mechanism)
pendingNav = newNav;
pendingReserveConsumed = reserveConsumed;
navProposedAt = block.timestamp;
emit NAVDecreaseProposed(newNav, reserveConsumed, block.timestamp + 24 hours);
} else {
navPerToken = newNav;
emit NAVUpdated(newNav, reserveConsumed);
}
}Lambda 흐름:
const writeoffs = applyOJKSchedule(partnerData.loans); // v3-13
const reserveConsumed = Math.min(writeoffs.total, pool.reserveBalance);
const remainingLoss = writeoffs.total - reserveConsumed;
const newNav = applyTrancheWaterfall(pool, remainingLoss); // v3-14 if grouped
await pool.updateNAV(newNav, reserveConsumed); // single atomic call리저브 상태 전이 (전체 목록):
| 사건 | reserveBalance 변화 | 호출자 |
|---|---|---|
| 입금 | +금액의 10% | 투자자 (deposit()) |
| 손실 인식 (상각 ≤ 리저브) | -상각액, NAV 그대로 | Aset Lambda (reserveConsumed > 0인 updateNAV()) |
| 손실 인식 (상각 > 리저브) | → 0, 초과분만큼 NAV 하락 | Aset Lambda (reserveConsumed = 현재 reserveBalance인 updateNAV()) |
| 상환 (리저브 충분) | -지급액 | 투자자 (redeem()) |
| 상환 (리저브 부족) | 리저브 전액 소모, PENDING_RESERVE 발동 | 투자자 (redeem()) |
| 파트너 보충 | +부족분 | 파트너 (fundRedemption()) |
| 패널티 수수료 (조기 상환) | +패널티액 | 투자자 (패널티가 붙은 redeem()) |
별도 함수 대신 통합한 이유:
- 원자적이다. 리저브 변경과 NAV 변경 사이에 경쟁 구간이 없다
- 이벤트 로그가 깔끔하다.
NAVUpdated(newNav, reserveConsumed)하나가 상태 변화 전체를 담는다 - 타임락과 맞물린다. NAV 하락과 리저브 소모가 24시간 타임락에 함께 들어가고 실행 시 함께 원자적으로 적용된다
독립 consumeReserveForLoss() 함수로 하지 않은 이유:
- 두 번 호출하면(리저브 소모 → updateNAV) 그 사이에 입금이나 상환이 어긋난 상태를 볼 수 있는 경쟁 구간이 생긴다
- 감사 기록에서 이벤트 둘을 서로 엮어야 한다
- Lambda가 복잡해진다(조율 로직)
예외 상황 — 어드민의 수동 리저브 조정: 드문 경우(법적 합의, 수동 리밸런싱 등)에는 어드민이 newNav = navPerToken으로 updateNAV(currentNav, amount)를 호출해 NAV 변경 없이 리저브만 소모할 수 있다. 필요하면 adminAdjustReserve(int256 delta, string memory reason)를 추가하는 것도 가능한데 구현 단계로 미뤘다.
⚠️ 일부가 v3-109(R8)로 대체됐다. 위의 원자성 논거는 여전히 유효하고 ABI도 그대로이지만, 리저브 소모는 더 이상 손실 흡수 단계가 아니다. 백엔드는 모든
updateNAV에서reserveConsumed = 0을 넘긴다. 이 카드는 그 인자가 왜 있는지를 읽는 곳이지 그것이 무엇을 하는지를 읽는 곳이 아니다.
참고: Writedown & NAV, Smart Contracts → Core Functions, v3-13(DPD 자동 상각. 이력: 한때 reserveConsumed를 채웠으나 R8 이후로는 언제나 0이다)
v3-17 — VASP 간 KYC 전략 (제공자가 같으면 SumSub 재사용 KYC, 아니면 빠른 재KYC) ✅ Decided
v3-17 — 유통시장 제휴를 위한 VASP 간 KYC
Date: 2026-06-02
결정: 파트너 플랫폼(예: Tokocrypto 유통시장)에서 오는 사용자를 온보딩할 때 KYC를 두 갈래로 간다.
갈래 1 — 파트너가 SumSub을 쓰는 경우: 재사용 KYC
- Aset과 파트너가 SumSub 재사용 KYC 계약을 맺는다
- 사용자가 "내 Tokocrypto 인증으로 가입"을 누른다
- Aset 백엔드가 SumSub share token을 요청하고 동의 모달을 띄우면 SumSub이 검증된 applicant 데이터를 넘긴다
- Aset SumSub이 수신자 수준 검사를 다시 돌린다(관할 화이트리스트, 요구 KYC 레벨, 만료, 제재 스크리닝)
- 통과하면 SBT를 민팅한다(
PlatformKYCSoulbound) - 사용자 체감 시간은 몇 분이다
갈래 2 — 파트너가 SumSub을 쓰지 않는 경우: 빠른 재KYC
- Aset이 자체 SumSub 흐름을 전부 돌린다
- UI 안내: "Tokocrypto 인증 사용자이신가요? 인증이 더 빠릅니다."
- SumSub의 재방문 사용자 흐름을 써서 이미 올린 서류를 건너뛴다
- 사용자는 추가 정보만 낸다(관할 확인, 미국인 여부 확인)
- 사용자 체감 시간은 5~10분이다(처음부터 하는 KYC는 약 15분)
두 갈래로 간 이유:
- 갈래 1은 마찰이 가장 적지만 파트너의 KYC 스택에 달려 있다
- 갈래 2는 언제나 되고, SumSub이 아닌 제공자를 쓰는 파트너의 폴백이다
- 둘 다 법적으로 적합하다(수신자 수준 검사는 늘 다시 돌고 제재 스크리닝은 언제나 우리 책임이다)
KYC를 아예 건너뛰지 않는 이유(예: 파트너의 인증을 그냥 믿기):
- 규제 당국(싱가포르 MAS, 인도네시아 OJK, EU)은 VASP가 자체 KYC를 하도록 요구한다
- 상류에서 무엇을 검증했든 책임은 각 VASP에 남는다
- 재사용 KYC는 검사를 건너뛰는 게 아니라 공유받은 데이터로 우리 검사를 다시 돌려서 이 문제를 피해 간다
Binance식 연합체로 하지 않은 이유:
- Binance는 지역 법인마다 새 KYC를 강제한다. 사용자 마찰이 크고 이전 작업을 재사용하지 않는다
- 재사용 KYC(갈래 1)는 같은 수준의 규제 준수를 훨씬 적은 마찰로 준다
채택하지 않음: 맞춤형 연합 신원 매칭(해시된 PII를 비교하는 양자 API)
- 파트너마다 별도 연동이고 표준이 아니며 제휴마다 법적 복잡성이 생긴다
구현 계획:
| 단계 | 시점 | 할 일 |
|---|---|---|
| 1단계 | 지금 | Aset SumSub KYC가 돌고 있다. "기존 사용자" 경로 UI 안내를 넣는다. 코드 변경 없음. |
| 2단계 | Tokocrypto 계약 체결 시 | Tokocrypto의 KYC 스택을 확인한다. SumSub이면 재사용 KYC 제휴를 켠다. 아니면 갈래 2로 간다. |
| 3단계 | 다중 파트너 확장 | 재사용 KYC는 파트너 수에 제한이 없다. Binance.SG, Coinbase 등에 같은 인프라를 쓴다. |
별개이지만 관련된 것: VASP 간 거래 데이터에 대한 FATF Travel Rule이다(Notabene나 Sygna를 통한 IVMS101 표준). Aset 풀과 파트너 지갑 사이에 임계값을 넘는 자금이 오갈 때 필요하다(인도네시아 $1,000, 싱가포르 SGD 1,500). 별도 운영 연동으로 다루고 v3-17의 범위가 아니다.
참고: KYC & Identity, v3-10(풀별 KYC 게이팅)
v3-15 — PARTNER_REPORTED 폐기. NAV는 언제나 Aset이 처리한다 ✅ Decided
v3-15 — 단일 NAV 처리 경로 (v3-08 대체)
Date: 2026-05-29
결정: nav_data_source 차원을 없앤다. 모든 풀이 같은 모델을 쓴다. Aset 오라클이 API(또는 어드민 입력)로 파트너 데이터를 받아 Aset의 산식을 돌리고(v3-13의 OJK 상각 기준과 v3-14의 트랜치 워터폴 포함) Pool.updateNAV()를 호출한다.
이유:
- 파트너가 NAV를 "보고"하더라도 Aset이 그것을 판정한다(타당성 검사, 상각 기준, 트랜치 워터폴). "순수 미러링"이라는 겉모습은 오해를 낳았다
- 트랜치 워터폴(v3-14)은 그룹 안의 풀마다 Aset이 NAV를 계산할 것을 요구한다. 미러링은 워터폴 보장을 깨뜨린다
- 처리 경로가 하나면 신뢰 모델이 하나이고 감사 설명이 단순해진다
구현:
nav_data_sourceenum과 컬럼을 삭제한다. 마이그레이션에서 폐기 표시를 하고 6개월 뒤 삭제한다- 모든 풀의 NAV가 같은 Lambda 파이프라인을 흐른다
- 파트너 API 연동(Joob eNote, LINE BK 리스크 대시보드, 앞으로의 것들)은 원본 입력의 데이터 출처일 뿐이고 최종 NAV 값의 출처가 아니다
파트너가 여전히 통제하는 것: 원본 데이터(자산 가치, DPD, 담보 상태). Aset이 통제하는 것: NAV 계산 산식, 상각 규칙, 트랜치 워터폴, 타임락.
참고: Writedown & NAV → NAV Update Flow, v3-13(OJK 기준), v3-14(트랜치 워터폴)
v3-09 — 트랜치 구조를 차원으로 🔄 Superseded by v3-14
v3-09 — 트랜치 구조 (2026-06-02 대체됨)
Date: 2026-05-29
원래 결정(2026-05-29): tranche_structure 차원을 둔다. SINGLE(기본. LP 토큰 하나)과 JUNIOR_SENIOR(LP 토큰 둘. Junior가 first loss를 흡수하고 Senior가 수익 우선권을 갖는다)다.
v3-14가 대체함: 풀 하나에 LP 둘을 두는 방식은 컨트랙트를 복잡하게 만들면서 강제력을 사 오지 못한다(v3-15에 따라 어차피 Aset Lambda가 NAV를 통제한다). tranche_group_id와 tranche_role로 연결된 별개의 SINGLE 풀 둘로 대체한다. Lambda가 NAV 계산 때 워터폴을 적용한다.
참고: 현재 모델은 v3-14.
v3-14 — 연결된 SINGLE 풀들로 트랜치를 구성 (v3-09 대체) ✅ Decided
v3-14 — 풀 묶기로 만드는 트랜치
Date: 2026-06-02
결정: tranche_structure enum을 풀 컬럼 둘로 대체한다. tranche_group_id(UUID, nullable)와 tranche_role(ENUM: SENIOR | MEZZANINE | JUNIOR, nullable)이다. 트랜치 상품은 tranche_group_id를 공유하는 별개의 SINGLE 풀 2~3개로 모델링한다. Aset 오라클 Lambda가 묶인 풀의 NAV를 계산할 때 손실 워터폴을 적용한다(Junior가 먼저, Mezzanine이 다음, Senior가 마지막으로 흡수한다).
스키마:
ALTER TABLE pools ADD COLUMN tranche_group_id UUID NULL;
ALTER TABLE pools ADD COLUMN tranche_role TEXT NULL
CHECK (tranche_role IN ('SENIOR', 'MEZZANINE', 'JUNIOR'));
-- Constraints
ALTER TABLE pools ADD CONSTRAINT pool_in_one_group UNIQUE (tranche_group_id, tranche_role);
-- Plus app-level check: max 3 pools per tranche_group_id (one per role)같은 그룹의 제약 (팩토리 배포와 어드민 UI에서 강제):
fund_wallet— 같은 파트너가 기초 자산을 통제한다operating_currency,chain_id,external_provider,maturity_days— 일치해야 한다reserve_percentage,apy_rate,kyc_level_required,lockup_days,penalty_*— 역할마다 다를 수 있다
Lambda 워터폴 (NAV 계산):
// For each group, on every NAV update:
const sortedByRole = group.sort(byPriority); // SENIOR first (absorbs last), JUNIOR last (absorbs first)
let remainingLoss = totalLossFromPartnerData;
for (const p of sortedByRole.reverse()) {
// iterate Junior→Senior
const absorbed = min(remainingLoss, p.capital);
navs[p.id] = (p.capital - absorbed) / p.lpSupply;
remainingLoss -= absorbed;
}Junior 소진 시 동작 (v3-12 연계): Junior 트랜치 NAV가 0이 되면 그 풀은 자동으로 IMPAIRED가 된다(입금 정지). 파트너가 회복하면(리저브 보충이나 자산 회수) ACTIVE로 돌아온다. 아니면 결국 WIND_DOWN으로 간다.
UI 정책: 그룹을 아는 표시다. 프런트엔드가 연결된 트랜치 풀들을 하위 등급 분해가 붙은 "Tranched Product" 카드 하나로 렌더한다. 단독 풀(tranche_group_id 없음)은 이전과 같다.
스마트 컨트랙트 영향: 없다. PlatformPool과 PlatformLPToken은 단순한 SINGLE 풀로 남는다. 풀마다 LP 둘을 다루는 로직도, 온체인 워터폴 코드도 없다. 워터폴은 Lambda로 강제한다(v3-15가 이것을 가능하게 했다).
v3-09보다 나은 이유:
- 컨트랙트 코드가 30% 작다(트랜치 분기가 없다)
- 감사 표면이 작다(워터폴이 우리가 통제하는 Lambda에 있고 파트너 NAV에 의존하지 않는다)
- 보호 보장은 v3-09와 같다(v3-15 덕에 어차피 우리가 NAV를 통제한다)
- JUNIOR_SENIOR enum을 개조하는 것보다 3단계(SENIOR/MEZZANINE/JUNIOR)를 모델링하기 쉽다
참고: Pool Models → Tranche Group, Writedown & NAV, v3-15(단일 NAV 경로), v3-12(IMPAIRED 상태)
v3-10 — 풀별 KYC 게이팅 (레벨 + 관할 화이트리스트) ✅ Decided
v3-10 — 풀별 KYC 게이팅
Date: 2026-05-29
결정: kyc_level_required(KYC/KYB)와 kyc_jurisdiction_whitelist(ISO 국가 코드의 TEXT[]) 차원을 둔다. 풀 컨트랙트가 입금 시 투자자 SBT 속성을 검증한다.
이유: 풀마다 규제 노출이 다르다. 기관 전용 풀에는 KYB가 필요하고, 지역이 제한된 풀에는 관할 화이트리스트가 필요하다. 플랫폼 수준에 박아 두면 확장되지 않는다.
v3-19 — KYC 유효기간과 만료 정책 (만료는 입금을 막고 상환은 막지 않는다) ✅ Decided
v3-19 — KYC 유효기간과 만료 정책
Date: 2026-06-12
결정: KYC SBT는 우리(VASP)가 정하는 유효기간을 갖는다. SumSub은 고정된 만료를 두지 않고 기초가 되는 신분 증명서만 자동 만료시킨다(증서에 인쇄된 만료일을 읽는다). 기본 유효기간은 1년이고 리스크 등급을 따른다(High 1년 / Medium 2년 / Low 3년). 상한은 신분증의 만료일이다. 재인증은 만료 30일 전에 SumSub의 정기 검증으로 안내한다(전면 재온보딩이 아니라 SBT를 갱신·연장한다).
독립적인 상태 셋 (불리언 하나가 아니다):
valid— 입금과 상환 모두 가능expired(유효기간 경과) — 새 입금은 막고 자기 자금의 상환은 열어 둔다. 재KYC를 유발한다revoked(제재, 사기, 발급자 조치) — 별개 플래그이고 상환도 막을 수 있다(실제 리스크 판정을 반영한다). 만료 시계와 무관하게 상시 AML 모니터링이 굴린다
이유: "들어오는 가치는 막되 나가는 가치는 절대 가두지 않는다." KYC가 만료됐다는 이유만으로 자기 자금의 상환을 막는 것은 업계에서 이례적이고 소비자 보호와 자금 동결 위험이다(WIND_DOWN 중이면 특히 심각하다). 규제 당국(FATF 권고 10, FinCEN, MAS)은 리스크 기반의 상시 CDD를 요구하지 달력이 넘어갔다고 자산을 동결하라고 하지 않는다(FinCEN은 명시적으로 사건 기반이다). 온체인 동종 사례들(ERC-3643, Coinbase Verifications, Securitize, Centrifuge)은 진입과 전송 유입을 게이팅하고 폐기와 증서 만료 트리거에 의존하지, 홀더를 가두는 고정 유효기간 시계를 쓰지 않는다. 통상적인 갱신 주기(High 약 1년 / Med 약 3년 / Low 약 5년)는 관례이지 법적 의무가 아니므로, 오래 가는 SBT를 유일한 최신성 신호로 쓰면 상시 CDD 의무와 충돌한다.
해결: SECURITY_REVIEW R-3(WIND_DOWN에서 KYC 만료로 자금이 갇히는 문제).
백엔드·컨트랙트 작업:
requestRedemption의 KYC 검사에서expired(이탈 허용)와revoked(차단)를 나눈다.WIND_DOWN과 종착 상태에서는 폐기되지 않은 홀더에 대해 만료 검사를 건너뛴다- SBT 민팅: 리스크 등급별 유효기간(1/2/3년), 상한은 증서 만료일
- 만료 30일 전 SumSub 정기 검증 규칙. 상시 AML 모니터링이
revoked를 굴리는 구조는 유지
참고: v3-10(풀별 KYC), v3-18(온체인 KYC 강제), KYC & Identity
v3-20 — 수익은 수동 청구만 (AUTO 분배와 `yield_trigger` 폐기) ✅ Decided
v3-20 — 수익 분배: 수동 청구만
Date: 2026-06-12
결정: 모든 풀이 청구 기반이고 수동으로 발동하는 수익을 쓴다(yield_trigger = MANUAL). 투자자가 직접 claimYield()(또는 reinvest())에 서명한다. AUTO와 예약 분배 선택지는 제거한다. 작동하는 컨트랙트 경로가 없었고 pools.yield_trigger와 어드민 UI에 v2.x의 잔재로만 남아 있었다.
이유:
- v3.0 컨트랙트는 본래 청구 기반이다.
depositYield(파트너) →distributeYield(Lambda가 pro-rata로 표시) →claimYield(투자자가 당김)다. 온체인에 자동으로 밀어 주는 경로는 존재한 적이 없다 - BD1(수동 청구)에 따라 원금과 수익은 분리돼 있고 LP 가격은 액면 $1.00에 머물며 투자자가 자기 서명으로 당긴다
- 비수탁 설계를 강화한다. 투자자가 자기 입금, 상환 요청, 그리고 수익 청구에 직접 서명한다(09-rbac 참조)
정리 작업(2026-06-12):
- 어드민 UI에서
yield_trigger제거(풀 생성·수정, 풀 상세의 설정과 수익 설정 탭, 수익 분배의 "Trigger" 열). 레거시AUTO행은MANUAL로 맞췄다 - DB 마이그레이션
0010_drop_yield_trigger가pools.yield_trigger를 삭제한다. 풀 생성·수정 Lambda도 더 이상 참조하지 않는다. 컨트랙트 영향 없음.
여전히 유효한 것: 분배 주기는 yield_frequency에 있다(공개된 약속이다). 다음 날짜는 Estimated → Funded D-day로 보여 준다. 추정 수익 금액 산식은 별개의 열린 트랙이다.
유통시장 단서: LP를 거래소(예: Tokocrypto)에 상장하면 청구 방식을 유지할지 적립·리베이스 수익으로 갈지가 다시 열린다(거래소의 옴니버스 보유자는
claim을 할 수 없다). 별도 열린 결정으로 추적하고, 직접 보유자에 대한 v3-20은 바뀌지 않는다.
참고: BD1(수익 분배 모델), 17-changelog v3-20, 11-db-schema(yield_trigger 삭제), 04-pool-models, 02-core-concepts
v3-25 — 풀 리스크 등급 배지 (Low/Med/High), 매출채권 우선 ✅ Decided (Phase 1)
v3-25 — 풀 카드의 리스크 등급
Date: 2026-06-16 (1단계)
결정: 폐기된 collateral_type 배지(v3-07)를 자동 계산되는 Low / Med / High 리스크 등급으로 대체하고 풀 카드와 상세(투자자·어드민 양쪽)에 보여 준다. 등급은 렌더 시점의 풀 상태에서 유도하고 어드민이 고르지 않는다.
모델(매출채권 우선): 우리 풀은 사모 신용·매출채권이라 액면이 약 100%다. 그래서 Maple, Goldfinch, Centrifuge처럼 담보 비율이 아니라 구조(트랜치)와 포트폴리오 건전성으로 등급을 매긴다. collateral_ratio는 비중을 낮추고 FX와 operating_currency는 제외한다(운영자가 관리한다). NPL은 직접 쓰지 않는다(이미 nav_per_token = (subscribed − npl)/subscribed에 들어 있다).
우선순위는 High → Med → Low다. High: IMPAIRED나 WIND_DOWN · is_emergency_frozen · tranche_role=JUNIOR · nav<0.90 · NPL>5%. Medium: nav 0.90–0.999 · SENIOR · NPL 2~5% 또는 DPD30/60 상승 · reserve_percentage<10%. 그 외는 Low. 임계값은 2026-06-16 확정(NPL <2/2–5/>5%, 리저브 10%, NAV <0.90/0.90–0.999). 주기: lifecycle·동결·NAV는 사건 기반으로 즉시, NPL·DPD는 월 단위. 흔들림을 막는 히스테리시스를 둔다.
단계:
- 1단계(구현됨):
lifecycle_status+is_emergency_frozen+nav_per_token+reserve_percentage. - 2단계(⏳ 대기):
tranche_role(v3-14, FE 미배선)과 파트너 NPL·DPD. Joob API 2차 데이터 요청에 달려 있다(DPD 비율의 분모outstanding_principal. 집중도·만기·소진율은 선택). 외부 펀드 데이터 제공자가 없는 풀은 리저브와 lifecycle, nav만 쓴다.
대기 중인 의존: Joob 2차 API(NPL·DPD 분모와 선택적 집중도·만기·소진율), 그리고 total_rni의 총액·순액 확인. Equity Buffer(파트너가 NPL 5% 이하를 흡수)는 오늘 전용 DB 필드가 없다. 제대로 지원하려면 온체인 운영자 first-loss 로직이 필요하고(다음 사이클), 그전까지는 표시 전용이다.
참고: Pool Models → Risk Levels, v3-07(collateral_type→description), v3-12(부실·청산), v3-14(트랜치), Grab Joob API — Aset 연동(RNI/NPL, Equity Buffer)
v3-26 — 모든 풀에 fund_id 필수 (Aset 수탁 직접 풀 없음) ✅ Decided
v3-26 — 모든 풀은 펀드에 연결된다
Date: 2026-06-16
결정: 모든 풀은 fund_id를 반드시 가져야 한다(파트너 펀드에 연결된다). 전에 문서화됐던 Aset 직접 풀 설정(fund_wallet = Aset treasury, fund_id 없음)은 제거한다. Aset은 수탁 풀을 운영하지 않는다. 만들 대상은 Aset을 통한 투자(전면 온체인) 모델이지 Joob의 읽기 전용 하이브리드가 아니다.
이유: 직접 풀에서는 입금의 90%가 fund_wallet = Aset treasury로 간다. 그러면 Aset이 투자자 자금을 보유하게 되고 수탁이 된다(비수탁·VASP 회피 자세를 깨뜨린다). 파트너 펀드를 요구하면 그 90%가 파트너가 통제하는 fund_wallet에 남는다.
강제(제품 계층): pools.post.create가 fund_id 누락을 거부하고, 어드민 풀 생성은 펀드 선택을 요구한다. 레거시 fundId ? FUND_POOL : AS_POOL 분기는 펀드 경로로 합쳐진다(fund_id 없는 분기는 죽은 코드가 된다. 무해하고 나중에 정리한다). **DB pools.fund_id NOT NULL**은 fund_id 없는 기존 행을 정리한 뒤로 미룬다.
⚠️ 범위: 이것은 제품 수준의 제약이지 비수탁 보장 자체가 아니다. 비수탁 여부는 컨트랙트 계층에서 정해진다. Aset이 단독으로 fund_wallet을 바꾸거나 money-path를 자기 쪽으로 업그레이드할 수 있는가다. 진짜 보장(불변 money-path, 이탈을 위한 독립 멀티시그와 타임락, fund_wallet ≠ treasury)은 폼이나 백엔드 검증이 아니라 컨트랙트와 법무 트랙에 있다(Custody vs Non-Custody 참조). 비수탁 최종 판단에는 법무가 필요하다.
컨트랙트 영향: 없다. fund_id는 오프체인 전용이다.
참고: Pool Models → fund_wallet, Core Concepts, Custody vs Non-Custody(노션), v3-01(pool_type→차원)
v3-27 — 비수탁 키 거버넌스: money-path는 업그레이드 불가, 주변부는 타임락 ✅ Decided
v3-27 — money-path 업그레이드 불가
Date: 2026-06-17
결정: 유저 자금 경로를 업그레이드 불가로 고정한다. 상환 지급은 request.investor(요청 시점에 LP가 잠긴, 검증된 홀더. 목적지 파라미터가 없다), 수익은 현 보유자, withdrawFees는 누적된 수수료로 상한이 걸려 고정된 treasury로 간다. 앞으로 컨트랙트 업그레이드를 도입하더라도 주변부(오라클 주소, 수수료 계산 등)만 대상이고 타임락을 거친다. 운영자 setter로 바꿀 수 있는 "투자자 지급 주소" 같은 기능은 처음부터 배제한다.
이유: 수탁 판정은 "운영자가 단독으로 도달할 수 있는 최악의 상태"를 기준으로 한다. 단독으로 즉시 업그레이드할 권한이 있으면 목적지를 바꾸는 새 구현을 올릴 수 있고 "고정된 목적지"가 무의미해진다. money-path를 업그레이드 범위에서 빼면 우리에게 수정 권한이 있어도 유저 자금의 목적지를 단독으로 바꿀 수 없으므로 수탁으로 분류되지 않는다.
현재 코드와의 정합: 지금 컨트랙트는 이미 업그레이드 불가다. PlatformPool과 LPToken은 Clones(EIP-1167, 고정 구현)이고 팩토리의 구현 포인터는 immutable이며 _authorizeUpgrade가 없다. 지급은 request.investor로 고정돼 있고 withdrawFees는 unclaimedYield로 상한이 걸려 있다. 즉 이 결정은 현재 설계를 명문화한 것이고, 나중에 업그레이드 가능한 모델로 가더라도 money-path는 불변 코어로 남는다. 멀티시그는 필요 없다(콜드 키 하나와 타임락으로 충분하다).
작업 요청: Claude-Plan/Active/non-custodial-money-path-implementation.md(검증, 문서화, 절충).
참고: 09-rbac → Key custody, Custody vs Non-Custody(노션), apps/contract/src/PlatformPool*.sol
v3-28 — 비상 동결의 이탈권: 기간 제한과 상환 폴백 (옵션 D) ✅ Decided · 🛠 Implemented
v3-28 — 비상 동결은 가두어서는 안 된다
⚠️ 두 번째로 supersede된 부분: 무권한 상환 폴백이 철회됐다 — v3-157. Option D의
redeem fallback절반이claimRedemptionFallback이었고, 그것이 끌어 쓰던 리저브가 지금 풀 밖에 있어 2026-08-31에 제거됐다. 이 카드의 freeze 부분은 그대로다 — 비대칭 게이트, 72시간 이탈 창, 7일 자동 만료는 살아 있다. 달라진 것은 얼어붙거나 응답 없는 즉시 요청을 7일 뒤 누구나 정산할 수 있던 경로가 사라졌다는 것이다. 이제 리저브 지갑을 기다린다.
Date: 2026-06-17 · 구현: 2026-06-22 (ch/product 머지로 컨트랙트 반영)
결정(옵션 D): is_emergency_frozen은 비상 대응을 위해 즉시 발동하되 자금을 기약 없이 가둬서는 안 된다.
- 이탈(상환)까지 막는 전면 동결은 **최대 72시간(3일)**이고, 그 뒤에는 상환(
redeem/claim)을 자동으로 허용하는 쪽으로 완화된다 - 그 밖의 동결 창은 기본 7일이다(Aset 어드민 타임락, 상환 회차와 일관된다)
- 연장과 해제는 즉시 단독으로 할 수 없고 거버넌스와 타임락을 거쳐야 한다(비대칭이다. 동결은 빠르고 복구와 연장은 느리다)
이유: 지금의 동결은 입금·출금·청구를 전면 차단하고 PAUSER가 단독으로 즉시 걸 수 있으며 시간 제한이 없다. 기약이 없으면 투자자 이탈권(수탁 기준 5번)을 침해할 소지가 있다. 업계 관행(Compound)은 정지 중에도 Redeem과 Repay(이탈)를 절대 막지 않고 해제는 거버넌스 전용이다. 이 결정은 그 원칙(일시적, 비대칭, 이탈 보장)을 72시간과 7일이라는 경계로 반영한다.
구현 영향(컨트랙트): 지금은 즉시이고 제한이 없는 emergencyFreeze와 unfreeze에 최대 기간 자동 만료, 또는 N일 뒤 타임락 redeem 폴백을 더한다. (PlatformPool.sol의 PAUSER 경로)
✅ 구현됨(2026-06-22 코드 확인, ch/product 머지 이후 PlatformPool.sol): emergencyFreeze()가 freezeStartedAt을 기록한다. 비대칭 게이트(_isFreezeActive = 플래그가 켜져 있고 FREEZE_MAX_DURATION 안)로, 자본 유입(deposit/depositYield/reinvest)은 동결 내내 막히고 가치 유출(requestRedemption/claimRedemption/claimYield/executeEpoch/fundRedemption/approve/fallback)은 FREEZE_EXIT_WINDOW = 72시간 뒤 자동으로 풀린다. 동결 전체는 FREEZE_MAX_DURATION = 7일 뒤 자동 만료된다. 적법한 연장은 proposeFreezeExtend/executeFreezeExtend/cancelFreezeExtend로 한다(DEFAULT_ADMIN과 거버넌스 타임락. freezeStartedAt을 앞으로 민다). unfreeze는 PAUSER로 남는다(복구는 빠르게). DB 미러: 마이그레이션 0023의 pools.freeze_started_at과, 풀 응답이 노출하는 freeze_started_at/freeze_exit_window_ends_at/freeze_auto_expires_at. (참고: 같은 날 앞서 진행된 감사가 이것을 미구현으로 표시했는데, 그건 머지 전 브랜치 상태였다. ch/product 컨트랙트 커밋이 반영했다. 17-changelog 2026-06-22 참조.)
⚠️ 한 부분이 나중에 대체됐다.
FreezeExtend삼총사는 제거됐다. 그 7일 거버넌스 타임락이 7일 동결 수명과 같아서, 연장은 동결이 이미 끝난 뒤에야 실행 가능해졌다. 그때 실행하면 아무 일도 없거나, 출금 권리를 되찾은 투자자에게 소급해서 풀을 다시 잠그고 72시간 이탈 차단을 다시 시작시켰다. 그건 v3-28이 막으려던 바로 그 해악이다.proposeFreezeExtend/executeFreezeExtend/cancelFreezeExtend는 더 이상 존재하지 않는다(GovernanceLib에 그런 함수가 없고 ABI에도 없다).pendingFreezeExtend*스토리지 슬롯만 남아unfreeze에서 지워지고,POST /pools/{id}/freeze는 연장 동작에 410을 반환한다. 이 카드의 나머지는 그대로다. 비대칭 게이트, 72시간 이탈 창, 7일 자동 만료는 살아 있다. 7일을 넘겨 강도를 올리려면pause나impairment를 쓸 것. 둘 다 이탈은 열어 둔다.
참고: 08 → Pauser/Admin functions + safety events, 09-rbac → Key custody, 09a → Exit right, 15 → /pools/{id}/freeze, Custody vs Non-Custody(5번 이탈권), 결정 로그 "is_emergency_frozen freeze fallback"(노션), Compound v2 Governance
v3-29 — DB와 온체인 필드 동기화 및 PATCH 강제 ✅ Decided
v3-29 — 온체인 값 동기화
Date: 2026-06-18
결정: 풀 설정 필드를 세 부류로 나누고 pools.patch.update.ts가 그에 맞춰 동기화와 잠금을 강제한다.
- A(온체인 setter 있음):
is_paused. PATCH가 온체인pause()/unpause()를 호출해야 한다(둘 다 즉시다). DB만 바꾸는 것은 보안 구멍이다(투자자가whenNotPaused로 게이팅되는deposit()을 직접 호출해 우회할 수 있다). 최우선이다 - B(온체인 불변. setter 없음): capacity, min/max_investment, penalty_type/rate/fee, redemption_type, notice_period, lockup/maturity, accepted_currencies. 배포 후 PATCH 거부(읽기 전용)이고 DRAFT에서는 자유롭게 편집할 수 있다. setter는 추가하지 않는다(EIP-170 한도에 가깝고, 04의 표에 따르면 이것들은 ACTIVE 이후 잠기는 투자자 조건이다)
- C(오프체인 전용): name, apy, yield_frequency, collateral. DB만 바꾸는 것이 맞다.
investment_blocked는 폐기한다. 이제 배선된 온체인is_paused와 중복이다. 투자 가능 판정에서 뺐고(이제ACTIVE && !is_paused다) DB 컬럼은 폐기 표시했다(나중에 삭제한다)
이유: 지금의 PATCH는 DB만 갱신해서 DB와 온체인이 갈라진다. A 부류에서는 실제 보안 우회이고 B 부류에서는 조용한 표시·동작 불일치다. B 부류 PATCH를 막으면 기존의 "ACTIVE 이후 편집 가능성" 결정을 컨트랙트 변경 없이 강제하게 되고, is_paused(A)를 배선하면 우회가 닫힌다.
구현: is_paused를 pause()/unpause()에 배선한다. 배포된 풀에서 B 부류 PATCH를 거부한다. C 부류는 DB 전용으로 둔다. (pools.patch.update.ts)
참고: 08 → Field Sync, 04 → Editability After ACTIVE, ch ONCHAIN_VALUE_SYNC_DECISION.md
v3-30 — 표적 모듈화: KYC만 업그레이드 가능한 프록시, 코어는 불변 ✅ Decided · 🛠 Implemented
v3-30 — 표적 업그레이드 가능성 (KYC만)
Date: 2026-06-18 · 구현: 2026-06-19
결정: 핵심 money-path는 불변으로 남는다(지금의 Clones). KYC 컨트랙트(PlatformKYCSoulbound)만 업그레이드 가능한 프록시(UUPS)와 타임락으로 바꾸고, 메인넷(9월) 이전에 구현한다. 나머지는 온체인 업그레이드 없이도 이미 갱신할 수 있다. 스테이블코인은 기존 add/removeStablecoin setter로, 오라클 출처와 수수료 계산, 검증은 **오프체인(Lambda)**이고, updateNAV의 범위 제한은 불변 코어에 있다.
이유: 완전히 불변인 Clones는 온체인 코드를 조금이라도 바꾸려면 재배포와 모든 풀의 이관을 뜻한다(비용이 크고 기존 풀은 패치할 수 없다). 온체인에서 진짜로 진화하는 유일한 부분은 KYC와 컴플라이언스다. 그것만 업그레이드 가능하게(프록시와 타임락) 만들면 컴플라이언스를 풀 이관 없이 바꿀 수 있고 money-path는 비수탁으로 남는다. 불변식: 주변부(KYC)는 자격을 게이팅할 수 있지만 자금을 옮기거나 지급 목적지를 바꿀 수는 절대 없다. 안전 경계는 코어에 남는다. KYC 업그레이드는 타임락이 걸린다(이탈 창, 5번).
⚠️ 단서(법무·감사): KYC는 REVOKED에 대해 상환을 게이팅한다(AML 목적이다). 업그레이드 가능한 KYC라면 그 게이팅을 바꿀 수 있고, 이는 이탈권(5번)에 대한 가벼운 고려 사항이다. 타임락(이탈 창)과 불변식(자격만, 자금 이동 없음)으로 완화한다. 법무와 감사의 확인이 필요하다.
시점: 메인넷 전이다. 테스트넷에 실제 자금이 없는 동안 감사 전에 구조를 바꾼다.
구현 영향(컨트랙트): PlatformKYCSoulbound을 타임락 뒤의 UUPS 프록시로 바꾼다. 풀은 고정된 프록시 주소를 참조한다. 코어 컨트랙트는 그대로다.
구현됨(2026-06-19, PlatformKYCSoulbound.sol): ERC1967 UUPS 업그레이드 가능 컨트랙트로 바꿨고(Initializable + UUPSUpgradeable) 7일 업그레이드 타임락(UPGRADE_TIMELOCK = 7 days)을 뒀다. proposeUpgrade(newImpl) → 7일 → executeUpgrade()(또는 cancelUpgrade())다. _authorizeUpgrade는 정확히 그 대기 중인 구현에 대한 타임락 경로만 받는다(직접 UUPS 업그레이드는 revert한다). 새 의존은 openzeppelin-contracts-upgradeable이고 이벤트는 UpgradeProposed/UpgradeExecuted/UpgradeCancelled다. money-path(PlatformPool/PlatformLPToken)는 불변 Clones로 남는다. 풀은 KYC 프록시 주소를 참조한다. 배포 참고: 기존 테스트넷 SBT는 백지 상태라 새 프록시 아래에서 다시 민팅했다(옛 토큰의 온체인 이관은 없다). KYC 프록시 배포 절차는 apps/contract/sepolia.md 참조.
⚠️ 일부 대체됨(2026-08-14): UPGRADE_TIMELOCK은 이제 0이다. 위에 적힌 7일 대기는 사라졌고 v3-71의 제안을 받아들였다. 나머지는 그대로다. propose와 execute 쌍, 직접 upgradeToAndCall을 막는 _authorizeUpgrade 게이트, 이벤트 셋이다.
참고: 08 → PlatformKYCSoulbound, 09a → Upgrade governance, 09-rbac, v3-27
v3-31 — 이탈권 폴백: 허가 없이 타임락으로 상환을 청구 ✅ Decided · 🛠 Implemented
v3-31 — 허가 없는 타임락 이탈 (수탁 5번)
⚠️ 절반이 supersede됐다 — v3-157. 즉시 상환 풀 쪽(
claimRedemptionFallback)은 2026-08-31에 제거됐다. v3-155가 리저브를 풀 밖으로 옮겨서 그 함수가 지급하던 잔액이 구조적으로 0이 됐기 때문이다. 아래 회차 풀 쪽은 그대로다 — 무권한executeEpoch와 대리 청구는 살아 있다. 이 카드는 절반만 구현된 것으로 읽어야 한다.
Date: 2026-06-18 · 구현: 2026-06-19
결정: 평상시 상환은 운영자가 발동하는 approveRedemption으로 정산된다(ORACLE 게이트). 빠르고, AML이나 이상 요청을 보류할 수 있다. 비수탁 **5번(이탈권)**을 충족하기 위해 허가 없는 타임락 폴백을 더한다. 상환 요청이 N일(통지 + 유예)을 넘겨 정산되지 않고 그리고 풀 리저브에 자금이 있으면, 어떤 투자자든 ORACLE 승인 없이 claimRedemption을 직접 실행할 수 있다(가용 리저브에 대한 pro-rata, 마지막으로 적용된 NAV 기준). 이 폴백은 AML과 KYC REVOKED 게이트를 존중한다(그러지 않으면 제재 대상 지갑이 타임락을 기다려 보류를 우회할 수 있다).
approveRedemption은 재해석된다. "이탈 게이트"가 아니라 "속도와 AML 보류 계층"이다. 이탈 자체는 폴백이 보장한다.
이유: 이탈이 전적으로 운영자의 재량에 달려 있으면 운영자가 사용자의 이탈 여부에 단독 통제권을 갖는 것이고 수탁 쪽으로 기운다. WIND_DOWN만으로는 부족하다. WIND_DOWN을 발동하는 것 자체가 운영자의 행위이기 때문이다(순환 의존). 유동성은 실제로 제약돼 있으므로(신용 펀드다) 폴백은 가용 리저브만 지급한다. 비유동 부분은 여전히 회차 펀딩을 기다린다(정당한 것이지 검열이 아니다). 벤치마크: Maple과 Goldfinch는 가용 유동성에 대해 허가 없는 출금을 허용한다.
구현 영향(컨트랙트): 기한 뒤 executeEpoch()를 허가 없이 호출 가능하게 만들고 claimRedemption(requestId)도 허가 없이(대리 청구, 지급은 원 요청자로 고정) 만든다. 둘 다 AML/KYC-REVOKED 아님으로 게이팅한다. pro-rata는 회차 fillRatio와 누적 지수(G/H)로 온체인에서 계산한다. 회차 상환 참조.
구현됨(2026-06-19) — 즉시와 회차 둘 다:
- 즉시 상환 풀 — 새
claimRedemptionFallback(requestId)(PlatformPool.sol). 허가가 필요 없다. 즉시 상환 풀의 요청이FALLBACK_NOTICE_DAYS = 7일 이상 정산되지 않고, 리저브가 그것을 감당할 수 있으며, KYC가 폐기되지 않았으면 누구나 지급을 발동할 수 있다. 자금은 언제나 고정된 원 투자자에게 간다(목적지 파라미터가 없다). 운영자가approveRedemption을 끝내 호출하지 않아도 이탈을 보장한다.RedemptionFallbackClaimed를 낸다. 백엔드는POST /redemption-requests/{id}/claim-fallback이다 - 회차 풀 — 이미 덮여 있다.
executeEpoch는 기한 뒤 허가가 필요 없고claimRedemption은 대리 청구다(지급은 요청자로 고정). 회차 이탈은 운영자 재량에 의존한 적이 없으므로 폴백 경로는 즉시 상환 풀 전용이다
참고: 08 → claimRedemptionFallback, 15 → /claim-fallback, 회차 상환, v3-28 freeze exit, 09a → Exit right, Custody vs Non-Custody(5번)
v3-32 — updateNAV의 범위 제한을 코어에 두고 role 관리는 콜드 키와 타임락으로 (멀티시그 없이) ✅ Decided · 🛠 Implemented
v3-32 — 코어의 NAV 경계와 role 관리 위험 수용
Date: 2026-06-18 · 구현: 2026-06-22 (ch/product 머지로 컨트랙트 반영)
결정(NAV 경계): 불변 코어의 updateNAV 안에 **이탈 상한과 서킷 브레이커(그리고 신선도 검사)**를 구현하고 메인넷 전에 낸다. updateNAV는 핫 키(서비스 키)로 호출되므로, 경계가 코어에 있어야 ORACLE_ROLE이 침해되더라도 기간당 작은 범위를 넘어 NAV를 움직일 수 없다. v3-29의 열린 항목을 닫는다. ✅ 구현(2026-06-22 코드 확인, PlatformPool.sol): updateNAV가 이제 _checkNavBound(newNav)를 호출한다(≤ 1.0 클램프 뒤, 즉시 상승 경로와 큐잉되는 하락 경로 양쪽에서). circuitBreakerTripped이면 revert하고, navDeviationCapBps > 0일 때 |newNav − navPerToken|이 어느 방향으로든 상한을 넘으면 revert한다. 그래서 핫 키의 쓰기가 이제 코어에서 제한된다(기본 상한은 0으로 설정 전까지 비활성이다). executeEpoch도 같은 게이트에 신선도 검사(navStalenessSeconds)를 더해 회차 정산 NAV에 적용한다. (같은 날 앞선 감사는 updateNAV가 여전히 제한 없다고 표시했는데, 그건 머지 전 브랜치 상태였다. ch/product 커밋이 _checkNavBound를 넣었다.)
결정(role 관리 훼방): DEFAULT_ADMIN_ROLE(콜드 키)은 임의의 role을 부여할 수 있다(예: 자기에게 ORACLE을 주고 NAV 훼방). 콜드 키 하나와 타임락으로 수용하고 멀티시그는 두지 않는다. role 부여는 이미 어드민 7일/30일 타임락 아래 돌고(09-rbac), money-path 불변성이 절도를 막으며, NAV 이탈 상한이 훼방을 제한하고, 타임락이 어떤 권한 상승도 드러내며 이탈 창을 준다(v3-31 폴백). 2-of-3 콜드 멀티시그는 선택적 다층 방어로 남겨 둔다.
이유: 함수를 제한하면 누가 role을 갖든 상관없이 NAV 훼방이 근원에서 무력화된다. role 부여를 제한하는 것보다 깔끔한 해법이다. 절도가 배제돼 있고(불변 money-path) 이탈이 보장되면(v3-31) 남는 콜드 키 위험은 타임락 아래에서 받아들일 만하다.
구현 영향(컨트랙트): updateNAV에 이탈 상한(예: 갱신마다 ±X%)과 누적 서킷 브레이커, 신선도를 더한다. EIP-170 여유를 확인한다(FOUNDRY_PROFILE=prod forge build --sizes).
구현됨(2026-06-19, PlatformPool.sol): updateNAV가 circuitBreakerTripped이면 revert하고, navDeviationCapBps > 0일 때 |newNav − navPerToken|이 어느 방향으로든 상한을 넘으면 revert한다(기본 상한 0은 비활성). 어드민 setter는 setNavDeviationCap / tripCircuitBreaker / resetCircuitBreaker이고(이벤트는 NavDeviationCapSet / CircuitBreakerTripped / CircuitBreakerReset) 브레이커는 executeEpoch도 게이팅한다. 경계가 Lambda가 아니라 불변 코어에 있으므로 핫 ORACLE 키가 침해되거나 콜드 키로 자기에게 ORACLE을 부여하더라도 그 경계를 넘어 NAV를 움직일 수 없다. 이것이 v3-29의 열린 항목을 닫는다(그리고 경계가 이미 있는 것처럼 읽히던 09-rbac의 안전장치 표를 정정한다). 따라서 role 관리 훼방은 콜드 키 하나와 7일/30일 타임락으로 수용하고 멀티시그는 두지 않는다(절도는 불변 money-path가 막고, 훼방은 근원에서 제한되며, 권한 상승은 드러나고 v3-31로 이탈 창이 있다).
참고: 08 → updateNAV / NAV bounds, 09-rbac → Key custody, v3-29, Custody vs Non-Custody(1번 단독 통제)
v3-26 (회차) — 회차 기반 상환: pro-rata + forward pricing + pull 청구 ✅ Decided
v3-26 — 회차 상환
Date: 2026-06-18(확정) · dev 구현 결정 2026-06-19
ℹ️ 번호 참고: 이것은 노션의 v3-26(회차 상환) 결정이다. 이 vitepress 로그에서
v3-26이라는 id는 이미 *"모든 풀은 펀드에 연결된다"*가 쓰고 있다. 노션 번호를 공유하는 별개의 결정이다. "v3-26 회차 상환"에 대한 상호 참조는 여기를 가리킨다.
결정: redemption_epoch_days > 0인 풀(REVOLVING·개방형)은 즉시 FIFO 대신 회차 창으로 이탈을 처리한다. 창이 끝날 때마다 상환이 가용 유동성에 대해 pro-rata로 정산되고 정산 시점 NAV로 가격이 매겨진다(forward pricing, SEC Rule 22c-1 방식). 체결되지 않은 잔량은 다음 회차로 이월된다. 정산(executeEpoch)은 O(1)이고 비율과 NAV만 계산한다. 투자자는 claimRedemption으로 지급을 당겨 간다(claimYield와 같은 누적기 패턴이다). 즉시 상환 풀(= 0, FIXED_TERM)은 그대로다. 입금은 즉시이고 원자적으로 남는다(한 방향만 회차다).
이유: 만기가 없는 풀에서 동시 이탈이 몰리면 즉시 FIFO와 기약 없는 PENDING_RESERVE가 무너진다. 선착순의 불공정과 뱅크런, 요청 시점 NAV 차익, 기약 없는 대기다. 회차 묶음 + pro-rata + forward pricing은 Maple, Goldfinch, Centrifuge의 표준이다. pull 정산은 push 루프가 갖는 블록 가스 한도와 DoS, 비용 공정성 문제를 피한다.
하이브리드 승인: 회차는 100% 자동으로 정산되고, 어드민은 이상치만 holdRequest/releaseRequest한다(TVL의 25% 이상인 큰 상환, NAV 이탈·낡음·브레이커, 컴플라이언스 플래그, 파트너 부실, 만성적인 낮은 체결률). 임계값은 설정이다(비율 과 절대 하한, 그리고 초기 풀 유예).
확정된 구현 결정(2026-06-19):
- 수익 발생은 (b) 증분 정산이다. 잠긴 LP도 계속 발생시키고, 요청 시점에 발생 커서를 스냅샷으로 뜬 뒤 소각 시점에 그 차이를 정산한다. ⚠️ v3-91 규칙 1이 이를 뒤집었고, v3-131 (3)이 실질적으로 되살렸다(2026-08-20 반영). 다만 멈춤 지점이 소각에서 정산으로 옮겼다. 소각 시점에 정산한 것이 투자자가 자기 청구를 미룰수록 더 받게 만든 원인이고, 그것이 규칙 1의 진단 중 유효했던 절반이다. 이 항목이 말한
accumulatedYieldPerShare/yieldDebt쌍은 더 이상 없다. 커서는 발생 지수다 - 강제 레버는 ① 파트너 잔여분 hold-back이다(⚠️ 잠자는 레버다. 그게 무슨 뜻인지와 왜 "그 90%"가 잔여분을 부르는 틀린 이름인지는 거기를 볼 것). 상환이 충족되지 않은 동안 새 입금의 파트너 잔여분을 별도
heldFundReleases버킷에 붙들어 두고(절대reserveBalance에 넣지 않는다. NAV 왜곡이 없다) 상환 유동성으로 쓴다. Centrifuge식 "상환이 밀린 동안 새 자금 집행 없음"이다. ⚠️ 정정: 이 항목은 "잔고가 해소되면 자동 해제된다"고 적혀 있었다. 자동 해제는 없다.setFundingRestricted(false)가 그 트랜잭션에서_releaseHeldFunds를 한 번 돌리고, 아무것도 다시 시도하지 않으며, 해제는 진짜로 여유 있는 만큼으로 잘린다(physicalBalance − reserveBalance − redemptionCommitted − unclaimedYield). 나머지는 버킷에 남는다(v3-100). 제품에도 만들어지지 않았다.setFundingRestrictedOnChain은 어떤 lambda나 어드민 화면에도 호출자가 없어서 이 레버는 컨트랙트를 직접 호출해야만 닿는다. 버킷의 의미: 23-money-path → Reserve and hold-back - 이월은 (2) Goldfinch식 회차 누적이다. 전역 지수
G(생존 비율의 곱)와H(단위당 누적 지급액)로 요청이 몇 회차에 걸치든 O(1)에 청구 가능해진다. 총량 이월은executeEpoch에서 일어나고 지연 청구는 그 지수를 읽는다
파트너 펀딩(3계층): 온체인 하드 기한과 EpochFundingNeeded 이벤트(신뢰가 필요 없는 파트너 keeper) · 오프체인 Lambda 알림(D-2, D-12h, notification_logs) · 90% hold-back 강제 레버.
컨트랙트 영향: 새 상태(epochDurationDays, currentEpochId/currentEpochEndsAt, epochTotalDemandLp/epochG/epochH/epochSettleNav, fundingRestricted/heldFundReleases). RedemptionRequest에 epochId/principalLp/lpRemaining/yieldAccSnapshot 추가. 새 함수 executeEpoch/claimRedemption/holdRequest/releaseRequest/setFundingRestricted. 새 상태값 QUEUED/PARTIALLY_FILLED. 이벤트 EpochSettled/RedemptionClaimed/RedemptionRolledOver/EpochFundingNeeded. ⚠️ EIP-170: 인라인 구현이 한도를 넘는 것을 받아들였다(라이브러리 리팩터가 메인넷 전 후속 작업이다). DB: pools.redemption_epoch_days, redemption_requests.{epoch_id,lp_filled,settled_nav}, 새 redemption_epochs 테이블.
열린 항목(PM 외): 개발·감사(G와 H의 정밀도, 이월과 G·H 산술의 일관성, 수익 이중 발생 경계) · ⚖️ 법무("확정됐으나 미청구"인 자금의 수탁과 실효 처리, [Legal A-1]).
참고: Redemption → Epoch-Based, Pool Models → redemption_epoch_days, Smart Contracts, Status Machines, v3-18(회차용으로 개정), v3-04(PENDING_RESERVE는 즉시 상환 전용), v3-31, v3-32, v3-34. 벤치마크: Centrifuge Epochs · Goldfinch withdrawals · Maple cycles · SEC Rule 22c-1.
v3-33 — 관리형(임베디드) 지갑 허용, 단 "Aset은 키 조각을 전혀 갖지 않는다"는 제약 아래 ✅ Decided
v3-33 — 관리형 지갑, Aset 지분 0
Date: 2026-06-18
결정: 플랫폼이 일반(크립토에 익숙하지 않은) 투자자를 대상으로 하므로, 자기수탁 외에 관리형·임베디드 지갑 선택지를 허용한다(이메일이나 소셜 로그인, 시드 문구 없음). 비수탁을 지키기 위한 구속 조건은 Aset이 키 조각을 전혀 갖지 않는 것이다. Aset은 단독으로든 공모로든 유저 자금을 움직일 수 있는 MPC 키 조각을 절대 보유하지 않는다. 구현은 제3자 비수탁 임베디드 지갑 제공자(Privy, Dynamic, Web3Auth, Magic)를 써야 하고 서명은 언제나 클라이언트에서 사용자가 통제한다. "사용자가 자기 입금·상환·청구에 직접 서명한다"는 전제가 유지되므로 VASP나 수탁자가 아니다.
⚠️ 피해야 할 수탁 유발 조건: 관리형 지갑이 Aset이 키 조각을 저장하거나 재분배하는 구조라면(Aset이 조각을 갖는 "B안 Dynamic Resharing" 변형) 단독 통제(1번)가 생기고 수탁이 된다. 그 형태를 채택하려면 6개 기준 전면 재검토와 VASP·수탁 라이선스 검토(법무)가 필요하다.
이유: 비수탁 아키텍처 전체가 사용자가 자기 키로 서명한다는 가정 위에 서 있다. Aset이 조각을 갖지 않는 제공자 관리형 지갑은 그 가정을 그대로 두면서 일반 투자자에게 마찰 없는 온보딩을 준다. 평판 있는 임베디드 지갑 제공자들 자체가 비수탁이므로(제공자 혼자 서명할 수 없다) 제공자도 Aset도 단독으로 자금을 움직일 수 없다.
참고: 09a → Non-custodial test, Non-Custodial checklist §6.3(노션), Custody vs Non-Custody(1번)
v3-34 — FM_ACCEPTED 제거 확정 (v3-04 후속) ✅ Decided
v3-34 — 상환의 FM_ACCEPTED 단계 제거
Date: 2026-06-18
결정: 상환의 FM_ACCEPTED 단계를 제거하고 v3-04를 마무리한다(v3-04는 절반만 적용돼 있었다. 그 단계가 코드와 seed, 문서에 살아 있었다). 상환 흐름은 REQUESTED → RECOMMENDED → APPROVED → …로 단순해진다(회차 풀은 회차·이월 흐름을 쓴다). 상환에서 FM에게 사전 승인 단계는 없다. 파트너의 유일한 의미 있는 행동은 리저브 부족분을 채우는 것이고, 그것은 워크플로 단계가 아니라 상태다(PENDING_RESERVE나 이월).
이유: v3-26이 상환에서 FM 승인을 아예 없앴는데, 절반만 적용된 v3-04 때문에 FM_ACCEPTED가 마이그레이션(폐기 표시)과 enum·엔드포인트·seed·배지·CLAUDE.md(여전히 살아 있음) 사이에서 자기모순 상태로 남았다. 펀드 풀의 FM 승인 단계가 정말로 불필요하다는 것을 확인했다.
정리 범위(dev): FM_ACCEPTED enum 값 · POST /redemption-requests/{id}/fm-accept 엔드포인트 · seed 행 · status-badge 항목 · fm_accepted_at(마이그레이션 0002에서 이미 폐기) 제거. CLAUDE.md의 상환 흐름 갱신. 07-redemption / 10-status-machines / 12-triggers / 15-api-reference / 18-failure 문서와 @aset/types의 RedemptionStatus union 갱신.
진행(2026-06-19): enum과 문서, 타입은 완료했다(redemption_status 최종값 = REQUESTED, QUEUED, PARTIALLY_FILLED, PENDING_RESERVE, PROCESSING, COMPLETED, REJECTED, FAILED). FE 배지 정리는 따로 추적한다. RECOMMENDED/APPROVED/CANCELLED는 흐름이나 온체인 개념이고 저장되는 DB enum 값이 아니다(10-status-machines).
참고: v3-04, 회차 상환 결정, §12.1(회차 페이지, 노션)
v3-36 — 펀드별 통계 드릴다운과 부족분 KPI ✅ Decided · 🛠 FE done, BE deploy pending
v3-36 — 펀드별 통계와 부족분 KPI
Date: 2026-06-22
결정(펀드별 통계): 어드민은 파트너 펀드를 여럿 관리하는데 펀드별 집계가 없었다(플랫폼 전체나 풀별뿐이었다). 펀드 상세에 "Fund Stats" 절을 더한다(TVL / Yield / Investors / Pending Redemptions / Awaiting Funding / Pending Yield). GET /dashboard/stats?fund_id=으로 가져오고 어드민은 아무 펀드나, FM은 자기 펀드만 볼 수 있다(서버가 소유를 확인하고 403을 낸다). 대시보드는 집계로 남는다(어드민은 플랫폼 전체, FM은 자기 펀드 합). 대시보드에 펀드 선택기는 두지 않는다(선택기 대신 펀드 상세 드릴다운을 골랐다. "옵션 B"). 기존 통계 라우트를 재사용하므로 새 인프라가 없다.
결정(부족분 KPI): PENDING_RESERVE 상환을 노출한다. 승인은 됐으나 리저브가 지급을 감당하지 못해 파트너가 보충해야 하는 것이고(fundRedemption) alerts.pending_reserve로 낸다. 대시보드의 "Awaiting Partner Funding" 조치 항목(→ /redemptions?tab=funding)과 펀드 상세의 "Awaiting Funding" 카드(0보다 크면 앰버)로 보여 준다. 건수만 센다(기간 합계가 아니라 현재 스냅샷이고, 상환에만 있다. 수익에는 부족분 개념이 없고 yield_overdue뿐이다).
- 금액($)은 미룬다. 온체인 부족분(
RedemptionPendingReserve/EpochFundingNeeded. 이미 리저브 차감 후 부족액payout − reserve다)을 인덱서가 잠자고 있는redemption_requests.funding_shortfall컬럼에 저장하면, 그다음에는 금액이 단순한SUM이 된다. RPC도 재계산도 없다. 백엔드 개발에 요청했다(노션 v3-26 §15). 온체인readEpochShortfallRPC와 리저브 산술 재유도는 비용이 크고 중복이라 기각했다
같은 날 함께(FM 읽기 전용 가드): 수익 페이지에서 FM에게 "Create Distribution"을 숨기고, 풀 Configuration 탭을 FM에게 읽기 전용으로 만든다. 백엔드 경계와 맞는다(FM_EDITABLE_FIELDS는 수익 설정뿐이다).
구현: FE는 머지됐다(jy/product). 백엔드(dashboard.get.stats.ts의 fund_id 파라미터와 pending_reserve 집계)는 CDK dev 배포가 필요하다.
참고: 09-rbac → Key custody / read-authz, FM Panel PRD §8과 Notification PRD §9(노션), v3-26 §15(funding_shortfall BE 요청)
v3-74 — 적격투자자 게이팅(미국 밖 Reg S): investor_tier → investor_status + eligibility_mode, 백엔드에서 강제 ✅ Decided
v3-74 — 적격투자자 게이팅 (미국 밖 Reg S)
Date: 2026-07-14
결정: 잠자고 있던 미국 중심 investor_tier enum(ACCREDITED/QP)을 은퇴시키고 관할 기반 모델로 풀을 게이팅한다. 백엔드에서 강제하고 온체인 변경은 없다. 사용자는 investor_status(RETAIL/PROFESSIONAL)와 qualification_* 필드를 갖고, 풀은 eligibility_mode(STATUS Status Gate 또는 MIN_TICKET Ticket Gate)를 갖는다.
이유: Aset은 미국 밖(Reg S) 전용이라 미국식 ACCREDITED/QP 틀은 잘못된 모델이었고 어떤 게이트도 그것을 읽은 적이 없다. 대상 관할(SG/HK/EU/JP/UAE/CH/UK/TH/MY)은 전문·적격 지위나 높은 최소 투자금액으로 비소매를 인정한다.
결과(마이그레이션 0072): checkKycGating이 eligibility_mode로 분기하고 사유 코드를 반환한다(NOT_KYC, KYC_EXPIRED, JURISDICTION_BLOCKED, US_PERSON, NOT_PROFESSIONAL, BELOW_MIN_TICKET). 최소 투자금액 하한도 게이트 안으로 들어왔다. 새 어드민 PATCH /users/{id}/qualification이 감사 기록과 함께 PROFESSIONAL을 부여하거나 회수한다. 국가별 임계값은 뼈대 설정이다(값은 BD와 법무를 기다리는 자리표시자다). SumSub 자동 채움은 2단계이고 v1은 어드민 수동이다.
참고: 11-db-schema, 03-kyc-identity, 04-pool-models. 17-changelog v3-74. 노션 "KYC/적격투자자 — BE 작업".
v3-75 — KYB(법인) 온보딩 정책: 관할에 맞추고 UBO는 20% 이상 수집, 판단은 관할별로 🚫 Superseded by v3-98 (KYB dropped)
v3-75 — KYB 법인 온보딩 정책
Date: 2026-07-14
결정: 다관할 법률 벤치마크로 KYB(법인·기관) 온보딩 정책을 확정한다. KYB는 현재 뼈대만 있고 프로덕션 준비가 안 됐다(레벨 enum과 SumSub KYB 레벨 매핑은 있지만 FE 제출은 플래그가 꺼져 있고 UBO 수집과 어드민 법인 심사는 만들지 않았다). 개인 대상 Reg S 게이팅(v3-74)과 별개 작업 흐름으로 돌고, 그쪽이 개인 트랙에서 먼저 나간다.
- 대상 관할 = KYC/Reg S 허용 목록과 같다(SG·HK·EU·JP·UAE·CH·UK·TH·MY). 인도네시아는 별도 OJK 샌드박스 트랙이다. KYB용 별도 국가 목록은 없다
- 법인 자격 = 기관·규제 금융기관은 자동으로 PROFESSIONAL이 된다. 그 외 법인은 "대형 사업체 2-of-3" 테스트를 통과해야 한다(자산 2,000만 / 매출 4,000만 / 자기자본 200만. 통화는 관할별이다. ADGM 자기자본 $100만, SG S$1,000만, HK HK$800만/4,000만, JP 5억 엔 또는 QII, TH THB 1억/2억, MY RM 1,000만)
- UBO = 언제나 자연인까지 내려간다. 지분·의결권 20% 이상과 지배력을 행사하는 사람 전부를 수집한다(20%는 말레이시아의 하한이고 25% 관할도 함께 덮는다). 판단은 관할별로 한다. 기본은 25%와 지배력, 그리고 최고 경영진 폴백이지만 일본은 캐스케이드(단독 50% 초과, 아니면 25% 초과, 아니면 지배력, 아니면 대표이사)이고 **말레이시아는 20%**다. 임계값은 관할·리스크별 설정이다(고위험 10~15%도 지원한다)
- 서류 = 표준 일습(설립증명 · 정관 · 이사·주주 명부 · 등록 주소 증빙 · 이사회 결의나 위임장 · UBO 신고)에 관할별 추가분(SG ACRA Bizfile, HK NAR1, JP 등기사항증명서)
- 대표자 = 이사가 아닌 서명자에게는 이사회 결의나 위임장이 필요하다. 신원(라이브니스)만으로는 부족하다. 그 사람을 법인 기록에 연결해야 한다
- SumSub이 수집, 등기 조회, UBO 매핑, 스크리닝을 흡수한다(Enterprise 등급. 8월 구독 예정). 최종 수락·거부와 법적 책임은 Aset에 남는다
이유: 법인을 온보딩하려면 누가 소유·지배하는지와 자격이 있는지를 밝혀야 하는데, Reg S 자체는 그중 아무것도 주지 않는다(Reg S는 미국인이 아닐 것과 역외 거래일 것만 요구한다). 25% UBO 규칙 하나로는 일본에서 소유자를 잘못 짚고 말레이시아에서 덜 수집하므로, 모델이 관할별 설정으로 굴러가야 한다.
열린 항목(BD·법무 승인): 각 관할에서 Aset이 "보고 기관"인지(어느 AML 규정집이 우리를 구속하는지), 최종 출시 국가와 정확한 임계 값, 인도네시아의 DFA 대 증권 성격 규정, SumSub의 미국 외 프리셋 범위. 임계값은 자리표시자로 나간다.
참고: 03-kyc-identity → KYB. 17-changelog v3-75. 노션 "KYB 관할권 벤치마크 & 결정" + "KYB 활성화" + KYC/KYB 기획서.
v3-76 — 상환 플로우 정비: 리저브로 게이팅되는 즉시 정산 + 락업 하드 차단 ✅ Decided
v3-76 — 상환 플로우 정비
Date: 2026-07-16
결정: 노션 "Redemption Flow" QA에서 나온, 즉시 상환 경로에 대한 맞물린 변경 둘이다.
- 리저브로 게이팅되는 즉시 정산(감당되면 에스크로 없음). 즉시 상환 풀의
requestRedemption이 이제 요청 시점 리저브로 분기한다.reserveBalance ≥ payout이면 LP를 투자자에게서 바로 소각하고 같은 tx에서 USDC를 지급한다(상태는COMPLETED). 에스크로 잠금도, 별도approveRedemption도, 통지 창도 없다- 부족하면 LP를 에스크로에 넣고 요청은
PENDING_RESERVE에 머문다. 파트너가fundRedemption으로 보충하고(더 이상 자동 완료되지 않는다) 그다음 어드민의approveRedemption이 펀딩된 지급을 집행한다. 요청 시점에 FM에게 알린다(fm_shortfall).PENDING_RESERVE요청에 대한cancelRedemption은 파트너가 넣은 펀딩을 환불한다 - 입금이 90%를
fund_wallet으로 보내므로(리저브 10%) 현재 리저브 이하인 상환만 즉시 정산되고 더 큰 것은 펀드 펀딩 경로를 탄다
- 락업은 하드 차단이다(모델 X. docs 07의 3단계 락업과 맞춘다). LOCKED 동안(
now < invested_at + lockup_days. ⚠️ 앵커는 이제 풀의 구현체에 달려 있다. pool-wide 라운드부터는subscription_end_date + lockup_days이고 그전에는invested_at + lockup_days이며 둘 다 영구히 살아 있다(Clones). 이 항목이 정하는 하드 차단 규칙은 그대로이고lockup_end를 무엇으로 계산하는지만 바뀐다. 07 → 3-State Lockup Model 참조) 상환은 완전히 막힌다. 패널티만 무는 게 아니다.requestRedemption이RedemptionNotAllowed로 revert하고 백엔드(create.ts)와 FE의 상환 진입점 둘(포트폴리오 인라인과RedeemModal)도 막는다.penalty_type별 수수료는 EARLY 구간(lockup_end ≤ now < 만기)에만 적용된다. 청산은 락업을 면제한다. (v3-66이 NO_EARLY를lockup_days = 0에 묶었으나 v3-83이 대체했다. 락업은 패널티 타입과 독립이므로 NO_EARLY 풀도 LOCKED일 수 있다.)
이유: (1) 리저브로 감당되는 이탈은 남는 에스크로 없이 즉시여야 하고(투자자 경험), 자금이 모자란 이탈에는 여전히 파트너와 어드민의 집행 게이트가 필요하다. (2) 컨트랙트가 전에는 락업 중에도 패널티를 물리고 상환을 허용했는데(isLockupActive || isBeforeMaturity) 그것은 LOCKED가 상환 불가라는 문서의 3단계 모델과 모순이었다. 패널티 타입은 EARLY 구간을 위한 것이지 락업 구간을 위한 것이 아니다. 이제 온체인과 BE, FE에서 강제한다.
FE 표현: 에스크로된 LP(진행 중인 상환)는 포트폴리오에서 "🔒 locked in redemption"으로 보여 준다(여전히 position.tokens에 잡힌다). 결과 화면은 즉시 "Withdrawal Complete"와 대기 중인 "funds being prepared for payout"을 구분한다.
참고: 07-redemption 3-State Lockup. 12-triggers Early Redemption Penalty. RedemptionLib.requestRedemption / approveRedemption. 노션 "Redemption Flow".
v3-77 — 디자인 시스템을 공유 패키지 하나로 통합 (web과 admin의 기준) ✅ Decided
v3-77 — 공유 디자인 시스템 패키지
Date: 2026-07-16
결정: @aset/ds-editorial을 web과 admin 양쪽 프런트엔드 디자인 시스템의 유일한 기준으로 삼는다. 폰트, 색 팔레트, 타입 스케일, shadcn 시맨틱 토큰, 패턴 컴포넌트 다섯, 그리고 살아 있는 /styleguide 쇼케이스가 여기 있다. 앱별 global.css는 더 이상 자기 팔레트나 폰트를 정의하지 않는다.
이유: 토큰이 apps/web/global.css와 apps/admin-web/global.css에 중복돼 있었고(admin은 팔레트를 finance-dashboard-kit에서 가져왔다) 디자인 참조는 서로 연결되지 않은 손수 만든 사본들에 있었다(claude.ai 아티팩트 하나, 드라이브의 색상 HTML 하나). 그것들은 이미 어긋나 있었고 교체 전의 Fraunces/Inter 폰트를 여전히 보여 주고 있었다. 편집할 원본이 하나면 어긋남이 사라진다.
결과:
- 값은
packages/ds-editorial/src/tokens.css에, 폰트 로딩은fonts.css에, 컴포넌트는patterns.tsx에, 쇼케이스는showcase.tsx에 있다 - 두 앱 다
fonts.css를 먼저 임포트하고, (admin만) 킷을, 그다음 킷 뒤에tokens.css를 임포트해 Aset 팔레트가 이기게 한다. 이제 web과 admin이 똑같은 팔레트를 렌더한다 /styleguide는 두 앱에 다 붙어 있고 살아 있는 토큰과 컴포넌트를 렌더한다. 둘 다 보는 것이 동등성 확인이다- 옛 claude.ai 아티팩트와 드라이브 색상 HTML은 폐기됐다. 쓰지 말 것
참고: Design System (Frontend). packages/ds-editorial.
v3-78 — 풀 상태 플래그: 자동 해제로 배타성 확보, 그리고 wind-down은 freeze를 켜지 않는다 ✅ Decided
v3-78 — 상태 플래그 자동 해제와 wind-down의 비동결
⚠️ v3-92가 수정함: "같은 BE 쓰기에서 자동 해제"가 DB만 쓰는 방식으로 구현돼서, 컨트랙트의 독립적인
Pausable플래그와 어긋난다. 이제 자동 해제는 온체인unpause()도 보내고(에스컬레이션 tx 뒤에), 그것이 성공한 경우에만is_paused = false를 쓴다. Date: 2026-07-20
결정: 두 축 상태 모델(lifecycle_status enum과 독립적인 is_paused / is_emergency_frozen 플래그)을 유지한다. enum 하나로 합치지 않는다. 대신 플래그가 배타적인 것처럼 동작하게 만든다.
- 자동 해제: 더 심각한 상태를 켜면 같은 BE 쓰기에서 낮은 플래그가 지워진다(freeze, impairment, wind-down 실행이 전부
is_paused = false로 만든다). 상태를 되돌려도 낮은 플래그가 자동 복원되지는 않는다(수동 재설정).pause는 풀이ACTIVE이고 동결되지 않았을 때만 받는다 - wind-down은 더 이상 동결하지 않는다(옵션 A):
executeWindDown이is_emergency_frozen = true를 설정하지 않는다
이유: 플래그는 lifecycle 위에 얹히므로 여럿이 동시에 "켜짐"으로 읽힐 수 있었고 UI가 모호해 보였다. 자동 해제는 유효 상태가 하나임을 보장한다. wind-down 경우는 보기 문제를 넘어 더 나빴다. is_emergency_frozen이 우선순위에서 WIND_DOWN보다 위라, 청산된 풀이 "Frozen (all halted)"으로 표시되고 pro-rata 상환 경로를 숨겼다. 청산의 목적과 정반대다. 온체인에서는 requestRedemption에 whenNotFrozen 가드가 없고 RedemptionLib이 청산 중 상환을 허용하므로 상환은 언제나 됐다. 틀린 것은 DB 플래그와 FE 표시뿐이었다. enum 하나로 합치지 않은 이유는 컨트랙트가 whenActive / whenNotPaused / whenNotFrozen을 독립 modifier로 유지하고, IMPAIRED와 writedown은 enum 하나로 표현할 수 없는 직교 조합이기 때문이다.
결과:
- BE(
apps/infra/lambda):pools.post.wind-down이is_emergency_frozen을 빼고is_paused: false를 더한다.pools.post.freeze는 동결 시is_paused를 지운다.pools.post.impairment는 실행 시is_paused를 지운다.pools.post.pause는 ACTIVE이고 동결되지 않았음을 확인한다.tsc -b통과 - 투자자 web은 이미 각 상태를 올바르게 렌더한다(앞서 있던 paused/upcoming 사이드바 공백 버그는 따로 고쳤다). BE가 동결을 멈추면 청산은 "Winding down"과 상환 경로를 보여 준다
- 어드민
pool-controls재설계(현재 상태 표시 하나, CTA를 안전한 것부터 비가역인 것 순으로, 높은 상태가 낮은 CTA를 비활성화)는 admin-web 세션으로 넘긴다
참고: 10-status-machines → Auto-clear. CLAUDE.md의 풀 복합 상태. 17-changelog v3-78. Claude-Plan A1 명세.
v3-79 — 조기 이탈 패널티를 PRINCIPAL_BASED로(YIELD_BASED 폐기) + 부분 상환 정책 ✅ Decided
v3-79 — 원금 기준 패널티와 부분 상환 정책
⚠️ v3-84가 일부 대체함: 아래의 YIELD_BASED 폐기는 뒤집혔다. YIELD_BASED는 락업 풀에 대해 재도입됐다(락업 해제를 첫 수익 분배 이후로 게이팅한다). PRINCIPAL_BASED가 기본이라는 것과 부분·전액 정책은 그대로다. Date: 2026-07-20
결정:
PRINCIPAL_BASED를 기본 조기 이탈 패널티로 삼고(상환 원금의 %)YIELD_BASED를 폐기한다(생성 시 선택 불가. 기존 풀은 PRINCIPAL_BASED로 이관)- 부분 상환은
ACTIVE와IMPAIRED에서 기본 허용하고 패널티는 상환한 몫에만 pro-rata로 매긴다.MATURED와WIND_DOWN은 전액만 가능하다
이유: "발생 수익의 %" 패널티는 수익이 아직 안 쌓였거나 이미 청구된 경우 0이다. 정확히 그것이 지켜야 할 EARLY 구간에서 이탈을 막지 못한다. 벤치마크 조사에서 수익 % 패널티의 실제 선례를 찾지 못했다. 은행 CD는 약정 이자를 몰수하고(원금까지 물릴 수 있다), 토큰화 RWA 펀드(Ondo, Superstate, Franklin)는 조기 이탈 패널티가 아예 없으며, 수수료가 있는 곳은 원금의 정률이다(Goldfinch 0.5%, 채권형 펀드 2% 이하). pro-rata 부분 상환은 업계 표준이고(Maple, Centrifuge, Clearpool) 전액만 허용하는 것은 포지션을 닫는 종착 상태에서만 말이 된다.
결과 — 컨트랙트 변경도 컬럼 삭제도 없다:
- 컨트랙트는 이미
PRINCIPAL_BASED를grossAmount × penalty_rate_bps / 10000으로 구현하고 있고(RedemptionLib)grossAmount가 상환한 몫이므로 부분 pro-rata가 자동이며 즉시와 회차에서 동일하다.YIELD_BASED는 옛 clone을 위한 죽은 온체인 분기로 남는다 penalty_type/penalty_rate_bps/penalty_fee_amount컬럼은 전부 유지한다.YIELD_BASED는 enum 값이지 컬럼이 아니다- 남은 일(핸드오프): FE 생성 폼에서 YIELD_BASED 선택지 제거와 문구 수정. BE가 기존 YIELD_BASED 풀을 데이터 이관(⚠️ 요율은 그대로 옮기지 말고 다시 설정해야 한다. bps가 "수익의 %"였는데 이제 "원금의 %"다). BE가 MATURED와 WIND_DOWN에서 부분 상환을 거부(FE는 금액을 전액으로 잠근다). 온체인
FullRedemptionRequired가드는 다음 재배포 묶음으로 미룬다(안전 문제는 아니다)
참고: 07-redemption → Penalty Types / Partial vs Full. CLAUDE.md의 Penalty Types. 17-changelog v3-79. Claude-Plan B 명세와 B-BE-handoff.
v3-80 — 규칙 7번 개정: 어드민 생성·수정 입력은 percent를 받는다 (단방향 무손실 → bps) ✅ Decided
v3-80 — bps 비율 설정에 대한 어드민 percent 입력
Date: 2026-07-20
결정: 규칙 7번(그리고 어드민 입력을 bps로 유지하던 v3-73의 예외)을 개정한다. 어드민 생성·수정 폼은 이제 비율 설정(reserve_bps, redemption_gating_bps, penalty_rate_bps)을 percent로 받는다. 운영자가 2.5 같은 값을 입력하면 폼이 저장 시 bps로 변환한다(Math.round(pct × 100)). 저장, API payload, Lambda, 컨트랙트는 bps 그대로다.
이유: 날 bps를 입력하는 것("2.5%"를 "250"으로)은 실수하기 쉽고, 다른 모든 곳에서 그 값이 표시되는 방식(v3-73 이후 percent)과도 어긋난다. v3-73이 걱정했던 것, 즉 양방향 percent↔bps 다리가 변환을 틀리는 문제는 여기서 생기지 않는다. 이것은 입력 경계에서의 단방향 변환 한 번이고(입력한 % → 저장하는 bps) 왕복이 무손실이기 때문이다(percent 소수 2자리 ↔ 정수 bps, 1 bps = 0.01%).
결과와 범위:
- 허용: 어드민 입력의 단방향 % 변환(v3-80)과 기존의 표시 전용 포맷터(v3-73). 여전히 금지: percent 저장, 그리고 저장·전송·컨트랙트 계층의 모든 양방향 다리
- 검증: 어드민 입력은
isPercent(0~100, 소수 2자리)로 검증한 뒤 변환한다. 다른 모든 경로는isBps정수 검증을 유지한다 - 남은 일(핸드오프): admin-web 생성·수정 폼 필드에
suffix="%"와= X bps힌트를 넣고,payload.ts가 변환된 bps를 보낸다(구조는 그대로)
참고: CLAUDE.md 규칙 7번. 14-decisions v3-73. 17-changelog v3-80. Claude-Plan B 명세(B3).
v3-81 — 생성 검증과 REVOLVING·APY 표시: 만기 > 락업, REVOLVING 발행 예외, 연 단위 APY ✅ Decided
v3-81 — 만기·락업 가드와 REVOLVING·APY 표시
Date: 2026-07-20
결정:
- 만기는 락업보다 엄격히 커야 한다(
maturity_days > lockup_days). 같은 값은 거부한다(전에는≥였다). 경계는 그대로다.lockup_days = 0은 통과하고maturity_days = 0(REVOLVING)은 예외다 - REVOLVING 풀(만기 없음)은 발행 시
end_date요구에서 면제된다. 발행은 언제나start_date를 요구하고end_date와 만기는 기간제 풀에만 요구한다 - 투자자 FE는 개방형 풀의 APY를 연 단위 요율로 보여 준다. APY는 연 단위다. 기간제 풀은 남은 기간에 비례 배분해서 보여 주고("Est. value at maturity") 만기가 없는 REVOLVING 풀은 이제 "at maturity"라고 잘못 붙은 1년치 뭉치 대신 연환산 수익(
~$X / year)을 보여 준다
이유: 만기와 락업이 같으면 길이가 0인 EARLY 구간이 남는다(말이 안 된다). REVOLVING에는 종료일이 없으므로 그것을 요구하면 정당한 영구 풀의 발행이 막힌다. "만기에 $108"이라는 수치는 만기가 오지 않는 풀에 대해 변동하는 연 요율을 고정된 총수익인 것처럼 과장했다.
결과:
- 투자자 web(
invest-sidebar.tsx): 만기 없는 풀의 연 단위 표시를 구현했다.tsc -b통과 - 남은 일(핸드오프): FE 생성 검증에서
≤를 같은 값 거부로(3곳). BEpools.post.create와patch.update에 만기>락업 가드. BE 발행 게이트가 REVOLVING을 제외하고end_date를 요구
참고: 07-redemption → 3-State Lockup. 17-changelog v3-81. Claude-Plan B 명세(B4/B5)와 B-BE-handoff.
v3-82 — 파트너 펀딩이 PENDING_RESERVE 상환을 자동 정산한다 (어드민 승인 게이트 제거) ✅ Decided
v3-82 — 파트너 펀딩 시 자동 정산
Date: 2026-07-20
결정: 즉시 상환의 부족분 경로에서 fundRedemption이 풀 잔액이 지급액을 감당하는 순간 요청을 자동 정산한다. 같은 호출 안에서 LP를 소각하고 투자자에게 지급하고 COMPLETED로 만든다. 어드민의 approveRedemption 단계는 없다. 이는 펀딩된 지급을 어드민 집행에 걸었던 v3-76의 하위 결정을 뒤집는다.
- 리저브 검사는 요청 시점에 그대로 있다(v3-76).
reserveBalance ≥ payout이면 요청 시점에 즉시 정산하고, 부족하면 LP를 에스크로에 넣고PENDING_RESERVE와fm_shortfall알림을 낸다 - 파트너가 보충하면 자동 정산한다.
fundRedemption(파트너,YIELD_DEPOSITOR_ROLE)이 보충할 때마다 컨트랙트 총 잔액을 확인하고, 지급액을 감당하게 되면 즉시 공유 지급 경로를 호출한다(여전히 모자란 보충이면 요청은PENDING_RESERVE로 남아 다음 펀딩을 기다린다) - 즉시 경로에는 컴플라이언스나 이상치 보류가 없다. 완전 자동이고 어드민은 관여하지 않는다(결정: 보류 없음)
approveRedemption은 선택적 수동 정산으로만 남는다. 리저브가 독립적으로 늘어나(예: 새 입금) 아직PENDING_RESERVE인 요청을 감당하게 됐는데 허가 없는claimRedemptionFallback의 통지 기간이 아직 안 지난 드문 경우를 위한 것이다. 평상시 흐름의 일부가 아니다
이유: v3-76의 어드민 집행 게이트는 파트너가 온체인에서 지급액을 채운 뒤에는 아무 가치가 없는 수동 단계를 더했다. 돈이 이미 풀에 있으므로 정산은 자동이어야 한다. 백엔드(fund.ts와 fundRedemptionOnChain)는 이미 자동 완료를 전제로 쓰여 있었고 v3-76이 컨트랙트만 바꿔서 BE와 컨트랙트가 어긋나 있었다. 이 결정은 백엔드가 기대하던 자동 완료를 되살리고 제품 의도("FM이 리저브를 채우면 밀려있던 요청이 자동 처리")와 맞춘다.
결과:
- 컨트랙트
RedemptionLib.fundRedemption(즉시)이 감당되면 자동 정산한다.approveRedemption주석은 "선택적 수동 정산"으로 고쳐 썼다. Foundry: 307개 테스트 통과(test_FundRedemptionAutoCompletes와test_PartialFundStaysPendingReserve추가.test_ApproveRedemptionByOracle은 수동 경로용으로 용도를 바꿨다). PlatformPool 크기는 19,480 / 24,576이다(여유 변화 없음) - 백엔드는 이미 처리하고 있었다(
fund.ts가onChainResult.completed일 때complete_redemption_atomic으로 완료한다). 로직 변경이 필요 없다 - [사용자] 컨트랙트 재배포가 필요하다. 새 풀이 이 동작을 받고 이미 배포된 풀은 옛 바이트코드를 유지한다
참고: 07-redemption → Instant Redemption Flow. v3-76(뒤집힌 하위 결정). RedemptionLib.fundRedemption.
v3-83 — 락업은 패널티 타입과 독립이다 (NO_EARLY와 lockup=0의 결합 제거) ✅ Decided
v3-83 — 락업과 패널티 타입은 서로 독립
Date: 2026-07-20
결정: lockup_days와 penalty_type은 독립적인 설정 축이다(v3-66을 대체한다).
- 락업(
lockup_days)은 LOCKED 구간을 정하고, 그 동안에는 패널티 타입과 무관하게 상환이 완전히 막힌다 - 조기 이탈 패널티(
penalty_type과 요율)는 EARLY 구간에서만(락업이 끝난 뒤 만기 전) 물리는 수수료다.NO_EARLY는 그 수수료가 0이라는 뜻이지 "락업 없음"이나 "아무 때나 이탈"을 뜻하지 않는다 - 그래서
NO_EARLY+lockup_days > 0(잠긴 뒤 무패널티 이탈)은 유효한 설정이고,NO_EARLY에 어떤redemption_type을 붙여도 마찬가지다
이유: RWA 관점에서 둘은 진짜로 다르다. "30일 락업 뒤 무패널티 상환" 상품은 평범하다. v3-66은 NO_EARLY를 "t0부터 자유 이탈"로 정의해 둘을 뒤섞었고, 그 바람에 lockup_days = 0과 ON_DEMAND를 둘 다 강제했다. 생성 위저드에서는 막다른 길이 됐다. 3단계가 "5단계에서" 패널티를 요구하는데 5단계는 만기가 없으면 패널티 선택기를 숨겨서, 그 요구를 만족시킬 방법이 없었다. 상환 런타임은 이미 락업과 패널티를 독립으로 다루고 있었다(RedemptionLib: LOCKED는 패널티와 무관하게 막고 NO_EARLY는 패널티 0이다). 결합을 코드로 새긴 것은 설정 시점 가드뿐이었다.
결과:
- 컨트랙트:
GovernanceLib.setLockupDays의NoEarlyRequiresZeroLockup가드와PoolConfigLib의NO_EARLY ⇒ ON_DEMAND가드를 제거하고, 이제 쓰이지 않는NoEarlyRequiresZeroLockup에러를 삭제했다(PlatformPool과 GovernanceLib). Foundry: 307개 테스트 통과(revert 테스트 3개를 성공 테스트로 바꾸고 락업+NO_EARLY상환 단언을 새로 넣었다). ABI를 다시 생성했다(에러 제거. selector 변경 없음) - 백엔드:
pools.post.create와pools.patch.update가NO_EARLY+ 락업이나NO_EARLY+ 비ON_DEMAND를 더 이상 거부하지 않는다 - FE: 생성 위저드에서 3단계의 락업↔패널티 게이트와
NO_EARLY⇒ON_DEMAND강제를 뺐다. 투자자 web의effectiveLockupDays가 모든 패널티 타입에 대해 실제lockup_days를 반환하고, 포트폴리오의 상환 게이트는 순전히 LOCKED만 본다(NO_EARLY풀도 이제 LOCKED일 수 있다). web과 admin-web 모두tsc -b통과 - [사용자] 컨트랙트 재배포가 필요하다. 제거된 가드는 새로 배포되는 풀에만 영향을 준다(clone 기반이고 v3-66 가드는 아직 배포 대기 중이었다)
참고: 07-redemption → 3-State Lockup. 04-pool-models → penalty_type. 24-field-governance. 17-changelog v3-83. v3-66을 대체한다. CLAUDE.md의 Penalty Types.
v3-84 — YIELD_BASED 패널티 재도입(락업 풀 전용, 해제는 첫 수익 분배 이후). v3-79의 폐기를 뒤집는다 ✅ Decided
v3-84 — 락업 게이팅과 함께 YIELD_BASED 재도입
Date: 2026-07-20
결정: YIELD_BASED를 유효한 조기 이탈 패널티 타입으로 되살린다. v3-79의 폐기 부분을 뒤집는 것이다. PRINCIPAL_BASED가 기본으로 남고 v3-79의 부분·전액 상환 정책도 그대로다. YIELD_BASED에는 안전장치 둘이 붙는다.
- 락업 풀 전용.
lockup_days > 0일 때만 고를 수 있다. 락업 없는 풀은 쓸 수 없다 - 락업 해제는 그 풀의 첫 수익 분배 이후로 게이팅된다. LOCKED 구간은 첫
distributeYield전에 끝날 수 없다. 그래서 투자자가 발생 수익 0인 채로 EARLY 구간에 들어가는 일이 없다
계산: penalty = accrued_yield × penalty_rate_bps / 10000. 투자자가 쌓은 수익의 일정 %이고 상환 원금에서 차감한다. payout = 상환 원금 − penalty이고 패널티는 풀 리저브로 간다(다른 타입과 같다). 이미 분배된 수익은 투자자에게 남는다. 이미 지급된 수익은 회수할 수 없으므로 그 회수분을 원금에서 뗀다.
이유(v3-79의 논리를 뒤집는다): v3-79는 "발생 수익의 %" 패널티가 수익이 안 쌓였으면 0이라 정확히 그것이 지켜야 할 EARLY 구간에서 무력하다는 이유로 폐기했다. 안전장치 둘이 그 실패 양상을 구조적으로 없앤다. YIELD_BASED는 락업 풀에만 존재하고, 락업은 첫 수익 분배 전에 풀릴 수 없다. 그래서 EARLY가 시작될 때는 반드시 수익이 쌓여 있고 패널티가 물린다. "일찍 나가면 번 수익의 일부를 회수한다"는 일관된 억제책이고, 락업 상품에 맞는다. 그리고 실제로 계약된 풀 하나를 되살린다. Joob(SSA §4.2 = "배당의 50% 몰수")은 정확히 YIELD_BASED 5000 bps에 대응하는데(20-joob-pool-config 참조) v3-79의 폐기는 그것과 모순이었다.
차감 대상 — 2026-07-21 결정: 원금에서. 패널티는 수익이 아니라 상환 원금에서 뗀다(payout = 상환 원금 − penalty). 이미 분배된 수익은 지급된 뒤에는 회수할 수 없으므로 억제책이 돌려주는 금액을 줄이는 방식이어야 한다. 이를 위해 컨트랙트 변경과 재배포가 필요했고 이후 반영됐다. 배포된 RedemptionLib은 모든 패널티 타입에 대해 payout = 상환 원금 − penalty를 계산하고 패널티를 fund_wallet으로 보낸다(v3-85가 원래의 reserveBalance 목적지를 대체했다). isYieldPenalty는 ABI 안정성을 위해 요청 struct에 남지만 언제나 false다.
결과와 남은 일(핸드오프):
- 컨트랙트: ✅ 반영됨. 배포된
RedemptionLib이 YIELD_BASED에 대해payout = 상환 원금 − penalty를 계산하고 패널티를fund_wallet으로 보낸다(v3-85). 요율은RedemptionConfig를 통해 적용된다(v3-18 수정. 더 이상 수익 전액을 몰수하지 않는다).isYieldPenalty는 ABI 안정성을 위해 struct에 남지만 언제나false다. 첫 수익 분배 이후 락업 해제 게이트는 새 불변식이다. 먼저 오프체인(BE)에서 강제하고 필요하면 온체인 가드를 후속으로 넣는다 - 백엔드:
pools.post.create와pools.patch.update에서lockup_days > 0일 때만YIELD_BASED를 다시 허용한다. 락업 해제 ≥ 첫 수익 분배를 강제한다. 이 타입에서penalty_rate_bps는 "발생 수익의 %"를 뜻한다(PRINCIPAL_BASED의 "원금의 %"와 대비된다. ⚠️ 타입을 바꿀 때 기준을 다시 확인하지 않고 요율을 그대로 옮기지 말 것) - FE: 생성 화면에 YIELD_BASED 선택지를 되살리되 락업이 설정된 뒤에만 보여 준다. 문구는 "조기 이탈 시 번 수익의 %를 회수한다"고 설명한다
참고: 07-redemption → Penalty Types. 20-joob-pool-config. 17-changelog v3-84. v3-79의 폐기를 뒤집는다. CLAUDE.md의 Penalty Types. 출처: JY(제품), 2026-07-20 Redemption Flow QA.
⚠️ 패널티 목적지는 v3-85가 대체했다(2026-07-21): 위의 "→ 풀 리저브"는 뒤집혔다. 패널티는 이제
reserveBalance가 아니라 **fund_wallet**으로 간다. 여기의payout = 원금 − penalty차감 규칙은 그대로다.
v3-85 — 조기 이탈 패널티의 목적지는 풀 리저브가 아니라 fund_wallet이다 (모든 패널티 타입) ✅ Decided
v3-85 — 패널티는 fund_wallet으로 (v3-84의 리저브 목적지를 뒤집는다)
Date: 2026-07-21
결정: 모든 조기 이탈 패널티(PRINCIPAL_BASED, FLAT_FEE, YIELD_BASED. 즉시와 회차 경로 모두)는 reserveBalance에 적립되지 않고 그 풀의 fund_wallet, 즉 펀드 매니저의 자본 지갑으로 이체된다. v3-84의 "패널티 → 풀 리저브" 목적지와, PRINCIPAL_BASED·FLAT_FEE에 대해 암묵적이던 이전의 리저브 목적지를 뒤집는다. 패널티의 금액과 산식은 그대로다. 어디로 가는지만 바뀐다.
이유: 조기 이탈 패널티가 풀이 아니라 펀드 매니저의 몫이라는 제품 결정이다(JY, Redemption Flow QA). ⚠️ 경제적 함의(의도한 것이다. 실제 메커니즘에 주의): 평시 navPerToken은 오라클이 설정하고 reserveBalance와 무관하다(리저브가 NAV에 들어가는 것은 WIND_DOWN뿐이고 거기서는 nav = reserveBalance / totalSupply다). 그래서 패널티를 리저브에 넣어도 남은 LP의 평상시 NAV가 오르지는 않았다. 실제로 하던 일은 둘이다. (1) 패널티를 풀의 상환 유동성 버퍼에 남겨 PENDING_RESERVE 부족을 줄였고, (2) 남은 LP의 청산 pro-rata 회수액을 높였다. fund_wallet으로 보내면 그 둘이 사라지고 패널티가 FM에게 간다. 목적지 확정(JY, 2026-07-21). fund_wallet 결정은 최종이고 구현을 진행해도 된다. 경제 논리와 투자자용 설명 문구는 Hana와 정리 중인데, 그것이 목적지를 다시 열지는 않는다.
결과와 남은 일:
- 컨트랙트(
RedemptionLib.sol): 패널티를 적립하던 모든 지점에서s.reserveBalance += penalty를 패널티의safeTransfer(요청 스테이블코인 단위로 되돌린 값)로 바꿔s.fundWallet에 보낸다. 즉시 경로의 리저브 충분 분기,_executeRedemptionPayout,_applyEpochPenalty(PRINCIPAL_BASED / FLAT_FEE / YIELD_BASED)다. 그리고reserveBalance와totalDeposited에서 패널티만큼 차감해 풀의 회계가 실제로 빠져나간 USDC와 맞게 한다. v3-84의 YIELD_BASED 지급 변경(payout = 원금 − penalty)과 함께 낸다. ⚠️ 재배포 필요(새 풀). 배포된 컨트랙트는 CH가 재배포하기 전까지 리저브 동작을 유지한다 - 백엔드·FE: 패널티 계산은 온체인이 기준이므로(v3-18) 오프체인 재계산은 없다. "패널티 → 리저브 / 풀에 이익"이라고 말하던 표시 문구를 "→ 펀드 매니저"로 고친다
참고: 07-redemption → Penalty Types. 17-changelog v3-85. v3-84의 패널티 목적지를 대체한다. CLAUDE.md의 Penalty Types. B-BE-handoff §1. 출처: JY(제품), 2026-07-21 Redemption Flow QA.
v3-86 — Audit Log 재정의: 감사 우선 기준 + 행위자 스냅샷 + 쓰기 범위 확대 + Audit와 Activity 분리 🔧 Decided · 🛠 Built · deep-link + failure paths open
v3-86 — Audit Log 재기획 (느슨했던 v3-41의 틀에 정체성을 못 박는다)
Date: 2026-07-21
배경: 감사 로그는 v3-41(두 스트림 피드) 위에 조각조각 얹혔고 그 로그가 무엇인지는 한 번도 정해지지 않았다. 그래서 actor_id는 저장되지만 해석된 적이 없어 UI가 날 UUID나 "Unknown", "SYSTEM"을 보여 줬고(2026-07-20 주간: "Actor ID → 사용자 이름 + 역할") 범위와 불변성도 임기응변이었다.
결정(6개 층):
- 정체성: 감사 기록이 **기준(SoT)**이고, 운영용 Activity 피드는 그 위에 얹은 읽기 뷰이지 동등한 스트림이 아니다
- 쓰기 범위: 돈, 권한, 투자자 상태, 풀·펀드 상태, 민감 데이터를 건드리는 사람의 재량 행위만 쓴다. 경제와 온체인 이벤트는 읽기 뷰의 UNION으로만 남는다(이중 쓰기 없음. v3-41 유지). 그동안 안 쓰던 구멍을 메운다. 풀·트랜치의 생성·배포·발행·수정·삭제, 펀드 CRUD와 펀드 멤버, 어드민 사용자 CRUD와 권한, 어드민 지갑 바인딩·검증, 투자자 자격·등급이다
- 행위자: 하이브리드다. 사람의 쓰기 스트림은 쓰는 시점에
actor_name과actor_role을 스냅샷으로 뜨고(조인은 폴백일 뿐이다), 투자자 경제 이벤트는users조인으로 해석하며, 시스템과 스케줄러는 "System"이다. 표시는이름 · 역할 배지이고 온체인 서명자는 System과tx_hash다 - 스키마: 표준 기록은 언제 · 누가(스냅샷) · 무엇을 · 대상(라벨 스냅샷) · **변경(before→after, 구조화) ** · 왜(
reason) · 증빙(tx_hash) · 심각도 · 결과다.reason은 고위험 행위(impair, wind-down, freeze, 권한, 자격)에만 필수다. 실패한 시도는outcome=failure로 기록한다 - 정보 구조: Audit / Activity 두 탭이다(Audit는 사람의 쓰기 스트림이고 기본. Activity는 경제와 온체인 전체 피드). 필터는 날짜 · 행위자 검색 · 분류 · 결과 · 심각도다. 엔티티 범위 딥링크가 있다. FM은 자기 펀드의 Activity만 본다(내부 감사, 권한, PII는 못 본다)
- 보관과 불변성: DB 수준 append-only다(UPDATE와 DELETE를 회수하고 아카이브 경로만 행을 제거할 수 있다). 5년 보관 뒤 뜨거운 테이블 밖으로 콜드 아카이브하고 피드에서 뺀다(지금의 하드 퍼지를 대체한다). 기록은 옮겨질 뿐 파괴되지 않는다. 해시 체인 위변조 탐지는 미룬다
결과와 남은 일:
- DB(0080~0082, 완료):
activity_events에actor_name/actor_role/actor_type/entity_label/before_state/after_state/reason/outcome을 더했다. BEFORE UPDATE/DELETE 트리거가 Lambda 서비스 키를 포함한 모든 role에 대해 테이블을 append-only로 만든다.audit_feed를 행위자 해석과actor_type을 담아 다시 만들었다. 아카이브 스케줄러는 이제 **archive_expired_activity_events()**를 호출해 오래된 행을 하드 퍼지 대신 새activity_events_archive콜드 테이블로 옮긴다(원자적 INSERT + DELETE). S3 콜드 스토리지는 나중의 개선이지 지금 나간 것이 아니다. 11-db-schema와 13-operations 참조 - BE(완료):
recordActivity()가 스냅샷 행위자,entity_label, before-after,reason,outcome을 받는다(lib/shared/audit/activity.ts). 구멍 4개 분류 전부에 라이터를 넣었다. 풀 생성·수정·삭제·배포·발행(pools.post.create,.patch.update,.delete,.worker.deploy,.post.lifecycle), 펀드 CRUD와 펀드 멤버(funds.*,fund-members.*), 어드민 사용자 CRUD와 권한, 지갑 바인딩(admin-users.*,admin-wallet.post.verify), 투자자 자격(users.patch.qualification), 그리고 A4 경로의NAV_APPROVE/NAV_OVERRIDE다. 고위험 행위(impairment 제안, 비상 동결, 자격 변경)에서는 핸들러가reason을 400으로 강제한다 - FE(
admin-web/audit-log.tsx, 완료): Audit와 Activity 탭이고 FM은 자기 펀드의 Activity에 묶인다.actor_name과 역할 배지, 또는 System. 날짜 · 검색 · 분류 · 결과 · 심각도 필터에 서버가 계산한 건수가 붙는다. 펼친 행에 before→after와 실패 상세가 나온다. 고위험 확인 모달에는 사유 입력이 필수다(pool-controls.tsx의requireReason) - 열린 항목: 엔티티 딥링크. 대상이 아직 링크가 아니라
entity_label · type/id평문으로 렌더된다. 실패 경로 범위가 얇다. 오늘outcome: 'failure'를 쓰는 것은pools.worker.deploy뿐이다. 말레이시아 관할의 6~7년 보관(법무). 스냅샷 이전actor_id의 백필은 없다(조인 폴백뿐). 해시 체인은 규제 당국이 요구할 때만.activity_events_archive테이블 뒤의 S3 콜드 스토리지
참고: 13-operations → Audit Log Retention & Export. 17-changelog v3-86. v3-41을 확장한다. 11-db-schema. 노션 "Admin-web Audit Log". 출처: JY(제품), 2026-07-20 주간 + 2026-07-21.
v3-89 — 상환 용어: OPEN_ENDED가 표준이다("revolving"은 UX 별칭). 개방형 풀에는 조기 이탈 패널티가 없다 ✅ Decided
v3-89 — OPEN_ENDED 표준화와 개방형 풀의 무패널티
Date: 2026-07-23
배경: 3단계 락업 모델이 "REVOLVING"을 마치 maturity_model 값인 것처럼 썼는데, enum 값은 FIXED_TERM과 OPEN_ENDED뿐이다("REVOLVING"은 표시·UX 문구이고 API는 거부한다). 표 자체도 개방형 풀에 대해 자기모순이었다. EARLY 행은 "REVOLVING에서는 만기 없음"이라고 적어 패널티가 붙는다고 했고, FREE 행과 예외 설명은 락업이 끝나면 바로 FREE라고(패널티 없음) 했다.
결정:
- 모델 —
OPEN_ENDED(만기 없음) 풀에는 EARLY 구간이 없다. 락업이 끝나면 곧바로 LOCKED → FREE이므로 조기 이탈 패널티가 붙을 일이 없다. EARLY와 그에 따른 패널티는 만기가 있는FIXED_TERM풀에만 존재한다. 컨트랙트와 맞고(maturityDate = 0이면now < maturityDate가 거짓이므로 EARLY가 아니다) 생성·수정 위저드와도 맞는다(만기가 없으면 패널티 절이 숨는다, v3-88 D2). EARLY 행의 "REVOLVING에서는 만기 없음"이 버그였고 제거했다 - 용어 —
OPEN_ENDED가 표준이고 규범이다(DB enum, 핸들러, 컨트랙트). "revolving"은 제품·UX 별칭이고(기초 자산이 회전한다는 뜻이다. 예를 들어 3~6개월 대출) 정의를 적은 딱 한 자리에만 남긴다. 규칙이나 표에서 실효적인 값으로는 절대 쓰지 않는다.07-redemption(3단계 표와 회차 절)과04-pool-models(회차 축)을 정규화했고, "Epoch-Based Redemption (REVOLVING pools)" 제목을 "(Open-Ended Pools)"로 바꾸면서 상호 링크 10개를 다시 가리켰다
결과: 문서만 바뀐다. 07-redemption, 04-pool-models(그리고 6개 파일의 앵커 갱신). LINE BK 예시(OPEN_ENDED + PRINCIPAL_BASED)는 무기력한 설정으로 표시했다. 패널티를 정말 의도했다면 LINE BK가 FIXED_TERM으로 바꿔야 한다. 과거의 결정·changelog 항목은 원래의 "REVOLVING" 표현을 유지하고 섹션 링크만 다시 가리켰다.
참고: 07-redemption → Epoch-Based Redemption, 04-pool-models → maturity_model. 17-changelog v3-89. v3-46을 명확히 한다. 출처: JY(제품), 2026-07-23.
v3-90 — Joob 펀드 데이터 의미: NPL은 DPD 90, 상각은 약 180 DPD, 펀드 가치는 현금 수취 기준, 갱신은 EOD 09:00 ✅ Decided
v3-90 — Joob의 손실 인식과 펀드 가치 기준 (2026-07-24 통화)
Date: 2026-07-24
배경: 오래 열려 있던 Joob 데이터 요청 두 건, 상각 시점과 current_fund_value가 발생 기준인지 현금 기준인지가 2026-07-24 Joob 통화에서 정리됐다.
결정(Joob 확인):
- NPL은 DPD 90, 상각은 약 6개월(약 180 DPD). 대출은 90 DPD에서 부실로 분류되지만 장부에 남는다. DPD 90 버킷에 머물면서 미상환 원금에 계속 잡히고
npl_ratio를 채운다. 실제 상각(장부에서 제거되어 실현cumulative_loss가 된다. Joob의 별도 "상각·회수 중" 항목)은 대략 **6개월(약 180 DPD)**에 일어난다. 그래서npl_threshold_days = 90과write_off_policy ≈ 180은 겹치지 않는다. 90~180 DPD 사이에 장부상 NPL 구간이 실제로 있고 리스크 배지의 선행 NPL 신호가 정상적으로 발동한다(예: 2026-07-24 스냅샷 약 10.6% NPL → HIGH). Joob이 90~180 사이에 OJK 단계별로 부분 충당을 잡는지는 확인되지 않았다. 자동 DPD→NAV 파이프라인이 미뤄져 있어(NAV는 수동) 지금은 의미가 없다 current_fund_value는 현금 수취 기준이다. 펀드 가치는 실제로 수취한 이자만 세고 승인됐지만 미수인 이자는 세지 않는다. 발생 이자는 현금으로 정산될 때까지 제외된다. 현재 승인에서 수취까지의 지연은 1~2일이다- 주기는 EOD 매일 09:00이다. Joob이 펀드 가치도 DPD처럼 매일 아침 09:00에 갱신되는 EOD 파이프라인으로 옮기고 있어서, 수집은 하루 한 번 들어온다
- Aset은 현금 기준
fund_value를 미러링하고accrued_income요청은 철회한다. 승인에서 수취까지 지연이 1~2일뿐이고(연 15% APY에서 하루 이자는 약 0.04%로 NAV 표시 정밀도 아래다) Aset은 NAV를원금 + 현금 + 발생 − 손실로 재구성하는 대신 Joob이 보고하는 현금fund_value를 그대로 수집한다.accrued_incomeAPI 요청(Joob 데이터 요청 §2)은 철회한다. 그 스냅샷 컬럼(마이그레이션 0068)은 잠자는 채로 둔다. 유의미한(예: 분기 단위) 발생 불일치가 나타나면 다시 본다. 투자자에게 보이는 발생 수익은 영향이 없다(이 펀드 수준 수치가 아니라yield_distributions에서 계산한다)
결과: 문서만 바뀐다(의미와 설정이고 스키마나 컨트랙트 변경 없음). Joob의 npl_threshold_days = 90과 write_off_policy ≈ 180 DPD(약 6개월)를 못 박고, NPL과 상각이 겹치지 않고 별개임을 확정하며, 펀드 가치 기준을 수취분 미러링으로 정리한다(발생분은 철회). 이 풀에 대해 리스크 배지의 선행 NPL 신호는 정상 발동한다(코드 변경 불필요). 미뤄진 자동 DPD→NAV 상각(v3-13)은 영향이 없다. 여전히 대출별 데이터에 막혀 있고 NAV는 수동이다. 상각 시점과 펀드 가치 발생 기준에 대한 Joob 데이터 요청의 열린 항목이 해소된다.
참고: 20-joob-pool-config → Fund value basis, 06-writedown-nav → NPL vs Cumulative Loss. v3-72와 미뤄진 자동 DPD 상각(v3-13)을 다듬는다. 17-changelog v3-90. 출처: Joob 통화, 2026-07-24.
v3-91 — 회차 상환 엔진 재설계: 달력 앵커 일정 + 수요 동결 + 정산 에스크로(이중 커밋 수정) + 요청 시 수익 정지 ✅ Decided · 🛠 Shipped (settlement escrow unconfirmed)
v3-91 — 회차 상환 엔진 재설계 (2026-07-24 감사와 설계 세션)
Date: 2026-07-24 · 상태: 결정됐고 아직 구현되지 않았다. 현재 온체인 회차 모델(07-redemption → Epoch-Based Redemption 참조)은 그대로다. 이것은 CH에게 넘긴 컨트랙트 재설계의 목표다.
⚠️ v3-93이 수정함: 아래의 동결은 접수 커서 분리만으로가 아니라 요청 창 하드 게이트로 이룬다(
requestRedemption이 창 밖에서 revert한다). 커서 분리만으로는 요청을 거절하는 일이 없어서 이 결정이 조용히 모델 A로 되돌아간다. v3-93은 일정의 기준점도 고친다(월말 규칙도 마감도 아니라 주기마다 어드민이 입력하는 펀딩일이다). 그리고 semi-auto 손잡이를 요청 창이 열리기 전까지만 자유롭게 못 박고, 취소를 요청 창 범위로 한정한다.
배경: RedemptionLib과 YieldLib을 회차 제품 의도와 한 줄씩 대조한 감사에서, 함께 고쳐야만 닫히는 맞물린 구멍 넷과 확정된 정책 몇 가지가 나왔다. 출처 기록: 노션 "Epoch Redemption — 기획 방향 & 코드 검수 정리"(CH 핸드오프).
결정 — 엔진 재설계 하나(🔴A 일정 + 🔴B 이중 커밋 + 🔴G 자금 격리 + 동결)와 정책:
- 달력 앵커 일정(지연 시작과 연속 회차를 대체). 경계를 첫 상환 요청이 아니라 풀 생성 시 고정한
fundingAnchor에서 결정론적으로 유도한다. 새 설정 축은scheduleType(월·분기),fundingAnchor,requestWindowDays,recallLeadDays다. ⚠️ 정정. 이 항목은 거꾸로 적혀 있었다. 원래 문장은 "달력 기반이고 주 배수 28/84일 근사는 폐기한다"였다. 반대가 결정됐고(2026-07-27) 반영됐다. 28/84일 주 배수가 열린 실패 기본값이 전진하는 단위다. 컨트랙트가 달력 산술을 절대 하지 않게 하려는 것이고 v3-93 §2도 그렇게 적고 있다. 진짜 월말 규칙은 온체인에서 비싸고, 어드민이 주기마다 실제 날짜를 입력하므로 무의미하다고 판단해 기각했다. 컨트랙트에는scheduleType필드가 아예 없다.epochDurationDays가 기간을 담고MONTHLY → 28/QUARTERLY → 84매핑은 BE 쪽에 있다. 역산:신청마감 = fundingAnchor − recallLeadDays,신청시작 = 신청마감 − requestWindowDays,claim = fundingAnchor(펀딩일)다. processingDays = 0이다(온체인 정산은 당일이고, 유일한 사람의 검토인 NAV 승인은 펀딩 이전에 고정된다. 월 단위 NAV 주기다). 프리셋: 월 7/10, 분기 14/21(기본값이고 풀마다 어드민이 수정할 수 있다) - 마감 시점의 수요 동결(A1+A2). 정산 커서(
currentEpochId)와 접수 커서(acceptingEpochId = floor((now − anchor) / duration) + 1)를 분리한다. 마감 뒤 요청은 다음 회차에 등록된다. 이것이 닫히는 회차의 수요를 동결해 확정된 상환 총액을 발행자(FM)에게 보내 회수를 발동할 수 있게 한다.fundRedemption보충은 접수 커서가 아니라 정산 중인(동결된) 회차를 대상으로 한다(아니면 돈이 엉뚱한 회차에 들어가고recallLeadDays가 깨진다) recallLeadDays게이트.executeEpoch는 요청 창이 끝난 직후가 아니라cutoff + recallLeadDays뒤에만 허용된다. 발행자에게 회수·펀딩 창을 준다. 이상치는 기존 자동 게이트(서킷 브레이커, NAV 낡음, 이탈 상한)와 청구 보류로 처리한다- 정산 예약 — 이중 커밋 수정(🔴B)과 자금 격리(🔴G). 정산 시
executeEpoch가 체결된gross를 유동성 버킷에서 즉시 차감해(epochFundTopUp[id]→heldFundReleases→reserveBalance) 예약해 둔다.claimRedemption은 살아 있는reserveBalance나heldFundReleases가 아니라 그 예약분에서만 가져간다. 연속된 회차가 같은 R/H를 두 번 세던 버그(정산은 약속만 했고 차감은 청구 시점에 일어났다)를 없애고, 수익과 리저브, 상환 담보가 공유 컨트랙트 잔액을 서로 빼먹는 것을 막는다.epochFundTopUp유령(체결에는 세면서 차감된 적이 없다)도 고친다. ⚠️ 형태는 v3-100이 대체했다. 이 항목은 원래 회차별epochClaimable[id]항아리를 명시했다. 2026-07-27에 철회했다. 청구는 회차를 가로지르므로(H[latest] − H[vintage]) 회차별 항아리를 비우는 것은 O(V)이고 O(1) 보장을 깨뜨린다. 실제로 나간 방식은redemptionCommitted스칼라 하나이고 불변식은physicalBalance ≥ reserveBalance + heldFundReleases + redemptionCommitted + unclaimedYield다 - 수익은 요청 시점에 멈춘다(C3 + I. 한 묶음). ⚠️ v3-131 (3)이 대체했다(2026-08-20 반영). 이대로 구현하지 말 것. 이것은 잠긴 LP가 청구 때까지 계속 발생하던 "방식 (b)"를 뒤집어
lockedYield적립을 없애고 에스크로된 LP를 분모에서 뺐으며(totalSupply() − balanceOf(self)) 에스크로 기간의 수익을 남은 홀더에게 전부 보냈다. 살아남은 절반은 진단이다. 청구 때까지 적립하면 투자자가 자기 청구를 미룬 데 대해 보상하게 된다. 살아남지 못한 절반은 처방이다. 요청 시점에 멈추는 것은 절약이 아니라 재분배이고, 만기에 모든 홀더가 요청해야 하는 풀에서는 분모를 0으로 만들어 쿠폰을 아무에게도 빚지지 않게 했다. 멈춤은 이제 그 둘 사이인 정산 시점에 있다 - 이월 우선 FIFO(C8). 다음 회차는 새 수요보다 이월된(미체결) 수요를 먼저 채운다. 2단 사다리(이월 버킷과 신규 버킷. 각각 내부에서 pro-rata)로 청구와 정산의 O(1)을 유지한다
- NAV 제안·승인·덮어쓰기와 타당성 하한(A3 + C9/H). BE가 NAV를 계산해 제안하고, 승인자가 받아들이거나 덮어쓴다(덮어쓰면 감사 로그에 누가·언제·제안값→적용값이 남는다). 둘 다 이탈 상한과 버그 방지용 하한
NAV > 0을 통과해야 한다(0.5 같은 의미 있는 하한은 두지 않는다. 규칙 2에 따라 실제 손실은 통과해야 한다)
결과: 컨트랙트 재설계(CH). 🔴A + 🔴B + 🔴G와 동결은 하나의 PR, 하나의 감사 단위로 나가야 한다(상태를 공유한다). C3+I(수익)와 C8(FIFO)은 엔진이 자리 잡은 뒤 그 위에 얹는다. 컨트랙트 외 후속(JY, 노션 PRD 페이지에서 추적): FM 상환 금액 알림, 투자자 회차 날짜 표시와 타임라인 바, 생성·수정 일정 UI와 semi-auto 펀딩일 운영, NAV 승인·덮어쓰기 UI, INVESTOR_CANCELLED 보고 분리. (LIQUIDITY_WINDOWS는 더 이상 후속이 아니다. 마이그레이션 0106이 값을 아예 제거했고 pools.post.create에도 참조가 없다.)
참고: 07-redemption → Epoch-Based Redemption. v3-26의 회차 동작과 v3-18의 수익 방식을 개정한다. v3-46·v3-38과 맞물린다. 17-changelog v3-91. 출처: 감사와 설계 세션(JY), 노션 "Epoch Redemption" 핸드오프, 2026-07-24.
v3-88 — 어드민 상환 단계 위저드: 일관성과 설정 가드 (D1~D9) ✅ Decided
v3-88 — 어드민 풀 생성·수정의 상환 단계 보강
Date: 2026-07-23
배경: 어드민 풀 생성·수정의 Redemption & penalty 단계가 의미 없는 설정이나 상환을 영구히 막는 설정을 표현할 수 있었고, 필드 라벨도 오해를 불렀다. 온체인 RedemptionLib, 백엔드 적격 판정 경로, 명세(노션 "Epoch config 정합성")와 대조해 감사했다.
결정(admin-web 위저드만. DB·스키마·컨트랙트·BE 변경 없음):
- 회차 전용 필드는 회차 모드에서만 보인다(D1/D3).
redemption_gating_bps와 즉시 전용 "예상 정산" 안내는 해당 모드에서만 나타나고, 모드를 바꾸면 낡은 값을 지운다 - 패널티 절은 만기가 있을 때만 보인다(D2). 수정 화면을 생성 화면과 맞췄다. 만기가 없으면 숨긴다(EARLY 구간이 없으니 물릴 것이 없다)
- 드롭다운에서
LIQUIDITY_WINDOWS제거(D5). 배선된 적이 없다(배포가 빈 창 배열을 보내므로 모든 요청이NotInLiquidityWindow로 revert하고 상환이 영원히 막힌다). enum은 온체인에 남고, 이미 그것으로 배포된 풀을 위해 수정 화면은 읽기 전용 레거시 폴백을 유지한다 OPEN_ENDED+FIXED_MATURITY차단(D6). 개방형 풀은 만기에 도달하지 않으므로FIXED_MATURITY는 이탈을 영원히 막는다. 위저드가redemption_type을ON_DEMAND로 강제하고 비활성화하며 검증 가드를 둔다FIXED_MATURITY는 회차 선택기를 없앤다(D7). 만기 풀은 한 번 지급하므로 정산 주기가 무의미하다. 위저드가 정산 블록을 숨기고redemption_type = FIXED_MATURITY이면epoch_duration_days = 0으로 강제한다. 이것은 어드민 UX 가드이지 온체인 규칙이 아니다(컨트랙트는 여전히 그 조합을 허용한다. 다만 기본 설정으로 쓸모 있었던 적이 없다. 계단식 만기 지급은 청산·lifecycle의 문제이지 기본 회차의 문제가 아니다). v3-46을 바꾸지는 않는다. 회차는maturity_model과 직교로 남고FIXED_TERM+ON_DEMAND+ 회차(조기 이탈 러시 관리)는 여전히 유효하다- 정산 UX(D8). 정산 필드가 이제 명시적인 즉시 / 회차 모드 선택이고 회차일 때만 주기 길이 입력을 보여 준다. "0이냐 N일이냐"라는 숫자 하나에 두 뜻을 싣지 않는다
redemption_type라벨 변경(D9). 그것이 실제로 정하는 것, 즉 조기 이탈 가능 여부에 맞췄다.FIXED_MATURITY → "Redeem at maturity only",ON_DEMAND → "Redeem on request"다. 도움말에서 오해를 부르던 "언제든 이탈"을 빼고 락업과 조기 이탈 패널티를 적었다
결과: admin-web의 pool-create·pool-edit step-redemption.tsx와 pool-create의 payload.ts·validation.ts다. tsc -b 통과. 배포된 풀 중 FIXED_MATURITY를 쓰는 것이 없으므로(Joob은 FIXED_TERM + ON_DEMAND다) 새 가드가 살아 있는 무엇과도 충돌하지 않는다.
⚠️ 후속(CH, 컨트랙트·BE. 위저드 밖): 만기가 설정되지 않은 FIXED_MATURITY는 갈린다. 온체인 _validateRedemption은 허용하는데(block.timestamp < 0이 거짓이므로 언제나 허용) 백엔드 redemption-eligibility.ts는 막는다. 컨트랙트·BE 트랙에서 맞춰야 한다.
참고: 07-redemption → Epoch-Based Redemption, 04-pool-models → epoch_duration_days. 17-changelog v3-88. 어드민 계층에서 v3-46을 조인다. 노션 "Epoch config 정합성". 출처: JY(제품), 2026-07-23.
v3-87 — 풀 상세 정보 구조: Controls 탭은 동작만, NAV 추이는 Overview 차트로, 거버넌스·lifecycle 이력은 전역 Audit Log로 ✅ Decided
v3-87 — 어드민 풀 상세의 Controls와 Overview 분리
Date: 2026-07-22
배경: 어드민 풀 상세의 Controls 탭에 서로 다른 셋이 한꺼번에 쌓여 있었다. 살아 있는 동작(pause·freeze·impair·wind-down·거버넌스), 인라인 NAV 변경 표, 거버넌스·lifecycle 이력 피드다. 시각적 위계도 일관되지 않았다(어떤 절은 그룹 라벨은 있고 제목이 없었고, 어떤 절은 제목은 있고 그룹 라벨이 없었다).
결정:
- Controls는 동작만. 모든 절이 3단 위계로 일관되게 읽힌다. 그룹 아이브로(분류) → 패널 제목 → 한 줄 부제. 그룹은
Deposits,Danger zone,Pool lifecycle이다(danger와 lifecycle은 빨강, 나머지는 중립. 색은 의미만 신호한다). 각 제어는 간결한 행이다(제목 + 뜻 + 동작). 긴 본문 문단은 뺐다. 확인 대화상자가 이미 담고 있기 때문이다 - 거버넌스 변경과 NAV 변경은 Controls의 동작이고, 각각 무게 있는 대화상자를 연다(타임락 안내 + 현재 값 맥락 + 주 제안 + 대기 중인 제안 관리). 늘 펼쳐진 인라인 폼이 아니다
- NAV 추이는 Overview로. 읽기 전용 NAV per token 이력이 이제 Overview에서 기간 탭(1M/3M/6M/1Y/All)이 붙은 브랜드 색 시계열 차트로 렌더된다. NAV 제안과 덮어쓰기는 Controls에 남는다
- 거버넌스와 lifecycle 이력은 풀 상세에서 제거했다. 전역 Audit Log(v3-86)가 그것의 유일한 집이다
결과: admin-web 풀 상세 리팩터다(BE·DB·스키마 변경 없음). 간결한 ControlRow와 공유 GroupEyebrow·PanelTitle 프리미티브, NavChangeDialog와 NavChart(recharts를 더한다. web에서 이미 쓰고 있다), 그리고 nav-changes-panel·governance-history-panel 위젯 삭제다.
참고: 17-changelog v3-87. v3-86(Audit Log) 위에 얹는다. 출처: JY(제품), 2026-07-22.
v3-92 — 상태 플래그 자동 해제는 온체인 pause도 함께 풀어야 한다 (v3-78 수정) ✅ Decided
v3-92 — is_paused 자동 해제의 온체인 절반
Date: 2026-07-27
배경 — 실제로 일어난 어긋남. Test Pool 260616-base에서 발견됐다. 어드민 pause가 **502 "On-chain pause operation failed"**를 반환했고 UI로는 복구할 수 없었다. 온체인 paused()는 true인데 DB is_paused는 false였고 pause()가 EnforcedPause()로 revert했다. 기록: PAUSE(2026-07-03, 온체인 + DB) → FREEZE(07-23) → UNFREEZE(07-27)인데 그 사이에 UNPAUSE가 없다. 동결의 v3-78 자동 해제가 컨트랙트를 건드리지 않고 DB 컬럼만 껐던 것이다.
결정: v3-78의 자동 해제는 DB 컬럼뿐 아니라 컨트랙트에도 적용된다. pools.post.freeze(동결), .impairment(실행), .wind-down(실행)이 공유 헬퍼 clearOnChainPause(poolAddress, chainId)(lib/shared/contract/pause.ts)를 호출하고, 그 헬퍼가 살아 있는 paused()를 읽어 켜져 있으면 unpause()를 보낸다.
- 에스컬레이션 tx가 먼저,
unpause()가 나중이다. 먼저 unpause하면 에스컬레이션이 실패했을 때 컨트랙트가 입금 열린 채로 남는다. 08 §A의 DB 전용 pause 구멍이다. 안전한 이유는unpause()에whenNotFrozen이나whenActive가드가 없기 때문이다(pause()에만 있다). 그래서 동결 뒤에도 성립한다 - DB는 의도가 아니라 체인을 미러링한다. 컨트랙트가 실제로 unpause된 경우에만
is_paused = false를 쓴다. 실패하면true로 남는다(감사에pause_clear_failed가 남고 성공하면unpause_tx_hash를 기록한다). 정직한 플래그 둘이 거짓말하는 플래그 하나보다 낫다. 둘 다 입금을 막고 표시 우선순위는 높은 상태가 가져간다 - DB 미러가 아니라 체인에서
paused()를 읽는다. 그래서 이미 어긋난 풀도 다음 에스컬레이션에서 스스로 낫는다
이유: is_paused는 온체인이 기준이고(08 §A: DB만 pause하면 투자자가 whenNotPaused로 게이팅된 deposit()을 직접 호출해 우회할 수 있다) OZ Pausable은 독립적인 컨트랙트 플래그다. PlatformPool.emergencyFreeze()는 isEmergencyFrozen만 설정하고 lifecycle 변경도 Pausable을 건드리지 않는다. 그래서 DB만 지우면 컨트랙트가 paused인 채로 남는다. 입금이 보이지 않게 revert되는데 UI는 풀이 열려 있다고 보여 주고, 나중의 pause()가 502를 내며, UI에는 unpause 버튼이 없다(DB가 paused가 아니라고 말하므로). 스스로 낫는 것도 없다. 온체인 인덱서는 paused()를 DB로 되돌려 미러링하지 않는다.
결과:
- BE(
apps/infra):lib/shared/contract/pause.ts에clearOnChainPause와OnChainPauseClearResult를 새로 넣었다(contract/index.ts로 내보낸다).pools.post.freeze/.impairment/.wind-down이 결과에 따라 조건부로is_paused를 지우고unpause_tx_hash나pause_clear_failed를 감사에 남긴다.tsc --noEmit통과(배포 대기. infra에는 자동 배포가 없다) - 이미 어긋난 풀에는 일회성
POST /pools/{id}/pause {"paused": false}가 필요하다. unpause는 BE에서 언제나 받아들이고 온체인에도 가드가 없다.Test Pool 260616-base(cb2a9e59…, 풀0x256910A6…, 체인 11155111)가 이 상태이고unpause()시뮬레이션은 통과한다 - 바뀌지 않은 것: pause 엔드포인트는 여전히
EnforcedPause()revert에 대해 스스로 낫지 않고 502를 낸다(멱등한 "이미 paused면 DB 동기화" 경로를 검토했으나 미뤘다)
참고: v3-78을 수정한다. 10-status-machines → Auto-clear. 08-smart-contracts §A. CLAUDE.md의 자동 해제(v3-78). 출처: CH(BE), 2026-07-27.
v3-93 — 회차 요청 창은 하드 게이트다. 펀딩일은 주기마다 어드민이 입력하고, 취소 창은 요청 창과 같다 (v3-91 수정) 🔧 Decided · contract redesign pending (CH)
v3-93 — 요청 창 게이트, 어드민이 입력하는 펀딩일, 취소 창
Date: 2026-07-27 · 상태: 결정됐고 아직 구현되지 않았다. v3-91의 목표 설계를 수정한다. 온체인 동작은 여전히 07-redemption의 현행 모델이다.
배경 — v3-91이 동결에 대해 자기모순이었다. v3-91은 모델 B(짧은 requestWindowDays 창 뒤에 요청을 받지 않는 공백이 오는 방식)를 명시하면서도 동결 메커니즘을 *"접수 커서를 정산 커서에서 분리한다. 마감 뒤 요청은 다음 회차에 등록된다"*고 서술했다. 커서 분리만으로는 아무것도 거절하지 않는다. acceptingEpochId = floor((now − anchor) / duration) + 1은 모든 순간에 대해 정의되므로 모든 요청이 어딘가에 안착하고 requestWindowDays는 강제되지 않는다. 그것은 경계에 이름만 새로 붙인 모델 A(늘 접수)이고, 정확히 v3-91 §A-(e)가 닫으려던 구멍이다.
결정:
- 요청 창은 하드 게이트이고, 그것이 동결 메커니즘이다.
requestRedemption은[windowOpen(N), cutoff(N))밖에서 revert한다(RequestWindowClosed). 아래쪽 경계만 검사하면 된다.N산식이 이미now < cutoff(N)을 보장한다. 커서 분리는 남되 늦은 정산에 대한 안전망으로 강등된다. cron 지연으로N+1창이 열릴 때까지 회차N이 정산되지 않았다면 새 요청은 여전히N+1에 등록된다. 더 이상 동결을 이루는 장치가 아니다- 창이 왜 장식이 아니라 무게를 지는가: 규칙 1(수익이 요청 시점에 멈춘다)과 짝이었다. 늘 접수하는 방식에서는 마감 직후에 요청한 투자자가
duration + recallLeadDays(월 단위면 약 40일)를 아무것도 벌지 못한 채 기다렸고, 창이 그것을 v3-91 프리셋이 말하는 14~17일로 제한했다. ⚠️ v3-131 (3) 이후 그 이유는 사라졌다. 기다리는 동안 발생한다. 창이 여전히 사 오는 것은 예측 가능하고 경계가 있는 대기열과 아래의 훼방 방어이지, 몰수로부터의 보호가 아니다
- 창이 왜 장식이 아니라 무게를 지는가: 규칙 1(수익이 요청 시점에 멈춘다)과 짝이었다. 늘 접수하는 방식에서는 마감 직후에 요청한 투자자가
- 주기마다 기준점 하나 — 어드민이 입력하는 펀딩일.
fundingAnchor는 고정된 달력 규칙이 아니다. 발행자가 오프체인으로 어드민에게 정산일을 알려 주고 어드민이 주기마다 입력한다(§3의 semi-auto 흐름). 발행자에게는 풀 생성 권한도 온체인 날짜 setter도 없으므로, 확정된 FM 지갑 모델(FM은depositYield와fundRedemption만 서명한다)은 그대로다. 주기마다 확정된fundingDate를 저장하고 나머지를 유도한다.cutoff(n) = fundingDate(n) − recallLeadDays,windowOpen(n) = cutoff(n) − requestWindowDays이고 정산은fundingDate(n)부터 허용된다. 어드민이 아무것도 입력하지 않으면 C2 열린 실패가직전 fundingDate + scheduleType 기간을 준다. 결과적으로 컨트랙트는 달력 산술을 절대 하지 않는다. 온체인 날짜 라이브러리도, 미리 구운 일정 배열도 필요 없고scheduleType은 "열린 실패 기본값이 전진하는 기간"으로 줄어든다. (여기서 낡은 서술 둘을 정정했다. §3 설정 표는 "월말·분기말 · 고정 기준일"이라고 적혀 있었고, v3-91 §9-4 스케치는 마감에 앵커해서 풀이 저장하는 값과recallLeadDays만큼 어긋나 있었다.) - semi-auto 펀딩일 손잡이는 그 주기의 요청 창이 열리기 전까지만 자유롭다. §3의 운영 흐름은 이미 어드민의 확인·조정 단계를 창이 열리기 전에 두고 있으므로("승인 → 창 열림") 이것은 규칙을 더하는 게 아니라 경계를 못 박는 것이다.
windowOpen(n)전에는 주기 전체가 정상적으로 다시 유도된다. 그 뒤의 조정은(흔한 경우다. 발행자가 늦는다) 정산만 옮기고 창과 마감, 확정된 수요는 얼어붙는다. 이것이 막는 실패 둘: 창이 열린 뒤 조정하면 투자자가 이미 보고 있는 기한을 다시 쓰게 되고, 마감 뒤 조정하면 닫힌 창을 다시 열고 발행자에게 이미 통지한 금액을 바꾸게 된다. 조정은 미루는 방향만 된다. 날짜를 앞당기면 마감에서 펀딩까지의 간격이recallLeadDays보다 짧아져 발행자의 회수 시간을 잡아먹는다. 구현: 운영자 손잡이는 독립적인settleAfter[id](가장 이른 정산 시각. v3-91 §10-5의 자유 setter)이고 마감 유도의 입력이 아니다 - 취소 창 = 요청 창.
cancelRedemption은requestRedemption과 같은 게이트를 갖고 둘이 함께 열리고 닫힌다. 마감 뒤 취소는 발행자에게 이미 보낸 총액을 거짓으로 만들고(과다 회수), 정산 뒤 취소는 정산 예약 불변식을 깨뜨린다. 이월된 수요가 갇히지는 않는다. 부분 체결된 투자자는 다음 요청 창에서 취소한다. 제약은 요청이 아니라 회차에 걸린다. 이것이 훼방 경로 둘을 덤으로 닫는다(마감 직전에 대량 요청 → 통지 → 취소해서 발행자를 이른 자산 회수로 몰아넣는 것, 그리고 C8 FIFO 사다리의 이월 계층에 자리를 차지하는 것). BE 변경은 없다. 온체인 취소를 먼저 검증하고 미러링한다. ⚠️ 여기의 UI 지시는 대체됐다. 에스크로 기간 수익이 환불되지 않는다고 투자자에게 알리라고 했는데, 그건 C3+I 아래에서만 참이었다. v3-131 (3)이 반영된 뒤로는 취소가 대기 기간 전체를 적립해 주고, 그것도 마지막 정산 시점이 아니라 지금까지다. 취소는 아무것도 가격 매기지 않으므로 시계를 멈출 지점 자체가 없다
열린 항목 — 여섯 개 전부 같은 날 v3-100이 닫았다: 과다 펀딩 뒤 남는 epochFundTopUp[id], 어드민 거부에 마감 게이트가 붙는지, 규제상 강제 취소 예외 경로, demand == 0인 회차, 즉시 PENDING_RESERVE 취소의 대칭성, WIND_DOWN 시점의 미결 회차 수요.
참고: v3-91을 수정한다. 07-redemption → Epoch-Based Redemption. 17-changelog v3-93. 출처: 노션 "Epoch Redemption" 핸드오프에 대한 JY 리뷰, 2026-07-27.
v3-94 — pause는 수동 전용이고 연체는 절대 자동 pause하지 않는다. 자동 pause를 하려면 pause 출처 기록이 먼저다 🔧 Decided (as-is) · auto-pause open
v3-94 — 연체 자동 pause 없음, 그리고 만들 경우의 선결 조건
Date: 2026-07-27
배경 — 어드민 UI가 존재하지 않는 기능을 안내하고 있었다. 풀 상세 배너가 is_paused && yield_overdue일 때마다 *"수익 분배 연체로 이 풀은 자동으로 정지되었습니다. 수익을 분배한 뒤 수동으로 재개하세요."*라고 적혀 있었다. 모든 쓰기 경로를 감사한 결과 그런 메커니즘은 없었다.
is_paused = true를 쓰는 곳은 정확히 둘이다.pools.post.pause(ADMIN·OPERATOR 엔드포인트)와pools.post.create(초기값)다. 어떤 스케줄러도 워커도 인덱서도 설정하지 않는다- 온체인
PlatformPool.pause()는onlyRole(PAUSER_ROLE) whenActive whenNotFrozen이다. 사람이 서명하는 경로뿐이다. 시스템에서 진짜로 자동인 유일한 정지는executeEpoch안의 NAV 낡음·이탈 보류(v3-32)이고, 그건 입금이 아니라 회차 정산을 막으며 플래그를 세우지 않는다.circuitBreakerTripped도 마찬가지로 수동이다(tripCircuitBreaker, PAUSER) pools.scheduler.yield-due는next_yield_due를 다시 계산하고yield_overdue불리언을 설정하며 알림을 큐에 넣을 뿐이다(yield_distribution_due는 전이 시 한 번,yield_distribution_overdue는 3일 유예 뒤이고 최대 7일마다 재발송).yield_overdue를 소비하는 것은 전부 배지 아니면 필터다- 상환에는 연체 개념 자체가 없다.
redemption-requests.scheduler.epoch-execute는 정산을 발동하고 컨트랙트가 이미 막는 이상치에 대해 어드민 알림을 올린다..partner-funding은 펀딩 리마인더를 보낸다. 둘 다 늦었는지를 판단하지 않는다
그래서 배너는 우연에 대해 발동하고 있었다. 운영자가 정지시킨 풀이 마침 수익도 밀려 있던 것인데, 그것을 인과로 서술했다. 안전망으로 읽히면 오히려 안전하지 않은 행동을 부른다. 연체 풀이 알아서 정지된다고 믿는 운영자는 가서 정지시키지 않는다.
결정(현행, 즉시 적용): pause는 수동 전용이고 어떤 상태 문구도 달리 말할 수 없다. 두 앱에서 고쳤다(v3-95). 수익 연체 신호는 있는 그대로 남는다. 알림과 운영 플래그다(/pools의 YIELD_OVERDUE·HAS_ISSUES 필터, OverdueBadge, Distribute CTA가 붙은 Overview의 자금 조치 알림).
결정(자동 pause를 만들 경우의 선결 조건): 지금 스키마로는 자동 pause를 만들 수 없다. is_paused가 출처 기록 없는 맨 불리언이기 때문이다. 그것만으로 풀 수 없는 충돌 둘이 따라온다.
- 재개의 모호성. 시스템이 정지시켰다면 누가 푸는가? 수동 재개라면 분배를 마친 FM이 여전히 운영자를 기다려야 한다(그리고 운영자에게는 기록된 사유가 보이지 않는다). 분배 시 자동 재개라면 운영자가 다른 이유로 건 정지가 FM의 지급으로 조용히 풀린다. 시스템이 보지도 못하는 사람의 결정을 되돌리는 것이다
- 에스컬레이션 재진입. v3-78과 v3-92는 풀이 freeze나 IMPAIRED, WIND_DOWN으로 올라갈 때
is_paused를 자동 해제한다(DB와 온체인 모두). 운영자가 올렸다가 풀을 ACTIVE로 되살린 뒤에도 연체 조건은 그대로이므로, 다음 sweep이 사람이 방금 의도적으로 다시 연 풀을 즉시 다시 정지시킬 것이다
따라서 pause_reason(enum: MANUAL / YIELD_OVERDUE / …)과 paused_by가 있으면 좋은 것이 아니라 반드시 필요한 선결 조건이다. 그것부터 만들거나, 아니면 자동 pause를 만들지 말 것.
열린 항목 — 진행할 경우에만 정할 것(권고를 붙였고 어느 것도 확정이 아니다):
- (a) 판정 조건 자체가 타당한가?
yield_overdue는 오늘 믿을 만한 부실 신호가 아니다. 앵커가마지막 DISTRIBUTED의 distributed_at ?? start_date이고 의도적으로 앞으로 굴리지 않으므로, 아직 첫 분배를 하지 않은 풀은start_date + 주기에 연체가 되고 그대로 머문다. 첫 지급이 정당하게 진행 중인 건강한 신규 풀도 포함된다. 날 플래그로 자동 정지하면 우리가 가장 열어 두고 싶은 풀을 정지시킨다. 권고: 적어도 이전에 DISTRIBUTED 분배가 한 번 있어야 대상이 되게 할 것 - (b) 임계값. 기존
YIELD_DUE_GRACE_DAYS = 3은 알림 주기이고 자본을 멈추기에는 턱없이 짧다. 권고: 일수가 아니라 놓친 주기 수(예: 연속 2회)로 표현한 별도의 더 긴 임계값. 그래야yield_frequency에 맞춰 커지고 월 단위 풀을 연 단위 풀보다 10배 가혹하게 다루지 않는다 - (c) 누가 서명하나. pause는 온체인 tx다. 선례는 있다(
pools.scheduler.lifecycle이 nonce 충돌을 피하려고 어드민 키 하나로 순차 서명한다). 다만 sweep이pools.post.pause의 가드를 그대로 따라야 하고(ACTIVE가 아니면 건너뛰고 동결된 것도 건너뛴다) 아니면pause()가whenActive나whenNotFrozen으로 revert한다. 그리고 tx가 실패했을 때 DB를 어긋나게 두면 안 된다(v3-92 규칙 2) - (d) 범위. 입금 정지(자본 유입)인가, 더 강한 것인가? 권고: 절대 더 강하게 하지 말 것. FM이 늦었다는 것이 기존 투자자의 이탈을 가둘 이유는 아니다
- (e) 상환 연체. 오늘 정의돼 있지 않고, 늦었다고 판단할 기준점이 필요하다. v3-93이 처음으로 그것을 준다(주기마다 어드민이 입력하는 펀딩일). 그러니 v3-93이 나온 뒤에 다시 볼 일이지 그 전이 아니다
- (f) 답이 pause이긴 한가. 권고: 아마 아니다. 실패 양상은 FM이 지급을 놓치는 것이고, 그것을 다루는 레버는 알림 에스컬레이션(만들었다), 운영 큐(만들었다),
fundingRestrictedhold-back, 그리고 7일 타임락과 사람의 판단이 붙은 IMPAIRED다. 조용한 기계적 정지는 저자 없는 상태를 하나 더하는 일이고, 그것이 정확히 위의 충돌 둘의 재료다
결과: apps/web과 apps/admin-web의 문구를 고쳤다(v3-95). 스키마·BE·스케줄러·컨트랙트 변경은 없다. pools.scheduler.yield-due의 동작은 그대로이고 이제 알림 전용으로 문서화됐다.
참고: 10-status-machines → Capability matrix / Status Banner Copy. v3-95. v3-78과 v3-92의 제약을 받는다. (e)는 v3-93에 달려 있다. 17-changelog v3-94. 출처: JY, 2026-07-27.
v3-95 — 상태 배너 문구는 정확성 표면이다: 기준 표와 규칙 다섯, 그리고 지어낸 투자자 드로어 제거 ✅ Decided
v3-95 — 상태 문구의 기준과 근거 없는 주장 제거
Date: 2026-07-27
배경. v3-94의 배너를 감사하다가 모든 상태 표면에서 같은 부류의 결함이 나왔다. 동작에서 흘러가 버렸거나 애초에 맞은 적이 없는 문구다. 심각도 순으로 네 가지다.
- 🔴 투자자용 pause 드로어가 처음부터 끝까지 지어낸 것이었다(
apps/web/shared/ui/PoolStatusAlert.tsx). 마운트 시점의 **Date.now()**로 계산한 "Paused: {날짜}" 줄(즉 언제나 오늘이고 실제 정지 날짜가 아니다), "Review completion: Expected by {오늘 + 45일}" 기한, "Status updates: Posted every Friday" 주기, "Alternative: Structured wind-down with principal protection" 결과, 그리고 원금이 "기초 자산에 담보돼 있다"(풀은UNSECURED일 수 있다), 수익이 "심사 중에도 계속 발생한다"(발생은MANUAL_CLAIM이고 자동 발생은 없다), "풀이 다시 열리면 출금이 재개된다"(멈춘 적이 없다)고 주장하는 "Your Investment Is Protected" 블록을 렌더했다. 죽은 버튼 둘("Read Official Notice", "Contact Support")도 있었고 이는CLAUDE.md규칙 2번 위반이다. 어느 것도 데이터에서 나오지 않았고 전부 약속으로 읽힌다 - 🔴
paused배너가 투자자에게 이탈이 막혔다고 말했다. "투자금은 보호됩니다. 풀이 다시 열리면 출금이 재개됩니다."is_paused는 자본 유입만 막는다.redemption-requests.post.create는 soft pause에서 상환이 정상 처리된다고 문서화하고 있다. 문구가 플래그의 의미를 뒤집었고 보호 주장까지 더했다 frozen배너가 기약 없어 보였다. "이 풀의 모든 활동이 정지되었습니다. 나중에 다시 확인해 주세요." 동결은 비대칭이고 스스로 만료된다(v3-28). 가치 유출은freeze_started_at72시간 뒤 다시 열리고 동결 전체는 7일에 트랜잭션 없이 소멸한다.freeze_exit_window_ends_at과freeze_auto_expires_at은 이미 읽기 모델에 있었는데 쓰이지 않고 있었다is_paused는 재투자도 막는데 어떤 어드민 문구도 그것을 언급하지 않았다(yield.post.reinvest가is_paused에서 거부한다. 컨트랙트 주석은 pause의 범위를 "입금과 재투자, 자본 유입만"으로 적고 있다)
결정. 상태 문구를 장식이 아니라 정확성 표면으로 다룬다. 틀린 상태 줄은 틀린 nav_per_token과 같은 부류의 결함이다. 투자자가 자기 돈을 뺄 수 있는지에 대해 듣는 유일한 설명이기 때문이다. 10-status-machines → Status Banner Copy가 두 앱에서 모든 상태의 제목과 본문에 대한 기준 표가 되고, 규칙 다섯이 그것을 다스린다. (1) 스케줄러가 실제로 쓰지 않는 한 어떤 상태도 자동이라고 서술하지 않는다. (2) 보호·보장·회수 표현은 절대 쓰지 않는다. (3) 날짜나 주기를 지어내지 않는다. 표시하는 모든 날짜는 컬럼으로 거슬러 올라가야 하고, Date.now()에서 유도한 날짜는 기본값이 아니라 조작이다. (4) 축을 밝힌다(자본 유입 대 가치 유출). "Paused"만 쓰면 "내 돈이 묶였다"로 읽힌다. (5) 배너나 드로어의 모든 조작 요소는 작동해야 한다.
결과:
apps/web:PoolStatusAlert의paused문구가 이제 입금과 재투자가 정지됐고 포지션·상환·출금·청구는 영향이 없다고 말한다.frozen은 선택적freezeExitWindowEndsAt·freezeAutoExpiresAt을 받아 실제 재개와 만료 시각을 말한다("나중에 다시 확인" 대신 "72시간"·"7일 이내" 표현으로 폴백한다).pool.$id.tsx가 둘 다 넘긴다. 드로어는 무엇이 정지됐나 · 무엇이 계속되나 · 알아 둘 점으로 다시 만들었다(날짜도, 결과 예측도, 보호 블록도 없다). 열 것이 없는 "Read Official Notice"는 뺐고 "Contact Support"는SUPPORT_EMAIL로 가는 실제mailto:가 됐다apps/admin-web: 풀 상세 배너가 **"이 풀의 입금이 정지되었습니다. 상환, 출금, 수익 청구는 계속됩니다."**로 읽히고 연체 안내는 별도 문장으로 내렸다. "Resume Pool"은 "Resume Deposits"가 됐다(정지가 풀을 멈춘 적이 없다). 입금 정지 제어 행과 확인 대화상자가 이제 자본 유입, 즉 입금과 재투자라고 말하고 무엇도 자동으로 정지시키지 않는다고 밝힌다- 문서: 기능 표의 Paused와 Frozen 행을 정정했고(재투자, 그리고 표가 전에 뭉뚱그려 ❌로 적었던 72시간 비대칭) 동결은 비대칭이고 스스로 만료된다는 절을 새로 넣었다
- 두 앱 모두
tsc -b통과. 스키마·BE·컨트랙트 변경 없음
2026-08-04 해소 — 청산의 "reserve" 표현이 이제 "가용 유동성"이다. ⚠️ 아래 문단은 결정 이전의 기록이다. 실제로 나간 것은 10-status-machines → Status Banner Copy를 볼 것. 두 앱 다 청산 지급을 리저브에서 pro-rata로 서술했고, 이 글을 쓸 당시에는 컨트랙트와 맞았다(executeWindDown: navPerToken = reserveBalance / totalSupply). 문구를 손대지 않은 논리는 reserveBalance가 입금과 재투자의 reserveBps 분리로만 늘어나므로 파트너가 회수 자본을 넣을 경로가 없고, 리저브 0 출시 가정 아래에서는 산식이 NAV 0을 낸다는 것이었다. 그래서 문구는 진실했고 회수 자본 모델은 결정됐지만 구현되지 않았다. v3-100이 분자를 바꿨다. 이제 reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted이고 totalEpochTopUp은 파트너 자신의 fundRedemption이 채운다. 그래서 회수된 자본이 실제로 분자에 닿고 "경로가 없다"는 전제는 거짓이 됐다. 그렇다고 "회수 자본"이 자동으로 맞는 표현이 되지는 않는다(청산에서는 정의상 파트너가 응답하지 않으므로, 실제로 펀딩하지 않는 한 리저브 0 풀은 여전히 0에 가깝게 가격 매겨진다). 다만 이 결정이 기다리던 조건은 충족됐다. 문서 수정이 아니라 제품 판단이다. → 2026-08-04 판단: 모든 표면에서 가용 유동성으로 바꾸고, "원금에 크게 못 미칠 수 있습니다"라는 단서를 유지하며, 투자자 문구에는 어떤 산식도 인용하지 않는다.
참고: 10-status-machines → Status Banner Copy. v3-94의 문구 절반을 구현한다. v3-28(동결의 비대칭). CLAUDE.md의 Pool Detail Banners와 규칙 2번. 17-changelog v3-95. 출처: JY, 2026-07-27.
v3-96 — 수익을 기록하는 것이 곧 분배다. 복구는 상태별로 범위가 잡히고, distribute tx_hash는 마무리 전에 저장한다 ✅ Shipped (admin) · infra deploy-pending
v3-96 — 수익 기록·분배는 한 번에. 안전한 복구 모델과 고아 PENDING 수정
Date: 2026-07-27
결정 둘이 v3-96 번호를 공유한다. 이것(수익)과 문구 쪽이고, 2026-07-27 같은 릴리스에 함께 나갔기 때문이다. 버전 번호는 의도적으로 둘 다에 남기고 앵커만 나눴다. 이 카드는 **
#v3-96-yield**로, 다른 쪽은#v3-96으로 링크할 것.
배경. 어드민 Yield 페이지가 Record와 Distribute를 두 단계로 보여 주고 PENDING 기록의 "Ready to distribute" 대기열을 노출해서, 기록한 뒤 분배하는 의도적인 파이프라인이 있는 것처럼 보였다. 백엔드는 그렇게 동작하지 않는다. yield-distributions.post.create가 같은 요청 안에서 분배 전체를 돌린다. 서버 경로(deposit:false)는 insert한 뒤 runYieldDistribution을 호출하고 DISTRIBUTED를 반환한다(또는 FAILED다. 온체인 분배가 실패하면 runYieldDistribution이 행을 FAILED로 표시한다). FM 경로는 클라이언트의 deposit_tx_hash와 함께 PROCESSING으로 기록하고, POST /{id}/distribute가 그 입금이 온체인에 안착했는지 확인한 뒤 마무리한다. PENDING 행을 소비하는 비동기 워커는 없다. 그래서 남아 있는 PENDING은 "대기" 상태가 아니라 시작됐다가 끝나지 못한 분배다. 어긋난 것이 둘 더 있었다. 수수료 설정이 없으면 모달이 기록을 막았는데(수수료는 선택이다. net_yield_fee_config가 null이면 net = gross이고, v3-69에 따라 ACTIVE 중에도 수수료 요율을 수정할 수 있다) 그리고 실제로 분배하는 동작에 "Record"라는 이름이 붙어 있었다.
결정.
- 수익을 기록하는 것이 곧 분배다(한 번에). "지금 기록하고 나중에 분배"라는 의도적인 상태는 없다. 동작과 라벨은 "Distribute"다. 수수료는 전제 조건이 아니다. 수수료 설정이 없으면 gross의 100%가 투자자에게 가고, 수수료를 설정하는 링크를 함께 보여 준다(lifecycle 어느 단계에서든 수정 가능하다)
- 복구는 백엔드가 안전하게 지원하는 범위로 한정한다. 무턱대고 재시도하면 이중 분배가 될 수 있기 때문이다.
POST /{id}/distribute는 PROCESSING만 받는다(FM 입금을 온체인에서 확인하고 DISTRIBUTED가 된 뒤에는 멱등이다). 그래서 PROCESSING → "Distribute"(마무리), FAILED → "Re-distribute"(새 기록을 연다. 실패한 시도는 온체인에서 분배된 적이 없으므로 다시 만드는 것이 안전하다), PENDING → 셀프서비스 동작 없음이다. 남아 있는 PENDING은 마무리 DB 고아이고 온체인에서는 이미 분배됐을 수 있으므로, 제자리 재시도는 두 번 지급할 위험이 있다. PENDING은 읽기 전용으로 노출한다 - distribute tx_hash를 마무리 전에 저장한다(근본 원인 수정).
runYieldDistribution이 이제 온체인distributeYield가 성공한 직후, 보강·마무리 UPDATE 전에tx_hash와tx_submitted_at을 쓴다(상태는 PROCESSING). 전에는 그 UPDATE가 tx_hash를 쓰는 유일한 곳이라, 온체인 분배 뒤 크래시나 DB 실패가 나면 행이 tx_hash 없이 PENDING이나 PROCESSING에 고아로 남았다. 인덱서(writeYieldDistributed)는 tx_hash로 정합하므로 그 행을 절대 고칠 수 없었고(그리고 중복 DISTRIBUTED 행을 넣었다). tx_hash를 일찍 쓰면 인덱서가 그런 행을 DISTRIBUTED로 정합하므로 고아가 남지 않고 어떤 재시도도 이중 지급할 수 없다. 온체인 호출과 금액은 그대로다
결과.
apps/admin-web(Yield 페이지): 동작 라벨과 토스트가 "Distribute"라고 말한다. 기록 모달의 게이트는 지갑만 본다(fund_wallet 풀은 depositYield에 서명하려면 FM 지갑 연결이 필요하다. 수수료는 더 이상 막지 않는다). Calculation 절이 풀 TVL · APY · 수익 기간 → 추정 수익을 항목으로 보여 준 뒤 Gross → Fee(부제에 항목별 분해, 풀의 수익 수수료 단계로 가는 링크, 설정 안 됐으면 "none") → 투자자에게 갈 Net을 보여 준다. 상태별 복구 동작은 위와 같다. Overview는 연체 풀의 추정 수익을 앞세우고 전체 분배 총계는counts.ALL을 쓴다.tsc -b통과apps/infra:runYieldDistribution의 tx_hash 저장 수정. 배포 대기(infra에는 자동 배포가 없다)
v3-96 — 문구가 한 번도 갖지 못한 실패 신호를 얻는다: 문구 모듈 + 빌드를 막는 가드 + 규칙 2-b ✅ Decided
v3-96 — 검증되지 않은 문구에 대한 구조적 해법
Date: 2026-07-27
결정 둘이 v3-96 번호를 공유한다. 이것(문구)과 수익 쪽이고, 2026-07-27 같은 릴리스에 함께 나갔기 때문이다. 버전 번호는 의도적으로 둘 다에 남기고 앵커만 나눴다. 맨 **
#v3-96**은 여기로 온다.
배경 — v3-95만으로는 왜 지탱되지 않는가. v3-95는 틀린 문자열을 고치고 맞는 문자열을 적어 뒀다. 그건 사례를 고친 것이지 메커니즘을 고친 게 아니다. 다음 검증되지 않은 문장이 같은 방식으로 나가는 것을 막는 것이 없다. 메커니즘을 정확히 지목하는 것이 중요한 이유는, 뻔한 진단 둘이 다 틀렸기 때문이다.
부주의도 아니고 리뷰 구멍도 아니다. 문구는 이 저장소에서 자동 실패 신호가 없는 유일한 표면이다. 타입은 tsc에서 실패하고 스키마 드리프트는 쿼리에서 실패하고 컨트랙트 변경은 테스트에서 실패하는데, 조작된 문구는 컴파일되고 타입체크를 통과하고 렌더되고 리뷰를 통과하고 배포된다. v3-95의 결함 둘이 정확히 그랬고 내내 초록불이었다.
그리고 그 실패 양상은 구체적으로 리뷰에 적대적이다. 컴포넌트에 텍스트 자리가 있고 명세가 없으면, 쓰이는 출력은 "틀려 보이지 않는 문장"이다. Date.now() + 45일이 "Review completion: Expected by …"로 렌더되는 것은 무작위 오류가 아니라 설득력 있는 목업이 생긴 모습이다. 그리고 목업 문구는 설득력 있게 쓰인다. Aset FE는 목업에서 옮겨 왔으므로, 그럴듯한 가짜 내용이 제품과 구분되는 표식 없이 함께 들어왔다.
그래서 근본 문제는 이렇다. 명세가 없다는 사실은 문구를 쓸 때는 보이지 않고 리뷰할 때는 비싸다. 아래의 모든 조치가 그 부재를 대신 눈에 보이게 만든다.
결정 — 무시하기 어려운 순으로 메커니즘 셋:
- 상태 문구는 컴포넌트 인라인이 아니라 문구 모듈에 산다.
apps/{web,admin-web}/app/shared/copy/status.ts다. 백엔드를 아는 사람이 JSX를 뒤지는 대신 파일 하나에서 표면 전체를 감사할 수 있고, 문구 변경이 문구 diff로 읽히며, 컴포넌트를 편집하는 에이전트가 조용히 문장을 지어낼 수 없다. 그러려면 diff가 크게 드러나는 모듈을 건드려야 하기 때문이다. 각 문자열은 그 주장을 강제하는 코드 경로를 함께 담는다(is_paused→redemption-requests.post.create.ts, 동결 비대칭 →PoolCommonLib.checkExitNotBlocked등). 그래서 리뷰어는 동작을 재구성하는 대신 인용을 따라가면 되고, 인용을 대지 못하는 저자에게는 자기가 추측 중이라는 신호가 된다 scripts/check-copy.mjs가 빌드를 막는다. 앱 소스 어디에서든 단정적인 보장·보호 표현, 지어낸 기한, 요일 주기를 금지하고, 문구 모듈 안에서 시계를 읽는 것도 금지한다(거기서는 문자열이 정적이므로Date.now()는 정의상 값을 지어내는 것이다). lint 규칙이 아니라 빌드 단계인 것은 의도적이다. CI는lint가 아니라build를 돌리고(deploy-*.yml이pnpm --filter … build:dev를 돌린다), 지금pnpm lint는 두 앱 모두 기존 오류 약 65건으로 실패한다. eslint만으로 게이트를 걸면 그 밀린 것이 정리되기 전까지 아무것도 막지 못했을 것이다. 두 앱의build/build:dev/build:prod에 붙였고 독립pnpm copy-guard도 있다- 부정된 면책 문구는 설계상 통과한다. "target returns are not guaranteed"는 필요한 문구이므로, 가드는 인접성에 기대지 않고 매치 앞의 짧은 구간에서 부정어를 찾는다. 인접성만으로는 "not a guaranteed amount"에서 깨진다(
epoch-countdown.tsx의 실제 문장이고 초안이 그것을 잡았다). 회귀 케이스 10개로 검증했다. v3-95에서 나온 나쁜 문자열 8개가 전부 걸리고 부정형 둘은 통과한다 - 탈출구: 해당 줄이나 그 위에
copy-guard-allow: <이유>를 단다. 이유는 필수다. 우회는 기록돼야지 어려워야 하는 게 아니다
- 부정된 면책 문구는 설계상 통과한다. "target returns are not guaranteed"는 필요한 문구이므로, 가드는 인접성에 기대지 않고 매치 앞의 짧은 구간에서 부정어를 찾는다. 인접성만으로는 "not a guaranteed amount"에서 깨진다(
CLAUDE.md규칙 2-b가 규칙 2번을 버튼에서 텍스트로 확장한다. 규칙 2번은 배선되지 않은 버튼을 가짜로 만들지 말고disabled로 두라는 규범을 이미 세웠다. 2-b는 같은 원칙을 적용한다. 검증되지 않은 제품 동작을 주장하는 문구는 그럴듯한 텍스트가 아니라TODO(copy)자리표시자와 표면화된 질문이어야 한다. 새 규칙이 아니라 이미 받아들여진 규칙의 확장으로 틀 잡은 것은 의도적이다. 그래야 훨씬 잘 붙는다
에디터 피드백용으로 app/shared/copy/**에 범위를 잡은 eslint no-restricted-syntax에도 같은 내용을 반영했다(빠른 절반이다. 강제하는 절반은 스크립트다).
왜 이 셋뿐인가. 후보 둘을 검토했고 지금은 채택하지 않았다. CI에 lint를 더하는 것(오류 65건에 막혀 있다. 별도 정리 작업이고 지금 강행하면 배포가 깨지거나 이 변경이 부풀어 오른다), 그리고 목업 내용에 표식을 강제해 목업→FE 이식에서 조용히 살아남지 못하게 하는 것(옳지만 이 저장소의 빌드가 아니라 목업 워크플로에 속한다).
결과:
- 새 파일:
apps/web/app/shared/copy/status.ts(변형 9개와 pause 드로어),apps/admin-web/app/shared/copy/status.ts(pause 배너와 확인 문구가 붙은 제어 레버 넷),scripts/check-copy.mjs PoolStatusAlert는 표현만 갖고(톤, 아이콘, 배치) 문자열은 갖지 않는다. 드로어는PAUSED_DRAWER에서 렌더한다. 어드민의pool-detail과pool-controls는POOL_PAUSE_BANNER/POOL_CONTROL_COPY를 참조한다(호출 지점 18곳)- 두 앱 다
tsc -b통과.copy-guard가 331개 파일에서 깨끗함. 게이트를 켠 채 두 앱 다build:dev통과. 심어 둔 나쁜 문자열에서 eslint 규칙이 발동하는 것을 확인 - 스키마·BE·컨트랙트 변경 없음
열린 항목: 이번에 감사하지 않은 표면에도 같은 부류의 결함이 거의 확실히 있다. 풀 제어 밖의 확인 대화상자, 알림 이메일 문구(lib/shared/notifications/copy.ts), 비활성 버튼 툴팁, 오류와 빈 상태다. 그중 결과가 가장 무거운 것은 알림 문구이고(플랫폼을 떠나면 회수할 수 없다) 그다음이 확인 대화상자 문구다(운영자가 비가역적 행동 직전에 읽는다). 둘 다 보장 단어 말고는 지금 가드의 패턴 집합에 덮여 있지 않다.
참고: v3-95를 강제 가능하게 만든다. 10-status-machines → Status Banner Copy. CLAUDE.md 규칙 2-b. 17-changelog v3-96. 출처: JY, 2026-07-27.
v3-141 — 모집 마감이 자기 컬럼을 갖는다. end_date가 "청약이 끝났다"와 "기간이 끝났다"에 숫자 하나로 답하고 있었다 ✅ Shipped · 0204 + sweep + wizard · deployed 2026-08-21 · contract gap closed
v3-141 — `subscription_end_date`, 그리고 처음으로 트래픽이 흐르는 CLOSED 경로
Date: 2026-08-20 · 상태: ✅ 반영됨(마이그레이션 0204, lifecycle sweep의 두 패스, POST /pools와 PATCH /pools/{id}, 발행 게이트, 생성 위저드의 5단계). 2026-08-21 배포: 마이그레이션이 schema_migrations에 기록됐고 API 스택이 subscription_end_date를 담고 있으며(GET /pools에서 확인) admin-web도 나갔고 컨트랙트 구멍은 두 라운드에 걸쳐 닫혔다(v3-142가 두 번째다). 아래 논리는 쓰인 그대로 두었고 구멍이 열려 있던 동안을 서술한 부분도 그대로다.
독자 둘, 컬럼 하나. 둘은 우연히만 일치했다. pools.end_date를 pools.scheduler.lifecycle의 3번 패스(ACTIVE → CLOSED)는 청약 기간이 끝났다로 읽었고, resolveMaturityAt은 기간이 끝났다로 읽었다(아직 온체인 maturity_date가 없는 풀에 대해 두 번째 후보 만기로 삼는다). 둘 다 같은 숫자에 대해 옳았다. 생성 위저드가 end_date = start_date + maturity_days로 계산했기 때문이다. 그래서 풀은 만기가 오는 그날까지 입금을 받았다. 마지막 날 오후에 들어온 돈은 하루 투자되고 원금 상환 대기열에 합류했다. 아무것도 실패하지 않았고 어떤 화면도 틀리지 않았으며 모든 필드가 변호할 수 있는 값을 갖고 있었다.
🔴 end_date를 그냥 앞당길 수는 없었다. resolveMaturityAt이 그것을 start_date + maturity_days보다 먼저 읽으므로, 발행됐지만 배포되지 않은 풀에서 그것을 줄이면 풀의 기간이 줄고, resolveNextYieldDue의 수익 상한이 날짜와 함께 의무까지 떨군다. 미지급 기간이 어디에서도 실패하지 않고 그냥 어느 화면에도 어느 알림에도 나타나지 않게 된다. end_date는 계속 만기를 뜻하고, 모집 마감은 새 컬럼이다. 마이그레이션이 미래의 독자가 볼 바로 그 자리에 그렇게 적어 뒀다.
sweeper의 3번 패스는 한 번도 돈 적이 없어서, 이것은 조정이 아니라 경로를 켜는 일이다. 그 패스는 만기 패스 뒤에 ACTIVE 풀을 다시 읽는데, 두 패스가 같은 날짜를 비교했으므로 FIXED_TERM 풀은 언제나 MATURED로 떠났고 3번 패스가 보기 전에 사라졌다. CLOSED에는 손으로만 갈 수 있었다(POST /pools/{id}/close). 그 경로를 켜자 2번 패스가 전제하고 있던 것이 드러났다.
🔴 만기 sweep은 ACTIVE만 골랐으므로, 일찍 닫힌 풀은 만기가 올 수 없었다. 무패널티 상환, 홀더에게 보이는 문구, 알림, 어드민 배지가 전부 달력이 아니라 MATURED를 본다. 그래서 기간이 끝나기 전에 모집을 닫은 풀은 만기를 지나고도 CLOSED에 앉아 있고 그 모두가 틀린 것을 읽는다. ⚠️ 누구의 돈도 가두지 않는다. 여기 있던 예전 표현은 그렇다고 적었다. 온체인의 패널티 판정은 PoolCommonLib.isBeforeMaturity(block.timestamp < maturityDate)이고 lifecycleStatus는 WIND_DOWN과 IMPAIRED에 대해서만 읽으며, REDEEMABLE_LIFECYCLES에는 이미 CLOSED가 들어 있다. 그래서 만기가 왔지만 CLOSED인 풀도 무패널티로 상환된다. 깨지는 것은 상태 계층이다. pool_matured 알림이 발동하지 않고, 배지는 Closed라고 말하며, MATURED가 전액만 허용하는 곳에서 부분 상환이 계속 허용되고, resolveNextYieldDue의 만기 상한이 걸리지 않아 풀이 영원히 지급일과 연체 플래그를 쌓는다. 필터는 이제 lifecycle_status IN (ACTIVE, CLOSED)이고, JS에서 실제로 subscription_end_date를 가진 CLOSED 풀로 좁힌다. 그것이 정확히 3번 패스가 만들어 내는 집합이다. 🔴 그 두 번째 조건은 단정함의 문제가 아니다. 손으로 닫는 것은 명시적으로 되돌릴 수 있고("종착 사실이 아니라 시장 상황에 대한 판단"이라고 pools.post.close가 적고 있다) reopen이 그런 풀을 ACTIVE로 되돌린다. 그것을 MATURED로 쓸어 보내면 그 선택지가 사라진다. MATURED → ACTIVE는 어떤 전이 표에도 없기 때문이다. pool_address가 없는 풀에서는 온체인의 무엇도 그 쓰기를 막지 않으므로 조용히, 그리고 즉시 일어난다. 그리고 한 번도 열린 적 없는 UPCOMING에서 닫힌 풀까지 잡게 된다. 상태 필터만 넓히는 것은 0204보다 앞선 풀들에 대한 동작 변경이고, 작업 지시는 그것들을 건드리지 말라고 한다. 패스 순서는 그대로다. 시간상 모집 마감이 먼저 오므로 풀은 이제 그날 ACTIVE → CLOSED로, 기간이 끝날 때 CLOSED → MATURED로 걷는다. 날짜 하나를 두 번 읽는 대신 날짜마다 전이 하나다. 3번 패스는 여전히 역방향을 거부하고, 이유는 늘 그랬던 것과 같다.
✅ b619d9a부터 체인이 CLOSED → MATURED를 허용한다. 이 카드를 쓸 때는 아니었고, 그 구멍은 오프체인에서 닫을 수 없었다. LifecyclePolicy.isValidTransition이 어드민 setter에 대해 CLOSED를 종착으로 다뤘고 transitionPools는 온체인부터 쓰므로 호출이 revert하고 DB도 움직이지 않았다(v3-92). 그 수정이 DAG의 죽은 간선도 함께 걷어 냈다. MATURED → CLOSED는 온체인에서 허용됐지만 어떤 오프체인 경로도 쓰지 않았고(pools.post.close의 CLOSEABLE = [ACTIVE, UPCOMING]이고 sweeper의 close 패스는 ACTIVE만 조회한다) 그것을 남겼다면 CLOSED ↔ MATURED가 순환이 됐을 것이다. 이제 불변식은 "CLOSED는 만기로만 벗어난다"이고, 재개방이 오프체인이므로 CLOSED → ACTIVE는 계속 거부된다. 새 enum 값도, DB 변경도, 오프체인 변경도 없고 ABI도 그대로다(LifecyclePolicy는 internal이라 인라인된다). 결정과 비용 비교는 Claude-Plan/Active/W9-D1-closed-terminal-decision.md이고, 새 lifecycle 상태를 배제한 명명 검토는 그 §6에 있다.
⚠️ Clones 컷오프. 규칙은 풀 구현체에 있고 풀은 clone이므로, 새 구현체와 팩토리가 살아난 뒤에 생성된 풀만 그것을 갖는다. 앞선 풀들에게 CLOSED는 영원히 종착이다. 이 갈래에서는 무해하다. 그 풀들은 subscription_end_date가 없어서 CLOSED에 닿을 수 없기 때문이다. 다만 그래서 W9 위저드로 실제 풀을 만들기 전에 구현체가 먼저 나가야 한다. PlatformPoolFactory.poolImplementation은 immutable이므로 새 팩토리가 그 배포의 일부이고 FACTORY_ADDRESS_{chainId}도 함께 움직여야 한다. ✅ 2026-08-20 반영(구현체 0xCfe751fd…, 팩토리 0x2DA6f9c6…)됐고 하루 뒤 v3-142가 대체했다(구현체 0x405E89Ec…, 팩토리 0x206fC348…). 그래서 컷오프가 둘이다. 2026-08-21 감사: dev에 배포된 풀 아홉 중 현재 구현체에 앉은 것은 없고, 어떤 풀이 어느 것에서 왔는지를 오프체인에 기록하는 것도 없다(apps/contract/sepolia.md → Live pool generations).
상한은 수익 지급 한 번이고 위저드에 산다. subscription_end_date <= 만기 − 수익 기간 1회분(월 30일, 분기 90일. firstYieldIntervalDays에서)이 경고가 아니라 단계를 막는다. 그 값이 배포되는 풀에 쓰이고, 피해는 아무 발언권도 없던 투자자에게 떨어지기 때문이다. 더 느슨한 띠인 만기 − 수익 기간 3회분은 막지 않고 경고하며, 마지막으로 들어온 투자자가 받게 될 횟수를 말해 준다.
- 🔴 회차 주기가 아니다. 제품에는 무관한 주기가 둘 있다. 달력 축의 수익(30/90)과 고정일 축의 만기 후 상환(28/84,
EPOCH_CADENCE_DAYS)이다. 이 규칙은 늦은 투자자가 수익을 받느냐에 대한 것이므로 수익 쪽 숫자다. 문구가 **"cycle"**이라고 말하면 안 되는 이유도 같다. 운영자가 그것을 상환 주기로 읽는다. 어떤 메시지에도 그 단어가 없음을 테스트가 단언한다 - 🔴 엔드포인트에 두지 않는다.
POST /pools는 관계만 검사하고(start_date < subscription_end_date) 발행 게이트가subscription_end_date < end_date를 더한다. 어느 해석으로도 틀린 부분들이다. 수익 기간 규칙을 서버 쪽에 다시 적는 것은 결국 서로 의견이 갈릴 계산의 사본을 하나 더 두는 일이고, 평가하려면 주기와 만기 모델이 필요하다 - 🔴 축은
redemption_type이 아니라maturity_model이다(2026-08-20 정정. 첫 구현이 더 좁은 해석으로 나갔고 운영자가 보고 있던 풀에서 필드가 렌더되지 않았다). 모든 형태에 대해 묻고, 형태가 규칙을 정한다. 만기가 있는 풀은 필수이고 상한과 경고, 타임라인 바가 붙는다.OPEN_ENDED는 선택이고 상한이 없으며(잴 대상이 없다) 요약 줄만 있다.FIXED_MATURITY로 범위를 잡으면 늦은 투자자를 해칠 수 있는 형태 넷 중 둘만 보호하면서 보호한 것보다 많은 비용을 물었다. "요청 시 상환"은 늦은 입금을 안전하게 만들지 않는다.lockup_days > 0이면 락업 중 상환이 아예 막히기 때문이다(v3-76). 그리고 요청형 풀은 마감일을 아예 설정할 수 없어서 만기 오는 날까지 계속 입금을 받았다. §3-3⑥ 자신의 근거는 막는 상한에 대한 것이고, 보이는 필드는 아무것도 막지 않는다. 이것이 작업 지시의 §3-3⑥과 §5를 대체한다
위저드의 5단계가 Schedule & redemption이 됐다. 시작일이 6단계에서 이리로 옮겨 왔다. 거기서는 발행 버튼 옆에 앉아 있었다. 풀이 언제 열리는지와 수익 주기가 언제부터 세기 시작하는지를 정하는 그 하나의 날짜를, 그것에 의존하는 모든 질문 뒤에 묻고 있었고 모집 창은 물을 자리조차 없었다. 6단계는 세 날짜의 읽기 전용 사본을 유지한다. 5단계 위의 타임라인 바가 모집 / 수익만 / 상환을 축척대로 그리고 가장 짧은 실효 기간을 아래에 괄호로 적는다. 원래의 결함이 숫자로는 보이지 않았고 가운데가 빈 막대로는 놓칠 수 없기 때문이다. 락업은 의도적으로 구간이 아니다. 홀더마다 자기 입금에서 시작하므로 풀 전체 축에서 자리를 가질 수 없다.
🔴 4단계가 5단계보다 앞인 것은 배치 취향이 아니라 의존이다. 상한은 4단계에서 고른 수익 주기로 계산한다. 둘을 바꾸면 수익 기간이 무엇인지 아무것도 모르는 상태에서 모집을 얼마나 열어 둘지 묻게 된다.
🔴 풀 읽기 select 둘 다 이 컬럼을 담고 있고, 그건 서류 작업이 아니다. pools.get.list의 LIST_SELECT와 DETAIL_SELECT는 명시적 허용 목록이고(보안 S-35) PoolRow의 유일한 출처다. 거기 빠진 컬럼은 undefined로 도착하고 매퍼가 null로 만들며 아무것도 실패하지 않는다. 풀 개요는 일찍 닫는 풀을 포함한 모든 풀에 "Runs to maturity"라고 적고, Configuration 탭의 Subscription Close는 값이 있는 풀에서도 비어 있으며, 풀 수정 폼은(그 필드를 이 값으로 채우고 다시 보낸다) 다음 저장에서 저장된 날짜 위에 null을 쓴다. ⚠️ 배포 순서: 이 Lambda들을 올리기 전에 0204를 적용할 것. Supabase는 나열된 컬럼이 없으면 select 전체를 거부하므로 GET /pools/{id}가 모든 풀에 대해 500을 낸다. 0111이 주석을 달아 둔 것과 같은 위험이다.
옛 컬럼이 렌더되던 모든 곳에서 라벨을 바꿨다. 라벨로서의 "End date"는 사라졌다. 풀 수정 폼과 Configuration 탭, 풀 개요가 이제 Maturity라고 말하고 그 옆에 Subscription close가 있다. 라벨이 결함의 대부분이었다. 아직 돈을 받고 있는 풀에서 "End date"를 읽은 운영자는 청약을 멈추고 싶은 날로 그것을 설정했고, 대신 기간을 옮겼다.
🟡 이 상한 전체가 임시방편이다. 만기가 풀 전체의 날짜 하나이기 때문에 존재한다. 만기가 포지션별이 되면(D2) 홀더마다 자기 입금에서 기간이 흐르고 늦은 진입이 그 홀더에게 아무 비용도 물리지 않으며 상한이 보호할 것이 남지 않는다. 그때는 features/pool-form/offering.ts를 이식하지 말고 삭제할 것. 모듈 맨 위에 그렇게 적어 뒀다.
기존 풀은 백필하지 않는다(JY, 2026-08-20). end_date를 복사해 넣으면 모든 모집 마감이 만기일이 되는데 그것이 지금 풀고 있는 뒤섞임이고, 그 풀들이 한 번도 지나 본 적 없는 sweep 경로에 불을 켜게 된다. 배포된 풀에 대한 회귀 확인은 명시적으로 면제했다.
🔴 절대 날짜가 하나 더 생겼다는 것은 배포가 그것들을 다시 읽어야 한다는 뜻이다. 만기는 저장되지 않고 배포 순간에 유도되는데(deploy_time + maturity_days, 마이그레이션 0161) start_date와 이 컬럼은 위저드가 쓴 절대 날짜다. 그래서 초안이 대기하는 동안 둘이 반대로 움직인다. 만기는 밀리고 모집 마감은 그대로이며 그 간격은 안정적이지 않다. 모집 마감을 지나서 배포하면 모집이 이미 끝난 풀이 나간다. 3번 패스가 subscription_end_date가 과거인 ACTIVE 풀을 매시간 닫으므로, 그 풀은 배포되고 보이고 온체인에 올라간 뒤 입금 하나 들어오기 전에 새 돈에 대해 닫힌다. 아무것도 잡지 못했다. 모든 날짜 검사가 풀의 날짜들을 서로 비교했고, 서로에 대해서는 완벽히 일관됐기 때문이다. 그 풀이 한 번도 대조되지 않은 기준은 오늘이었다.
결정: 발행 경로와 재시도 배포 경로가 둘 다 날짜를 다시 읽고 낡은 모집 마감을 거부한다(pools.post.lifecycle). 그리고 둘 다 운영자가 확인하기 전에 배포가 정하는 날짜와 이미 고정된 날짜를 보여 준다. 날짜를 지우지 않고 거부하는 이유는 "기간 끝까지 모집"(NULL)과 "더 나중 날짜에 마감"이 둘 다 정당하고 서로 다른 결정이며, 그 이유를 아는 것은 운영자이기 때문이다. 밀린 만기와 이미 지나간 시작일은 막지 않고 주의만 준다. 둘 다 원할 만한 정당한 것이고 화면에 적힌 이상 조용하지 않다. 🔴 창이 가장 넓은 곳이 재시도 경로인데 거기에는 검사가 아예 없었다. 이미 발행된 풀에 대해 핸들러가 모든 발행 검증을 건너뛰기 때문이다. 선례: pools.worker.deploy의 W2-4 가드가 이미, 밀린 만기를 더 이상 넘기지 못하는 회차 펀딩 앵커를 거부한다. 위저드 자신의 하한이 오늘부터 세므로 추정치라는 논리였다. 이것은 그 논리를 날짜 자체에 적용한 것이다. ⚠️ 배포는 여전히 end_date를 다시 쓰지 않으므로, 밀린 풀의 Configuration 탭은 지키지 않는 만기를 보여 준다. 고치는 대신 04-pool-models → maturity_days에 기록했다. 저장된 날짜를 배포 시점에 다시 쓰는 것은 그 자체로 별개의 결정이기 때문이다.
참고: 11-db-schema → v3-110 pool close의 pools.end_date 설명을 정정한다. v3-110 B(자동 마감 패스)와 v3-132(이것이 범위로 삼는 만기 후 상환 풀)를 확장한다. 10-status-machines → pool lifecycle · 11-db-schema → pools. 마이그레이션 0204. 출처: JY(작업 지시 W9), 2026-08-20.
v3-142 — 닫힌 모집을 다시 열 수 있고 체인에도 알려야 했다 ✅ Shipped · contract deployed 2026-08-21 · 0205 applied · infra deploy pending
v3-142 — `CLOSED → ACTIVE`, 그리고 재개방이 풀을 닫은 날짜를 어떻게 다루는가
Date: 2026-08-20(결정) / 2026-08-21(온체인) · 상태: ✅ 반영됨. LifecyclePolicy(3edf75b), Base Sepolia의 새 구현체 0x405E89Ec…와 팩토리 0x206fC348…, 그리고 어드민 재개방 대화상자다.
🔴 화면은 2026-08-04부터 이것을 약속하고 있었고 한 번도 작동한 적이 없다. POST /pools/{id}/close에 action: reopen을 주면 setLifecycleStatus(ACTIVE)를 보내는데, LifecyclePolicy.isValidTransition은 CLOSED → ACTIVE를 한 번도 받아들인 적이 없다. v3-141의 라운드 전에는 return false로 떨어졌고, 그 뒤에는 새 CLOSED 분기가 to == MATURED만 반환했다. 그래서 배포된 풀에 대한 모든 재개방이 revert했고 핸들러는 502를 냈다. 그동안 어드민 풀 페이지는 Reopen 버튼을 렌더했고, 마감 확인 문구는 *"만기가 오기 전에는 다시 열 수 있습니다"*라고 말했으며, 10-status-machines는 그 전이 행에 ✅를 달고 있었고, 핸들러 자신의 주석은 마감을 *"종착 사실이 아니라 시장 상황에 대한 판단"*이라고 불렀다. 네 곳이 어떤 기능을 주장하는데 그중 아무도 전이 표를 읽지 않았다.
결정: 약속을 지우지 말고 간선을 연다. CLOSED에 이제 출구가 둘이다. 기간이 끝나면 MATURED, 모집을 다시 열면 ACTIVE다. 이유 셋. ① 되돌릴 수 있다는 것이 설계된 동작이다. 핸들러가 자기에 대해 그렇게 말하고, 일찍 닫는 것은 운영상의 판단이다. ② 약속을 지우는 것이 더 싸지도 않다. 같은 서너 표면을 고쳐야 하고, 운영자에게 이미 제공되고 있는 선택지를 없애게 된다. ③ 다른 안은 엔드포인트와 버튼과 문서화된 ✅가 전부 502를 내는 상태로 제품을 남긴다.
⚠️ ACTIVE ↔ CLOSED는 의도된 순환이다. 없애야 했던 순환은 CLOSED ↔ MATURED다. 만기를 되돌리기 때문이고, v3-141의 라운드가 MATURED → CLOSED를 제거해 닫았다. 어떤 오프체인 경로도 쓰지 않던 죽은 간선이었다(pools.post.close의 CLOSEABLE = [ACTIVE, UPCOMING]이고 sweeper의 close 패스는 ACTIVE를 조회한다). MATURED는 여전히 어드민 setter가 끝나는 곳이다.
왜 자기 배포가 필요했나. 결정이 b619d9a가 이미 쓰인 뒤에 나와서, 2026-08-20 팩토리는 전이 셋 중 둘만 담고 이것은 담지 못했다. poolImplementation은 immutable이고 풀은 Clones이므로 기존 구현체에 간선을 더할 방법이 없다. 새 구현체, 새 팩토리, 새 FACTORY_ADDRESS_{chainId}, 그리고 API 재배포다. 나중이 아니라 즉시 하는 것이 나았던 이유는 08-20 팩토리로 만들어진 풀이 아직 하나도 없어서 간선 없이 영구히 굳은 것이 없었기 때문이다.
🔴 재개방은 미래의 모집 마감을 유지하고, 대화상자가 이제 어느 날짜인지 말해 준다. pools.post.close는 subscription_end_date가 과거일 때만 지운다. 필요한 처리다. 그러지 않으면 3번 패스가 다음 매시간 실행에서 풀을 다시 닫고 운영자의 행동을 아무 설명 없이 되돌린다. 미래 날짜를 유지하는 것은 의도적이다. 그 풀은 손으로 일찍 닫혔고 앞에 계획된 마감이 여전히 있다. 운영자가 아직 도달하지 않은 날짜를 지우는 것이 파괴적인 해석이다. 그 결과 풀이 그날 스스로 다시 닫히는데, 예전 확인 문구는 그것을 아예 언급하지 않았다. 그래서 설계대로 정확히 동작한 재개방이 먹히지 않은 것처럼 읽혔다. 이제 확인 문구가 유지되는 날짜를 말하거나, 없으면 모집이 기간 끝까지 간다고 말한다.
⚠️ 여기서는 Clones 컷오프가 v3-141 때와 달리 무해하지 않다. 풀은 subscription_end_date 없이는 sweep으로 CLOSED에 닿을 수 없으므로 만기 갈래는 새 풀에만 의미가 있었다. 그런데 어떤 풀이든 손으로 닫을 수 있으므로 재개방 갈래는 존재하는 모든 풀에서 닿는다. 오래된 풀은 절대 재개방할 수 없는데, 어떤 구현체에서 clone됐는지를 오프체인에 기록하는 것이 없다(pools에는 pool_address, deploy_tx_hash, deploy_status, created_at이 있고 구현체 컬럼이 없다). 그래서 어떤 화면도 세대를 구분하지 못하고 모든 풀이 같은 버튼과 같은 약속을 받는다. 2026-08-21 감사: dev에 배포된 풀 아홉 중 재개방할 수 있는 것은 없고 그중 일곱은 08-18 라운드보다도 앞선다(apps/contract/sepolia.md → Live pool generations).
✅ 미루지 않고 만들었다. 풀별 세대 신호(0205, JY 2026-08-21). 카드는 처음에 이것을 미룬 것으로 적었다. dev 풀은 현재 팩토리로 다시 만들면 된다는 논리였다. 같은 날 뒤집었다. dev 풀은 그대로 두기로 했으므로 거기서 두 세대가 공존하고, prod는 애초에 다시 만들어 빠져나올 수 없었다. pools.pool_implementation을 이제 pools.worker.deploy가 factory.poolImplementation()에서 읽어 쓰고, canReopenOffering(@aset/types)이 그것을 답으로 바꾼다. 어드민 제어는 버튼을 숨기지 않고 그 자리에 사유를 보여 준다. 운영자는 "이 풀은 영영 못 한다"에 대해 행동할 수 있고, 버튼이 조용히 사라진 행은 권한 버그로 읽힌다. pools.post.close도 체인이 revert해 502가 되게 두지 않고 같은 경우를 409로 거부한다. 🔴 규칙은 할 수 있는 구현체의 허용 목록이 아니라 할 수 없는 구현체의 닫힌 목록이다. 허용 목록은 재배포마다 덧붙여야 하고, 누군가 처음 잊는 순간 그것을 지원하는 풀에서 재개방이 조용히 제공되지 않게 된다. 할 수 없는 집합은 할 수 있는 구현체가 나온 순간 자라기를 멈췄으므로 관리가 필요 없고, 모르는 구현체는 할 수 있는 것으로 친다. 그쪽 실패 양상은 502이고(복구 가능하며 이미 갖고 있던 것이다) 아무도 모르게 기능이 회수되는 것이 아니다. ⚠️ 오래된 풀을 재개방 가능하게 만들지는 못한다. 무엇도 못 한다. 구현체는 생성 시 고정되고 업그레이드 경로가 없으므로 유일한 길은 새 풀로 이관하는 것인데, 실제 홀더에게는 그 자체가 동의 문제를 안은 온체인 행위다. pool_factory는 다른 질문을 위해 함께 기록한다. 백엔드가 실제로 어느 FACTORY_ADDRESS_{chainId}를 들고 있었는가다. 재배포 뒤 그 env 교체를 조용히 놓칠 수 있고, 그러면 새 풀이 계속 옛 구현체에서 나오는데 아무것도 이상해 보이지 않는다.
참고: v3-141을 완성한다(같은 라운드의 세 번째 전이). 10-status-machines의 전이 표와 CLOSED 행. LifecyclePolicy.isValidTransition, pools.post.close.ts, apps/admin-web/app/shared/copy/status.ts.
v3-157 — 무권한 이탈 폴백이 철회됐다 ⚠️ 결정 · 보장 철회
v3-157 — `claimRedemptionFallback` 제거는 함수 정리가 아니라 보장의 철회다
날짜: 2026-08-31(컨트랙트) · 기록: 2026-09-04 · 상태: ✅ 컨트랙트에서 제거됨. 뒤따르는 문서 정리는 아직 부분적이다(마지막 문단).
claimRedemptionFallback과 approveRedemption이 PlatformPool에서 사라졌다. 둘 다 reserveBalance를 끌어 썼는데, v3-155가 리저브 몫을 입금 시점에 외부 지갑으로 보내므로 풀이 갖고 있지 않은 잔액에서 지급하라고 요구하는 함수가 됐다.
🔴 이것은 정리가 아니라 보장의 철회다. 컨트랙트가 그 함수가 있던 자리에 그렇게 적어 뒀다(PlatformPoolBase.sol:668). 폴백은 즉시 상환 풀의 요청이 만들어진 뒤 FALLBACK_NOTICE_DAYS(7일)가 지나면 누구나 정산할 수 있게 했다. 그것이 "응답을 멈춘 운영자가 홀더를 온체인에 가둘 수 없다"(v3-28 · v3-31)를 약속이 아니라 컨트랙트의 성질로 만들던 장치다. 지금은 약속이 됐다.
왜 그대로 둘 수 없었나. 리저브가 풀 밖으로 나가면 폴백이 끌어 쓰던 available이 구조적으로 0이다. 아무도 구제할 수 없다. 남겨 두면 리버트되는 보장이 된다.
이제 이탈권이 무엇에 기대나. 리저브 지갑이 fundRedemption으로 오프체인에서 요청에 자금을 넣는 것이다. 이건 실제 맞교환이고 그렇게 기록한다. 누구나 할 수 있던 행위가 키 보유자의 재량 행위로 바뀌었다.
변하지 않은 것: 회차 풀. executeEpoch는 기한 후 여전히 무권한이고 claimRedemption도 여전히 대리 청구(지급 대상은 요청자로 고정)라, 정산된 회차는 Aset이 움직이든 말든 지급된다. 🔴 v3-31의 절반은 살아 있다. 즉시 상환 쪽만 없어졌다. *"무권한 이탈이 없어졌다"*를 양쪽으로 읽지 말 것.
같이 제거된 것: API 미러 POST /redemption-requests/{id}/claim-fallback(15-api-reference)와 RedemptionFallbackClaimed 이벤트(08-smart-contracts).
⚠️ approveRedemption은 같은 제거에 들어갔지만 이유가 다르고, 철회된 보장이 아니다. 요청 이후 새 입금으로 리저브가 늘어난 드문 경우를 위한 선택적 수동 정산이었다. 입금이 더 이상 리저브를 늘리지 않으니 그 경우는 드문 게 아니라 불가능해졌다.
🔴 문서가 아직 다 따라오지 않았고, 이 카드는 그걸 감추지 않는다. 여섯 페이지가 폴백을 현재형으로 서술하고 있고 그중 하나는 비수탁 보장이라는 제목의 절 안에 있다(08a-contract-reference §3.5 · 07-redemption · 06-writedown-nav · 03-kyc-identity · 10-status-machines · FALLBACK_NOTICE_DAYS 상수 행). 나중에 발견되게 두지 않고 여기 적는다. 철회된 보장이 어딘가에 아직 적혀 있는 것이 이 카드가 막으려는 실패다. 같은 축의 컨트랙트 주석 2곳(PlatformKYCSoulbound.sol:75 · PlatformPoolBase.sol:128)이 남아 있다.
참고: v3-28과 v3-31의 폴백 부분을 supersede · 원인은 v3-155 · PlatformPoolBase.sol:660-673 · 09a-custody 기준 5번 · 09-rbac · 출처: CH 컨트랙트 라운드 2026-08-31, JY 기록 2026-09-04.
v3-155 — 리저브가 풀 밖으로 나간다 ✅ Decided
v3-155 — 리저브 몫이 외부 지갑으로 나가고, 그래서 `reserve_bps`가 보유 비율에서 라우팅 비율로 바뀐다
Date: 2026-08-27 · 상태: ✅ 결정됐고 만들지 않았다. 컨트랙트 변경이고 새 풀에만 해당한다. 풀은 Clones라 구현체가 생성 시 고정되기 때문이다.
리저브 몫이 입금 시점에 외부 EOA(reserveWallet)로 라우팅되고, 풀 안의 reserveBalance 카운터는 제거된다. 지금은 deposit()이 들어온 스테이블코인을 나누고 리저브 쪽을 PlatformPool 안에 남긴다. 이후에는 펀드 쪽이 이미 그러듯이 같은 트랜잭션에서 컨트랙트를 떠난다.
🔴 reserve_bps가 "얼마가 여기 남는가"를 뜻하기를 그친다. "얼마가 그 주소로 가는가"가 된다. 같은 숫자, 다른 문장이다. 리저브를 *"PlatformPool 안에 남겨 두는 비율"*로 서술하는 모든 문서는 결정된 컨트랙트가 아니라 배포된 컨트랙트를 서술하는 것이다.
무엇을 잃고, 그럼에도 왜 옳은가. 풀은 자기가 지급해야 할 돈을 더 이상 볼 수 없다. 오늘 상환 가용성은 reserveBalance를 읽는다. 컨트랙트가 소유하는 숫자다. 이후에는 펀딩이 톱업으로 도착해야 하고, 그것은 회차 풀이 이미 쓰는 메커니즘이다. 얻는 것은 리저브와 fund wallet이 서로 다른 종류의 것이기를 그친다는 점이다. 둘 다 운영자가 보유하는 외부 잔액이 되고, 지급이 나올 수 있는 곳이 정확히 하나가 된다.
⚠️ 청산 가격에 미치는 영향은 별개의 결정이다. v3-152다. 둘은 함께 나가고 어느 하나만으로는 말이 되지 않는다.
⚠️ 배포된 모든 풀은 옛 동작을 영구히 유지한다. 23-money-path의 다이어그램과 예시, 두 버킷 틀은 그 풀들에 대해 정확히 그대로 참이다.
참고: 23-money-path → §6 Reserve · 04-pool-models(입금 분리) · v3-152와 짝이다 · v3-11의 "리저브는 지갑이 아니다" 절반을 대체한다
v3-154 — 발생 모드마다 요율 필드는 하나 ✅ Decided
v3-154 — 풀이 갖는 요율 필드는 발생 모드가 정한다. 둘 다 가진 풀은 한 질문에 답이 둘이기 때문이다
Date: 2026-08-27 · 상태: ✅ 결정됐고 만들지 않았다. 아직 마이그레이션이 없다. 스키마에서 apy_rate는 여전히 NUMERIC NOT NULL이다.
apy_rate와 accrual_rate_bps는 배타적이 되고, 풀이 어느 쪽을 갖는지는 발생 모드가 정한다. TARGET 풀은 apy_rate를 갖고 accrual_rate_bps를 비운다. FIXED 풀은 accrual_rate_bps를 갖고 apy_rate를 비운다.
🔴 오늘은 두 컬럼 다 NOT NULL이라 모든 풀이 둘 다 갖는다. 언제나 둘 중 하나는 아무도 읽지 않는 숫자이고, 어느 쪽인지를 말해 주는 것이 스키마에 없다. 이것이 막는 실패는 크래시가 아니라, 어떤 화면이나 보고서가 틀린 쪽을 골라 그럴듯하게 말하는 일이다.
⚠️ 둘은 단위가 같지 않고 같았던 적도 없다. accrual_rate_bps는 basis point이고(INTEGER, CHECK 0..10000) apy_rate는 퍼센트 NUMERIC이다. perf_hurdle_bps와의 허들 비교는 accrual_rate_bps를 × 100 없이 그대로 가져간다. 26-glossary → ratios 참조. 둘을 다 갖고 있으면 정확히 그 곱셈 실수를 부른다.
⚠️ accrual_rate_bps는 생성 시에만 정한다. PlatformPool.initialize에 배포되고 setter가 없다. 그것을 바꾸면 이미 발생한 채무를 다시 쓰게 되기 때문이다(v3-131 (3)). 이 결정은 필드가 채워지는지를 바꾸는 것이지 수정 가능한지를 바꾸는 게 아니다.
이 결정이 정하지 않는 것. accrual_mode가 컬럼이 되는지는 아니다. 풀의 모드는 어느 구현체에서 clone됐느냐이고 PoolCreated가 그 주소를 담는다. 저장된 사본은 자기 입력과 어긋날 수 있는 두 번째 답이다. v3-148 ①과 같은 논리다. 생성 경로가 저장하는 것은 요율이고, 그것은 유도된 사실이 아니라 생성 시점의 입력이다.
참고: 11-db-schema → pools · 26-glossary → ratios · v3-131 (3)을 읽는다 · v3-148 ①과 같은 논리
v3-153 — 이자는 납입 자본에 붙는다 ✅ Decided
v3-153 — 이자는 납입 자본에 붙으므로 상각은 NAV를 움직이고 청구액은 움직이지 않는다
Date: 2026-08-27 · 상태: ✅ 결정됐고 만들지 않았다.
이자는 원금에 대해 발생하고, 원금은 납입 자본을 뜻한다. 들어온 돈에서 자본으로 돌려준 돈을 뺀 것이다. 상각으로 줄지 않고 LP 토큰 수도 아니다.
🔴 하나처럼 보이는 둘을 갈라놓는 것이다. NAV는 *"이 보유분이 얼마짜리인가"*에 답한다. 발생은 *"파트너가 얼마를 빚졌나"*에 답한다. 상각은 앞의 것에 일어난 사건이지 뒤의 것에 대한 지급이 아니다. 둘을 한 수치로 읽는 것이 부도 난 풀이 조용히 차주를 면책하는 방식이다.
회계 자체는 새롭지 않다. 대주의 수익 인식은 자산이 신용 손상되면 상각후원가로 옮겨 간다(IFRS 9 §5.4.1(b)). 다만 기준서는 그때도 계약상 이자 의무 자체는 변하지 않는다고 명시한다. 우리가 만드는 것은 투자자의 손익계산서가 아니라 파트너의 채무 원장이므로 계약 기준이 맞다.
그리고 제품이 이미 쓰는 우선수익 관례와도 맞는다. 우선수익은 출자 자본에 대해 발생하고 자본의 환급으로만 줄지 상각으로는 줄지 않는다.
✅ 원장은 이미 정확히 이것을 다른 이름으로 구현하고 있다. s.totalDeposited는 입금 때 늘고 자본 환급 때만 준다. s.positions[u].totalInvested가 홀더별로 같은 일을 한다. 둘 다 상각에 움직이지 않는다. 빠진 것은 누적기가 아니라, 발생 엔진이 지금 그것 대신 LP 토큰 수로 가격을 매긴다는 점이다.
참고: 06-writedown-nav → 표준 산식 · 26-glossary → principal term · PoolLedgerLib(totalDeposited, positions[u].totalInvested) · NAV 산식의 total_deposited 라벨과는 다르다
v3-152 — 청산은 가격을 다시 계산하지 않는다 ✅ Decided
v3-152 — 청산은 가격을 다시 계산하지 않는다. 리저브가 풀 밖으로 나간 뒤에는 나눌 만한 것이 남지 않기 때문이다
Date: 2026-08-27 · 상태: ✅ 결정됐고 만들지 않았다. 컨트랙트 변경이고 새 풀에만 해당한다. Clones가 생성 시 구현체를 고정한다.
executeWindDown이 navPerToken을 다시 계산하기를 그친다. 오라클이 마지막으로 설정한 NAV가 청산 가격이 된다.
🔴 다시 계산하던 것은 distributable / (totalSupply − settledUnclaimedLp)였고 distributable은 풀 자신의 잔액이다. 리저브가 외부 지갑으로 라우팅되면(v3-155) 그 잔액은 더 이상 풀이 청산 중인 돈이 아니라 마침 거기 놓여 있는 무엇이다. 그것으로 나누면 가격처럼 보이지만 가격이 아닌 숫자가 나온다.
정직한 수치는 장부가다. 청산에서 홀더의 지급을 실제로 좌우하는 것은 다시 계산한 가격이 아니라 펀딩의 도착이고, 회차 톱업이 펀딩 메커니즘이 된 뒤로 계속 그랬다. 재계산을 없애면 컨트랙트가 더 이상 관측할 수 없는 것을 주장하기를 그친다.
⚠️ R10을 은퇴시키는 것이 아니다. 이미 배포된 모든 풀에 대해 정확히 옳고, 그 풀들은 구현체를 영구히 유지한다. settledUnclaimedLp getter, pre-fix 대 now 구분, 예시가 전부 그것들을 계속 서술한다. 이 결정은 변경 이후에 만들어진 풀에 적용된다.
참고: 06-writedown-nav → Writedown vs Wind-Down · R10 · v3-100(분자)과 v3-109를 좁힌다 · v3-155와 짝이다
v3-151 — 재투자를 제공하지 않는다 ✅ Decided
v3-151 — 재투자를 모든 화면에서 뺀다. 경로를 실제로 닫는 것은 사라진 버튼이 아니라 컬럼 기본값이다
Date: 2026-08-27 · 상태: ✅ 결정됐고 만들지 않았다. CTA는 아직 화면에 있다.
재투자를 투자자 앱과 어드민에서(펀드 매니저 포함) 뺀다. allow_rollover 토글은 어드민 폼에서 빠지고 투자자의 "Reinvest" CTA는 포트폴리오에서 빠진다.
🔴 버튼을 없애는 것은 경로를 닫지 않는다. 기본값이 닫는다. Pool.reinvest는 온체인에 남고 POST /yield/reinvest도 API에 남는다. 호출을 실패시키는 것은 pools.allow_rollover BOOLEAN DEFAULT false이고, 다른 무엇이 돌기 전에 RolloverDisabled로 revert한다. 반대로 *"재투자는 사라졌다"*고 적으면 다음 독자는 호출자가 그것에 닿을 수 없다고 믿게 되는데, 그건 사실이 아니다.
⚠️ 제거되는 것은 없다. 컬럼(allow_rollover, min_reinvest_amount)도, 엔드포인트도, 컨트랙트 함수도 전부 남는다. 폐기가 아니라 표면에 대한 범위 결정이다.
⚠️ BD5의 형태를 대체하는 것이지 그 논리를 대체하는 게 아니다. BD5는 투자자 CTA가 붙은 수동 재투자 V1을 명시했고, 회수되는 것은 그 CTA다. BD5의 다른 조항인 *"부분 없이 수익 전액만"*은 이미 코드가 앞질렀다. 코드는 청구 가능 잔액까지 부분 재투자를 허용한다. v3-64와 YieldLib.reinvest ②·③ 참조.
참고: 05-investment-lifecycle → Reinvest V1 Policy · 02-core-concepts → Yield Model · 11-db-schema(allow_rollover, min_reinvest_amount) · 24-field-governance · BD5의 CTA 절반을 회수한다
v3-150 — 정산 상한이 제품 표면에서 빠진다 ✅ Decided
v3-150 — 회차별 정산 상한은 제품 표면에서 빠지고 컨트랙트에 남는다
Date: 2026-08-27 · 상태: ✅ 결정됐고 만들지 않았다. 어드민 폼과 투자자 문구가 아직 담고 있다.
redemption_gating_bps는 폐기하고 MVP에 넣지 않는다. 화면과 API에서 뺀다. 컨트랙트와 컬럼은 그대로다. 스토리지, 거버넌스 setter, 그 이벤트는 건드리지 않는다.
🔴 이것은 제거가 아니라 라벨 변경이다. MVP에서 값은 언제나 0이나 NULL이고, 그러면 상한 항이 무기력하다. available = reserveBalance + epochFundTopUp[id]이고 천장이 적용되지 않는다. 그래서 07-redemption의 정산 산식은 쓰인 그대로 둔다. 그것은 여전히 상한을 담고 있는 컨트랙트를 서술한다.
왜 빠지나. 이번 라운드가 실제로 만드는 풀 형태, 즉 공표된 일정에 맞춰 펀딩되는 상환 풀에서는 두 번째 상한이 그 일정을 깨뜨릴 수만 있다. 그래서 생성 위저드가 그 조합에서 이미 필드를 숨긴다. 어떤 풀도 설정하지 않는, 거버넌스 타임락이 걸린 파라미터를 지고 다니는 것은 뒤에 제품이 없는 관리 비용이다.
⚠️ 다른 게이트는 영향이 없다. 투자자는 자기 LP 잔액만큼만 요청할 수 있다(InsufficientLPTokens). 별개 규칙이고 계속 유효하다.
참고: 07-redemption(정산 산식은 그대로 둔다) · 11-db-schema · 24-field-governance · 04-pool-models(이 조합에서는 이미 숨김) · v3-67을 좁힌다
v3-149 — 즉시 상환에는 취소가 없다 ✅ Decided
v3-149 — 즉시 상환은 되물릴 수 없고, 그것을 허용하던 온체인 분기는 그냥 호출자를 잃는다
Date: 2026-08-27 · 상태: ✅ 결정됐고 만들지 않았다. 즉시 취소 경로가 아직 가능한 것으로 서술돼 있다.
취소는 회차 흐름에서만 제공하는 기능이다. 투자자는 QUEUED나 PARTIALLY_FILLED 동안 cancelRedemption을 할 수 있다. 투자자 앱에 즉시 취소 버튼은 없다.
🔴 cancelRedemption의 즉시 분기는 온체인에 남는다. MVP 범위 밖이라는 것과 제거됐다는 것은 같지 않고, 두 번째로 적으면 다음 컨트랙트 독자에게 눈에 보이는 분기가 존재하지 않는다고 말하는 셈이다.
두 흐름이 왜 다른가. 즉시 요청은 거절되거나 지급되거나다. "취소"가 의미를 갖는 창은 블록 몇 개 너비이고, 그렇게 좁은 기능은 제품이 지킬 수 없는 약속으로 읽힌다. 회차 요청은 진짜로 머문다. 요청 창을 지나고 정산을 지나므로 그것을 취소하는 것이 실제 행동이 된다.
⚠️ 회차 취소는 그대로다. 요청 창 안에서만 가능하고, 잠긴 LP가 반환되며 epochTotalDemandLp가 줄어든다. 파트너 펀딩을 돌려주지는 않는다. 취소된 요청은 failure_type = INVESTOR_CANCELLED인 REJECTED로 남는다. 별도 CANCELLED 상태는 없다.
참고: 07-redemption · 10-status-machines(REJECTED + INVESTOR_CANCELLED) · RedemptionLib.cancelRedemption(즉시 분기 유지)
v3-148 — 이탈 경로는 유도하고 저장하지 않으며, 그 판단은 모든 축을 쓴다 🔨 Built · front-end · jy/product
v3-148 — 보유분이 어떻게 빠져나가는지는 풀이 이미 가진 축들에 대한 유도이고, 그 답을 내는 함수에는 축을 전부 넘겨야 한다
Date: 2026-08-26 · 상태: 🔨 만들었다(apps/web, apps/admin-web). 프런트엔드만이고 컬럼도 엔드포인트도 컨트랙트 변경도 없다. 여기서 제품이 하는 일은 아무것도 바뀌지 않는다. 답이 어디서 오는지가 바뀐다.
제품 모델은 이미 적혀 있었고, 클라이언트에게 그것을 읽을 방법이 주어지지 않았을 뿐이다. 07-redemption은 redemption_type × epoch_duration_days가 만드는 조합과 그중 표현할 수 없는 둘을 적고 있다. 공유된 유도가 말하지 않던 것은 보유분에 대한 귀결이었다. 그 축들과 오늘 날짜가 주어졌을 때, 이 포지션은 실제로 어떻게 빠져나가는가?
🔴 투자자 앱에는 다른 질문에 대한 판정 하나가 있었다. LOCKED / EARLY / FREE는 "이 홀더가 나갈 수 있는가"에 답한다. 그런데 여덟 표면이 FREE를 "지금, 전액"으로 읽었고 그건 풀 형태 넷 중 정확히 하나에만 참이다. 상환 풀에서 FREE는 "창이 열리면 대기열에 설 수 있다"이고, 만기가 온 일시금 풀에서는 "한 번에, 지금"이며, 만기 전 FIXED_MATURITY 풀에서는 컨트랙트가 모든 요청을 아예 거부한다. 단어 하나 뒤에 문장 셋이 있었고 열두 곳이 틀린 것을 말했다.
결정은 두 부분이다.
① 답은 축들에서 유도한다. 절대 저장하지 않는다. 컬럼도, 필드도, 캐시된 모드도 없다. 축(redemption_type, epoch_duration_days, 지금 대비 만기일)은 이미 존재하고 체인이 강제한다. 저장된 사본은 그중 하나가 움직이는 순간 자기 입력과 어긋나는 두 번째 답이다. accrual_mode가 컬럼이 되지 않게 한 것과 같은 논리다. 풀의 모드는 어느 구현체에서 clone됐느냐이고 PoolCreated가 그 주소를 담는다.
② 그 판단은 모든 축을 받고, 타입이 그렇게 말한다. 실제로 실패했던 절반이 이쪽이다. 투자자 주기 배너가 epoch_duration_days만 읽어서, 만기 뒤에 회차로 갚는 풀에 대해 *"지금 요청할 수 있습니다. 이번 주기는 9월 17일에 닫힙니다."*라고 알렸다. 로그인하지 않은 방문자에게, 만기가 올 때까지 모든 requestRedemption이 revert하는 풀에 대해서다. 날짜는 진짜였고 그것을 둘러싼 문장이 진짜가 아니었다. 축의 일부만으로 만든 판단이 그렇게 되는 방식이고, 축을 하나씩 넘기는 것이 그것을 허용한다.
🔴 만기 게이트가 주기보다 우선한다. 주기가 있는 FIXED_MATURITY 풀은 둘 다 갖고 있는데, 주기를 먼저 읽으면 홀더에게 자기를 받아 줄 수 없는 창을 지켜보라고 말하게 된다. PlatformPool은 시계를 보기 전에 거부한다.
🔴 만기일이 없는 FIXED_MATURITY 풀은 열린 게 아니라 닫힌 것이다. requestRedemption은 maturityDate == 0 || block.timestamp < maturityDate에서 revert한다. 없는 날짜는 그 조건의 첫 절반이지 면제가 아니다. 가상의 형태가 아니다. 값이 없으면 setMaturityDate를 그냥 보내지 않고, 그래서 이제 풀 생성이 모집 마감 없는 FIXED_TERM 풀을 거부한다(v3-145). 그 가드보다 앞선 풀들은 이미 배포돼 있다. 클라이언트의 유도 둘이 정확히 이 경우에 대해 의견이 갈렸고, 그중 하나는 그런 풀을 "주기로 정산됨"이라고 서술했다.
이 결정이 정하지 않는 것. 어떤 조합이 허용되는지는 아니다. 그건 07-redemption이고 그대로다. 락업 앵커(v3-144)나 기간 앵커(v3-145)도 아니다. 둘 다 이것이 읽지만 어느 것도 움직이지 않는다.
참고: v3-132(만기 후 회차 풀)를 소비한다 · v3-144 / v3-145(읽는 두 앵커) · 07-redemption의 조합 표 · apps/web/app/shared/lib/redemption-state.ts
v3-147 — 만기 후에는 쿠폰이 상환 회차를 따라간다 🔨 Built · deploy pending
v3-147 — 만기 온 상환 풀의 수익 날짜는 그 회차의 날짜이고, 컨트랙트의 약속 계층이 처음으로 라이터를 얻는다
Date: 2026-08-25 · 상태: 🔨 만들었고 배포 대기. 컨트랙트 코드로만 보면 오프체인 작업이다. 온체인 절반은 2026-08-21 라운드부터 있었지만 호출자가 0이었던 setter다.
운영상의 합의(JY, 2026-08-24). 만기 뒤 여러 회차에 걸쳐 갚는 풀에서 수익 지급과 원금 상환은 같은 날 떨어진다. 트랜잭션은 둘이다. 체인에 그것을 위한 함수가 둘 있고(depositYield와 fundRedemption) 둘을 함께 하는 세 번째가 없기 때문이다. 하지만 날짜는 하나다.
🔴 둘 중 하나만 고정돼 있으면 두 사다리가 같은 날짜에 머물 수 없다. 회차의 날짜는 미리 정해지고 펀딩이 늦어도 움직이지 않는다. 수익 날짜는 성과에서 유도됐다. 다음 지급일 = 마지막으로 지급한 기간의 끝 + 주기 하나다. 10일 대신 20일에 한 번 지급하면 이후 모든 쿠폰이 20일로 옮겨 가고 회차는 10일에 남는다. 합의는 첫 지연에서 깨지고 깨진 채로 남는다.
결정. 만기 전에는 아무것도 바뀌지 않는다. 붙을 회차 사다리가 없고 주기가 맞다. 만기 뒤에는 수익 기간 n이 회차 n의 지급일에 도래하고 유도가 아니라 저장된 값을 쓴다.
어디서 오나: 새로 만든 것은 없다. redemption_epochs.funding_date가 이미 주기마다 받은 날짜를 담고 있고, 자기 주석이 다른 것을 거기 쓰는 것을 금지한다("계획한 날짜나 다시 계산한 날짜는 절대 아니다. 계획은 pools에, 사실은 여기에 산다"). 만기 뒤 쿠폰 날짜가 바로 그 날짜이므로, 답은 컬럼을 읽는 것이었지 컬럼을 더하는 것이 아니었다. 마이그레이션 없음.
⚠️ 그 컬럼의 NULL이 무게를 진다. 체인이 그 회차의 날짜를 받은 적이 없고 직전 + epochDurationDays로 유도하고 있다는 뜻이다. 월 단위 계획의 첫 달에는 3일 이르고 넷째 달에는 8일 이르다. 유도된 날짜를 약속으로 승격시켜서는 절대 안 되므로, 쓰이지 않은 회차는 목록에서 읽지 않고 뺀다. 그 공백이 펀딩일 리마인더(v3-135)가 쫓는 대상이다.
다음 날짜를 어떻게 고르나: 앵커 이후 가장 이른 회차 날짜. "회차 번호 n"이 아니다. 그 표현에는 커서와 만기 전 기간의 수, 회차 인덱스를 수익 기간 인덱스로 옮기는 매핑이 필요한데, 셋 다 기간이 누락되거나 늦게 정산되는 순간 어긋난다. "앵커 이후"에는 그중 아무것도 필요 없고, 쿠폰을 못 받은 회차는 자기 날짜를 유지한 채 연체로 읽힌다. 일정의 나머지와 정확히 같다.
🔴 순서: MATURED 상한이 먼저 돌고, 계획은 상한이 떨군 것만 가져간다. 기간이 아직 돌고 있을 때 도래한 기간은 만기 전 의무이고 늦은 것이다. 그것을 계획에 넘기면 회차 날짜로 앞당겨져 조용히 연체가 아니게 된다. 상한은(v3-124 옆에 있고 resolveNextYieldDue 안이다) 그 기간을 있던 자리에 그대로 두고, 계획은 상한이 일정을 끝내던 지점에서 넘겨받는다.
온체인 절반 — 호출자 없는 setter. 2026-08-21 라운드(v3-143 ③)가 컨트랙트에 계층 둘을 줬다. 운영자가 쓰는 저장된 yieldDueDate[n]과, 아무도 확정하지 않은 기간을 위한 선형 yieldPeriodDays 예측이다. 저장 계층이 있어야 약속이 달력 날짜일 수 있다. 오프체인의 무엇도 setYieldDueDate를 호출한 적이 없어서 예측만 굴러갔고, 풀은 제품이 약속하지 않은 날짜를 공표했다. 더 나쁘게, settleYield가 그 계층 자체를 전진시킨다(YieldLib.sol:183-187). 그래서 정산할 때마다 체인이 저장된 날짜라는 이름으로 28일 예측을 들고 있다. 동기화는 빈 슬롯을 채우는 게 아니라 그것을 덮어써야 한다.
🔴 어느 기간을 쓸지는 체인에서 읽지 절대 유도하지 않는다. yieldDueDate의 인덱스는 회차 번호가 아니라 앵커부터 센 수익 기간 번호다. 만기 전에 월 쿠폰 열두 번을 지급한 풀은 회차 1이 기간 13에 떨어진다. 그 오프셋을 유도하려면 만기 전 횟수가 정확히 맞아야 하는데, 쿠폰 하나가 누락되는 순간 움직인다. 동기화는 yieldPeriodsSettled를 읽고 + 1을 쓴다. 풀이 다음에 빚지는 기간이고 유일하게 건드려도 되는 기간이다.
확정 엔드포인트가 아니라 매일 도는 sweep에 붙는다. confirm_funding_date는 배포 시점, 즉 만기 전에 도는데 그때는 오프셋을 알 수 없다. sweep은 이미 정확히 그 풀들에 대해 계획의 답을 계산하므로, 만기 뒤 첫 쓰기와 이후의 어긋남을 다 덮고, 날짜가 바뀌지 않은 날에는 트랜잭션 비용이 들지 않는다. 그래서 pools.scheduler.yield-due가 서명 함수가 된다. ORACLE_ROLE 정책에 자기 실행 role을 더한다. 공유 role에 서명 함수를 두면 옆의 모든 스케줄러에 kms:Sign을 쥐여 주게 되기 때문이다.
✅ 구조적으로 치명적이지 않다. yieldDueDate는 체인에서 아무것도 게이팅하지 않는다. apps/contract/src에서 grep하면 예측과 nextYieldDueAt이 읽고 그 외에는 없다. 동기화가 실패하면 낡은 공지가 남을 뿐 돈은 건드리지 않는다. 그래서 정산의 일부가 아니라 sweep의 꼬리에 있다. 풀이 이미 지난 날짜에 대한 InvalidSchedule은 버그가 아니라 예상된 트래픽이다. 자기 날짜가 지난 뒤 펀딩된 회차가 거기 떨어지고, 그 기간은 진짜로 연체다.
🔴 읽기 실패를 "날짜 없음"으로 넘기지 않는다. 빈 계획은 "일정이 끝났다"로 해석되고 그 답이 쓰인다. 쿼리 하나가 실패하면 모든 상환 풀의 next_yield_due가 한꺼번에 지워지는데, due 큐와 연체 에스컬레이션은 그 컬럼만 읽는다. 배치 sweep은 중단하고, 단일 풀 경로는 저장된 날짜를 그대로 두고 다음 sweep이 다시 계산하게 한다.
금액 둘, 그리고 그것들이 어디서 와야 했나. GET /pools/{id}/repayment-cycles가 이제 회차마다 원금 축과 수익 축으로 답하고, 각각이 자기 상태와 화면이 보여 줄 수치 하나, 그리고 그 수치가 추정인지를 담는다. 합계는 없다. 한쪽이 미결인 동안에는 거짓이고, 한쪽이 먼저 도착하는 것은 예외가 아니라 모든 회차가 지나는 상태다. 백엔드가 두 축을 다 정한다. 그래야 투자자와 FM, 어드민 화면이 한 회차를 세 가지로 맞추지 않는다.
🔴 redemption_epochs.total_demand_lp은 어떤 라이터도 채운 적 없는 컬럼이라(정의 하나, 쓰기 0회) 원금 수치는 체인 말고는 갈 곳이 없다. 날짜는 여전히 거기서 읽지 않는다. 유도된 날짜와 받은 날짜를 구분할 수 없고 그것이 이 엔드포인트의 존재 이유다. 다만 잔액에는 그런 모호함이 없고, 그 읽기는 날짜를 받은 회차에 대해서만 한다.
🔴 epochFundTopUp은 정산 전후로 다른 뜻이다. 정산이 그것을 먼저 차감하므로(RedemptionLib.sol:934-938) 정산 전에는 파트너가 넣은 금액이고 정산 후에는 수요가 쓰지 않은 금액이다. 그래서 이름 둘로 보고한다. funded_usd와 surplus_usd다. 두 번째를 첫 번째로 읽으면 완전히 상환된 회차를 미펀딩이라고 부르게 된다.
그리고 같은 규칙이 기간의 반대편에서도 성립해야 했다. derivePeriod는 여전히 주기를 걸었고 period_end가 다음 앵커가 된다. 그래서 월 단위 주기에 대한 28일 계획에서는 앵커가 다음 회차의 날짜를 지나 떨어졌고, 그 회차의 쿠폰은 영영 도래하지 않았다. 이제 두 규칙은 nextPlannedPayout을 공유하는 한 규칙이다. 함께 발견된 것: 격자의 폴백 시작이 start_date, 즉 모집이 열린 날이라 풀의 첫 쿠폰이 자기 모집 기간을 덮고 있었다([v3-145]가 다른 모든 앵커를 옮기면서 이것을 놓쳤다).
화면: 셋 다 만들었다(D-4 §3-3). 어드민 상환 카드는 두 행짜리 열을 얻는다. FM의 회차 블록은 펀딩 동작 둘을 순서 없이 나란히 둔다. 체인에 둘 사이 게이트가 없고, 원금을 먼저 하도록 강제하면 수익만 준비된 매니저를 막게 되기 때문이다.
투자자는 자기 금액을 본다(JY, 2026-08-26). 그리고 두 행이 같은 종류의 진술이 아님이 드러났다.
🔴 수익은 홀더별 사실일 수 있지만 원금은 누구에게도 회차별 사실일 수 없다. yield_distribution_investors는 컨트랙트가 홀더마다 적립해 준 값을 기록하고, 우리가 계산한 지분 비율이 아니라 정산 전후의 claimableYield에서 가져온다. 원금에는 회차별 수치가 아예 없다. claimRedemption 한 번이 홀더가 마지막으로 청구한 뒤의 모든 회차를 정산한다. 자기 vintage 회차를 새 수요로, 나머지는 누적 이월 사다리로 처리한다(_settleEpochClaim, RedemptionLib.sol:1131). RedemptionClaimed가 회차 id를 담지 않는 것은 누락이 아니다. 정산 단위가 청구다. 그것을 오프체인에서 쪼개려면 2단 사다리와 그 세대, 재앵커링을 다시 유도해야 한다. 컨트랙트에서 가장 정교한 계산에 대해, 그것도 돈에 대해 두 번째 의견을 내는 일이다.
그래서 원금 행은 상태를 말하고, 그게 어차피 더 쓸모 있다. 만기 후 상환은 requestRedemption을 거치므로, 한 번도 요청하지 않은 홀더는 기약 없이 아무것도 상환받지 못하는데 아무도 그걸 말해 주지 않는다. NOT_REQUESTED가 상환 동작을 담는다. 정확히 한 회차에만 붙는다. 날짜를 가진 것 중 가장 이른 미정산 회차다. 열두 행에 반복되면 할 일 열두 개로 읽히고, 날짜를 받지 않은 회차에 붙이면 체인이 그저 유도하고 있는 날짜에 동작을 붙이는 셈이다.
⚠️ 분배가 존재하는 회차인데 기록된 수치가 없는 홀더는 금액 없는 PENDING으로 보고한다. 거기서 풀 전체 값으로 폴백하면 남의 돈을 그 홀더의 행에 올리게 된다.
참고: v3-132(상환 계획)와 v3-143 ③(약속 계층)을 확장한다. 함께 맞물리는 상한은 resolveNextYieldDue에 있다. yield-interval.ts, yield-schedule.ts, contract/yield-due-date.ts, yield/sync-onchain-yield-due.ts, pools.scheduler.yield-due.ts. 출처: JY(제품), 2026-08-24.
v3-146 — 락업의 세 날짜는 생성 시점의 결정 하나다 🔨 Built · deploy pending
v3-146 — 락업에는 앵커가 필요하므로 날짜 하나가 얼고 풀 형태 하나가 락업을 잃는다
Date: 2026-08-25 · 상태: 🔨 컨트랙트는 배포됨(Base Sepolia, 2026-08-25 라운드). 오프체인도 만들었지만 살아 있지 않다. FACTORY_ADDRESS_84532가 여전히 이전 팩토리를 가리키므로 오늘 만드는 모든 풀은 여전히 투자자별이다.
온체인에 나간 것(CH). subscriptionEndDate가 이제 RedemptionConfig 안에서 lockupDays, maturityDate 옆에 살고, isLockupActive는 그것부터 세며 investor 인자를 잃었다. PoolConfigLib.validateTerms가 셋을 한 집합으로 판정한다. initialize에서 한 번, 그리고 셋 중 하나를 건드리는 모든 setter에서 다시 한다. 이것이 v3-144의 컨트랙트 절반이다. 그 카드가 앵커를 결정했고 이 카드는 조건들이 어떻게 됐는지를 기록한다.
🔴 자르지 않고 거부하는 것이 무게를 지는 선택이다. 앞선 개정판은 isLockupActive 안에서 해제 시점을 maturityDate로 잘랐다. 그러면 풀이 배포된 뒤 공표한 것보다 적은 락업을 조용히 강제하게 되고, 앵커가 만기에 닿으면 아예 없게 된다. 검사를 설정이 정해지는 자리로 옮겼고 그 자르기도 함께 갔다. 이제 만들 수 없는 상태가 둘이다. 모집 마감 없는 락업(LockupRequiresSubscriptionEnd)과 기간을 넘겨 끝나는 락업(LockupOutlastsTerm)이다.
오프체인 비용. 규칙 둘, 사실 하나. 모집 마감이 더 이상 우리만의 것이 아니다.
🔴 1. 모집 마감은 락업의 앵커가 되는 순간 얼어붙는다. 어드민 폼은 그것을 수정 가능한 일정 손잡이로 제공했고 "모집 마감은 어떤 컨트랙트에도 닿지 않는다"는 주석을 근거로 삼았다. pool-wide 풀에서는 컨트랙트가 자기 사본을 갖고 있고, 수정을 되돌려 쓰는 것이 없으며(setSubscriptionEndDate에는 호출자가 없다) 풀이 LP를 갖고 있으면 어차피 거부한다. 그래서 배포 뒤의 수정은 우리 쪽 절반만 영구히 옮긴다. 그리고 깨지는 방향은 되돌리는 쪽이다. 날짜를 비우면 lockupAnchorMs가 null로 답하고 백엔드는 "락업 없음"으로 읽어 컨트랙트가 그다음 거부할 상환을 받아들인다. 이제 수정 폼과 PATCH /pools/{id}에서 거부하고, @aset/types의 규칙 하나에서 나오므로 툴팁과 409가 어긋날 수 없다.
⚠️ 범위가 잡혀 있고 두 절반 다 제 값을 한다. lockup_days == 0이면 그 날짜가 온체인에서 무기력하므로 모집을 줄이는 것은 여전히 정당한 라이브 수정이다. 그리고 lockupIsPoolWide는 DRAFT가 지닌 null 구현체에 대해 이미 false로 답하므로, 이 값이 정당하게 설정될 수 있는 그 한 순간을 잠그지 않는다. 이미 생성 전용이던 lockup_days와 maturity_days를 세 번째 조건이 따라잡은 것이다.
🔴 2. 개방형 풀은 락업을 갖지 않는다(JY). 그 풀의 모집 창은 선택이고(만기가 오지 않는 풀에서 빈 창은 진짜 답이다) 그러면 pool-wide 락업이 셀 날이 없다. 대안은 "예정된 끝이 없다"가 형태인 풀에게 마감을 요구하거나, 아무것도 기록하지 않는 두 번째 앵커를 지어내는 것이었다. lockupApplies가 이제 redemption type뿐 아니라 maturity model도 읽는다. 서로 무관한 두 이유로 필드를 잃는 길이 둘이다.
결과를 그대로 적자면, 개방형 풀에는 출구 게이트가 아예 없다. 패널티는 무엇에 대해 조기인지가 있어야 하는데(maturityDate가 없으면 isBeforeMaturity가 거짓이다) 락업이 유일한 것이었다. dev의 모든 개방형 풀은 이미 그렇게 돈다(락업 0, NO_EARLY). 그래서 형태가 달라지는 것은 없다. 최소 보유 기간은 지금 두 번째 앵커 위에 만들지 않고 포지션별 라운드로 미룬다(JY, 2026-08-25).
✅ 두 거부는 전제와 함께 녹아 없어지고, 코드에 그렇게 적어 뒀다. 만기가 포지션별이 되면(v3-131 4번) 락업도 각 홀더의 입금으로 따라 돌아간다. 마감이 앵커이기를 그치고, 개방형 풀이 다시 최소 보유 기간을 가질 수 있으며, v3-144의 reopen 금지도 함께 풀린다. 그래서 코드에 기록한 사유는 언제나 **"풀 단위 앵커라서"**이지 "락업은 재개방이나 개방형 풀과 못 쓴다"가 아니다. 두 번째로 적으면 제약이 그것을 만든 전제보다 오래 살아남는다.
은퇴하는 것. lockupWithoutAnchorReason이다. 마감 없는 락업을 거부하던 것인데 엉뚱한 분기에 앉아 있어서 발동할 수 없었다. 그것이 지키던 상태는 이제 만들 수 없고, 그것을 강제하는 경계 둘(배포 트랜잭션 전의 checkLockupWithinTerm, 온체인의 validateTerms)은 그대로다. 고치지 않고 삭제했다. 발동할 수 없는 가드는 보호처럼 읽히지만 보호가 아니다.
⚠️ 가는 길에 발견한 조용한 무동작. maturity_model은 PATCH /pools/{id}의 필드 허용 목록에 한 번도 없었고 parseJsonBody는 아무 모양이나 받으므로, 수정 위저드의 maturity model 선택이 DRAFT에서 아무것도 저장하지 않았고 그렇다고 말해 주는 오류도 없었다. 이제 진짜 필드이고 다른 생성 시점 조건들과 같은 B 부류다. 그리고 그 enum과 maturity_days와의 짝짓기가 pools.post.create에서 나와 두 라이터가 모두 닿는 validatePoolConfig로 옮겼다. 초안을 OPEN_ENDED로 바꾸면 maturity_days: null을 보낸다. 키가 없으면 "변경 없음"이고, 그것이 새 모델 뒤에 옛 조건을 남겨 두고 있었다.
⚠️ 팩토리가 바뀌기 전까지 이 중 무엇도 살아 있지 않다. 새 구현체는 배포됐고 쓰이지 않는다. 어떤 풀이 그것을 받았는지에 답하는 것은 pools.pool_implementation(v3-144)이고, dev의 모든 풀이 아직 "아니오"라고 답한다.
참고: v3-144의 컨트랙트 절반 · 앵커 산술은 v3-145에서 · PoolConfigLib.validateTerms, PoolCommonLib.isLockupActive, offeringCloseLockedByLockupReason/lockupApplies, pools.patch.update.ts, pool-field-validation.ts
v3-145 — 기간은 모집 마감부터 센다. 그래서 누구도 그보다 짧게 투자되지 않는다 🔨 Built · deploy pending
v3-145 — `maturity_date = subscription_end_date + maturity_days`, 그리고 W9 상한이 함께 은퇴한다
Date: 2026-08-24 · 상태: 🔨 만들었고 배포 대기. 오프체인만이다. maturityDate는 백엔드가 계산해 setMaturityDate로 보내는 절대 타임스탬프라서 컨트랙트 변경이 없고 CH가 할 일도 없다.
무엇이 틀렸나. pools.worker.deploy가 maturityDate = nowSeconds + maturityDays로 계산했으므로 풀의 기간이 누군가 Deploy를 누를 때 시작됐다. 결과가 둘이고 둘 다 조용했다.
- 초안의 만기가 밀렸다. 배포되지 않고 앉아 있던 만큼 밀렸는데, 그 풀의 다른 모든 날짜는 위저드가 둔 자리에 그대로였다.
deploy-dates.ts는 그 어긋남을 이름 붙이려고 존재했다 - 풀이 모집을 마치기 전에 기간이 시작됐다. 모집 마지막 날에 들어온 투자자는 이미 그 창 전체 동안 돌고 있던 기간에 대해 재어졌고, 더 짧게 보유하고도 온전한 기간의 쿠폰을 받았다. 07-redemption이 그것을 알려진 결함으로 적어 뒀고, 결정된 해법(포지션별 만기, v3-131 4번)은 컨트랙트 변경이 필요해 만들어진 적이 없다
결정. 기간을 모집 마감부터 잰다. 입금은 그 전에만 받으므로 어떤 홀더도 온전한 기간보다 짧게 투자될 수 없고, 만기는 위저드가 보여 줄 수 있고 배포가 그대로 재현하는 날짜가 된다.
🔴 만기를 포지션별로 만들지 않고 늦은 투자자 결함을 고친다. 만기가 홀더별이 된 게 아니라 일찍 시작하기를 그친 것이다. 짚어 둘 만한 이유는 포지션별 카드가 아직 열려 있는데 그 동기가 이제 사라졌기 때문이다.
함께 은퇴하는 것.
- W9 상한(
모집 마감 ≤ 만기 − 수익 기간 1회분),maxOfferingDays,minEffectiveDays, "늦은 투자자는 N번만 받습니다" 경고. 넷 다 모집이 기간의 앞부분에서 잘려 나갔기 때문에 있었다. 이제 잘려 나가는 것이 없으므로 모집이 길어지면 만기가 뒤로 밀린다.features/pool-form/offering.ts는 다른 경로로 자기 은퇴를 예견하고 이식하지 말고 삭제하라고 적어 뒀고, 실제로 그렇게 됐다 - 늦은 투자자 락업 가드(D-7,
lockup > maturityDays − offeringDays). 이제 기간의 양 끝이 앵커를 공유하므로 조건이lockup > maturityDays로 붕괴하고, 그건 위저드가 늘 강제해 온 것이다. ⚠️ 정정에 주의할 것. v3-144는 앵커가 옮겨도 산술이 살아남으므로 이 가드는 절대 은퇴할 수 없다는 앞선 주장을 기록하고 있다. 락업만 혼자 옮길 때는 맞았고 만기가 함께 옮긴 뒤로는 틀렸다. 가드를 은퇴시키는 것은 양 끝이 앵커를 공유하는 것이다 - 배포 순간의 어긋남.
deploy-dates.ts는 진짜로 그 순간에 대한 검사 둘(이미 지난 모집 마감, 그리고 얼마나 임박했는가)을 유지하고 보고할 움직이는 날짜가 남지 않았다
🔴 FIXED_TERM 풀에는 이제 모집 마감이 필요하다. 비워 두는 것은 "기간이 끝날 때까지 모집"이라는 뜻이었는데(0204) 기간이 마감으로 정의되면 그게 성립하지 않는다. "내가 모집을 멈추는 시점이 정하는 날짜까지 모집한다"로 읽힌다. 의도적으로 세 곳에서 거부한다. 위저드의 발행 게이트, POST /pools/{id}/lifecycle, 배포 워커다. 워커는 중복이 아니다. 재시도 배포는 발행 검증을 건너뛰고, setMaturityDate는 값이 없으면 실패하는 게 아니라 그냥 안 보내진다. 그러면 maturityDate == 0이 남고 _validateRedemption이 그것을 "만기가 오지 않음"으로 읽어 FIXED_MATURITY 풀의 모든 요청을 영원히 revert한다.
⚠️ 새 배포에만 적용된다. 이미 체인에 올라간 풀은 받은 maturityDate를 유지하고 아무것도 이관되지 않는다. end_date는 위저드의 추정치이고 배포가 그것을 다시 쓰지 않으므로, 옛 규칙으로 만들어진 풀은 Configuration 탭에 시작일 기준 숫자를 그대로 보여 준다. resolveMaturityAt은 maturity_date를 우선하므로 동작은 맞고 그 행만 낡았다. DeployDateReview가 이제 그것을 이름 대어 말한다.
함께 바뀐 것. 타임라인이 기간 → 상환이 아니라 모집 → 기간 → 상환을 그린다.
✅ 수익 격자도 옮겼다(JY, 2026-08-24). next_yield_due를 start_date + 주기가 아니라 subscription_end_date + 주기로 심고 yield-schedule.ts의 마지막 폴백 앵커도 따라간다. 반나절 동안 제품 질문으로 열어 뒀다가 결정했고, 기간의 귀결이 아니라 자기 이유가 있다. 격자가 start_date에 있으면 한 기간보다 길게 모집하는 풀이 아직 모집 중인데 분배를 빚졌다. 아직 다 도착하지 않은 자본에서, 아직 바뀌고 있는 홀더 집합에게다.
🔴 표시용 추정치만이 아니다. pools.post.create가 심은 날짜를 yieldAnchorDate로 배포에 넘기고 그것이 컨트랙트의 yieldAnchor(AccrualConfig)가 된다. setter가 없으므로 풀이 생성될 때 받은 앵커가 그 풀이 유지하는 앵커다. start_date는 마감이 없는 풀의 폴백으로 남고, 이번 라운드 뒤로 그것은 개방형 풀을 뜻한다.
⚠️ 위저드의 눈금이 한 라운드에 두 번 뒤집혔고, 거기서 나온 규칙을 그 눈금이 계산되는 자리에 적어 뒀다. 눈금 목록은 pools.post.create가 심는 것을 그대로 비추고 그 외에는 아무것도 비추지 않는다. 처음에는 시작일 기준이었고(백엔드와 일치), 기간에 맞추려고 마감으로 옮겼다가(그래서 위저드가 아무것도 만들지 않는 일정을 약속하게 됐다), 되돌렸고, 심는 값 자체가 옮긴 뒤 다시 옮겼다.
🔴 이제 마감이 월 단위로 걷는 격자를 앵커하고, 그것은 유도된 날짜다. computeNextYieldDue가 마지막 지급일에서 addUtcMonths로 전진하므로 한 번 잘리면 영구적이다. 1월 31일 마감은 1월 31일, 2월 28일, 3월 28일, 4월 28일을 지급하고 31일로 돌아오지 않는다. 상환 roll day를 1~28로 제한한 것이 정확히 이 때문인데, 모집 마감은 start_date + 모집 일수라 아무도 입력하지 않고 아무것도 검사하지 않고 있었다. 이제 위저드가 마감이 29~31일에 떨어지는 창을 거부하고 28일이나 1일에 떨어지는 길이 둘을 알려 준다. 월 단위로 걷는 주기에만 적용한다(WEEKLY와 CUSTOM 일수는 고정 일수를 더하므로 잘릴 수 없고, 그것들을 거부하면 뒤에 실패가 없는 규칙이 된다).
⚠️ 만기 후에는 수익 날짜와 상환 날짜가 같은 날로 합의됐는데, 그것은 아직 만들 수 없다. 두 사다리의 일정 방식이 다르다. 상환 날짜는 funding_anchor_date에서 고정되고 수익 날짜는 마지막 지급에서 유도되므로 한 날짜에 머물 수 없다. 늦게 지급하면 수익 격자가 옮겨 가고 상환 사다리는 제자리에 남는다. 컨트랙트에는 이미 메커니즘이 있고(setYieldDueDate. 선형 예측을 덮어쓰는 저장된 날짜이고 v3-143 ③에서 나갔다) 오프체인의 무엇도 그것을 호출하지 않는다. 기간별 수익 날짜 컬럼도 없다. 범위를 잡아 Claude-Plan/Handoff/post-maturity-yield-redemption-same-date.md로 제품에 넘겼다.
마이그레이션 0209에서 컬럼 주석을 정정했다(주석만). pools.start_date는 수익 일정이 그것부터 센다고 적혀 있었고 pools.subscription_end_date는 자기를 모집 마감이라고만 서술했다. 마감은 이제 풀 전체 타임라인의 원점이다. 별개 규칙 셋(만기, pool-wide 락업, 수익 격자)이 그것부터 세므로 그것을 옮기면 셋이 다 옮긴다. resolveMaturityAt의 마지막 폴백이 subscription_end_date + maturity_days를 읽는데, 이는 resolver가 그 컬럼을 절대 읽으면 안 된다던 W9 규칙을 뒤집는 것이다. 그때는 맞았다. 읽었다면 운영자가 창을 줄여서 풀의 기간을 줄일 수 있었다. 지금은 반대 방향으로 맞다. 그것이 곧 기간이기 때문이다.
🔴 하류의 둘이 여전히 오늘에서 기간을 투사하고 있었고, 예외 케이스 점검에서 발견해 함께 고쳤다. 상환 계획의 하한(earliestAnchorMs, 회차 1의 펀딩일에 대한 W2-2 규칙)과 바의 상환 구간(repaymentSpanDays)이 둘 다 오늘 + maturity_days로 계산하고 있었다. 만기가 배포 순간에 고정돼 있을 때는 공정한 추정이었지만, 이제는 모집 길이만큼 이르다. 구간이 짧은 것은 세그먼트를 잘못 그릴 뿐이지만 하한이 이른 것이 실패하는 방향이다. 위저드가 회차 1의 창이 만기 전에 열리는 펀딩일을 받아들이고, 그러면 배포 워커의 W2-4가 검증을 통과한 폼에 대해 배포를 거부한다. 이제 둘 다 마감을 쓰고, 호출 지점 여섯이 공유하는 하나의 내보낸 유도(offeringCloseOf)를 거친다. 블록, 회차 블록, 단계, 발행 게이트, payload 빌더, 미리보기다. 그중 하나만 다르게 유도해도 위저드의 계획이 배포가 쓰는 것과 다른 만기 위에 놓인다.
⚠️ 개방형 풀은 여전히 앵커가 없을 수 있고, 그럴 수 있는 형태는 그것뿐이다. FIXED_TERM 풀은 마감 없이 발행할 수 없으므로 락업이 언제나 셀 것을 갖는다. OPEN_ENDED 풀은 락업 필드를 유지하고(만기가 없으면 락업이 유일한 출구 게이트다) 모집 창은 선택이다. 그 조합이면 pool-wide 락업이 아무것도 게이팅하지 않는데 모든 화면은 lockup_days를 광고한다. 30일 락업이라고 팔린 풀에서 투자자가 첫날에 걸어 나가는 것이다. 조용히 0으로 만들지 않고 위저드에서 거부한다(lockupWithoutAnchorReason). "락업 없음"과 "강제할 수 없는 락업"은 다른 풀이기 때문이다.
참고: 빈 마감에 대한 v3-141/0204의 해석에서 앵커를 뒤집는다 · v3-131 4번의 동기를 없앤다 · 07-redemption · 04-pool-models · pools.worker.deploy.ts, pools.post.lifecycle.ts, lib/shared/business/pool-maturity.ts, features/pool-form/offering.ts, features/pool-form/deploy-dates.ts. 출처: JY(제품), 2026-08-24.
v3-144 — 락업은 모집 마감부터 센다. 풀이 clone이라 두 앵커가 함께 살아 있다 🔨 Off-chain built · contract pending
v3-144 — 락업 앵커는 풀 구현체의 속성이고, 모르는 경우는 pool-wide로 떨어진다
Date: 2026-08-21 · 상태: 🔨 오프체인 절반은 만들었다(@aset/types의 lockupAnchorMs / lockupIsPoolWide / reopenBlockedByLockupReason, 투자자 앱의 판정, 생성·수정 위저드의 문구, POST /redemption-requests 게이트, reopen 거부). 컨트랙트 절반은 CH에게 넘겼고 배포되지 않았다. 그래서 오늘의 모든 풀은 여전히 투자자별이다.
무엇이 틀렸나. 만기는 pool-wide인데(s.maturityDate, 배포 시 설정) 락업은 홀더별이었다(investedAt + lockupDays). 그래서 홀더별 경계가 pool-wide 경계를 앞지를 수 있었다. 모집 20일, 락업 15일, 만기 30일은 모든 가드를 통과하는데, 마지막 날 투자자는 풀이 모두에게 열리는 그 순간에 혼자 만기를 5일 넘겨 잠겨 있게 된다. RedemptionLib.requestRedemption에서 출구 게이트 둘이 순서대로 돈다. FIXED_MATURITY 검사가 maturityDate 전에 revert하고 그다음에야 isLockupActive를 본다. 그래서 만기만 있는 풀에서 락업은 만기 전에 아무것도 하지 않고 만기 뒤에 해를 끼칠 수만 있다.
결정. 모두에 대해 락업을 풀의 모집 마감부터 센다. 입금은 모집 중에만 받으므로 아무도 앵커 전에 투자되지 않고, 일찍 투자한 사람은 더 이른 이탈이 아니라 더 긴 보유를 사는 셈이다. 그리고 그것을 투자할 때 볼 수 있다.
🔴 두 앵커가 영구히 함께 살아 있다. 풀은 생성 시 구현체에 고정된 Clones 프록시이고 업그레이드 경로가 없으므로, 이것은 마이그레이션이 아니고 "이 날짜부터는 이쪽이 맞다"가 성립하지 않는다. 오늘 존재하는 모든 풀은 투자자별 규칙을 영원히 유지한다. 판정하는 곳은 lockupAnchorMs(@aset/types) 한 곳뿐이다. 같은 락업을 셋이 판단하기 때문이다. 투자자 앱이 버튼을 고르고, redemption-requests.post.create가 요청을 받거나 거부하며, 컨트랙트가 그것을 인정하거나 revert한다. 셋이 어긋나면 보기 차이가 아니라 리버트되는 트랜잭션이다.
모르는 경우는 pool-wide로 떨어지고, 안전한 방향은 하나뿐이다. close + lockup ≥ investedAt + lockup은 언제나 성립한다. 투자자별 풀을 pool-wide로 읽으면 해제일이 늦게 보인다. 보수적이고 어떤 트랜잭션도 실패할 수 없다. pool-wide 풀을 투자자별로 읽으면 이르게 보이고, 컨트랙트가 거부하는 상환을 제안하게 된다. UI가 한 약속에 가스를 쓰는 것이다. 그래서 목록은 투자자별인 구현체의 목록이고, 기록되지 않은 것은 pool-wide다. 구현체가 기록되지 않은 풀은 투자자별이다. 0205보다 앞서고 따라서 이 모든 것보다 앞서기 때문이다. ⚠️ 앞으로 배포되는 구현체 중 여전히 investedAt부터 세는 것은 목록에 덧붙여야 한다. 잊으면 락업을 과대 표시할 뿐이다.
⚠️ subscription_end_date가 없는 pool-wide 풀에는 앵커가 없고 락업이 아무것도 막지 않는다. 컨트랙트를 그대로 비춘 것이다. 거기서는 설정되지 않은 앵커가 0이고 락업 전체가 과거에 놓인다. 입금일로 폴백하면 컨트랙트가 허용하는 상환을 거부하게 된다.
🔴 lockup_days > 0인 동안 reopen은 거부된다(D-2). 그 이유는 앵커다. 옛 마감을 유지하면 새 투자자에게는 이미 지나간 락업이 붙고, 새 마감으로 옮기면 이미 락업을 벗어난 홀더가 다시 잠긴다. 어느 쪽도 풀이 있을 수 있는 상태가 아니다. "reopen과 락업은 함께 못 쓴다"가 아니라 앵커의 속성으로 적었다. 만기가 언젠가 투자자별이 되면 락업도 그것을 따라 입금으로 돌아가고 제약이 녹아 없어지기 때문이다. reopen에 합류하는 홀더는 자기 진입부터 잠기고, PlatformPool은 이미 그것을 찍는다.
이것이 위저드 가드를 은퇴시키지 않는다. 이 노트의 앞선 개정판은 은퇴시킨다고 적었다. 그 거부는 lockup > 만기 − 모집이고 앵커를 옮겨도 그 산술은 그대로다. 바뀌는 것은 그것이 누구에 대한 것인가다. 오늘은 늦은 홀더 하나이고, 이후에는 모든 홀더가 풀 자신의 만기를 넘겨 잠긴다. 사라진 게 아니라 더 나빠졌다.
가는 길에 발견한 것. create-pool.ts가 아직 poolImplementation()을 호출하고 있었다. 2026-08-21 라운드에서 poolImplementationFixed()로 이름이 바뀐 것이다. 옛 이름은 0을 반환하는 대신 revert하고, 이 읽기는 의도적으로 치명적이지 않으며, canReopenOffering(null)은 없는 세대를 너무 오래된 것으로 읽는다. 그래서 이름 변경 뒤 배포된 모든 풀이 재개방할 수 없다는 말을 듣고 있었고, 정확히 거꾸로다. FACTORY_ABI는 Abi로 캐스팅되므로 그 이름을 타입체크하는 것이 없었다.
참고: 앵커의 비대칭은 07-redemption → 3-State Lockup Model에 적혀 있다 · 세대 컬럼은 v3-142/0205 · packages/types/src/index.ts, apps/web/app/shared/lib/redemption-state.ts, apps/infra/lambda/redemption-requests.post.create.ts, apps/infra/lambda/pools.post.close.ts. 출처: JY(제품), 2026-08-21.
v3-143 — 에스크로된 LP도 계속 발생시키고, 발생 기준이 이미 남의 이름이 붙은 집합이기를 그친다 ✅ Shipped · on chain 2026-08-21 · 0206–0208 · no pool created yet
v3-143 — 발생 기준은 `totalLpSupply − poolHeldLp`이고, 에스크로 몫은 그 경계에서 장부에 잡힌다
Date: 2026-08-20(컨트랙트) / 2026-08-21(기록) · 상태: ✅ 반영됨. FixedYieldEngine과 YieldAccrualPolicy, 마이그레이션 0206 / 0207 / 0208, 그리고 아래의 API 표면이다. ⚠️ 새 풀에만 해당한다. 구현체 0x7C21153E…와 팩토리 0xE8453DAc…를 2026-08-21 Base Sepolia에서 검증했고, 배포된 바이트코드가 이 브랜치의 소스를 바이트 단위로 재현한다. 그런데 poolCounter()가 0이다. 그 팩토리로 만들어진 것이 없으므로 온체인에서 발생이 굴러간 적이 없고, 존재하는 모든 풀은 옛 규칙으로 도는 더 오래된 구현체에 고정돼 있다.
무엇이 바뀌었나. 발생 기준이 v3-104 옵션 A 아래에서는 totalSupply() − balanceOf(self)였고 이제는 totalLpSupply − poolHeldLp, 즉 인식된 채무만이다. 🔴 같은 식, 반대 규칙이다. 옵션 A는 에스크로된 LP는 아무것도 벌지 않는다였고, 여기서는 홀더가 계속 벌고 장부에 잡히는 시점만 옮긴다. 이것이 v3-131 (3)의 착지이고 v3-91 규칙 1을 뒤집는다.
에스크로 몫이 어떻게 인식되나. 요청마다 회차 사다리에 대해 발생한다. epochGy이고, 현금이 아니라 발생 지수로 잰 epochG / epochH의 쌍둥이다. 그리고 에스크로가 끝날 때 채무와 채권이 한 순간에 인식된다(FixedYieldEngine.recogniseCredit). 그전까지 풀이 빚지게 될 것과 장부에 잡은 것의 차이가 요청별 previewEscrowAccrual(requestId)다.
🔴 두 절반이 왜 한 집합이어야 하나. 첫 시도는 채무를 생기는 순간 잡고 홀더에게는 에스크로가 끝날 때 적립했다. 그 사이의 펀딩이 아직 아무도 갖지 않은 청구권을 갚았고, 나중 펀딩이 같은 적립을 다시 갚았다. 홀더가 파트너가 보낸 것보다 약 2.8% 더 인출할 수 있게 됐다. 하나의 의무가 타임스탬프가 다른 두 원장에 얹힌 것이다.
경계는 청구가 아니라 정산이다. previewEscrowAccrual은 마지막 정산 이후 구간을 포함해 지금까지 번 것을 전부 보고하지만, 청구는 그 정산까지만 확정한다. 나머지는 다음 정산이 접어 넣는다. 취소나 거부는 전부 확정한다. 그것들은 아무것도 가격 매기지 않기 때문이다. 그래서 투자자가 자기 청구를 미뤄서 버는 경로는 닫힌 채로 남는다.
이것을 말해도 되게 만드는 불변식. testFuzz_escrowNeverShrinksTheTotalBill(test/EscrowAccrualRecognition.t.sol): 파트너가 결국 빚지는 총액은 에스크로로 바뀌지 않는다. outstandingYieldLiability는 에스크로가 열려 있는 동안 그것을 뒤따를 뿐이고, 그 차이는 열린 요청들의 previewEscrowAccrual 합과 정확히 같다. test_EscrowEarnsTheSameAsStayingIn이 에스크로하는 것과 그대로 있는 것을 4 wei 이내로 같은 지급에 못 박는다.
⚠️ accrualBasisLp가 0으로 읽힐 수 있고, 그것은 더 이상 옛 실패가 아니다. 만기에 모든 지분이 에스크로되는 것은 만기 후 회차 풀에서 예외가 아니라 평범한 경우다. 옛 규칙에서 0은 그 창의 이자가 아무에게도 속하지 않는다는 뜻이었다. 적립될 홀더가 없는 채로 unclaimedYield에 쌓였다. 이제 0은 아직 인식된 것이 없다는 뜻이고, 그 창은 여전히 그 안에 있는 홀더들에게 빚진 것이다(test_Repro_Deal_FourCycleWindow_InterestIsOwedToTheHolders).
🔴 poolHeldLp는 settledUnclaimedLp가 아니다. 카운터 둘, 질문 둘인데 R10 아래의 분모 세 개 표가 그것을 혼동하고 있었다. 발생 쪽 항이 들어갈 자리에 청산 항을 찍었고, 그건 그 표가 자기 바로 윗줄에서 경고하는 바로 그 실패다. settledUnclaimedLp는 청산 분모이고(R10) 발생 기준은 그것을 쓴 적이 없다. 2026-08-21에 그 표와 23-money-path, 05-investment-lifecycle에서 정정했다. 05에는 *"발생 기준의 어느 쪽도 움직이지 않는다"*는 문구가 있었는데 청산 항에 대해서만 참이었다. v3-131 본문도 함께 고쳤다.
오프체인. accumulatedYieldPerShare와 yieldDebt가 사라져서 미러링할 지분당 수치가 없고 백엔드가 읽을 때마다 풀에 묻는다. 0206이 컬럼을 은퇴시키고, 0207이 pools.accrual_rate_bps를 더하며(생성 전용이다. 컨트랙트에 setter가 없다), 0208이 exit window 앵커를 더한다. POST /pools는 accrual_rate_bps를 요구하고 GET /portfolio-positions는 escrow_accrual을 반환하며 수익 due 행은 outstanding_net_liability를 담는다.
참고: v3-131 (3)을 구현한다 · v3-91 규칙 1을 뒤집고 v3-104 옵션 A를 다시 뒤집는다. 두 카드는 쓰인 그대로 둔다. 그때 무엇이 결정됐는지의 기록이기 때문이다 · 마이그레이션 0206 / 0207 / 0208 · 26-glossary → 분모 · 17-changelog · src/libraries/FixedYieldEngine.sol, src/libraries/RedemptionLib.sol, src/policies/YieldAccrualPolicy.sol
v3-140 — AUTO가 지켜보던 유일한 것을 끄고 그 일을 아무에게도 넘기지 않았다. 그래서 리마인더가 아니라 스위치를 뺀다 🔨 Built · wizard + sweep + 0192 · deploy pending
v3-140 — `epoch_cycle_mode`를 더 이상 읽지 않는다
Date: 2026-08-18 · 상태: 만들었다(위저드와 풀 수정에서 필드를 뺐고 sweep에서 검사를 뺐다. 마이그레이션 0192는 주석만이다). 배포되지 않았고 0192도 적용되지 않았다. v3-135에 기록된 열린 이음매를 닫는다. 다만 그 카드가 제안한 두 경로 중 어느 것도 아니다.
컬럼은 더 이상 질문이 아닌 것에 대해 선호를 제공하고 있었다. SEMI_AUTO는 "주기마다 지급일을 확정하라고 운영자에게 알려라"였고 AUTO는 "알리지 마라"였다. v3-135는 이미 그 리마인더를 취향이 아니라 상태의 결과로 재정의했다. 유일한 질문은 이 주기의 날짜가 설정됐는가다. 그것이 질문이 되는 순간 이 컬럼은 아무것에도 답하지 않는다.
🔴 AUTO는 유일한 감시자를 끄고 그 자리에 아무것도 두지 않는 스위치였다. 펀딩일을 정하는 일을 누구에게도 무엇에게도 넘기지 않았다. 그저 묻기를 침묵시켰다. AUTO인 풀은 날짜가 설정되지 않은 채로 각 주기에 도달하고, PoolCommonLib.epochFundingDateAt은 어쨌든 직전 + epochDurationDays로 답하며(정확히 한 주기 뒤를 본다) 정산이 그 값을 스토리지에 굳힌다(RedemptionLib.sol:894). revert 없음. 틀린 날짜가 한 주기씩 앞으로 기어간다.
그리고 요청형 조합이 면제가 아니라 더 나쁜 절반이다. v3-135는 이것을 만기 후 문제로 틀 잡았고 그 틀은 너무 좁았다. 상환 계획에는 적어도 배포가 회차 1을 쓰고 나머지를 시도한다(v3-133). 요청형 회차 풀은 아무것도 미리 쓰이지 않고 운영자가 주기가 올 때마다 확정하는 모델이다. 그래서 거기서 AUTO는 아무도 이 날짜를 정하지 않았다를 풀의 예외가 아니라 기본 상태로 만든다.
이음매가 실제로 어떻게 닫혔나. v3-135는 경로 둘을 제시했다. 위저드가 AUTO를 보내지 않게 하거나, 계획 분기를 모드 검사 위로 올리거나다. 나간 것은 둘 중 어느 것도 아니고 어느 쪽도 충분하지 않았을 것이다. 위저드가 아무것도 안 보내도 0104의 NOT NULL 기본값이 컬럼에 그대로 앉아 있고, 분기를 재배치해도 모든 요청형 풀이 여전히 모드로 게이팅된다. 검사를 게이팅에서 아예 제거한다.
| 이전의 런타임 독자 | 하나. 매시간 sweep이다. 나머지 스케줄러 둘은 그것으로 게이팅하지 않는다는 주석만 달고 있었다 |
| 지금의 sweep | 분기 둘, 모드 검사 없음. 같은 질문에 증거가 다르다. 계획 풀은 redemption_epochs.funding_date의 차집합으로 답하고 시계가 필요 없다. 요청형 풀은 주기가 올 때 행이 생기므로 "N+1 주기에 날짜가 없다"와 "N+1 주기가 아직 없다"를 구분할 수 없고, 그래서 달력으로 판단한다 |
| 생성 | 컬럼을 아예 보내지 않는다. 0104 기본값이 NOT NULL을 채운다. 리터럴을 쓰면 아무도 하지 않은 선택을 코드가 다시 주장하게 되고, 어차피 기존 행은 전부 SEMI_AUTO다 |
pool-edit | UI도 없이 permissions.editable과 PATCH payload로 컬럼을 왕복시키고 있었다. 나머지와 함께 제거했다 |
컬럼은 남고 읽기만 사라진다. 모든 행이 NOT NULL 기본값 아래 값을 갖고 있고, "이제 신경 쓰지 않는다"를 표현하려고 컬럼을 지우면 옛 해석 아래 만들어진 풀들의 역사를 다시 쓰게 된다. 은퇴는 그 자체로 의도적인 별도 작업이다. 그때까지 정직한 상태는 존재하고, 채워져 있고, 읽히지 않는다이며 0192가 컬럼 주석에 정확히 그렇게 적는다. DDL도 없고 건드리는 행도 없다.
🔴 다시 읽기 시작하지 말 것. 앞으로 어떤 기능이 "이 풀에 대한 알림은 그만"을 원한다면 그것은 알림의 억제이고 알림 환경설정에 속한다. 풀이 어느 날짜에 지급하는지를 조용히 바꾸는 일정 컬럼이 아니다.
동작이 바뀌는 것을 물려받는 풀은 없다. dev에서 측정: 풀 8개가 전부 SEMI_AUTO이고 계획 풀은 없다. 그래서 어떤 풀도 실제로 AUTO였던 적이 없다.
참고: v3-135의 이음매를 닫는다. 리마인더의 상태 게이트는 v3-135이고 날짜 제안은 v3-136이다. 그것이 막는 유도는 v3-133이다. v3-105의 epoch_cycle_mode 범위 잡기와 v3-93/0104의 모드 자체를 대체한다. 22-notifications → repayment-plan reminder gating · 11-db-schema → pools · 17-changelog v3-140. 마이그레이션 0192(주석만, dev 적용 대기). 출처: JY(제품), 2026-08-18.
v3-139 — 펀딩일 엔드포인트가 주기 번호를 받고, 풀 행은 여전히 접수 중인 주기만 대변하며, 아무도 검사하지 않던 순서를 보내기 전에 검사한다 🔨 Built · endpoint only · deploy pending
v3-139 — 주기별 펀딩일 확정과 setter 앞의 단조성 가드
Date: 2026-08-17 · 상태: 만들었다(POST /pools/{id}/epoch-schedule가 epoch_id와 전송 전 순서 검사를 얻는다). 배포되지 않았다. API는 수동 배포다. 컨트랙트 변경 없음. 순서 절의 단서를 볼 것.
엔드포인트는 주기 하나만 지목할 수 있었는데 v3-133은 아무 주기나 지목할 수 있어야 한다. confirm_funding_date는 acceptingEpochId를 읽어 그 주기를 확정했고, 요청형 풀에는 정확히 맞다. 굴러가는 주기가 하나이고 운영자가 그것을 확정한다. 상환 계획은 배포 시 쓰인 유한한 목록이고, 확정 카드가 마무리하는 것은 배포가 닿지 못한 것, 즉 커서에서 몇 달 떨어진 주기들이다. 그래서 epoch_id는 선택 필드다. 생략하면 서버가 전처럼 접수 주기를 해석하고, 기존 호출자 하나는 넘기지 않으므로 어떤 경로도 바뀌지 않는다. 접수 주기보다 뒤진 주기는 보내지 않고 409로 거부한다. 창이 이미 열려 있어 체인이 WindowAlreadyOpen으로 revert할 것이고, 이유를 말하는 409가 디코딩된 revert보다 낫다.
🔴 풀 행은 미래 주기를 기록할 자리가 아니다. pools.next_funding_date와 v3-107의 출처 컬럼 둘은 한 주기, 즉 접수 주기를 서술한다. 4번 주기 쓰기를 거기 미러링하면 풀이 4번 주기의 날짜를 자기 다음 펀딩일로 공표하고, 창이 열리지도 않은 주기에 대해 fundingDateConfirmed를 true로 만들며, Epochs 목록과 투자자 배지, 리마인더에게 같은 틀린 주기를 확정된 사실로 건넨다. 그래서 미러는 대상이 접수 주기일 때만 쓴다. 미래 주기의 기록은 있어야 할 곳에 산다. EpochFundingDateSet → redemption_epochs.funding_date(0190)와 활동 로그이고, 둘 다 자기를 "다음"이라고 주장하지 않는다.
그 결과로 남길 만한 작은 함정이 하나 더 생겼다. 미러가 조건부가 되면서 응답의 mirrored: false가 "미러가 실패했다"와 "미러할 것이 없었다"를 둘 다 뜻하게 됐고, 재시도로 그것을 구분하려는 호출자는 이미 동의한 체인에 두 번째 트랜잭션을 보내게 된다. mirror_scope(accepting_cycle / future_cycle_no_pool_row)가 그것을 나눈다.
주기의 날짜가 이웃 사이에 있는지를 아무것도 검사하지 않고, 그 패턴은 이미 거기 있었다. GovernanceLib.setEpochFundingDate는 0이 아닌 날짜, 0이 아닌 주기, 창 가드를 검사하고 순서는 보지 않는다. 바로 아래의 setEpochSettleAfter는 펀딩일과 비교하므로, 이 누락은 입장이 아니라 실수로 읽힌다. 직전 주기보다 이른 날짜는 그 주기의 요청 창을 과거에 두는데, 거기서는 _requireRequestWindow가 모든 요청을 거부하고 setEpochFundingDate가 모든 정정을 거부한다. 과거의 창은 열려 있기 때문이다. 쓸 수도 고칠 수도 없는 주기가 조용히 만들어진다. 가드는 양쪽 이웃을 본다. 다음 주기보다 늦은 날짜는 반대편에서 본 같은 결함이기 때문이다. ⚠️ 이후 다듬었다. 양쪽이 대칭이 아니다. 유도된 다음 주기의 날짜는 epochFundingDate[이 주기] + 주기, 즉 지금 교체되는 슬롯이지 경계가 아니다. 그래서 실제로 설정돼 있을 때만 비교하고, 온체인 사본이 읽는 것도 같은 비대칭이다(직전은 유도, 다음은 스토리지에서). 앞선 주기는 유도 비교를 유지한다. 그 날짜에 대해서는 이번 쓰기에 의존하는 것이 없기 때문이다.
🔴 가드는 컨트랙트가 아니라 엔드포인트에 있고, 그것이 보호 범위를 정한다. checkFundingDateOrder는 트랜잭션을 보내기 전에 pools.post.epoch-schedule.ts에서 돈다. 컨트랙트는 그대로이므로 ORACLE 키를 쥐고 setEpochFundingDate를 직접 호출하는 것은 여전히 우회한다. 운영자 실수에 대한 가드로는 변호할 수 있는 입장이고 침해된 키에 대한 가드로는 아니다. 그리고 그 덕에 배포 라운드가 필요 없었고 이미 살아 있는 것을 포함한 모든 풀을 덮는다. 새 풀만이 아니다. 온체인으로 옮기는 선택지는 열려 있고 그 절충을 뒤집는다.
🔴 저장된 이웃뿐 아니라 유도된 이웃과도 비교한다. 유도된 날짜는 체인이 실제로 답하는 값이고 투자자가 보는 값이며 다음 정산이 스토리지에 굳히는 값이다. 그래서 저장된 이웃만 검증하면 실제로 겹치는 계획이 통과한다. 0으로 읽히는 이웃은 경계로 다루지 않고 건너뛴다. 0은 유도 사슬이 끊겼다는 뜻이지 그 이웃이 1970년에 있다는 뜻이 아니고, 날짜로 읽으면 가능한 모든 값을 거부하게 된다. 메시지는 부딪힌 이웃이 설정된 것인지 유도 중인지를 이름 대어 말한다(0190 행이 그 절반을 준다). 고치는 방법이 다르기 때문이다. 이 날짜를 옮길 것이냐, 이웃을 먼저 쓸 것이냐다.
참고: v3-133과 v3-134를 뒷받침한다. v3-107의 확정 엔드포인트와 출처 컬럼을 확장한다. 15-api-reference → Pools · 07-redemption. 17-changelog v3-133–v3-139. 마이그레이션 없음, 컨트랙트 변경 없음. 출처: JY(제품), 2026-08-17.
v3-138 — 키 분리로 새 풀이 전부 서명할 수 없게 됐고, 정지 역할은 회수하기 전에 넘긴다 🔴 P0 fixed · deploy pending
v3-138 — role 부여는 배포자가 아니라 서명자를 따라간다
Date: 2026-08-17 · 상태: 배포 워커에서 고쳤고 테스트로 못 박았다. 배포되지 않았다. 상환 계획과 무관하게 이미 있던 문제이고, 같은 워커에 계획 자신의 쓰기를 더하다가 발견했다.
아무도 밟지 않은 이유는 아무도 풀을 만들지 않았기 때문이다. KMS 키 분리(2026-08-14) 전에는 admin과 oracle 신원이 같은 키였으므로, 배포 계정에 role을 주면 우연히 서명자에게도 준 것이 됐다. 분리로 둘이 다른 주소가 됐는데 create-pool.ts는 계속 account.address, 즉 admin 서명자에게 ORACLE_ROLE을 부여했다. 그 뒤로 풀이 생성된 적이 없어서 결함이 나가서 기다리고 있었다.
| 어디 | 무엇이 틀렸나 |
|---|---|
create-pool.ts | grantRole(ORACLE_ROLE, account.address)가 oracle이 아니라 admin 서명자를 지목했다 |
handler-roles.ts | 호출 지점 9곳이 oracle로 서명하는데 그 주소는 아무 role도 갖고 있지 않았다 |
PlatformPool.initialize | PAUSER_ROLE이 배포 키인 _admin에게 갔고, 운영용 부여 목록은 PAUSER를 아예 언급하지 않았다 |
그 상태로 태어난 풀이 할 수 없는 일. 모든 oracle 경로가 revert한다. NAV 표시, 상환 승인과 거부, 수익 정산, 회차 일정, 트랜치 상각이다. 그리고 PAUSER로 게이팅되는 함수 5개도 전부 revert한다. pause, unpause, emergencyFreeze, unfreeze, tripCircuitBreaker다.
⚠️ 마감, 청산, 부실 선언은 깨진 적이 없다. 이 기록의 앞선 판은 깨졌다고 적었다. setLifecycleStatus는 DEFAULT_ADMIN_ROLE이고 배포 키가 그것을 갖고 있다. 그 핸들러들에서 실제로 실패한 것은 에스컬레이션과 함께 도는 clearOnChainPause 갈래다(v3-92). 그 갈래가 unpause를 호출하기 때문이다. 틀린 전제 위에 맞는 결론을 기록하면 다음에 읽는 사람을 오도한다. 조용히 고치지 않고 정정을 여기 남기는 이유다.
🔴 옛 보유자에게서 회수하기 전에 새 보유자에게 부여한다. 배포 키의 부트스트랩 PAUSER_ROLE은 회수된다. PlatformPool.initialize에서 그 부여 위의 주석이 이미 그 인계를 의도된 최종 상태로 서술하고 있었고 이제 그것을 수행하는 코드를 이름 대어 말한다. 다만 순서가 무게를 진다. 그 절차가 중간에 죽으면 살아남아야 하는 것은 누군가 여전히 멈출 수 있는 풀이다. 회수 후 부여는 그 반대를 남긴다. 호출이 존재한다고 단언하는 대신 두 호출의 인덱스를 비교하는 테스트로 못 박았다.
참고: KMS 서명자 분리(440f708)를 따른다. 09-rbac → the grant defect · 09-rbac → on-chain roles. 17-changelog v3-133–v3-139. 출처: 코드 리뷰에서 발견, 2026-08-16/17.
v3-137 — 한 주기에 남은 펀딩은 그 주기에 남고, 없는 정리 로직은 아무도 만들지 않은 기능이 아니라 아무도 내리지 않은 컨트랙트 결정이다 ✅ Confirmed in code
v3-137 — 주기의 남은 톱업은 그 주기에 묶인다
Date: 2026-08-17 · 상태: 컨트랙트와 대조해 확인했다. 운영 규칙은 어드민 펀딩 화면에 있다. 컨트랙트나 스키마 변경 없음. v3-100 1번을 정정한다. 그것은 v3-93의 과다 펀딩 질문을 "남은 것은 다음 회차로 이월된다"로 닫았는데, 그 절반이 틀렸고 그 문장이 이 오류의 모든 하류 사본이 인용하는 문장이다.
질문은 펀드 매니저가 얼마를 보내야 할지 확신하지 못할 때 무엇을 하느냐다. 본능은 조금 더 보내는 것이고, 이 풀 형태에서 그 본능은 어떤 오류 메시지도 알려 주지 않는 방식으로 틀렸다.
호출 지점 셋이 그것을 정하고, 네 번째가 논쟁을 끝낸다. 펀딩은 epochFundTopUp[cycle]에 쌓이고(PoolLedgerLib.sol:203-204), 정산은 톱업을 먼저, 리저브를 두 번째로 가져가며(:246-250), 정산이 가져갈 수 있는 금액은 현재 주기의 버킷만 읽는다(RedemptionLib.sol:955). 결정적인 사실은 부정형이다. reserveBalance +=는 저장소 전체에서 정확히 한 번 나온다(PoolLedgerLib.sol:73, 입금 시 10% 분리). 톱업을 리저브로 옮기는 경로가 없고 따라서 그것을 다른 주기가 쓸 수 있게 만드는 경로도 없다.
사라지는 것은 아니고, 사라진다고 말하면 거짓인 데다 불필요하게 놀라게 한다. 남은 것은 totalEpochTopUp에 계속 잡히고 그것이 청산 분자다(PlatformPool.sol:1438). 그러니 여전히 풀 자산이고 청산에서 홀더에게 분배된다. 정확한 표현은 **"정상 운영에서는 그 주기에 묶여 있다"**이고, 그것이 행동을 바꾸는 표현이기도 하다. "사라졌다"는 문의를 부르고 "묶여 있다"는 파트너가 얼마를 보낼지를 바꾼다.
🔴 정리 로직이 없는 것은 구멍이 아니라 구조다. 버킷이 주기별로 키잉돼 있으므로 정리 로직은 "어느 주기로"에 답해야 하는데, 그것은 기본값이 없는 정책 질문이고 답하는 것이 컨트랙트 변경이다. 아직이나 지원하지 않음 같은 표현으로 얼버무리는 문구는 아무도 내리지 않은 결정을 아무도 끝내지 않은 작업으로 보고하는 것이다.
수치는 보여 주지 않는다. 닿을 수 있는 수치가 없기 때문이다. readSettledEpoch().leftoverTopUp이 존재하고 체결률과 같은 배치에서 주기별 잔여를 읽지만, 유일한 호출자가 청구 핸들러이고 어떤 어드민 엔드포인트도 그것을 노출하지 않는다. 그래서 화면은 규칙만 말하고 숫자를 말하지 않는다. 지어내면 CLAUDE.md 규칙 2-b의 결함 부류가 된다.
✅ 2026-08-18 수정됨(문구와 단언, 문서를 한 커밋에). 아래 문단은 무엇이 틀렸는지의 기록이다. 지금 뭐라고 말하는지는 22-notifications 참조.
🔴 살아 있는 알림이 정반대를 말하고 있었고 테스트가 그것을 붙들고 있었다. over_funding_detected는 잉여를 남긴 모든 정산 뒤 fundManagers에게 발동하고, 본문이 *"추가된 {excessAmount} {currency}는 다음 회차로 이월되므로 별도 조치가 필요 없습니다"*라고 읽힌다(catalog/redemption.ts:319-324, 짝이 되는 프리헤더와 "Excess carried" 상세 행이 있다). 두 절반 다 틀렸고 두 번째가 비싸다. 그 돈이 다음 주기에 쓸 수 없으므로 올바른 안내는 다음 주기를 다시 전액 펀딩해야 한다는 것이고, "조치가 필요 없다"의 정반대다. 그 항목 위의 주석은 v3-93 C10을 인용하며 그 결론에 이르는데, 그건 epoch-redemption.ts에 정정 전까지 앉아 있던 것과 같은 오독이다. 그리고 catalog-integrity.test.ts:232가 /carries over/i를 단언하므로 그 문장이 v3-96의 조작된 *"자동으로"*처럼 테스트로 고정돼 있다. 2026-08-18 문구 수정. 본문이 이제 잉여를 들고 있는 주기를 이름 대어 말하고 다음 주기가 그것을 쓸 수 없다고 밝힌다. 단언은 이월 표현이 돌아오면 실패하도록 뒤집었고, 둘 다 이 문서와 함께 옮겼다. 격리를 문서화하면서 메일을 고치지 않았다면 참인 진술을 문서에, 거짓인 진술을 수신함에 두는 셈이었다.
파트너에게 준 운영 규칙: 각 주기에 대해 그 주기가 필요로 하는 만큼을 펀딩할 것. 정산이 쓰지 않은 것은 나중 주기가 가져다 쓸 수 없고 인출할 수도 없다.
⚠️ 이 엔진에서 "이월"되는 것이 둘이고 그중 하나만 진짜다. 미체결 수요는 다음 주기로 굴러간다(epochCarryDemandLp이고 새 수요보다 먼저 처리된다). 그렇게 말하는 투자자용 문구는 맞다. 펀딩은 아니다. 첫 번째 문장을 두 번째에 빌려 쓰는 문구가 이 결함이 나타나는 방식이다.
참고: 07-redemption → constraints에 적혀 있다. 청산 분자는 R10 / v3-128에 따른다. 17-changelog v3-133–v3-139. 출처: 코드 조사, 2026-08-17.
v3-136 — 저장된 규칙이 만든 날짜는 사람에게 보여 주는 것 말고는 아무 데도 쓸 수 없다 ✅ Decided · supersedes the v3-132 wording
v3-136 — 규칙 컬럼 둘을 무엇을 위해 읽어도 되는가
Date: 2026-08-17 · 상태: 결정됐고 컬럼 주석과 독자 셋이 그것과 맞는다. v3-132를 수정한다.
v3-132는 런타임에 epoch_date_basis와 epoch_roll_day를 무엇도 읽으면 안 된다고 적었다. 겨냥한 위험은 맞았고 선을 한 발짝 넓게 그었다. 확정 카드와 위저드 미리보기, 리마인더가 전부 그것들을 읽어야 하고 셋 다 정당하다. 선은 읽기가 아니라 그 답으로 누가 행동하는가다.
저장된 규칙에서 나온 날짜는 사람에게 보여 줄 수 있다. 체인에 쓸 수 없고 일정을 정하게 할 수 없다.
| 허용 | 거부 |
|---|---|
| 위저드의 배포 전 미리보기 | 스케줄러가 규칙에서 날짜를 유도해 온체인에 쓰는 것 |
| 확정 카드의 예상 대 기록 diff | 정산 경로가 규칙을 평가해 다음 주기를 고르는 것 |
| 리마인더가 사람이 확정할 날짜를 제안하는 것 | 나온 날짜를 합의된 것으로 다루는 무엇이든 |
이 구분이 왜 성립하나. 사람에게 보여 준 유도 날짜는 그 사람이 거절할 수 있는 제안이다. 같은 날짜를 기계가 쓰면 아무도 동의하지 않은 채로 풀의 실제 동작이 되고, 이 풀 형태에서는 정산이 그것을 스토리지에 영구히 굳힌다(v3-133). 이 설계가 없애려는 실패 양상은 유도가 아니라 유도와 체인 사이에 사람이 없는 것이다.
컬럼 주석이 그 표현 그대로 적고 있다. "no runtime path may DERIVE A SCHEDULED DATE from these; a human-facing suggestion may read them."
참고: v3-132를 수정한다. 리마인더의 제안은 v3-135다. 11-db-schema → pools · 24-field-governance §5. 17-changelog v3-133–v3-139. 마이그레이션 0189. 출처: JY(제품), 2026-08-17.
v3-135 — 펀딩일 리마인더는 몇 달 전에 고른 설정이 아니라 계획의 상태로 켜지고 꺼진다 🔨 Built · deploy pending · one seam open
v3-135 — 리마인더 게이팅은 선호가 아니라 귀결이다
Date: 2026-08-17 · 상태: pools.scheduler.epoch-funding-date에 만들었다. 배포되지 않았다. ⚠️ 아래에 미해결 이음매가 하나 있다. v3-132를 수정한다.
v3-132는 이 풀 형태에서 리마인더를 껐는데, 그 전제가 참이기를 그쳤다. 전제는 "모든 날짜가 미리 고정되므로 확정할 것이 없다"였다. v3-133이 배포가 그 날짜들을 시계에 대고 쓰게 만들었으므로, 계획이 온전할 수도 잘릴 수도 있고 풀 행만 봐서는 어느 쪽인지 아무도 모른다. 그러면 끄는 것은 아무도 알아내지 못하도록 보장하는 유일한 설정이다.
쓰이지 않은 주기가 하나라도 있으면 알린다. 모든 주기가 쓰였으면 조용하다. 게이트는
epoch_cycle_mode토글이 아니라redemption_epochs.funding_date IS NULL(0190)이다.
구분은 선호와 귀결 사이에 있다. 요청형 회차 풀은 주기마다 날짜 하나를 영원히 확정하므로, 운영자가 알림을 원하는지는 상시적인 선택이고 epoch_cycle_mode가 그것의 올바른 집이다. 상환 계획은 끝났거나 안 끝났거나인 유한한 목록이고, 그것은 취향이 아니라 풀에 대한 사실이다.
🔴 확정 카드가 배포되기 전까지 이 리마인더가 유일한 복구 경로다. 배포는 재시도하지 않는다. 그 쓰기가 돌 때 deploy_status는 이미 DEPLOYED이므로 재전달된 메시지는 멱등 가드가 돌려보낸다. 그리고 쓰이지 않은 주기는 온체인에서 빠진 것으로 읽히지 않는다. 나중에 누군가 이것을 "그냥 알림"이라고 판단해 꺼 버리면, 잘린 계획과 운영자가 승인한 적 없는 날짜에 지급하는 풀 사이에 남은 마지막 장치를 없애는 것이다. 여기뿐 아니라 호출 지점에도 적어 뒀다.
그리고 리마인더가 말하는 날짜가 바뀌었다. 전에는 직전 + 주기를 제안했는데, 파트너가 15일에 송금하는 월 단위 계획에서는 12일이다. 계획에 들어 있던 적 없는 날짜를 확정하라고 운영자에게 말하는 셈이다. 이제 앵커에서 저장된 규칙을 걷고, 그것이 v3-136이 허용하는 사람 대상 제안이다. 새 알림 이벤트는 없다. 기존 것을 고쳤다.
✅ 이 카드가 연 이음매는 v3-140이 닫았다(2026-08-18). 처음 커밋했을 때 sweep은 epoch_cycle_mode !== 'SEMI_AUTO' → 건너뜀을 상태 게이트에 닿기 전에 평가했고 위저드는 여전히 이 조합을 AUTO로 내보내고 있었다. 그래서 상태 게이트가 그것을 위해 쓰인 바로 그 풀들에서 닿을 수 없었다. ⚠️ 여기 제안된 두 해법 중 어느 것도 나가지 않았고 어느 쪽도 충분하지 않았을 것이다. 위저드가 아무것도 안 보내도 NOT NULL 기본값이 컬럼에 남고, 분기를 재배치해도 모든 요청형 풀이 여전히 모드로 게이팅된다. 모드 검사를 게이팅에서 아예 제거했고, v3-140은 진단도 넓힌다. AUTO는 두 조합 모두에서 대체자를 세우지 않고 유일한 감시자를 껐으며, 요청형 쪽이 둘 중 더 나쁘다.
참고: v3-132를 수정한다. v3-133과 0190에 의존한다. 22-notifications → repayment-plan reminder gating. 17-changelog v3-133–v3-139. 출처: JY(제품), 2026-08-17.
v3-134 — 라이터 둘이 같은 목록을 반대 순서로 체인에 올렸고, 그 이유는 서로 같은 이유가 아니다 🔨 Built · deploy pending
v3-134 — 배포는 오름차순, 확정 카드는 내림차순으로 쓴다
Date: 2026-08-17 · 상태: 만들었다(writeFundingDates가 호출자에게서 순서를 받는다). 배포되지 않았다. ⚠️ 오늘 호출자가 있는 것은 배포 쪽뿐이다. 배포 후 확정 카드는 아직 없고 이 풀 형태를 위한 어드민 화면 계층도 작업 중이다. 내림차순 규칙을 지금 기록하는 이유는 그것이 화면이 아니라 컨트랙트 가드의 속성이고, 나중에 다시 발견하면 절대 정정할 수 없는 주기 하나를 잃기 때문이다.
두 호출자가 같은 주기를 같은 setter에 각각 트랜잭션 하나씩으로 쓰고, 각자 자기 순서를 밝혀야 한다. 그래서 모듈은 순서를 고르지 않고 인자로 받는다.
배포: 오름차순(2 → 3 → … → N). Lambda 시계 때문에 실행이 중간에 끊길 수 있으므로, 쓰이지 않고 남는 것은 가장 먼 주기여야 한다. 가까운 주기가 요청 창을 먼저 열고, 그래서 잘림이 건드리면 안 되는 것들이다. 이것이 안전한 이유는 배포가 이미 회차 1의 창이 만기 뒤에 열리지 않으면 진행을 거부하기 때문이고(v3-132의 재검증) 그러면 이후 모든 주기는 더 멀리 있다. 🔴 그 검사를 느슨하게 하면 이 순서는 더 이상 안전하지 않다.
확정 카드: 내림차순(N → … → 3 → 2). 거기서는 잘리는 것이 없고 위험이 반대다. setEpochFundingDate는 그 주기의 창이 열린 뒤에는 거부하는데, 그 창은 epochFundingDateAt으로 계산되고 그것은 직전이 쓰이지 않은 주기에 대해 0을 반환한다. 0인 창은 "아직 안 열림"으로 읽히므로 가드가 잠들어 있다. 그리고 주기 K를 쓰는 것이 주기 K+1의 가드를 무장시킨다. 유도할 진짜 직전 값을 주기 때문이다. 더 이상 멀지 않은 일정에서 오름차순으로 쓰면, 아무도 고르지 않은 날짜를 근거로 주기 하나가 잠겨 버릴 수 있다. 내림차순은 마지막 쓰기까지 가드를 재워 둔다.
이것이 왜 세부가 아니라 결정인가. 두 순서는 불일치처럼 보이고 자연스러운 정리는 하나로 고르는 것이다. 어느 쪽을 고르든 다른 호출자가 조용히 깨지고, 그 결과는 절대 정정할 수 없는 주기로 나타난다. 테스트 스위트를 일부러 나눈 것도 같은 이유다. 컨트랙트 스위트가 배포의 오름차순 경우를 못 박고, 카드의 테스트는 "전부 내림차순"이 규칙이 아니라고 말 그대로 적고 있다.
참고: v3-133의 쓰기 쪽을 구현한다. 주기 id를 받는 엔드포인트는 v3-139다. 07-redemption → how the dates reach the chain. 17-changelog v3-133–v3-139. 출처: JY(제품), 2026-08-17.
v3-133 — 배포가 모든 주기의 날짜를 쓴다. 쓰이지 않은 주기는 온체인에서 빈칸이 아니라 자신 있는 오답이고 정산마다 굳어지기 때문이다 🔨 Built · deploy + indexer + 0190 · pending apply
v3-133 — 배포가 펀딩일 N개를 모두 쓴다
Date: 2026-08-17 · 상태: 만들었다(배포 워커 · 인덱서 · 마이그레이션 0190 · GET /pools/{id}/repayment-cycles). 0190은 dev에 적용되지 않았고 API는 수동 배포다. 메커니즘으로서 v3-132의 "배포 후 운영자가 목록을 승인한다"를 대체한다.
체인에는 규칙이 없고 유도만 있으며, 정확히 한 걸음만 뒤를 본다. PoolCommonLib.epochFundingDateAt은 주기에 저장된 값이 있으면 그것을, 없으면 직전 + epochDurationDays를 반환한다. 그래서 아무도 쓰지 않은 주기는 빈칸으로 읽히지 않고 진짜 날짜만큼 자신 있게 말해진 날짜로 읽힌다. 매월 15일 달력 계획에서는 이렇다.
위저드가 승인했던 것 3/15 · 4/15 · 5/15 · 6/15
체인이 만들어 낼 것 3/15 · 4/12 · 5/10 · 6/ 7두 번째 주기에서 3일, 네 번째에서 8일 어긋나고 어디에서도 revert하지 않는다. 그리고 회복 가능한 채로 남지도 않는다. 모든 정산이 다음 주기의 유도값을 스토리지에 쓰므로(RedemptionLib.sol:894) 정산마다 틀린 날짜가 하나씩 영구히 고정되고 오차가 앞으로 밀려 나간다.
그래서 배포가 그것들을 쓴다. 쓸 수 있는 유일한 순간에. 만기는 배포 시점에 진짜 타임스탬프가 되고, 그것이 계획을 그것에 맞춰 다시 세울 수 있는 첫 순간이자 풀이 불완전한 일정으로 살아나기 전 마지막 순간이다. 회차 1은 앵커로 이미 온체인에 있고, 2..N은 만기에서 다시 유도하지 않고 그 앵커에서 앞으로 걸어 나간다. 다시 유도하면 첫 날짜가 달라져 회차 1이 나머지와 다른 일정에 속하게 될 수 있기 때문이다.
🔴 상한은 트랜잭션 수가 아니라 시계다. 개수로 하려면 아무도 재 본 적 없는 트랜잭션당 실제 시간을 추측해야 하고, 그 추측은 체인이 느린 날 틀린다. 그날이 정확히 중요한 날이다. 워커는 쓰기 전마다 남은 시간을 다시 읽고 온전한 receipt 대기 하나가 들어갈 여유가 남아 있을 때 멈춘다(예산 300초, 여유 60초).
🔴 부분 실행은 실패한 배포가 아니다. 풀은 온체인에 있고 올바르게 설정돼 있다. 빠진 것은 창이 몇 달 뒤 열리는 주기들의 날짜이고, 각각은 자기 창이 열리기 전까지 어떤 순서로든 나중에 쓸 수 있다. 풀을 DEPLOY_FAILED로 표시하면 살아 있는 풀에 대한 거짓 기록이 된다. 부분 실행이 되어서는 안 되는 것은 조용한 것이고, 그것이 v3-135의 존재 이유다.
마이그레이션 0190 — redemption_epochs.funding_date, 관측된 사실만. EpochFundingDateSet에서만 쓰고 그 외 어디에서도 쓰지 않는다. 의도한 날짜를 여기 기록하면 0189가 그은 선이 무너진다. 계획은 pools에, 일어난 일은 redemption_epochs에 산다. 이 컬럼의 가치 전부가 NULL이 날짜가 없다가 아니라 아무도 설정하지 않았다를 뜻한다는 데 있다. ⚠️ 주기 2부터 센다. 회차 1은 setEpochSchedule이 쓰는데 그건 EpochScheduleSet을 내고 EpochFundingDateSet은 절대 내지 않는다(GovernanceLib.sol:154,171). 그래서 구조상 행이 없고 1부터 세면 모든 계획을 영원히 한 주기 모자라게 보고한다.
인덱서가 이것이 의존하는 이벤트를 버리고 있었다. EpochFundingDateSet은 접수 주기를 지목하지 않으면 통째로 버려졌는데, 배포가 쓰는 모든 주기가 그렇다. 그대로 뒀다면 배포는 성공하고 0190은 비어 있었을 것이고, 리마인더와 확정 카드가 완성된 계획을 미작성으로 보고했을 것이다.
GET /pools/{id}/repayment-cycles는 그 null을 노출하려고 존재한다. 회차 요약은 커서가 놓인 주기에 대해 풀마다 한 행이고, 계획에는 모든 주기가 한꺼번에 필요하다. 🔴 의도적으로 체인을 읽지 않는다. 체인 읽기는 모든 주기에 대해 숫자를 반환해 호출자가 찾으러 온 구분을 지운다. 원장에 있든 없든 1..term의 행을 반환하고(아무도 아무것도 기록하지 않은 주기가 보여 줄 가치가 있는 경우다) term을 넘는 인덱스는 버리며 term: null을 빈 주기 목록과 구분해 유지하고, 원장 읽기 실패에는 []가 아니라 500을 낸다. 빈 목록은 "어떤 주기에도 날짜가 없다"로 읽히고 카드가 트랜잭션 하나씩으로 전부 쓰겠다고 제안하게 된다.
참고: v3-132의 메커니즘을 대체한다. 쓰기 순서는 v3-134, 게이팅은 v3-135, 목록을 마무리하는 엔드포인트는 v3-139다. 07-redemption → how the dates reach the chain · 11-db-schema → redemption_epochs · 15-api-reference → Pools. 17-changelog v3-133–v3-139. 마이그레이션 0190. 출처: JY(제품), 2026-08-17.
v3-132 — 상환 계획은 날짜 목록으로 저장하고, 그것을 만든 규칙은 사람이 같은 목록을 두 번 만들 수 있게만 남긴다 🔨 Built · wizard + schema · amended by v3-133/135/136
v3-132 — 만기 후 상환 계획, 저장 방식
Date: 2026-08-16 · 상태: 만들었다(어드민 위저드 + 마이그레이션 0189 + 배포 가드). 적용되지 않았고 배포되지도 않았다. API는 수동이므로(deploy:dev:api) push가 초록불이라고 이것이 살아 있는 것은 아니다.
⚠️ 하루 뒤 세 지점이 수정됐다. 누가 날짜를 쓰는가: v3-133. 카드가 쓰는 게 아니라 배포가 N개를 다 쓰고 배포 후 카드가 확정한다. 규칙 컬럼 둘을 무엇을 위해 읽어도 되는가: v3-136. 규칙이 사람이 볼 날짜를 만들어도 되고, 금지되는 것은 기계가 그것으로 행동하는 것이다. 리마인더: v3-135. epoch_cycle_mode = AUTO가 아니라 쓰이지 않은 주기가 남았는지로 게이팅한다. 이 카드의 나머지는 그대로다.
v3-131이 남긴 것. 그 결정은 조합을 열었을 뿐 누가 언제 날짜를 정하는지는 말하지 않았다. 여기서 답한다. 목록은 배포 뒤에 실제 만기에 맞춰 고정되고 모든 날짜가 개별로 온체인에 쓰인다. 위저드의 목록은 미리보기이고 그렇게 표시된다. ⚠️ v3-133이 일부 대체함: 쓰기는 배포 워커에서 그 순간에 일어나고, 운영자의 배포 후 행위는 확정과 시계가 끊은 나머지다.
왜 배포 뒤인가. 만기는 배포 순간에 센 지금 + maturity_days이므로, 초안으로 저장했다가 나중에 배포한 풀은 만기가 나중이다. 배포 전에 날짜를 승인한다는 것은 실제로 쓰이지 않을 날짜를 승인하는 것이고, 그 일정은 나중에 고칠 수도 없다. setEpochSchedule은 생성 전용이고 setEpochFundingDate는 요청 창이 열린 주기를 거부한다. 배포 뒤에 승인하면 그 불일치가 구조적으로 불가능해진다.
컬럼 셋(0189)과 그것들이 넘으면 안 되는 선. redemption_term_epochs는 계획의 길이이고 회차 수다. 달력 기준에서만 개월이 회차로 딱 나눠떨어지기 때문이다. 4 × 28일은 112일이고 그건 정수 개월이 아니다. 그래서 위저드는 CALENDAR에서는 개월로, FIXED_DAYS에서는 회차로 묻지 변환을 지어내지 않는다. epoch_date_basis와 epoch_roll_day는 생성 시점의 답이고, 확정 카드가 같은 질문을 두 번 하지 않고 실제 만기에 맞춰 같은 목록을 다시 만들 수 있게 저장한다.
🔴 런타임에 규칙 컬럼 둘을 읽는 것은 없고 읽기 시작해서도 안 된다. 날짜가 산출물이고 규칙은 사람이 다시 돌리라고 남긴 것이다. 이것들에서 주기 날짜를 유도하는 스케줄러는 이 설계가 없앤 것을 정확히 되살린다. 운영자가 검토한 것과 다른 날짜를 실패 신호 없이 조용히 만들어 내는 유도다.
roll day는 28에서 멈춘다. 2월에는 그 뒤 날이 없고, 없는 29~31일을 메우는 모든 규칙은 파트너가 동의한 지급일을 옮긴다. 월말 고정(한 번 마지막 날에 떨어지면 이후에도 마지막 날에 머무는 방식)이 그것을 올바르게 다루는 규칙이고, 반쯤 구현하지 않고 그 경우와 함께 미룬다. 단순히 자르면 일정이 영구히 앞당겨진 채로 걷는다.
배포가 앵커를 다시 검증하고 거부한다. v3-131은 앵커가 maturityDate + recall_lead_days + request_window_days를 넘겨야 한다고 적고 강제를 열어 뒀다. 위저드의 하한은 오늘부터 세므로 추정치다. 구속력 있는 검사는 만기가 진짜 타임스탬프가 되는 배포 워커에 있다. 회차 1의 창이 만기 전에 열릴 것 같으면 배포가 실패하고 deploy_error에 정정된 날짜를 담는다. 그 상태로 배포된 풀은 첫 주기가 모든 요청을 거부하고 고칠 수 없기 때문이다. FIXED_MATURITY로 범위를 잡았다. 요청형 풀은 만기 전에도 요청을 받으므로 기간 안의 앵커가 평범하다.
이 조합에는 epoch_cycle_mode = AUTO. SEMI_AUTO 리마인더는 pools.next_funding_date와 그것의 "직전 + 주기" 유도를 읽는데, 둘 다 미리 확정된 날짜를 모른다. 그래서 이 풀에서는 온체인에 있는 것과 다른 날짜를 말하게 된다. 시끄러운 것보다 틀린 게 나쁘다. 라이브 DB와 대조해 확인했다(2026-08-15). 배포된 풀 중 AUTO를 쓰는 것이 없으므로 동작 변화를 물려받는 것이 없다. ⚠️ v3-135가 대체함: "모든 날짜가 미리 고정된다"는 전제는 쓰기 실행이 언제나 완료될 때만 성립하는데 그것은 시계로 제한된다. 리마인더는 이제 쓰이지 않은 주기로 게이팅되고 제안 날짜는 저장된 규칙에서 나온다. 위저드는 여전히 AUTO를 설정하고, 그것이 그 카드에 기록된 열린 이음매다.
투자자 앱이 이제 만기 게이트 뒤에 주기를 읽는다. 표면 셋이 "이 홀더가 나갈 수 있는가"보다 "이 풀에 주기가 있는가"를 먼저 물었는데, 같은 질문을 잘못된 순서로 한 것이고 이 풀 형태에서만 틀린다. 브라우즈 카드는 그것을 Epoch · 28d로 라벨했고 포트폴리오는 그 보유분을 "주기로 정산됨" 아래 넣었다. 유동성 모델이 여섯 번째 상태 AWAITING_MATURITY를 얻었다. 락업은 벗어났는데 풀이 만기까지 모든 요청을 거부하는 상태다. LOCKED가 아니고(그건 홀더 자신의 날짜에 끝나고 이건 풀의 날짜에 끝난다) EPOCH도 아니다(아직 아무것도 정산되지 않는다). 🔴 그 분기는 FIXED_MATURITY로 범위가 잡혀 있다. 요청형 회차 풀은 만기 전에도 주기로 정산하고, 배포된 회차 풀 둘 다 요청형이므로 넓히면 살아 있는 회귀가 된다. 양방향 테스트로 못 박았다. 그 아래의 3단계 모델(redemption-state.ts, @aset/money-domain으로 공유)은 의도적으로 건드리지 않았다. 그것은 redemption_type을 모르고, 가르치면 모든 FIXED_MATURITY 유도가 한꺼번에 움직인다.
만기에서 회차 N까지의 타임라인 W4는 미루지 않고 버렸다. 그것이 그렸을 날짜 표가 이미 만기와 모든 지급, 모든 요청 창을 나열하므로 같은 배열을 한 번 더 그리는 것은 사실을 더하지 않는다. 그리려던 이유였던 만기와 회차 1 사이의 간격은 이제 운영자가 그것을 설정하는 자리에서 말로 두 번 적힌다. 배포 전 목록을 타임라인으로 그리면 추정치에 일정의 권위를 빌려주게 되는데, 그것이 W0=C가 피하려는 것이다. 나중에 그림이 제 값을 하게 되면 날짜가 진짜인 배포 후 확정 카드에 속한다.
참고: v3-131의 (1)과 (2)를 구현한다. 컬럼 셋은 11-db-schema → pools. 17-changelog v3-132. 마이그레이션 0189. 출처: JY(제품), 2026-08-15/16.
v3-131 — 만기 뒤 여러 회차로 갚는 풀은 체인이 이미 허용하는 조합이고, 그것을 거부하는 것은 위저드 가드뿐이다 🔧 Decided · not built · MVP = 2 of 5 items
v3-131 — 만기 후 회차 상환
Date: 2026-08-15 · 상태: 결정됐고 만들지 않았다. 스키마도 컨트랙트도 UI도 바뀐 것이 없다. 아래 다섯 항목 중 둘이 MVP이고 하나는 미정, 둘은 명시적으로 범위 밖이다.
제품. 만기까지 상환이 거부되고, 만기부터 풀이 일시금 대신 여러 회차에 걸쳐 원금을 갚으며 그동안 남은 잔액에 대해 쿠폰을 지급한다. 설정으로 적으면 redemption_type = FIXED_MATURITY에 epoch_duration_days > 0이다.
온체인에서 새로 얻는 값이 없고, 그게 핵심이다. 이것은 구간 × 모드 표의 조합 ②다. 이미 존재하고 이미 컨트랙트가 강제하는 독립적인 설정 둘이다. RedemptionLib이 maturityDate 전의 이탈을 거부하고 회차 엔진이 주기에 맞춰 정산한다. 어느 쪽도 다른 쪽이 설정됐는지 알지도, 신경 쓰지도 않는다. 조합을 막는 것은 v3-88 D7, 즉 어드민 위저드 가드다. redemption_type = FIXED_MATURITY이면 epoch_duration_days = 0을 강제하고, 만기 온 풀은 한 번 지급하므로 주기가 무의미하다는 논리였다. 그 논리는 그것이 쓰일 때 존재하던 모든 풀에 대해 성립하고 이 풀에 대해서는 성립하지 않는다.
대안이었던 세 번째 redemption_type 값은 취향이 아니라 선례 때문에 기각했다. redemptionConfig는 initialize에서만 설정되고 setter가 없으며 PlatformPool은 업그레이드 불가다. 그래서 각 풀이 생성될 때의 구현체에 고정되고 새 enum 값은 새 풀에만 닿는다. 선례는 마지막 세 번째 값이다. LIQUIDITY_WINDOWS는 온체인에 나갔지만 배포 워커가 그 창 배열을 채운 적이 없어서 모든 requestRedemption이 revert했다. v3-88 D5가 드롭다운을 닫았지만 값은 API로 주입 가능한 채였고, 풀 넷이 그 상태로 배포됐다. 영구히 상환 불가이고, 라벨을 바꾸지 않고 폐기했다. 컬럼을 다시 쓰면 DB가 컨트랙트에 없는 기능을 주장하게 되기 때문이다(마이그레이션 0106).
모델이 일반적이지 그 거래가 일반적인 게 아니다. 이것을 촉발한 4개월 일정도, 원금 4분의 1 분할도 어디에도 저장되지 않는다. 일정 길이는 풀마다이고 회차별 금액은 아예 설정하지 않는다. 각 회차는 발행자가 그 회차에 대해 펀딩한 만큼을 pro-rata로 정산하므로, 고르지 않은 분할이 설정도 코드도 없이 이미 작동한다. 일정에서 진짜로 빠진 것은 그 끝이다. 앵커와 주기는 있고 종료가 없는데, 그것이 ACTUS가 쓰는 것과 같은 세 값 분해다.
항목 다섯, 그중 중요한 둘.
| # | 항목 | 계층 | 상태 |
|---|---|---|---|
| 1 | 위저드가 조합을 받고 일정 블록을 **"만기 후 상환"**으로 다시 라벨한다 | FE/BE | 🔴 MVP |
| 2 | redemption_window_epochs — "4회차 중 2회차" 카운터 | FE/BE + 스키마 | ✅ redemption_term_epochs(0189)로 만들었고 선택이 아니라 필수다. v3-132 / v3-133 참조 |
| 3 | 수익 발생이 요청이 아니라 정산에서 끝난다 | 컨트랙트 | ✅ 2026-08-20 반영. 새 풀에만 |
| 4 | 만기를 투자자별로 앵커 | 컨트랙트 | ⬜ 범위 밖 |
| 5 | 마지막 회차 뒤에도 요청하지 않는 홀더 | 운영 | ⬜ 만들 것이 아님 |
(1)은 한 줄짜리 가드가 아니다. pool-create/payload.ts가 redemption_type !== FIXED_MATURITY로 셋을 게이팅한다. 주기, 앵커 기반 일정 블록 전체(epoch_schedule_type · funding_anchor_date · request_window_days · recall_lead_days · epoch_cycle_mode), 그리고 redemption_gating_bps다. 그중 마지막만 게이팅으로 남아야 한다. 앞의 둘은 함께 풀어야 한다. 생성 엔드포인트가 회차 풀의 체인 조건을 한 집합으로 요구하고 setEpochSchedule이 생성 전용이라, 앵커 없는 풀은 절대 고칠 수 없기 때문이다. 앵커는 maturityDate + recall_lead_days + request_window_days도 넘겨야 한다. 아니면 회차 1의 요청 창이 만기 전에 열리고 거기서는 모든 요청이 revert한다. 위저드가 그것을 어떻게 강제할지는 아직 열려 있다.
(3)은 v3-91 규칙 1을 두 번째로 뒤집고, 이번에는 그 검토가 재지 않은 근거 위에서다. 규칙 1은 에스크로된 LP를 수익 분모에서 빼므로 요청자는 요청과 청구 사이에 아무것도 벌지 못한다. 반론은 요청자가 조금 손해 본다는 게 아니라 발행자의 쿠폰이 줄지 않는다는 것이다. 그래서 요청자의 몫이 남아 있는 홀더들에게 넘어간다. 쿠폰이 만기 전과 정확히 같이 계속된다고 적은 파트너 계약에 대해, 플랫폼이 조용히 그 조건을 지키지 않는 것이다. 여러 회차 일정에서는 그것이 회차마다 되풀이되고, 자기 포지션 전부를 한 번에 요청한 투자자는 일정 전체의 쿠폰을 몰수당한다.
발생은 executeEpoch, 즉 자금이 확정되는 순간에 끝난다. 청구 시점이 아니다. 그러면 투자자가 자기 청구를 미룬 데 대해 지급하게 된다. 요청 시점도 아니다. 그것이 결함이다. 요청과 청구 사이에는 제외 구간이 셋 있고 executeEpoch에서 정산하면 셋이 다 닫힌다. executeEpoch가 LP를 소각하지 않아서 생기는 눈에 안 띄는 구간까지 포함해서다. v3-91이 규칙 1을 쓸 때 기댔던 방어 둘은 그대로이고 그것들이 이 뒤집기를 안전하게 만든다. 회차 가격은 forward이고(요청이 가격을 고정하지 않는다) 마감 뒤 취소는 이미 불가능하다.
✅ (3)은 2026-08-20 반영됐다(컨트랙트. (3) 자신의 설명대로 새 풀에만).
⚠️ 첫 시도가 과다 지급했고, 그것을 어떻게 잡았는지가 남길 만하다. 에스크로 발생을 생기는 순간 채무로 잡고 홀더에게는 에스크로가 끝날 때 적립했다. 그래서 그 사이의 펀딩이 아무도 갖지 않은 청구권을 갚았고 나중 펀딩이 그 적립을 다시 갚았다. 홀더가 파트너가 보낸 것보다 약 2.8% 더 인출할 수 있게 됐다. 총량 담보 불변식은 어느 버킷이 틀렸는지 말할 수 없어서 더 날카로운 것을 더했고(invariant_claimableNeverExceedsFundedMinusWithdrawn: 홀더는 펀딩됐고 아직 인출되지 않은 것보다 많이 인출할 수 없다) 그것이 즉시 위치를 짚어 냈다. 두 번째의 더 조용한 결함은 단언 자체에 있었다. 18자리 발생 수치를 6자리 토큰 보유와 비교해서 수억 wei씩 어긋나는 게 당연했고, 진짜 위반을 가릴 만큼 시끄러웠다. 해법은 채무와 채권을 같은 순간에 인식하는 것이고(FixedYieldEngine.recogniseCredit) 그래서 발생 기준이 totalLpSupply − poolHeldLp가 되며 에스크로 몫은 요청별로 그 경계까지 보관된다. 이 부류의 대수가 그 라운드에서 이미 네 번 틀렸기 때문에 프로퍼티 테스트부터 걸어 봉인했다(test/EscrowAccrualRecognition.t.sol, 6만 케이스). 발생 분모는 이제 totalLpSupply − poolHeldLp, 즉 인식된 채무만이고 청산이 제외하는 집합이 아니다(R10은 settledUnclaimedLp를 쓴다. 다른 질문에 대한 다른 항이다). 에스크로된 LP는 잠기는 순간 그 기준을 떠나고 그 몫은 에스크로의 경계까지 요청별로 보관되므로 accrualBasisLp가 여전히 0으로 읽힐 수 있다. 만기에 모든 지분이 에스크로되는 것이 정확히 이 거래다. 그리고 0은 이제 아무것도 빚지지 않았다가 아니라 아직 아무것도 인식되지 않았다를 뜻한다(test_Repro_Deal_FourCycleWindow_InterestIsOwedToTheHolders).
무엇이 그것을 O(1)로 만들었나. executeEpoch는 계층 비율을 고정하고 홀더별 체결을 실체화하지 않으므로(그건 청구 시점에 유도한다) "이 홀더를 정산에서 멈춘다"에 걸 곳이 없었다. 그것을 범위 잡은 검토가 요청별 수익 회계를 노력을 결정하는 변수라고 부른 이유다. 그래서 회차 사다리가 epochG(생존 비율)와 epochH(지급된 현금) 옆에 세 번째 누적을 갖는다. **epochGy**이고, 같은 생존 비율을 발생 지수에 대해 적분한 것이며 재시작에서는 generationCloseGy가 generationCloseH를 그대로 비춘다. 요청의 에스크로 기간 수익은 그러면 체결이 이미 나누는 두 구간이다. 자기 회차가 정산될 때까지는 원금 전액에 대해 평평하고, 그다음은 미체결 잔량에 대한 사다리다. 에스크로가 끝날 때 요청별로 creditAccrued에 적립된다. 루프도 실체화도 없고, 한 번 릴리스에서 쓰기 전용이 됐던 EpochRequest.yieldAccSnapshot이 다시 무게를 진다.
짚어 둘 비대칭: 구간 ③(정산 → 청구)은 아무것도 벌지 않는다. 지급이 이미 가격 매겨졌기 때문이다. 취소나 거부는 지금까지 적립한다. 그것들은 아무것도 가격 매기지 않기 때문이다. 뒤의 절반이 없으면 한 회차 안에서 열렸다 취소된 요청이 두 정산 사이에 끼어 그 창을 통째로 잃었을 것이다.
즉시 상환 풀도 덮인다. PENDING_RESERVE 대기에는 부분 체결이 없으므로 잠금부터 정산까지 평평한 구간이다. ⚠️ 투자자에게 보이는 수치는 갈린다. pendingYield는 포지션만 덮고, 열린 요청에 붙은 수익은 previewEscrowAccrual(requestId)다. 첫 번째만 보여 주는 UI는 상환 중인 사람에게 과소 보고한다.
(3)과 (4)는 함께 나가거나 (4)가 기다린다. 둘 다 컨트랙트 변경이고 나누면 배포 라운드가 하나 더 늘 뿐이다. 어차피 어느 쪽도 기존 풀에 닿지 않는다. 풀은 자기 구현체에 고정되므로 두 변경 다 함께 나가든 따로 나가든 새로 만들어지는 풀에 적용되고, 저울질할 마이그레이션이 없다. 플랫폼은 아직 메인넷 전이라 어느 쪽이든 살아 있는 포지션에 영향이 없다. 그럼에도 (4)는 MVP 밖이다. 중간 통제는 2주 정도의 모집 창이고, 그것이 늦은 입금자가 과다 수령하는 쿠폰을 그 창 너비로 제한한다.
(2)는 기본적으로 미룬다. 운영은 텀시트에서 회차 수를 아니까 이 필드는 메커니즘이 아니라 표시용 카운터와 일정 종료 신호를 사 오는 것이다. 다만 생성 전용이므로 늦게 정할 수는 있어도 나중에 끼워 넣을 수는 없다.
기각된 것들, 각각 남길 이유와 함께.
| 기각 | 이유 |
|---|---|
새 redemption_type 값 | 온체인이고 initialize에서만 설정된다. 배포된 풀은 자기 구현체에 고정돼 있어 절대 인정하지 않는다(마이그레이션 0106) |
| 새 lifecycle 단계 | 같은 이유. MATURED가 이미 그 의미를 담고 있다 |
| 쿠폰 기준 설정 | 미상환 원금이 표준 기본값이고 분배 로직이 가진 유일한 동작이다. 선택지가 하나뿐인 설정이 된다 |
| 영업일 관례 / 휴일 달력 / 금융 중심지 | 은행에 닿는 것은 발행자의 회수뿐이고 recall_lead_days가 그것을 덮는다. 청구와 펀딩은 온체인이고 은행 영업시간에 매이지 않는다. 마감에 걸치는 휴일은 그 회차의 펀딩일을 옮기는데, 운영자가 이미 할 수 있는 일이다 |
| 가중평균 만기 앵커 | 조기 이탈이 없는 풀에는 조기 이탈 패널티가 없으므로 가격 매길 차익이 없다. 모집을 일찍 닫는 것이 통제 수단이다 |
이 조합에서의 redemption_gating_bps | 발행자가 이미 일정에 맞춰 펀딩하는데 회차별 정산에 상한을 건다. 두 번째 상한은 그 일정을 깨뜨릴 수만 있다. 이 조합에서는 숨긴다 |
요청하지 않는 홀더(5)는 순서를 정했지 만들지 않았다. 알림이 먼저, 그다음 발행자와 합의해 조건에 적어 넣는 종료 조항, 그다음 그 조항이 요구하는 만큼의 코드다. 조항이 있기 전에 메커니즘을 만들면 돌릴 권한 없는 것이 나온다. 청산 경로는 아니다. 권한은 편해 보이지만 의미가 뒤집힌다. 투자자는 자기 풀이 청산 중이라고 표시된 것을 보게 된다. 그동안 기다리는 것이 벌은 아니다. 요청하지 않은 투자자는 에스크로되지 않고 계속 벌기 때문이다.
아직 열린 것: 발행자가 쿠폰 기준과 "월 9%"가 정확히 무엇을 뜻하는지(연환산, 지급 주기, 일수 계산), 원금과 이자가 한 날짜에 떨어지는지, 날짜나 금액을 놓쳤을 때 어떻게 되는지를 확인해야 한다. 출시 전에는 회차별 펀딩 절차, 일정 중의 손실 처리, 계약상 고정 요율에 대한 공개 문구, 그리고 재투자를 켜 둘지가 남아 있다. 재투자는 상환 중인 풀로 자본이 다시 들어오게 하므로 이 상품에서는 꺼야 한다.
참고: 07-redemption → post-maturity epoch redemption과 구간 × 모드. 04-pool-models → redemption_type · redemption_window_epochs · maturity_days. v3-88 D7의 위저드 가드를 푼다. v3-91의 규칙 1을 뒤집는다. 주기 의미는 v3-124에서. 17-changelog v3-131. 노션 "풀 상환 모델 — 구간 × 모드 (기획서)"와 "만기 기준을 풀 단위 → 투자자별로 전환". 출처: JY(제품), 2026-08-15.
v3-130 — 락업 앵커는 추론이 아니라 이벤트로 내보내고, 재투자도 입금과 똑같이 앵커된다 ✅ Decided
v3-130 — BD5의 "재투자된 LP는 락업 면제"를 대체한다
Date: 2026-08-13
결함. positions[investor].investedAt은 컨트랙트 상태인데 이벤트로 나간 적이 없어서, 원장 fold가 LP 민팅에서 그것을 재구성했다. 컨트랙트가 검사하는 민팅 전 잔액을 아직 담고 있는 유일한 로그이기 때문이다. Transfer는 어느 함수가 민팅했는지 말할 수 없고 reinvest()도 똑같은 것을 민팅한다. 그래서 완전히 이탈한 뒤 남은 수익을 재투자한 홀더에게 체인에 없는 락업이 붙었다. redemption-requests.post.create가 requestRedemption이라면 받아들였을 상환에 대해 400 Redemption is locked until the lockup period ends로 답했다. 아무것도 실패하지 않았다. 프로젝션의 값과 체인의 값이 각각 따로 보면 그럴듯하고 체인의 값은 오프체인에서 관측할 수 없기 때문이다.
변경 둘, 그중 중요한 것은 첫 번째다.
1. 컨트랙트가 앵커를 말한다. Deposited와 Reinvested가 각각 investedAt을 담고, 그것을 설치할 수 있는 분기 뒤에 읽는다. 그래서 이벤트가 이 입금이 앵커를 세웠는지 기존 것을 다시 말한 것인지를 알려 준다. fold는 그것을 복사하고 앵커 규칙이 남지 않는다. 이것은 이 저장소가 YieldDistributed.yieldPerShare에 이미 적용하는 규칙이다. 컨트랙트가 자기가 장부에 잡은 것을 내보내고, 그래서 프로젝션이 읽기가 된다.
occurred_at만으로는 부족했다. 새 진입에서는 그것이 앵커와 같지만 추가 매수는 다시 앵커해서는 안 되고, 어떤 입금이 앵커했는지는 민팅 전 잔액, 즉 원장이 기록하지 않는 순간의 컨트랙트 상태에 달려 있다. 빠져 있던 것은 타임스탬프가 아니라 그 판단이었다. 플래그가 아니라 값을 내보내면 프로젝션이 스스로 낫기도 한다. 모든 입금이 유효한 앵커를 다시 말하므로 부분 이력에서 재구성해도 수렴하지 invested_at이 영원히 null로 남지 않는다.
2. reinvest()가 deposit()과 같은 잔액 0 분기를 탄다. BD5(수동 재투자 V1)는 재투자된 LP를 "락업 면제"라고 불렀다. 그 규칙에는 대상이 없다. 락업은 투자자당 불리언 하나이고(PoolCommonLib.isLockupActive) LP별이나 입금별이었던 적이 없다. 그래서 잔액이 0이 아니면 어느 진입점도 다시 앵커하지 않고 "면제"는 아무것도 바꾸지 않는다. 도달 가능한 유일한 경우는 잔액 0에서의 재진입이었는데, deposit() 자신은 그것을 다시 잠가야 하는 재방문 투자자라고 부른다. BD5가 내세운 가드, 즉 패널티가 악용을 막는다는 것도 그 경우를 덮지 못한다. EARLY 구간은 FIXED_TERM에만 있으므로 개방형 풀에는 물릴 패널티가 없다.
컨트랙트는 이미 다른 모든 축에서 두 진입점을 하나로 다뤘다. 똑같은 nonReentrant whenNotPaused whenNotFrozen whenActive requiresKYC 스택, 같은 creditPrincipal, 그리고 E1 / v3-85에 따른 money-path 동등성이다. 락업만 비대칭이었다.
⚠️ 옛 풀은 잘못 세는 대신 던진다. 이벤트 둘 다 시그니처가 바뀌어 topic0이 바뀌었고 기존 원장 행에는 investedAt이 없다. fold는 그것들을 거부한다. null 앵커는 UNLOCKED로 읽히고, 그것으로 폴백하면 지금 잠겨 있는 모든 홀더가 풀려난다. 옛 구현체로 만들어진 풀에는 두 번째 코드 경로가 아니라 이 재배포가 필요하다. 0183이 hold-back 종류에 대해 취한 것과 같은 입장이다.
정정된 문서: 05 → Reinvest V1, 04 → allow_rollover, 07 → 3-State Lockup Model이고, 마지막 것은 문서가 컨트랙트 주석에 맡겨 뒀던 포지션별 앵커 규칙을 이제 명시한다.
⚠️ POOL-WIDE 풀에서는 일부 대체됐다(W9 / 0204). 변경 1은 어디서나 유효하다. 두 이벤트가 여전히 investedAt을 담고 fold가 여전히 복사하며 여전히 추론이 아니라 읽기다. 바뀌는 것은 그 앵커가 무엇을 정하느냐이고, 락업을 subscription_end_date부터 재는 구현체에서만 그렇다. 거기서는 이 항목이 논하는 재진입 경우가 다시 잠기지 않고 변경 2의 "같은 잔액 0 분기를 탄다"에 락업 관련 귀결이 없다. 투자자별 풀에서는 이 항목이 그대로이고 그 풀들은 사라지지 않는다(Clones). investedAt은 두 경우 모두에서 포지션이 언제 시작됐는지에 대한, 내보내진 기록으로 남는다. 07 → 3-State Lockup Model 참조.
v3-129 — 폰에서는 글자가 작아지고 모니터에서는 열이 늘어난다: 큰 화면은 더 큰 내용이 아니라 더 많은 내용을 얻는다 ✅ Decided
v3-129 — 앱의 반응형 램프는 마케팅 사이트의 것이 아니다
Date: 2026-08-13
배경. 랜딩 사이트에는 문서화된 브레이크포인트 체계가 있고 앱들에 그것을 채택하라는 요청이 왔다. 그것을 읽으면서 두 가지가 나왔다. 첫째, 랜딩에는 체계가 둘이지 하나가 아니다. widgets/landing/*(8개 파일)은 요소마다 5단계 램프를 손으로 쓰고(text-[2rem] sm:text-[2.5rem] md:… lg:… xl:…) ch/product에서 온 새 페이지 넷(widgets/{trust,investors,about,fund-managers}/*, 20개 파일)은 global.css에 한 번 선언한 clamp() 토큰을 쓴다. 돌고 있던 표는 앞의 것을 문서화하고 있다. 둘째, 앱의 레이아웃 반응성은 이미 대체로 있었다. 셸 전환, 반응형 그리드 35개, 테이블 오버플로가 있고 web에는 4단계 여백 램프도 있었다. 반면 타입과 밀도의 반응성은 없었다. 여덟 토큰 스케일이 모든 너비에서 고정 px였다.
결정: 요소별 램프가 아니라 토큰 메커니즘을 가져온다. web에는 컴포넌트 파일 84개, admin에는 118개가 있다. 요소마다 손으로 관리하는 5단계 램프는 랜딩 섹션 여덟 개에는 감당되고 여기서는 감당되지 않는다. 토큰은 파일 하나 수정이고 두 앱이 물려받는다. 랜딩 자신이 첫 형태에서 두 번째로 흘러간 것이 그 증거다.
결정: 디스플레이 타입은 유동적이되 줄어들기만 한다. 각 clamp의 최댓값이 토큰이 이미 갖고 있던 고정값이므로 어떤 화면에서도 전보다 크게 렌더되지 않는다. 디스플레이 크기 다섯이 375→1280px 뷰포트에 걸쳐 stat 18→20, lead 20→24, dstat 24→32, hnum 28→40, hero 32→48로 오른다. label/data/read(12/14/16)는 고정이다. 그건 디자인 선택이 아니라 가독성 하한이다.
랜딩은 키우는데 왜 여기서는 줄이기만 하나. 랜딩의 램프는 극적 효과를 만들려고 있다. 카드가 200→600px, 헤딩이 40→72px로 큰다. 앱의 큰 화면은 정보를 사야지 배율을 사면 안 된다. 27인치 화면의 홀더는 2배 크기의 APY 숫자가 아니라 더 많은 행을 원한다. 그래서 성장 예산은 열로 가고, 실제로 망가져 있던 것은 작은 쪽이었다. 375px 폰에서 48px 히어로가 화면을 잡아먹었다.
결정: 모니터 단계는 두 앱을 반대 방향으로 움직인다. 토큰 하나 --container-monitor(105rem = 1680px)다. web은 1280px에서 잘려 2560px 화면의 절반을 여백에 쓰고 있었으므로 넓어진다. admin에는 상한이 아예 없었으므로 상한이 생긴다. 목록 라우트가 넓은 표이고 일곱 열짜리 행이 약 2496px에 걸쳐 늘어나면 한 행을 읽는 일이 눈의 여행이 되기 때문이다. "모니터 지원"을 한 방향으로 틀 잡는 것이 이 결정이 대체하는 실수다.
의도적으로 미룬 것. admin과 FM의 폰 지원이다. lg: 드로어는 만든 그대로 두고, admin은 당분간 노트북 이상으로 다룬다. 그래서 램프는 폰 레이아웃을 벌어들이는 게 아니라 작은 너비에서 셸이 깨지지 않게만 하면 된다.
⚠️ 브라우즈 그리드는 lg 위로 뷰포트 브레이크포인트를 받지 않는다. 뷰포트가 그 너비가 아니기 때문이다. AppShell이 lg+에서 라우트된 콘텐츠 왼쪽에 240px 사이드바를 두고, 그것은 런타임에 접힌다. 뷰포트로 키잉된 모든 열 단계가 같은 방식으로 틀렸다. xl:grid-cols-3은 1280에서 발동하는데 그때 열은 912px이라 288px 카드를 만들었고, 2xl:grid-cols-4는 274px을 만들었다. 둘 다 직전 열 수가 이미 주던 368px보다 좁았다. 그러니 "열을 늘리는" 단계마다 실은 카드가 작아지고 있었다. 이제 lg:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]이다. 300px 이상 트랙을 들어가는 만큼 만든다. auto-fit이 아니라 auto-fill이다. auto-fit은 짧은 목록이 채우지 못하는 트랙을 접어 카드 둘을 약 760px로 늘린다. 300px이 손잡이이고 공짜 이득이 아니라 진짜 절충이다. 1280~1315 구간에서 열 하나를 잃고(거기서 카드가 288 → 444) 1640부터 네 번째를 얻는다. 항목 수가 고정된 그리드는 평범한 열을 유지한다. portfolio의 "Discover more"는 .slice(0, 3)이고, 거기서는 채울 수 없는 트랙이 카드 셋을 좁게 만들 뿐이다.
⚠️ tailwind-merge가 킷 컴포넌트 안에서 커스텀 크기 토큰을 조용히 지운다. 그리고 admin에서만 그렇다. tailwind-merge는 CSS 속성이 아니라 클래스 이름으로 분류한다. text-lead는 알려진 font-size 패턴 어디에도 맞지 않으므로 텍스트 색상으로 분류되고, CSS에는 존재하지도 않는 충돌에서 옆의 text-neutral-700이 이긴다. twMerge('text-lead font-bold text-neutral-700')은 'font-bold text-neutral-700'을 반환한다. 그것이 대체한 text-2xl은 내장 스케일에 있어서 살아남았으므로, 이번 전환이 그 파손을 만들어 냈다. admin CardTitle 일곱 개가 상속 크기로 떨어져 24px이 16px로 렌더됐다. 같은 집합의 DialogTitle 둘은 이 작업 전부터 text-lead를 달고 킷의 기본 18px으로 조용히 렌더되고 있었으므로, 같은 수정이 그 둘은 깨뜨린 게 아니라 고쳤다.
저장소는 세 곳 중 둘에서 이미 이것을 막고 있다. packages/ds-editorial/src/cn.ts와 apps/web/app/shared/lib/utils.ts가 둘 다 extendTailwindMerge로 cn을 만들고 여덟 토큰을 font-size 아래 등록한다. 거기 주석이 정확히 이 실패를 서술한다. apps/admin-web에는 자기 cn이 없다. Card, Dialog, Button이 @shard-lab/finance-dashboard-kit에서 오고 그것이 평범한 twMerge를 번들해서 패키지 안에서 병합하므로, admin 쪽 확장은 참조될 일이 없다. 호출 지점에서 text-(length:--text-lead) leading-tight로 고쳤다. 평범한 tailwind-merge도 크기로 분류하는 형태이고, font-size만 담으므로 line-height를 따로 적었다.
그 밖에 노출된 것은 없다. web의 PoolStatusAlert.tsx:216-217은 똑같아 보이지만 괜찮다. 확장된 cn이다. 공유 patterns.tsx도 같은 이유로 괜찮다(측정: SectionEyebrow가 두 앱에서 12px로 렌더된다). <Link> 같은 통과 컴포넌트는 아예 병합하지 않는다. admin에 이미 있던 사례 둘은 고치지 않고 기록만 했다. pool-controls.tsx:639와 nav-change-dialog.tsx:173이 DialogDescription을 16px 대신 14px로 렌더한다.
⚠️ 어떤 게이트도 이것을 볼 수 없다. tsc -b, 프로덕션 빌드 둘, 문구 가드가 내내 초록불이었다. 클래스는 유효하고 컴파일되며 방출된 CSS에도 있다. 런타임에 제거된다. 토큰을 달고 있는 요소를 DOM에서 훑어도 놓친다. 클래스가 그것이 크기를 정해야 할 요소에서 사라졌기 때문이다. 프로브 요소에 대한 getComputedStyle 측정이 그것을 드러냈다.
⚠️ 유동 토큰은 rem + vw이지 맨 vw가 아니다. 맨 뷰포트 단위는 브라우저의 텍스트 확대 설정을 무시하므로 rem 항이 확대를 살려 둔다. 랜딩의 min-h-hero가 rem 하한을 가진 것과 같은 이유다.
정리 범위(L4). 큰 글자 호출 지점 111곳을 스케일로 옮겼다. Tailwind 기본 단(text-xl→stat, text-2xl→lead, text-3xl→dstat)과 임의 픽셀 값(21/22/23/24/26/30/34/40px)을 가장 가까운 단으로 옮겼고 최대 편차는 2px이다. 작은 글자 약 1,023곳(text-xs/text-sm/text-[11px])은 일부러 두었다. 12~14px은 배율이 바뀌면 안 되므로 여기서 통합해 봐야 얻는 게 없다. text-4xl 여섯 곳도 남겼다. 그것들은 타입이 아니라 장식 글리프(◆, ⌕, 이모지)의 크기를 정한다. "앱이 서로 경쟁하는 타입 체계 셋을 쓴다"가 세어 보이는 것보다 작은 문제인 이유다.
⚠️ 토큰 스케일은 그것이 대체한 Tailwind 단보다 좁은 line-height를 갖는다(1.05~1.1 대 1.2~1.4). 한 줄짜리 숫자에는 맞고 줄바꿈되는 헤딩에는 답답하다. 그래서 명시적 leading-*이 없던 전환된 제목 30개에 leading-tight를 더했다.
확인: 두 앱에서 tsc -b 통과 · 프로덕션 빌드 둘 다 통과 · copy-guard 깨끗함(177 + 240개 파일) · 컴파일된 CSS에서 clamp들과 page-shell의 규칙 다섯, 두 앱의 2xl 상한, 2xl:grid-cols-4를 확인했다.
참고: 25-design-system → Breakpoints and the page column · packages/ds-editorial/src/tokens.css · apps/web/global.css · apps/admin-web/app/shared/ui/page-layout.tsx. 출처: JY, 2026-08-13.
v3-128 — hold-back을 뺀다: 아무도 당기지 않는 기능도 그것을 지고 다니는 모든 산식에 비용을 물린다 ✅ Decided · removed
v3-128 — v3-112를 뒤집는다. 레버와 그 버킷을 삭제한다
Date: 2026-08-13
무엇이 제거되나. 파트너 잔여분 hold-back 전체다. 컨트랙트 스토리지의 fundingRestricted, heldFundReleases, heldFundReleasesRaw, setFundingRestricted, _releaseHeldFunds와 그 클램프, FundReleases* 이벤트 둘, HELD_FROM_PARTNER / HOLDBACK_RELEASED 원장 종류와 그 fold 규칙, money_pool_state.held_fund_releases 컬럼, 인덱서 라이터, business/holdback-release.ts, 그리고 holdback_release_deferred 알림이다. deposit()은 이제 갈래가 하나다. 파트너 잔여분은 언제나 fund_wallet으로 이체된다.
왜 v3-112를 뒤집나. 그 결정은 "제품 경로를 만들지 않는 것"과 "메커니즘을 삭제하는 것"을 저울질해 삭제가 비가역이고 비싸다고 보고 거기서 멈췄다. 삭제 비용은 정확히 매겼고 유지 비용은 전혀 매기지 않았다.
유지 비용은 레버가 아니라 버킷이었다. heldFundReleases는 자금 산식 다섯의 항이었다. 청산 NAV 분자, 회차 가용 유동성 합, 즉시 정산 게이트, 회차 체결 차감 순서, 청구 인출이다. 각각이 컨트랙트에, fold에, 오프체인 미러에, 두 프런트엔드에, 그리고 이 문서에 그것을 지고 다녔다. 존재한 모든 풀에서 0이었던 값을 위해서다. 상환 유동성을 따져 보려는 사람은 항 하나를 읽고 그것이 잠자고 있음을 확인하고 치워 놓아야 했다. 연달아 두 번째 리뷰가 정확히 그 일에 시간을 썼다. v3-112 자신도 클램프에 미해결 의무 계산 문제가 있다고 적고 그것을 컨트랙트 소유자에게 넘겼다. 그 질문은 기다리는 대신 코드와 함께 죽는다.
왜 지금 제거해도 안전한가. 삭제 전에 확인했다. 배포된 풀 일곱 전부에서 fundingRestricted가 false이고 heldFundReleases가 0이며, 원장에 HELD_FROM_PARTNER / HOLDBACK_RELEASED 행이 0개이고, 버킷이 0이 아닌 프로젝션이 없으며, setFundingRestrictedOnChain은 어디에도 호출자가 없다. 제거는 어떤 풀의 어떤 숫자도 바꾸지 않는다.
⚠️ 풀은 EIP-1167 clone이고 구현체는 생성 시 고정된다. 옛 구현체로 만들어진 풀은 레버를 유지하고 여전히 그 이벤트를 낼 수 있다. 그리고 fold에는 더 이상 그것에 대한 규칙이 없으므로 잘못 세는 대신 던진다. 그것이 의도된 실패다. 그런 풀에는 우회 패치가 아니라 이 되돌림이 필요하다. 컨트랙트는 레버 없이 재배포한다. 그 뒤로 옛 팩토리로 풀을 만들지 말 것.
라벨 둘이 코드보다 오래 남는다. 잠자는 레버는 살아 있는 사례 없이 26-glossary에 남는다. 그 상황이 되풀이되고, 죽은 항이 돌고 있다고 조용히 암시하는 산식은 이름 붙일 가치가 있기 때문이다. 그리고 holdback_release_deferred는 자동으로라는 정정이 나온 자리다. 존재하지 않는 메커니즘을 약속한 문구와 그 거짓 단어를 단언하는 유닛 테스트였다. 그래서 22-notifications가 항목을 지운 뒤에도 그 이야기를 남긴다.
제거하지 않은 것: enum 라벨 HELD_FROM_PARTNER / HOLDBACK_RELEASED다. Postgres는 타입을 다시 만들고 컬럼을 다시 캐스팅하지 않고는 enum 값을 삭제할 수 없는데, 뷰 넷이 money_events.kind를 읽고 그중 하나는 며칠 전 v3-126에서 다시 쓰였다. 라벨 둘을 지우려고 뷰 정의 넷을 마이그레이션에 복사해 넣는 것은 없는 위험을 실제 위험과 맞바꾸는 일이다. money_events_no_holdback_kinds가 그것들을 쓸 수 없게 만들고 events.test.ts가 기존 RETIRED 목록에 선언한다. 그것이 이 저장소가 정확히 이런 경우를 위해 이미 갖고 있던 메커니즘이다.
참고: 마이그레이션 0183 · 23-money-path §6 · 07-redemption · 26-glossary → Retired terms
v3-127 — SBT가 신원의 기준이므로, 민팅이 그 절반을 지어내서도 안 되고 로그인이 나머지 절반을 뭉개서도 안 된다 ✅ Decided · fixed
v3-127 — 온체인 자격증명은 그 가장자리의 가장 허술한 번역만큼만 믿을 만하다
Date: 2026-08-12
KYC/SBT 가장자리의 결함 둘, 원인 하나. SBT는 신원의 기준이고(v3-19) 온체인 입금·이탈 게이트가 그것을 직접 읽는다. 두 결함 다 경계에서의 손실 있는 번역에서 왔다. 하나는 아무도 검증하지 않은 값을 썼고 다른 하나는 필요한 구분을 잃은 값을 읽었다. 어느 쪽도 오류를 내지 않았다. 둘 다 자신 있는 오답을 만들었고 온체인 게이트가 그것을 존중했다.
(1) 모르는 국가가 KOR로 민팅됐다. mintKYCSBT가 countryCode?: string을 선언하고 기본값을 countryCode = 'KOR'로 뒀는데, 호출자는 그것을 조건부로 넘겼다(...(user.country ? { countryCode: user.country } : {})). 그래서 국가 미러가 비어 있던 홀더가 한국 관할을 주장하는 자격증명을 받았다.
그 문자열은 설명용이 아니다. 온체인 투자자 게이트 둘의 입력이다.
| 게이트 | 읽는 것 |
|---|---|
| 관할 화이트리스트 | PlatformPool.sol:632 → jurisdictionAllowed[kycContract.jurisdictionHashOf(investor)] |
| 미국인 여부 | PlatformKYCSoulbound._isUsCountry(data.countryCode) |
오프체인 게이트는 모르는 국가에 대해 닫히는 쪽으로 실패한다(checkKycGating → US_PERSON / JURISDICTION_BLOCKED). 그래서 양쪽이 위험한 방향으로 어긋났다. 온체인은 지어낸 값을 존중했고, 투자자는 오프체인 게이트를 아예 거치지 않고 deposit()을 직접 호출할 수 있다. KOR을 화이트리스트에 둔 풀은 관할을 알 수 없는 홀더를 받아들였을 것이고, _isUsCountry는 아무도 위치를 특정하지 못한 사람에 대해 "미국인이 아니다"라고 답했을 것이다.
null 미러에 닿는 경로가 둘 있었다. applicant 조회가 실패한 GREEN 심사(국가가 그냥 쓰이지 않았다)와, 온체인 자가 치유로 APPROVED가 된 행(애초에 국가가 없었다)이다.
결정 — 국가는 필수이고, 확정되지 않은 국가는 민팅을 막는다. 기본값 없이 countryCode: string이므로 빠뜨리면 조용한 대체가 아니라 컴파일 오류다. mintSbtForApprovedUser가 resolveInvestorCountry로 그것을 해석한다. 입금 게이트가 쓰는 것과 같은 헬퍼이므로 양쪽이 법인 규칙(company_country ?? country)을 포함해 같은 국가를 읽는다. 미러가 비어 있으면 SumSub에서 applicant를 한 번 가져와 저장한다. 그래도 확정할 수 없으면 민팅이 닫히는 쪽으로 실패한다(sbt_status = FAILED, sbt_error = 'Investor country could not be verified', 재시도 가능). 해석은 재인증 소각 전에 돈다. 소각 뒤에 거부하면 홀더에게 자격증명이 아예 없게 된다.
기각: 센티널 국가나 어떤 기본값이든. 후보 값은 전부 진짜 관할이거나(어떤 풀은 그것을 화이트리스트에 둔다) 화이트리스트 비교가 조용히 "어디에도 허용되지 않음"으로 다루는 가짜 값이다. 후자는 명시된 실패가 아니라 영구적이고 보이지 않는 입금 차단이다. 자격증명은 그것이 주장하는 바가 검증되지 않았다면 존재해서는 안 된다.
(2) 로그인이 값이 넷인 상태를 불리언으로 읽었다. POST /auth/verify가 checkValidKYC → isValidKYC를 호출했고 그것은 kycStateOf(user) == VALID다. 그래서 EXPIRED와 REVOKED가 "한 번도 검증한 적 없음"과 같은 false로 도착했고, resolveSbtSync에는 false 분기가 하나뿐이었다. NOT_MINTED / NOT_STARTED / GUEST로 강등하는 것이다.
- 폐기된 홀더가 로그인만으로 거부 상태에서 풀려났다.
applyRedReview의 AML 분기는kyc_status = REJECTED를 기록하고 토큰을 유지하는데, 로그인 강등이 그것을NOT_STARTED로 덮어썼다.isFinalRejected는REJECTED상태로 판단하므로 종착 차단이 풀렸고 홀더가 새 인증을 시작할 수 있었다.GET /kyc/status에는 정확히 이 가드가 이미 있었는데(self-heal.ts의 B2 예외) 로그인 경로에는 없었다 - 만료된 홀더가 자기 돈에 접근하지 못했다. 만료는 새 입금을 막고 이탈은 절대 막으면 안 된다.
canRedeem은VALID || EXPIRED이고 v3-28이 기약 없는 가둠을 금지한다. 그런데 강등이role = GUEST와kyc_status = NOT_STARTED를 남겼고,POST /yield-claims는 가치 유출을INVESTOR+APPROVED로 게이팅한다. 오프체인 게이트가 컨트랙트가 명시적으로 허용하는 출금을 거부한 것이다
결정 — 전체 상태에 대해 정합하고, 컴플라이언스 판정을 절대 넓히지 않는다. resolveSbtSync가 'NONE' | 'VALID' | 'EXPIRED' | 'REVOKED' | null을 받고 자기가 의견을 가진 컬럼만 쓴다.
| 온체인 | DB 쓰기 | 이유 |
|---|---|---|
읽기 실패(null) | 없음 | 모르는 것 ≠ 없는 것(기존 A1 가드) |
VALID | 뒤처진 행을 APPROVED / MINTED / INVESTOR로 전진 | REJECTED 행에는 절대 하지 않는다. 대기 중인 폐기도 잠깐은 VALID로 읽힌다 |
EXPIRED | 없음 | 자격증명은 존재한다. 갱신은 행을 지우는 게 아니라 sbt_expires_at이 굴린다 |
REVOKED | kyc_status = REJECTED만 | 여전히 APPROVED를 주장하는 행을 조인다(폐기가 경로 밖에서 들어올 수 있다). 토큰은 MINTED로 남는다 |
NONE | sbt_status = NOT_MINTED, role = GUEST(그리고 NOT_STARTED) | 행이 토큰을 주장할 때만. REJECTED 판정은 보존한다 |
checkValidKYC는 남겨 두지 않고 삭제했다. 상태 넷을 둘로 뭉개는 래퍼는 이 저장소에서 감당할 수 있는 호출자가 없다.
기각: 불리언을 유지하고 가치 유출 게이트마다 EXPIRED를 특수 처리하기. 상태 손실이 결함이다. 그 귀결을 땜질하는 것은 지금과 앞으로의 모든 게이트에서 예외를 되풀이한다는 뜻이고, 하나만 빠뜨려도 투자자 자금이 다시 갇힌다.
확인하다가 발견해 같은 작업에서 고친 것들.
- "민팅은 하나만 진행 중"이 엔드포인트 하나의 분기 하나에만 있었다. 10분 선점이
POST /kyc/mint-sbt의force경로에서만 검사돼서, 평범한 경로가 두 번째 민팅을 큐에 넣어 첫 번째가 방금 민팅한 토큰을 태울 수 있었다. 게다가 그 검사는 읽고 나서 쓰는 방식이라 동시 호출자 둘이 다 통과한다. 이제enqueueSbtMint안의 조건부UPDATE다(원자적이라 한 호출자만 이긴다). 모든 호출자가 그것을 받는다. 엔드포인트 경로 둘(거부 시 409), GREEN 심사, 정합 sweep이다 - FINAL 거부가 나중 로그 행으로 지워질 수 있었다.
kyc_logs.status_result는 어휘 둘을 담는다. 판정(GREEN/RED.reject_type을 가질 수 있는 유일한 행)과 lifecycle 이벤트(IN_REVIEW/RESET/DEACTIVATED. 절대 갖지 않는다)인데,isFinalRejected가 종류를 가리지 않고 최신 행을 읽었다.RED/FINAL뒤의applicantDeactivated가reject_type없는 행을 맨 위에 올렸고 차단이 풀렸다. 이제 읽기가 판정으로 거른다(readLatestReviewLog) .single()은 "행 없음"을 오류로 보고하므로 민팅이 삭제된 사용자와 실패한 읽기를 구분하지 못하고 둘 다 영구 실패로 다뤘다..maybeSingle()이 그것을 나눈다. 읽기 실패는 재시도 가능이고 부재는 종착이다sbt_expires_at이 그것이 서술하던 토큰보다 오래 남았다. 갱신이 SBT 미러를 초기화하면서 옛 만료를 유지해서, 재민팅이 실패한 갱신에서는/kyc/status폴백과 T-30 재인증 sweep이 이미 태워진 자격증명의 기간을 인용했다- KYC가 없는 방문자에게 KYC 폴링이 영원히 돌았다. refetch 판정이 종착 상태를 열거하면서
NOT_SUBMITTED를 빠뜨려서, 로그인한 모든 방문자가 대기 중인 것도 없이 10초마다GET /kyc/status를 폴링했다. 호출마다 users 읽기,kyc_logs읽기, 지갑 조회, 온체인kycStateOf다. 이제 판정이 기다림을 열거하고(빠진 상태는 기본적으로 멈춘다) 행이 민팅된 토큰을 기록할 때만 체인을 읽으며, 거부 조회는REJECTED홀더에 대해서만 돈다
⚠️ 기존 데이터는 코드로 고쳐지지 않는다. 국가 미러가 비어 있는 동안 이미 민팅된 SBT는 체인에 KOR을 담고 있다. 그 홀더들은 확인되는 대로 force 재민팅(소각 후 재발급)이 필요하다. 어떤 마이그레이션도 온체인 속성을 고칠 수 없다.
참고: v3-19(3상태 SBT, 리스크 등급별 유효기간), v3-28(기약 없는 가둠 금지), v3-10(관할과 미국인 게이팅), 03-kyc-identity → Login-time reconcile, 10-status-machines. 출처: KYC 플로우 리뷰(2026-08-12).
v3-126 — 삭제된 테이블의 기본값이 살아남았다: 상환 뷰가 PENDING_RESERVE를 뒤집어 갖고 있었고 "보류 중인가"를 NULL 안전하게 물을 방법이 없었다 ✅ Decided · fixed
v3-126 — 상태는 이벤트에서 유도하므로, 엉뚱한 이벤트를 읽는 뷰는 엉뚱한 것을 자신 있게 말한다
Date: 2026-08-12
money_redemption_list의 결함 둘, 원인 하나. money-flow 재작업이 redemption_requests를 money_events + money_redemption_workflow로 대체했는데, 뷰가 옛 테이블에서는 참이고 새 것에서는 거짓인 가정 둘을 그대로 들고 왔다. 어느 것도 오류를 내지 않았고 둘 다 자신 있는 오답을 만들었다. 유도된 상태가 갖고 저장된 상태는 갖지 않는 실패 양상이다.
(1) PENDING_RESERVE와 "이미 펀딩됨"이 뒤바뀌어 있었다. 분기가 이렇게 읽혔다.
WHEN fund.id IS NOT NULL THEN 'PENDING_RESERVE' -- fund = a REDEMPTION_FUNDED eventRedemptionFunded는 fundRedemption(), 즉 파트너의 지급이 낸다. 그리고 즉시 경로에서 그 호출은 요청이 온체인에서 이미 PENDING_RESERVE여야 한다고 요구한다(RedemptionLib.sol:566, 아니면 RedemptionNotPendingReserve). 그러니 이 이벤트는 기다림이 끝나는 순간을 표시한다. fold.ts도 산문으로 같은 말을 한다("파트너의 회수").
한편 진짜로 기다리고 있는 상태에는 원장 이벤트가 아예 없다. RedemptionPendingReserve는 KIND_BY_CONTRACT_EVENT에 없고 인덱서는 그것을 money_redemption_workflow.funding_shortfall로만 기록한다. 뷰는 그 테이블을 이미 조인하고 있었고 아는 컬럼 하나를 읽지 않고 있었다.
| 실제 | 뷰가 말한 것 | 지금 |
|---|---|---|
| 승인됐고 리저브가 모자라 파트너를 기다림 | REQUESTED | PENDING_RESERVE |
| 파트너가 펀딩했고 정산은 아직 | PENDING_RESERVE | PROCESSING |
PROCESSING은 닿을 수 없었다. CASE에 아예 없었고, 그래서 deriveFundingStatus의 status === 'PROCESSING' → 'FUNDED' 분기가 죽은 코드였다.
⚠️ 분기 순서가 무게를 진다. funding_shortfall은 한 번 쓰이고 지워지지 않으므로 펀딩된 요청도 양수 값을 계속 갖는다. fund 분기가 부족분 분기 위에 있어야 하고, 아니면 모든 펀딩된 요청이 영원히 PENDING_RESERVE로 읽힌다.
파급 범위. PENDING_RESERVE로 키잉된 모든 것이 원하던 집합의 여집합을 봤다. 어드민 Redemptions의 펀딩 히어로, 확정 의무 타임라인, 풀별·펀드별 분해, Fund-all 목록이 전부 이미 펀딩된 요청을 나열하고 실제로 돈을 기다리는 것을 빠뜨렸다. GET /dashboard/stats의 pending_reserve_amount도 같은 상태로 거르므로 플랫폼의 "펀딩 대기" 수치가 엉뚱한 행을 합산하고 있었다. 부족분 산식은 이미 맞았지만(v3-122가 체인이 말하는 값을 읽도록 옮겼다) 그 입력 집합이 틀렸다.
(2) 새 is_held 컬럼. exclude_held가 평범한 요청을 전부 떨구고 있었기 때문이다. redemption_requests.funding_status는 DEFAULT 'FUNDED'였고 그것이 맨 funding_status <> 'HELD'를 정당화했다. 후속 컬럼에는 기본값이 없고 nullable이며, 쓰는 것은 명시적인 어드민 행위뿐이다(보류 승인·해제, 펀딩, record-funding). 그래서 평범한 새 요청은 NULL이다. NULL <> 'HELD'는 NULL이고 WHERE가 그것을 버리므로, 회차 Open과 Rollover 큐가 아무것도 반환하지 않는 동안 그 옆의 수요 집계는(별개의 SQL RPC라 영향이 없다) 계속 LP를 보고했다. 아래에 행이 하나도 없는 총계다.
뷰에서 COALESCE(funding_status = 'HELD', false) AS is_held로 고쳤다. 쿼리의 조건식이 아니다. null 접기는 그것을 일으키는 컬럼 옆에 한 번만 있어야 한다. 그리고 의도적으로 .or('funding_status.is.null,funding_status.neq.HELD')**가 아니다.** 그 자체로는 맞지만 엔드포인트의 검색 필터 옆에 두 번째 or=파라미터를 내보내는데, PostgREST가 반복된or`를 어떻게 결합하는지는 이 쿼리가 의존할 것이 아니다.
기각: DEFAULT 'FUNDED' 되살리기. 'FUNDED'는 정확한 라벨이었던 적이 없다. 아무도 펀딩하기 전에 요청이 갖고 있던 값이다. NULL이 "이력에 보류가 없다"를 뜻하는 것이 정직한 상태다.
확인하다가 발견해 같은 작업에서 고친 인접 버그 둘. POST /redemption-requests/{id}/notify-fund가 부족분으로 payout_amount를 보냈다. 그것은 지급된 금액이고 정의상 지급되지 않은 요청에서는 null이라 Number(null) = 0이 됐고, 실제 알림이 "[Action needed] Reserve shortfall … needs 0 USDC top-up"으로 나갔다. 이제 funding_shortfall을 읽고 수치가 없으면 이름 대는 대신 409를 낸다. 그리고 readPoolHolders가 lp_balance > 0으로 걸렀는데, LP에는 맞고 수익에는 틀리다. settleYield는 잔액이 바뀔 때마다 이탈을 포함해 대기 수익을 accrued_yield에 넣고 claimYield에는 LP 전제 조건이 없다. 그래서 완전히 이탈한 홀더에게도 여전히 빚이 있을 수 있다. 두 호출자 다 의무를 숨기는 방향으로 과소 계산하고 있었다(영수증의 미청구 미터가 낮게 읽혔고, 수익 정합기는 그 홀더들을 체인과 대조한 적이 없다. 주석은 "LP 없는 포지션에는 청구 가능한 수익이 없다"고 주장하고 있었다).
배포 순서는 선택이 아니다. Lambda가 is_held로 거르는데 PostgREST는 뷰에 없는 컬럼에 400을 반환한다. 그래서 0180을 코드 뒤에 적용하면 빈 큐가 오류로 바뀐다. 마이그레이션 먼저, 그다음 infra, 그다음 admin-web이다.
참고: v3-122(원장 fold와 money 테이블 넷), v3-96(검증되지 않은 문구), 07-redemption, 23-money-path. 마이그레이션 0180_redemption_list_status_and_hold_flag.sql. 출처: JY(2026-08-12).
v3-125 — 포트폴리오는 원금을 가격 매기고 원금은 액면이 상한이다: 포지션 변화는 수익이 아니라 상각이다 ✅ Decided · shipped
v3-125 — NAV는 액면 상한이 있는 원금 가격이므로 투자자 포트폴리오는 "원금 변화"를 보고하고 수익은 yield에서 온다
Date: 2026-08-12
포트폴리오가 스키마가 금지하는 이익을 중심으로 만들어져 있었다. pools.nav_per_token은 CHECK (nav_per_token > 0 AND nav_per_token <= 1.000000)을 갖고 nav_history.new_nav도 같은 상한을 갖는다. NAV는 원금만 가격 매긴다. 수익은 따로 분배되고 청구되므로(MANUAL_CLAIM) 절대 토큰 가격에 쌓이지 않는다. 평범한 입금은 1.0에 민팅되므로 tokens × NAV는 취득원가와 같거나 그 아래일 수밖에 없는데, 히어로 헤드라인과 Value 열, 행 상세가 전부 건강한 경우에 초록색 +$0.00 (+0.0%)을 렌더하고 있었다. 투자 수익으로 읽히는 라벨("Unrealized") 아래에서다.
반올림 자잘한 문제가 아니다. 투자자에게 자기 수익이 가격 상승에서 온다고 말하는 것인데, 이 상품에서 수익은 yield이고 그 수치는 원금이 얼마나 상각됐는가다.
결정 — 그 수치는 "원금 변화"이고, 변화 없음은 말로 적는다.
| 상태 | 표시 | 톤 |
|---|---|---|
|Δ| < $0.005 | "At par, no change" / "Principal at par" | 중립, 색 없음 |
Δ < 0 | −$1,200.00 (−4.0%) | error |
Δ > 0 | +$180.00 (+6.4%)와 이유를 밝히는 힌트 | success |
양수 분기는 지우지 않고 유지한다. 이익은 정확히 한 경로로만 가능하다. 액면 아래로 들어오는 것이다. entry_price가 전송 수령분과 2차 보유분에 기록되므로(21-holder-verification의 State C) $0.94에 사서 액면으로 회복된 풀을 가진 홀더는 정당하게 이익이고, 행이 그것을 설명 없이 불가능해 보이는 숫자로 두지 않고 말해 준다. 변화 없음은 실제로 렌더되는 정밀도(소수 2자리)로 비교하므로 1센트 미만 차이가 손실로 칠해지는 일이 없다.
손실의 틀은 v3-109 / R8에서 바뀌지 않았고 이 결정이 그것을 다시 열지 않는다. 손실은 액면 아래의 NAV이고 그 외에는 없다. 리저브는 크기로 보여 주고, 포지션이 가격 매겨진 NAV 안에 이미 들어 있는 상환 유동성이라고 서술하며, 흡수한다거나 완충한다거나 쓰인다고는 절대 말하지 않는다.
유령 필드 둘을 FE의 머릿속 모델에서 뺐다. redemption.nextSettlementAt과 redemption.expectedFillPct가 PoolRow에 선언돼 있는데 어떤 마이그레이션도 두 컬럼을 만든 적이 없고 어느 pools select도 읽지 않는다(grep next_settlement_at apps/infra → 0건). 언제나 null이다. 포트폴리오의 상환 대화상자가 첫 번째를 읽고 있어서, 펀딩일이 기록돼 있어도 모든 회차 풀이 조용히 "N일마다"로 폴백했다. 회차 풀이 실제로 가진 앵커는 **next_funding_date**이고 LIST_SELECT가 그것을 담는다. fundingDateConfirmed에는 목록 payload가 생략하는 컬럼 둘이 필요하므로, 포트폴리오는 날짜를 예정으로 배지한다. 안전한 방향이다.
함께 정정: 회차별 상환 상한이 투자자에게 날 bps로 공시되고 있었다("up to 500 bps"). 표시는 v3-73에 따라 percent다. NAV 표시는 소수 2자리로 통일했고, position-card.tsx와 computePoolRiskTier가 이미 쓰던 관례다. 소수 4자리를 쓰던 호출 지점 둘(포트폴리오 상환 대화상자, RedeemModal)을 맞췄다.
수정이 아니라 가드: 읽을 수 없는 날짜가 더 이상 라우트를 잡아먹지 않는다. Intl.DateTimeFormat.formatToParts()는 잘못된 날짜에 RangeError를 던지고 shared/lib/formatters.ts의 모든 포맷터가 렌더에서 바로 호출되므로, payload 어딘가의 파싱 불가 타임스탬프 하나가 루트 오류 경계까지 전파돼 앱 전체를 "Something Went Wrong"으로 대체했다. 셸도 페이지도 없고 어느 필드가 나쁜지도 말하지 않는다. 이제 zonedParts가 그것을 감지하고 각 호출자가 대시로 낮춰 표시한다. calendarDateInDisplayZone과 shiftDays는 계속 키를 조립한다. 호출자가 결과를 YYYY-MM-DD 키로 쓰고 언제나 todayInDisplayZone()에서 유도한 날짜만 넘기기 때문이다.
데이터가 없어서 지어내지 않고 뺀 것들. 지갑 유휴 잔액(잔액 훅이 없어서 요약에 "투자 가능" 구분이 없다), 포트폴리오 수준의 가치 추이 차트(풀별 GET /pools/{id}/nav-history는 있고 집계 엔드포인트는 없다. NAV 이력을 클라이언트에서 합치면 각 보유가 실제로 언제 보유됐는지를 무시한다), 수익의 이자·수수료·상각 분해(claimable_yield가 이미 순액이고 분해하려면 세 줄 중 둘을 지어내야 한다), 지갑별 보유(money_positions는 사용자로 키잉되고 주소 컬럼이 없다. LP 상태 구분으로 대체했다. 가용 / 상환 중 / 잠김 / 전액 상환 가능).
결과: /portfolio 정보 구조가 요약 → 유동성 → 포지션 → 최근 활동 → 발견이 됐다. 새로 만든 것: widgets/portfolio/{PortfolioComposition,LiquidityTimeline,PortfolioHistory}.tsx, shared/lib/portfolio-liquidity.ts, shared/copy/portfolio.ts(v3-96에 따라 동작을 주장하는 문구를 모듈 하나에). /investor-activity 행 → 원장 매퍼를 shared/lib/transaction-entries.ts로 옮겨서 Activity 페이지와 포트폴리오의 최근 활동 절이 지급액 대 LP 금액 규칙에서 어긋날 수 없게 했다. 구성 색상은 검증된 단일 색조 서열 램프다(brand-700/500/400/300). DS에는 색각 이상 분리를 통과하는 범주형 세트가 없고, 상태 색조는 자산군에 대해 좋은 소식과 나쁜 소식으로 읽히며, 이전의 600/500/400은 인접 명도 검사를 통과하지 못했다. 그래서 세그먼트를 구분하기 어려웠다. 네 단계가 상한이므로 다섯 번째 그룹은 크기 순으로 "Other"에 접힌다. /styleguide/portfolio가 실제 위젯을 픽스처에 대고 렌더한다. 포지션 상태 넷(즉시, 진행 중인 요청이 있는 회차, 잠기고 상각된 것, 만기 온 것)이 한 계정에 공존할 수 없기 때문이다.
tsc -b 깨끗함, 프로덕션 빌드 깨끗함, copy guard 깨끗함, 테스트 26개 통과. BE 변경 없음, 마이그레이션 없음, 컨트랙트 변경 없음.
열린 항목: 구역 간 행 수 불균형(Yield가 2행인데 Liquidity가 9행이다)은 미관 문제이고 해결되지 않았다. 빈 상태와 새 위젯 셋의 모바일 레이아웃은 좁은 뷰포트에서 검토하지 않았다.
참고: v3-109(손실은 액면 대비 NAV), v3-96(문구 모듈), v3-73(저장은 bps, 표시는 percent), 21-holder-verification(State C의 entry_price), 07-redemption. 출처: JY(제품), 2026-08-12.
v3-124 — 시간 기준 셋을 떼어 놓는다: 주기는 "N일마다", 한 달은 30, 수익은 달력 ✅ Decided · shipped
v3-124 — 기간에서 "한 달"은 30일이고, 회차 주기는 일수이며 절대 달이라고 부르지 않고, 수익은 달력에 남는다
Date: 2026-08-12
서로 다른 메커니즘 셋이 전부 "달"이라는 단어에 답하고 있어서 같은 질문이 계속 다시 붙었다. 1개월 락업은 28일인가 30일인가? 회차 주기는 28이고 firstYieldIntervalDays는 30이며 수익 분배는 둘 다 아니다. 그것을 맞추려는 모든 시도가 숫자 중 하나는 틀렸을 것이라는 가정에서 출발했는데, 실은 각각이 다른 메커니즘에 앵커돼 있고 어느 것도 자유롭게 움직일 수 없다.
결정 — 축 셋에 이름을 붙이고 한 단어를 셋 모두에 쓰기를 그친다. 전체 정리는 26-glossary → month에 있고, 실효 규칙은 28이 정확히 한 곳에만 존재하고(회차 주기, 일수로) "달"이라 불리는 모든 값은 30이라는 것이다.
| 축 | 기준 | 부르는 법 |
|---|---|---|
| 회차 상환 주기 | 고정 28 / 84일 | "28일마다". 절대 "월 단위"가 아니다 |
| 수익 분배 | 달력 월, 연 12회(addUtcMonths, 월말 클램프) | "월 단위" |
| 락업 · 만기 · 기간 표시 | 30일(1개월 = ×30) | "N개월" |
바뀐 상수는 없다. 이것은 명명과 표시에 대한 결정이다. 회차 주기는 28 / 84로 남고, 수익은 달력에 남으며, firstYieldIntervalDays 하한은 30 / 90으로 남고, epoch_schedule_type은 MONTHLY / QUARTERLY enum 값을 유지한다. 바뀐 것은 어드민 주기 선택기가 Monthly (28 days) 대신 Every 28 days로 읽히는 것이고, 그것이 "달"이 한 가지를 뜻하게 풀어 준다. 개월은 일수 필드를 위한 입력 편의이고, percent → bps처럼(v3-80) 경계에서 한 번 변환된다.
🔴 firstYieldIntervalDays를 28로 낮추지 말 것. 그것은 "달"의 정의가 아니라 락업이 풀리기 전에 풀의 첫 수익 분배가 있었음을 보장하는 하한이고, 그래야 YIELD_BASED 패널티가 가져갈 것이 있다. 그것은 달력 축을 따르고 거기서는 첫 분배가 30~31일 뒤에 떨어진다. 28이면 락업이 이틀 일찍 끝나고 accrued_yield가 여전히 0이라 조기 이탈이 아무것도 몰수당하지 않는다. v3-84가 닫으려고 존재하는 구멍이다. 변환기로 30을 고른 것이 "1개월" 락업이 경고 없이 정확히 그 하한을 넘게 만든다.
🔴 회차 주기를 30 / 90으로 옮기지 말 것. 30 mod 7 = 2이고 90 mod 7 = 6이라 요일 안정성이 사라지고 창이 주말로 흘러간다. 90은 정확히 MAX_EPOCH_DURATION_DAYS에 앉는다. 그리고 setEpochSchedule이 생성 전용이라 이미 28인 풀은 그것을 유지하고 자산이 갈린다. 어차피 완전한 통일은 불가능하다. 그러면 수익이 고정 일수 축에 놓이는데, 연 13회 지급에 날짜가 흘러가고 월말 NAV와 명세서 주기가 그것을 흡수할 수 없다. DPD는 어느 쪽으로도 걸림돌이 아니다. 이미 일수로 센다.
그리고 위저드가 이제 주기 전체를 그린다. 남는 구간이 보이지 않았기 때문이다. 타임라인이 펀딩일에서 멈춰서 마치 주기가 창 + 리드인 것처럼, 그리고 다음 주기가 지급에서 시작하는 것처럼 읽혔다. 그렇지 않다. 다음 창은 온전한 한 주기 뒤에 열리므로 월 프리셋은 28일 중 7일 요청을 받고 지급 뒤에는 11일의 닫힌 구간이 온다. 그 남는 구간은 주기 − 창 − 리드이고 아무도 그 안에 들어가지 않으며, 운영자가 실제로 입력하는 숫자 둘의 직접적 귀결이다. 07 → 모델 B 참조.
참고: 26-glossary → month(정리) · 07-redemption → Model B · 04-pool-models → yield_frequency · 17-changelog. v3-84를 지킨다. v3-93이나 v3-107을 바꾸지 않는다. 출처: JY(제품), 2026-08-12.
v3-123 — Yield 리뷰는 가격이 매겨진 집합 전체를 상환 러너로 서명하고, 정산이 아니라 입금을 센다 ✅ Decided · shipped
v3-123 — 두 자금 경로에 러너 하나. settle이 실패한 입금은 `done`이고 카운터가 그렇게 말한다
Date: 2026-08-12
Yield 리뷰가 도래한 모든 풀에 가격을 매긴 뒤 영구히 비활성인 버튼에서 끝났다. 툴팁은 운영자 한 명이 집합 전체에 서명할 수 없다고 설명했는데, 그것은 2026-08-11에 v3-121의 형제 A-2가 목록을 걸으며 항목마다 서명하는 러너를 낸 뒤로 참이 아니게 됐다. 그 모달은 이미 FM 전용이었으므로 펀드 지갑 하나가 자기 풀 전부에 서명할 수 있다. 하루 동안 그것을 막은 것은 막혀 있다고 적은 안내문뿐이었다.
결정 — 두 번째 러너를 쓰지 말고 use-fund-all-flow에 YIELD_DEPOSIT 종류를 더한다. 다중 항목 실행에서 어려운 것은 전부 도메인과 무관하고 거기서 이미 풀려 있다. 루프(wagmi 프리미티브를 한 번만 가져온다. 단일 풀 플로우는 훅 수준에서 풀을 묶으므로 풀 N개면 훅 N개가 필요하다), 항목별 상태 기계, ref 기반 이중 지급 가드, 항목별 체인 전환, 그리고 지갑 불일치를 거부된 트랜잭션이 아니라 문장으로 바꾸는 서명자 검사다. 다른 것은 둘뿐이다. 호출하는 풀 함수(depositYield 대 fundRedemption)와, 수익 기록이 서버 호출 둘이라는 점이다(deposit_tx_hash를 담은 POST /yield-distributions, 그다음 409에 재시도하는 POST /{id}/distribute). 하나가 아니다.
🔴 여기서는 0 금액이 실패인데 즉시 펀딩에서는 완료된다. 즉시 쪽은 0을 의도적으로 done으로 표시한다. 리저브가 움직였고 다시 보내면 아무도 빚지지 않은 돈을 지급하게 된다. 0인 수익 행은 반대로 FM이 가격을 매기지 않은 행이고, done은 재시도를 억누르는 상태다. 그래서 완료 처리하면 그 풀이 자기 실행에서 잠긴다. 비워 둔 행은 목록을 만들기 전에 떨구고, 러너의 분기가 마지막 안전망이다.
🔴 settle이 실패한 입금은 failed가 아니라 done으로 표시한다. 입금은 비가역이고, failed는 같은 수익을 두 번 입금하게 만들 재시도를 부른다. 그래서 항목은 진짜 주인을 이름 대는 메시지를 달고 완료된다. 이것은 PROCESSING + deposit_tx_hash 정체이고 복구는 Aset 키의 ORACLE_ROLE로 게이팅되며 새로고침이 아니라 운영이 필요하다. 그래서 성공해 보이는 일괄 실행이 정체된 행을 남길 수 있다. 13-operations 참조.
🔴 진행 카운터는 "distributed"가 아니라 "deposited"로 읽는다. done이 위 항목을 포함하므로 "4개 중 2개 분배됨"이라는 헤더가 홀더가 지급받지 않았다고 말하는 행 바로 위에 앉게 되고, 운영자가 가장 믿어야 할 한 줄과 모순된다. 입금됨은 모든 done 항목에 대해 참이고 나머지는 항목별 메시지가 담는다. 정산을 과소 표현하는 것은 회복 가능하고 과대 표현하는 것은 아니다.
그리고 계획은 실행이 열릴 때 스냅샷을 뜬다. 분배를 기록하면 due 큐가 무효화되므로 성공할 때마다 그 풀이 살아 있는 목록에서 빠진다. 라이브로 읽으면 행이 완료되는 대신 스테퍼에서 사라지고, 온전한 실행이 빈 대화상자로 끝난다.
⚠️ 자금 경로는 검증되지 않은 채로 나갔다. 컴파일과 문구 가드, 렌더 상태 넷은 확인했고 Redemptions 호출자는 그대로다(대화상자의 동사 문자열 셋이 옛 표현을 기본값으로 하는 prop이 됐다). 서명 경로 자체는 한 번도 돌려 보지 못했다. 도래한 풀의 펀드 지갑을 쥔 FM 지갑과 dev 스테이블코인, 도래한 기간이 필요하다. /preview/yield가 존재하는 이유는 그 조합을 원할 때 만들 수 없기 때문이고, 그것이 이 항목의 카운터 결함을 잡았다. 첫 실제 사용은 큐 전체가 아니라 도래한 기간 한둘이어야 한다.
의존: v3-120(이 큐를 채우는 선행 창) · v3-121(모달을 FM 전용으로 만든 FM·어드민 역할 분리) · 참고: 13-operations
v3-122 — 잔액은 append-only 원장을 접어 만든다. money 테이블 넷은 사라지고 legacy는 남는다 ✅ Decided · shipped
v3-122 — 돈은 `money_events`에 대한 fold다. 그것이 대체한 테이블은 삭제하고 리셋 이전 이력은 삭제하지 않는다
Date: 2026-08-10
모든 잔액이 마침 그것을 보고 있던 쪽에 의해 증분되고 있었다. 입금 핸들러가 pools.tvl을 올리고, 인덱서 라이터가 정합 때 또 올리고, 저장 프로시저가 세 번째로 올렸으며, 각각은 따로 보면 옳았다. 그것들이 어긋나도 아무것도 실패하지 않았다. 숫자가 그럴듯한 채로 남았고, 그래서 어긋남을 알람이 아니라 재생이 찾아냈다.
결정 — append-only 원장 하나 money_events, 그리고 그것에 대한 순수 fold인 프로젝션. UNIQUE (chain_id, tx_hash, log_index)가 유일한 멱등 키이고, 재생 순서는 (chain_id, block_number, log_index)이지 id가 절대 아니며, 모든 행이 자기 금액이 어느 축에 있는지를 기록한다(raw / normalized / LP / NAV). 그래서 프로젝션이 배율을 추론할 일이 없다.
6단계는 그것이 대체한 것을 삭제한다. deposits, redemption_requests, yield_claims, redemption_fills, 그리고 잔액을 쓰던 RPC 일곱이다. 여전히 존재하는 빈 테이블이 위험이다. 앞으로의 INSERT를 막는 것이 없고, 돌아온 두 번째 라이터는 오류가 아니라 그럴듯한 숫자로 보인다.
legacy 스키마는 남는다. 계획과 다르게. 계획의 지시가 원장이 이력을 대체했다고 전제했는데 측정 결과는 다르다. 입금 35건 중 17건, 상환 11건 중 8건, 수익 분배 5건 중 0건이고 NAV는 아예 없다(legacy.nav_history에는 tx_hash 컬럼이 없다). 그 간극의 대부분은 의도적이다. 소프트 삭제된 풀을 복원에서 제외했다. 다만 살아 있는 풀의 여섯 행은 회복 불가다. null chain_id, 손으로 만든 tx 해시, 은퇴한 체인이다. 그 부재가 pools.nav_per_token이 여전히 가격의 기준으로 남는 이유이기도 하다.
이관이 발견한 것들, 어느 것도 크게 실패하고 있지 않았다. 디스패치된 적 없는 회차 청구 경로가 있어서 정산된 상환이 여전히 QUEUED로 읽혔고, 발생 수익이 서로 다른 분모로 두 번 계산되면서 프로젝션의 0이 이겼으며, 하드 삭제 가드가 빈 테이블 셋에게 풀에 금융 기록이 있냐고 묻고 있었고, 어드민 대기 큐가 요청 둘이 기다리는데 0으로 읽혔으며, 투자자 앱이 만기를 풀이 아니라 각 홀더의 입금에 앵커하고 있었다.
의존: v3-101(금액 축을 타입으로) · 참고: 11-db-schema, 23-money-path, docs/refactor/money-flow-architecture.md
v3-121 — Yield 화면은 "누구를 쫓아야 하나"와 "누가 받았나"에 답한다. 이미 있던 기록으로 ✅ Decided · shipped
v3-121 — 펀드별 롤업과 투자자별 영수증. FM 통지 상태는 저장하지 않고 유도한다
Date: 2026-08-07
화면이 표로 열렸고, 표는 무슨 일이 있었는지를 말하지 무엇을 해야 하는지를 말하지 않는다. 미결 기간이 풀별로 나열돼서 풀 넷을 가진 펀드가 서로 무관한 문제 넷으로 읽혔고, 그 FM에게 알렸는지는 화면에 아예 없었다. 엔드포인트 둘이 그것을 닫고, 둘 다 시스템이 이미 쓰고 있는 데이터의 읽기다.
결정 — GET /yield-distributions/fund-summary. 미결 기간이 있는 펀드마다 한 행이고, 밀린 금액(연체만), 뒤처진 풀 수, 다음 도래일, 그 펀드의 통지 상태를 담는다. Pending 큐가 읽는 같은 fetchYieldDueRows 위에 세웠다. 브라우저에서 묶을 수도 있었고(usePools()가 fundId를 담고 due 행이 서버 추정치를 담는다) 바로 그래서 그러지 않았다. 같은 돈에 대한 독립적인 합 둘은 어긋나고, 화면이 그 둘을 나란히 보여 준다(노출 카드와 By-Fund 총계).
결정 — GET /yield-distributions/{id}/investors. 영수증 뒤의 투자자별 분해다. run-distribution.ts는 늘 홀더마다 user_id / share_percentage / amount를 저장해 왔고 아무도 그것을 다시 읽지 않았다. PII는 기존 viewInvestor 헬퍼를 거치므로 FM은 이름과 지갑을 보고 이메일은 절대 못 본다(09-rbac). 그 정책의 사본을 하나 더 두지 않는다.
🔴 FM 통지 상태에는 컬럼이 없고, 컬럼처럼 보이는 필드 둘은 둘 다 틀렸다. yield_distributions.fm_notified_at은 run-distribution.ts가 분배가 돌 때 찍으므로, 이 컬럼이 다루려는 미지급 기간에 대해서는 정확히 null이다. 그리고 POST /pools/{id}/escalate-yield는 DB 행을 아예 쓰지 않는다. 자기 헤더가 그렇게 적고 있다. 지속되는 기록은 에스컬레이션이 남기는 notification_events 행이다(event_key='yield_distribution_escalation', subject_type='pool', (subject_type, subject_id) 인덱스).
그리고 그 테이블만 읽었다면 거짓말을 내보냈을 것이다. dispatch.ts는 이벤트를 먼저 넣고 대상을 나중에 해석하므로, FM이 없는 펀드를 에스컬레이션해도 아무도 받지 않은 메시지에 대해 "우리가 알렸다"는 흔적이 남는다. 이 컬럼이 드러내려는 바로 그 경우다. 그래서 상태는 notification_events를 notifications(수신자별 수신함 행)에 조인해 유도하고, 서로 바꿔 쓸 수 없는 값 셋을 준다. UNREACHABLE(활성 FM이 없거나 아무에게도 전달되지 않았다. FM을 지정해야 하고 다시 알려도 아무 일도 없다), NOTIFIED(전달됐고 펀드 지갑을 기다린다), NONE(닿을 수 있고 보낸 것이 없다)이다. 첫 번째를 두 번째로 뭉개면 운영자가 나간 적 없는 요청을 기다리게 된다.
기각 — pools.yield_escalated_at 추가. 질의가 싸고 뻔한 모양이지만, notification_events가 이미 저장하는 사실을 복제하고 두 번째 라이터는 참으로 유지해야 할 것이 하나 더 늘어난 것이다. 조인은 인덱스가 걸린 컬럼에 대한 쿼리 하나 추가다.
죽은 컬럼 둘이 죽었음을 확인해서 아무것도 읽지 않는다. yield_distribution_investors.investor_name과 .investor_address가 존재하고 채우는 라이터가 없다(run-distribution.ts는 user_id / share_percentage / amount만 넣는다). 이름은 users 조인에서 온다. yield_distribution_investors.claimed_at도 마찬가지로 어디에도 라이터가 없다(grep 0건). 그래서 영수증의 청구 미터가 회차별이 아니라 풀 수준 누적이다. 실제로 추적되는 것은 portfolio_positions.accrued_yield이고 정산 때 적립되고 청구나 재투자 때 차감된다. 그것을 "이번 회차 미청구"라고 라벨하면 아무것도 뒷받침하지 않는 숫자가 되므로, 제목이 풀을 이름 댄다.
역할 분리를 API뿐 아니라 화면에서도 강제한다. 어드민과 오퍼레이터는 펀드 지갑을 쥐지 않으므로 depositYield에 서명할 수 없다. 헤더의 Distribute 버튼, 행별 Distribute 동작, 일괄 리뷰가 FM 전용이고 모든 어드민 경로는 Escalate to FM으로 끝난다. 예외 하나는 그대로다. 정체된 PROCESSING 행을 settleYield로 복구하는 것이고 그것은 Aset의 ORACLE_ROLE이다. 용어는 v3-96을 따른다. "Record"는 사라지고 동작은 Distribute 하나이며 "서명"은 절대 표면에 드러내지 않는다.
참고: 15-api-reference · 09-rbac · v3-104 · v3-96 · v3-120. 스키마 변경 없음(마이그레이션 없음. 두 엔드포인트 다 읽기다)이고 컨트랙트 변경도 없다. 출처: JY(제품), 2026-08-07.
v3-120 — 수익 기간은 늦기 일주일 전부터 조치 대상이 되고, 어느 쪽인지를 말한다 ✅ Decided · shipped
v3-120 — due 큐에 7일 선행 창이 생기고, 아무것도 조용히 과대 계산하지 않도록 `is_overdue` 플래그가 붙는다
Date: 2026-08-07
due 큐가 연체 전용이라, 기간이 처음 나타나는 곳이 이미 늦은 날이었다. due-rows.ts가 next_yield_due < now를 골랐다. 분배 펀딩은 당일에 되는 일이 아니다. FM이 펀드 지갑에 돈을 갖고 있어야 하고 depositYield에 서명해야 한다. 그래서 큐의 가장 이른 신호가 그것이 경고하던 기한 뒤에 도착했다. 그보다 이른 경고는 yield_distribution_due 알림뿐이었고 그것은 작업 큐가 아니라 이메일이다.
결정 — YIELD_DUE_LEAD_DAYS = 7. 창이 next_yield_due < now + 7d가 된다. 알림 파이프라인과 겹치지 않고 그 앞에 놓이도록 골랐다. 그 파이프라인은 도래 후 YIELD_DUE_GRACE_DAYS(3)를 기다린 뒤 에스컬레이션하고 7일마다 재발송한다(pools.scheduler.yield-due.ts). 즉 도래일 뒤에 시작한다. 이제 기간은 일주일 동안 보이고, 연체가 되고, 그다음에야 에스컬레이션 시계가 시작한다. 둘이 같은 날을 두고 다투지 않는다.
🔴 이 변경에서 위험한 부분은 창이 아니라 창이 깨뜨리는 것이다. Yield 화면의 노출 수치와 "연체 풀 N개" 집계가 due 큐 전체에 대한 합이었고, 그건 큐가 연체 전용인 동안에만 옳았다. 창을 넓히면 아직 빚지지 않은 돈이 연체라고 라벨된 숫자에 조용히 접혀 들어간다. 타입 오류도, 실패하는 테스트도, 눈에 보이는 결함도 없다. 그냥 너무 큰 수치다.
그래서 이제 모든 due 행이 is_overdue를 담고 그것이 유일하게 옳은 필터다. overdue_days로 대체할 수 없다. Math.max(0, …)라서 다가오는 기간과 오늘 도래한 기간이 둘 다 0으로 보고된다. 클램프는 음수를 허용하지 않고 유지한다. D+${overdue_days}가 이미 CSV 내보내기에 있었기 때문이다. "홀더가 이미 빚진 돈"을 뜻하는 모든 것이 이 플래그로 거른다. 노출 카드, By-Fund의 owed 총계, 연체 집계다. 반면 큐 자체와 Pending 탭, FM의 Upcoming 타임라인은 창 전체를 보여 준다.
그대로인 것. missed_periods는 여전히 최소 1이므로, 다가오는 기간이 0이 아니라 한 주기치 gross를 추정한다. 다가오는 행에 대해서도 추정을 계산하는데 그게 핵심이다. FM이 일찍 보는 이유는 펀딩할 금액을 보기 위해서다. CSV의 D-day 열은 이제 D+${overdue_days}가 아니라 getDDay로 렌더한다. 그러지 않으면 아직 늦지 않은 모든 것을 "D+0"으로 내보냈을 것이다.
참고: 13-operations · 15-api-reference · v3-104 · v3-121. 스키마 변경 없음(창은 쿼리 경계이고 is_overdue는 읽기 모델에서 계산하지 저장하지 않는다)이고 컨트랙트 변경도 없다. 출처: JY(제품), 2026-08-07.
v3-119 — 펀딩되는 단위가 주기이므로 엔드포인트가 받는 단위도 주기다 ✅ Decided · shipped
v3-119 — 풀 수준 회차 펀딩, 그리고 배선하다 발견한 KYC 게이트 정정 둘
Date: 2026-08-06
돈은 온체인에서 이미 풀 수준이었고 API만 다르게 우겼다. 회차 풀에서 Pool.fundRedemption(requestId, stablecoin, amount)은 넘겨받은 요청을 건드리지 않는다. 회차 분기 전체가 epochFundTopUp[currentEpochId]에 적립하고 반환하며, requestId는 낸 이벤트 말고는 아무 데도 쓰지 않는다(RedemptionLib.sol:444-459). 그런데 거기 닿는 유일한 경로가 POST /redemption-requests/{id}/fund였으므로 "이 주기를 펀딩한다"를 어떤 큐 행에 걸어야 했다. 어드민 Epochs 탭은 그러기를 거부하고 FM의 유일한 동작을 비활성으로, 요청 행을 가리키는 안내와 함께 내보냈다. 그때는 옳았고, 이것이 그 간극을 닫는다.
결정 — POST /pools/{id}/epoch-funding. 요청별 라우트가 가진 것과 같은 커스터디 경로 둘을 담는다. amount(플랫폼 키가 서명한다. 펀드 지갑이 바로 그 키인 경우에만 가능하다. fundRedemption은 onlyRole(YIELD_DEPOSITOR_ROLE)이고 그 role은 init에서 _fundWallet에 부여된다)나 fund_tx_hash(FM이 펀드 지갑에서 서명했다. 엔드포인트가 receipt가 성공했고 이 풀을 건드렸는지 검증한다. 해시를 그대로 감사 기록에 믿고 넣지 않는다)다. requestId는 0으로 보낸다. 요청이 없고, 오프체인의 무엇도 RedemptionFunded를 소비하지 않으므로(인덱서 라이터도 리더도 없다) 빌려 온 id는 요청이 펀딩됐다고 로그가 주장하게 만들 뿐이다.
의도적으로 redemption_requests에 아무것도 쓰지 않는다. 적립은 주기의 것이다. 그 주기의 큐잉된 행 N개에 펀딩 tx 하나를 찍으면 그것을 N번 잘못 귀속시키고, 그 행들의 tx_hash를 덮어쓰면(요청별 회차 경로가 자기 단일 행에 대해 그렇게 한다) 각 투자자 자신의 요청 해시가 파트너의 이체로 바뀐다. 오프체인 기록은 새 POOL_EPOCH_FUNDED 활동 이벤트이고 온체인 기록은 체인이다. 응답은 주기를 다시 읽어(readEpochShortfall) 호출자가 "펀딩했지만 아직 $X 모자람"과 "펀딩했고 충족됨"을 왕복 한 번 더 없이 구분하게 하고, 그 읽기가 실패하면 숫자를 지어내는 대신 snapshot_unavailable: true를 반환한다.
부분 펀딩은 오류가 아니라 지원되는 결과다. 모자란 전달은 요청을 pro-rata로 채우고 나머지를 이월하므로, 어드민 제어가 금액을 부족분으로 기본 설정하고 아래로 수정할 수 있게 한다. 이것이 즉시 펀딩 플로우를 재사용할 수 없었던 이유이기도 하다. 그쪽은 금액을 요청별로 온체인에서 유도하고(netPayout + penalty − poolBalance) forward pricing 아래 정산 전에는 동등한 값이 없다.
🔴 앵커 설정 풀의 배포 실패는 앵커가 아니라 가스 추정에 있었다. 위저드에서 회차 풀을 배포하면 setEpochSchedule이 InvalidSchedule()로 revert하며 죽었다(dev, "QA Epoch Verify 260806", 인자 2026-09-30 / 10 / 7, 풀 0xC706DD59725…eeC993). 유력한 가설은 컨트랙트가 앵커를 최소 온전한 한 주기 뒤로 요구한다는 것이었다. 그렇지 않고 그런 규칙은 없다. 네 번째 가드 fundingAnchor <= (recallLeadDays + requestWindowDays) * 1 days(GovernanceLib.sol:211)는 절대 unix 타임스탬프를 초 단위 기간과 비교한다. 17일은 약 1.47e6이고 실제 앵커는 약 1.79e9다. 그러니 쓰레기 값에서만 발동할 수 있고, 위저드 자신의 earliestAnchorMs가 이미 더 엄격하다.
실제로 발동한 가드는 recallLeadDays + requestWindowDays >= s.epochDurationDays가 17 >= 0으로 읽힌 것이다. s.epochDurationDays를 쓰는 setter는 정확히 하나인데(GovernanceLib.sol:113) clone의 initialize가 그것을 건드리지 않기 때문이다. epoch_duration_days가 DB 행에도, 팩토리에 넘기는 RedemptionConfig에도 함께 실려 가는데도 그렇다. create-pool.ts는 setEpochDurationDays를 setEpochSchedule 앞에 큐잉했고 주석은 순서가 무게를 진다고 적었지만, 순서는 실행에만 강제됐다. 모든 설정 호출이 앞에서 병렬로, 설정되지 않은 clone에 대고 가스 추정을 받았다. 추정은 시뮬레이션이고 실제 호출과 똑같이 revert하므로, 배포가 트랜잭션 하나 보내지 않고 사전 점검에서 죽었다. 그래서 receipt 자취가 setEpochSchedule을 가리켰고 앵커가 책임져 보였다.
보내는 순서를 재배치하지 않고 사전 점검이 그것을 따르게 해서 고쳤다. 처음에는 단계화된 사전 점검으로 썼는데(호출이 stage를 담고 단계는 순서대로 채굴되며 한 단계 안의 호출은 계속 파이프라인된다) 그 판은 같은 실패의 두 번째 원인과 부딪혀 살아남지 못했다. 같은 날 독립적으로 발견됐다. ops 키가 EIP-7702 위임을 갖고 있어서 노드가 대기 트랜잭션을 하나로 제한하고, 병렬 배치는 제출에서 -32000: in-flight transaction limit reached for delegated accounts로 거부된다. 이미 clone을 만드느라 가스를 쓴 뒤에 말이다. 단계화된 파이프라인도 0단계 안에서 여전히 호출 여섯 개를 한꺼번에 쏘므로 그 제한이 그것도 깨뜨린다. 그래서 createPoolOnChain은 완전히 직렬이다. 추정, 전송, 대기, 다음이고 각 호출이 자기 전송이 실행될 상태에 대해 측정된다. ConfigCall.stage와 planConfigStages는 중복이라 뺐다. buildEpochConfigCalls는 남는다. 배열 순서가 이제 실행뿐 아니라 시뮬레이션도 구속하고 주기→일정 의존은 체인 없이도 단언할 가치가 있기 때문이다. 추정 실패는 함수 이름과 "no tx sent"라는 말과 함께 다시 던져서, 다음에 이런 부류의 의존이 실패한 트랜잭션인 척하지 않고 스스로 이름을 대게 했다. 그리고 배포 후 읽기 확인이 fundingAnchor와 함께 epochDurationDays를 단언한다. 위임 자체는 17-changelog의 in-flight 제한 항목을 볼 것. 그것이 진짜 해법이고 아직 남아 있다.
바꾸지 않은 것: deploy_status = DEPLOY_FAILED와 나란한 lifecycle_status = ACTIVE. 상태 불일치로 신고됐지만 의도된 짝이다. lifecycle은 운영자의 발행 의도이고 deploy_status는 체인이 따라잡았는지다. Retry Deploy는 앞의 것이 보존돼야 재시도가 원래 대상에 떨어진다(pools.post.lifecycle.ts:62-78). DRAFT로 되돌리면 새 발행 분기를 타고 그 대상을 잃는다. 이 조합은 이미 모든 표면에서 통제된다. pool_address는 성공했을 때만 쓰이므로 어떤 온체인 경로도 작동할 수 없고, 투자자는 API와 클라이언트 양쪽에서 걸러지며, 어드민 칩은 lifecycle 칩보다 Deploy failed를 앞세워 렌더한다.
KYC 차단 경우를 배선하다 발견한 정정 둘.
- 회차 청구는
RedemptionBlockedByKyc로 revert하지 않는다. 같은canRedeem검사를 돌리지만PoolCommonLib.KYCRequired를 올린다(RedemptionLib.sol:1049).RedemptionBlockedByKyc는 즉시 정산 경로(:544)와 허가 없는 폴백(:416)의 것이다. 틀린 이름에 맞춰 프런트엔드 매핑을 더하면 컴파일되고 배포되고 조용히 한 번도 발동하지 않는다. A9 취소 대화상자는 그것에 의존하지 않고(폐기된 soulbound로 미리 게이팅한다) 그렇다고 적은 주석을 정정했다 NONE이 이탈 게이트에서 문서화된 적이 없다.canRedeem은VALID || EXPIRED이므로(PlatformKYCSoulbound.sol:371-373)NONE은REVOKED와 똑같이 거부된다. v3-19는EXPIRED통과 /REVOKED차단 쌍만 이름 댔고 그건REVOKED가 유일한 차단 상태인 것처럼 읽혔다. 03-kyc-identity에 경로별 revert 표와 함께 문서화했다
참고: 15-api-reference · 03-kyc-identity · v3-19 · 17-changelog. 새 활동 이벤트 타입 POOL_EPOCH_FUNDED. 스키마 변경 없음(마이그레이션 없음. 적립은 온체인 상태다)이고 컨트랙트 변경도 없다. 엔드포인트가 기존 fundRedemption을 호출한다. 출처: JY(제품), 2026-08-06.
v3-118 — 04-pool-models에 색인과 단계적 노출을 넣는다. 잘라 내는 것은 없다 ✅ Decided · docs only · presentation
v3-118 — 24개 차원 레퍼런스는 읽히기 전에 훑을 수 있어야 한다
Date: 2026-08-06
결정 — 04는 모든 차원의 색인으로 시작하고 깊은 메커니즘은 <details> 뒤로 접는다. v3-114가 발굴 기록을 옮겨 04를 개념 명세로 만들었다. 남은 것은 여전히 레퍼런스 벽이었다. 어떤 설정이 있는지 알려면 분류 표 일곱을 스크롤하고 하위 절 약 60개를 지나야 했다. 그래서 이번 작업은 표현만 바꾸고 사실을 지우거나 옮기지 않는다. 색인 표 하나가 문서화된 설정 29개 전부를(설정 25개에 런타임·표시 플래그 넷) 한 줄짜리 "무엇을 제어하는가"와 가변성, 상태와 함께 나열하고, 이름마다 자기 정의로 링크된다. 그 아래로 깊은 메커니즘 블록 여섯과 YAML 예시 셋이 <details> 뒤로 접힌다. 접기는 노출만 바꾼다. 모든 줄이 여전히 페이지에 있고 DOM에도 있으므로 Ctrl-F와 사이트 검색이 닿는다.
무엇이 접혔고 왜 그것들인가: 트랜치 묶기 메커니즘(묶기가 어떻게 작동하는지, 같은 그룹의 요건, 제약, Junior 소진), 바로 위 다이어그램을 다시 말하는 입금 흐름의 번호 붙은 8단계, mermaid를 다시 말하는 청산 ASCII 타임라인, 60+30일의 근거, 리스크 등급의 "매출채권 우선" 근거와 단계 표, 그리고 긴 YAML 설정 셋(33 / 36 / 40줄)이다. 예시 3은 열어 뒀다. 설정이 두 줄이라 접으면 아끼는 것보다 드는 게 많다.
이번 작업이 지켜야 했던 앵커 규칙. 네이티브 <details>는 :target에 대해 안정적으로 열리지 않으므로, 접힌 절로 가는 딥링크는 닫힌 상자에 착지한다. 그래서 모든 헤딩과 모든 명시적 <a id>는 접힘 밖에 남고 본문만 감싼다. 04는 다른 문서에서 오는 앵커 26개를 담고 있고(#emergency-wind-down 하나에만 13개다) 별칭 태그 둘(#penalty-type, #mutability-matrix-summary)도 있다. 변경 뒤 빌드된 HTML에서 전부 존재를 확인했다.
부수 효과로 유도된 숫자 하나와 낡은 다이어그램 라벨 하나를 정정했다. 분류 불릿 일곱이 분류별 개수를 달고 있었는데(Asset Configuration (7), Operations / Advanced (1)) 자기 표와 맞지 않았다. 일곱 중 다섯이 틀렸다. 다시 타이핑하는 대신 이제 색인이 그 개수를 표현한다. 색인은 표 자체에서 생성되므로 표와 어긋날 수 없다. 불릿은 사라졌다. 별개로, 청산 mermaid 라벨이 여전히 NAV = distributable/totalSupply로 읽히면서 서른 줄 위 같은 절의 올바른 산식과 배포된 컨트랙트와 모순됐다(R10, v3-116 참조). 이제 distributable / claimingSupply로 읽힌다.
명시적으로 다시 하지 않은 것: 흐름 절들(How Funds Flow, How Yield Works, How Redemption Works)을 다른 문서가 소유한 내용이 있는지 다시 확인했고 모든 지점이 이미 밖으로 링크하고 있었다. Reserve Split은 23 §3a로, Fee Destination은 v3-69와 가변성 표로, 청산 산식은 06 → R10으로다. 링크할 것이 없어서 건드리지 않았다.
참고: 04-pool-models · v3-114(이것이 딛고 선 정리) · 17-changelog. 명세 Claude-Plan/Active/04-pool-models-scannability-spec.md(로컬, git에 없음). 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-06.
v3-117 — 거버넌스 타임락에 실행 기한 알림이 붙고, 제3자 실행이 이제 DB에 닿는다 ✅ Decided · shipped
v3-117 — 부실 선언과 청산을 스스로 실행하는 것은 없고, 지금까지 그렇게 말해 주는 것도 없었다
Date: 2026-08-06
전제 확인. NAV 하락은 자동 적용된다. nav-changes.scheduler.apply-pending이 applyPendingNavOnChain을 호출하는 예약 lambda다. 부실 선언과 청산에는 동등한 것이 없다. executeImpairmentOnChain과 executeWindDownOnChain은 각각 호출자가 정확히 하나, 자기 POST 핸들러의 action === 'execute' 분기뿐이고 두 핸들러 다 HTTP paths:로 등록돼 있지 schedule:이 아니다. 그래서 타임락이 만료돼도 그 자체로는 아무것도 바뀌지 않는다. 사람이 행동해야 하고, 그때까지 제안은 제안 이전 상태의 풀과 함께 앉아 있으며 그 결정이 집행되지 않았다고 말해 주는 것이 없다.
결정 1 — 알리되 자동화하지 않는다. opsTeam 이벤트 둘 impairment_execute_due와 winddown_execute_due를 매시간 sweep(pools.scheduler.governance-execute-due)에서 D-1 / D-3에 보낸다. fundManagers가 아니다. 실행은 운영자의 행위이고 FM의 이해관계는 이의 제기 창인데 그건 제안 안내가 이미 덮는다. 그리고 명시적으로 자동 실행이 아니다. 부실 선언은 비가역적 상태 변경이고 청산은 종착이므로, cron이 깼다는 이유로 둘 중 무엇도 일어나서는 안 된다. 창에는 상한이 없다(실행 한 번 실패가 유일한 알림을 떨궈서는 안 된다). 한 번만 발동하는 것은 멱등 키가 보장하고, 그 키는 취소 후 재제안에서 다시 무장한다.
결정 2 — executeWindDown()은 허가 없이 유지한다. 그것에는 role modifier가 없고(PlatformPool.sol:1330) executeImpairment()는 onlyRole(DEFAULT_ADMIN_ROLE)이다. 그 비대칭이 보안 버그처럼 보였고 그렇게 접수됐다. 문서가 그것을 의도적으로 명시하고 있고("타임락 통과 → 누구나 Pool.executeWindDown()을 호출할 수 있다") 이미 승인되고 타임락이 걸린 행위를 누구나 발동할 수 있게 하는 표준 패턴이다. 제안과 취소는 어드민으로 게이팅되고 실행자는 어떤 파라미터도 바꾸지 않는다. modifier를 붙이지 말 것. 문서화된 활성성 설계를 깨뜨린다. 우려는 철회했다.
결정 3 — 🔴 진짜 구멍은 미러였고 이제 닫혔다. 허가 없는 실행은 그 실행이 미러링될 때만 안전하다. 인덱서가 ImpairmentExecuted도 WindDownExecuted도 LifecycleStatusChanged도 보지 않았고, lifecycle_status = WIND_DOWN을 쓰는 유일한 것이 BE 실행 분기였다. 그래서 외부인이 온체인에서 청산을 집행하는 동안 DB는 여전히 ACTIVE로 읽히고 UI는 컨트랙트가 revert하는 입금을 계속 제공할 수 있었다. 가설이 아니라 실제 트리거를 가진 v3-92 부류다. 이제 이벤트 셋을 다 본다. *_executed 라이터 둘은 핸들러 자신의 멱등 키를 재사용하므로 API 경로가 이중 발송 대신 중복 제거된다. is_paused는 의도적으로 건드리지 않고(제3자 실행은 unpause()를 보내지 않는다) pool_updates 피드 항목은 인정된 잔여물이다. createSystemPoolUpdate에 멱등 키가 없어서 인덱서가 그것을 호출하면 API 경로에서 항목이 중복된다.
그리고 공유 상수 하나와 공유 NAV 포맷터 하나. 7일과 30일 기간이 Solidity 상수와 아무 연결 없이 네 곳에 있었고, 그래서 winddown_proposed_ops가 FM에게 "7일"을 내보내며 이의 제기 창을 23일 과소 표현했다. 이제 두 핸들러가 business/governance-timelock.ts를 임포트하고, 새 *_execute_due 쌍은 기간을 이름 대는 대신 계산된 만료를 렌더한다. 별개로 notifications/format-nav.ts가 이제 유일한 NAV 포맷터다. nav_change_proposed가 날 NUMERIC 컬럼을 '{oldNav} → {newNav}'에 넣어 투자자에게 0.9166666666666666 → 0.85를 렌더하고 있었다.
열린 항목: ImpairmentProposed와 WindDownProposed는 아직 관찰하지 않는다. 그래서 온체인에 직접 올려진 제안은 제안 안내에도, 실행 기한 sweep(DB 컬럼을 읽는다)에도 보이지 않는다. 실행 쪽은 덮였고 제안 쪽은 아니다.
참고: 22-notifications · v3-92 · v3-97 · 17-changelog. 코드(infra): lib/shared/business/governance-{timelock,execute-due}.ts · lambda/pools.scheduler.governance-execute-due.ts · lib/shared/notifications/{catalog/ops-variants,format-nav}.ts · lib/shared/indexer/{events,engine,writers/governance}.ts · lib/shared/contract/enums.ts · lib/stacks/api-stack.ts. 스키마·컨트랙트 변경 없음. 출처: JY, 2026-08-06.
v3-116 — 07-redemption은 동작만 남기고 발굴 기록을 뺀다. "미구현" 콜아웃 셋이 낡아 있었다 ✅ Decided · docs only · 🔴 four stale claims corrected
v3-116 — 07을 현재형으로 정리하기, 그리고 그 과정에서 드러난 이제는 없는 구멍들
Date: 2026-08-06
결정 — 07-redemption은 상환의 주인이므로 동작은 남는다. 04나 05와 달리 07은 다른 문서의 내용을 다시 말하고 있지 않았다. 모델 선택기, 출구 게이트, 3단계 락업, 패널티 타입 넷, 흐름 시퀀스 둘, 모델 B의 창 산술, 이월 우선 2단 체결과 사다리 세대, 취소, 3계층 파트너 펀딩, 이상치 보류 표가 전부 여기 속하고 손대지 않았다. 나간 것은 자기 발굴 기록(배포 날짜, 배포 주소, selector, 커밋 해시, "앞선 초안은…", "테스트 N개 통과"), 내부 중복(사실 열한 개가 각각 3~9번씩 적혀 있었고 이제 하나씩 집이 있다), 그리고 콜아웃 색을 입은 상태 배지다. 608 → 429줄이고 콜아웃은 40 → 11인데 그중 8개가 번호 붙은 흐름 단계다. :::warning / :::danger는 이제 투자자 자금 위험에만 쓴다(실패 경로, 비수탁 청구 보장, 두 버킷 혼동).
공유 문장 하나가 clone 단서 일곱 벌을 대체했다. "풀은 Clones이고 구현체가 생성 시 고정되므로 이것은 새 풀에만 적용된다"가 반영된 컨트랙트 항목마다 똑같이 나타났다. 이제 문서 제목 아래 한 번 적혀 문서 전체에 적용된다. 의도적으로 지우지 않고 유지했다. 배포된 시스템의 현재 상태이고 회차 FE가 여전히 만들어 볼 것이 없는 이유이기 때문이다.
🔴 낡은 주장 넷, 전부 코드와 대조해 정정했다. 그중 셋은 이 문서에서 가장 크게 말하던 내용, 즉 모델 B가 설계는 됐지만 켜진 적이 없다고 알리는 전면 danger 콜아웃이었다. 쓰일 때(2026-07-31)는 참이었고 2026-08-03의 6c0a08f가 닫았는데 문서가 따라잡지 못했다.
- "확정 동작이 아직 체인에 닿지 않는다."
pools.post.epoch-schedule.ts가setEpochFundingDateOnChain을 호출하고POST /pools/{id}/epoch-schedule에 등록돼 있다. 확정 배지에 쓰기 경로가 있다. - "출처 컬럼 둘 다 아직 없다."
pools.next_funding_date_confirmed_at과next_funding_date_set_by가 마이그레이션0111에서 나갔고,DETAIL_SELECT가 다른 회차 일정 컬럼과 함께 그것들을 담는다. 이는 v3-107에 있던 형제 발견, 즉GET /pools/{id}가 날짜도 배지도 카운트다운도 렌더할 수 없다는 것도 함께 닫는다. - "
setEpochSchedule에 호출자가 없어서 어떤 풀에도 창이 없다." 일정 조건 셋이 생성 시 필수이고create-pool.ts로 보내지며 배포 뒤 체인에서 다시 읽어 단언되고, 컨트랙트는 지연 엔진으로 조용히 떨어지는 대신ScheduleNotConfigured로 revert한다. 새 풀은 앵커 없이 배포될 수 없고 레거시 예외는 이제 진짜로 예외다.EpochScheduleSet/EpochFundingDateSet/EpochSettleAfterSet에 대한 인덱서 라이터와 ABI 조각도 자리에 있다. - 청산 분자가 컨트랙트가 하지 않는 뺄셈과 함께 쓰여 있었다. 07은
distributable = 리저브 + 회수된 자금 − 정산됐으나 미청구인 채무라고 적었다.PlatformPool.sol:1370은reserveBalance + heldFundReleases + totalEpochTopUp이고− redemptionCommitted가 없다. 그 뺄셈은 v3-100에서 나갔다가0abe168에서 제거됐다. 세 버킷이 이미 그것을 차감한 값이라 다시 빼면 같은 돈을 두 번 없앤다. 남는 비대칭은 분모 쪽이고(R10) 문서가 이제 그렇게 말한다. 같은 틀린 산식이 07의 C7 설명에도 메아리치고 있었고("redemptionCommitted… 는 청산 분자에서 차감된다") 함께 사라졌다.
내부 모순 하나 제거. NAV 하락 예외가 Payout Formula 절에서는 "✅ 반영됨, 2026-08-04 배포"로, 같은 문서의 흐름 단계 ②에서는 "🔴 미구현"으로 표시돼 있었다. 반영된 것이 맞고(RedemptionLib.sol이 PoolCommonLib.effectiveNav를 거친다) 흐름 단계는 이제 자기 문장을 갖는 대신 그 하나의 진술로 링크한다.
✅ 조작으로 의심된 둘을 확인했고 진짜다. 지우지 말 것. GET /redemption-requests/{id}/exit-gate는 살아 있는 ADMIN 전용 엔드포인트이고(redemption-requests.get.exit-gate.ts, api-stack에 등록) POST /redemption-requests/{id}/fm-accept가 폐기됐다는 문서의 주장도 정확하다(마이그레이션 0050이 fm_accepted_at을 삭제한다. v3-34). v3-114나 v3-115와 달리 이 문서에는 지어낸 API가 없었다.
쓴 소유자 지도: 배포 날짜와 주소 → 17-changelog · "왜 바뀌었나" → 해당 v3-NN(v3-91 / v3-93 / v3-100 / v3-105 / v3-107) · revert 이름과 selector, 뷰 시그니처 → 08a · DB 컬럼과 마이그레이션 → 11 · NAV 동작 → 06 · 자금 버킷 → 23 · 알림 문구 → 22 · 전체 청산 절차 → 04.
여전히 열린 채로 그대로 넘어간 것: rejectRedemption이 QUEUED와 PARTIALLY_FILLED를 거부하는 동안 청구도 취소도 못 하는 KYC 폐기 회차 투자자(P1. 어느 쪽도 요청을 닫을 수 없다), 어드민 거부에 마감 게이트가 붙는지, 실효 처리와 무주물 재산의 법무 항목, 그리고 어떤 lambda나 어드민 화면에도 호출자가 없어 제품에서 여전히 쓸 수 없는 hold-back 레버다.
참고: 07-redemption · v3-114 · v3-115(04와 05에 대한 같은 작업) · v3-107(2와 3에서 닫힌 구멍) · 17-changelog. 명세 Claude-Plan/Active/07-redemption-readability-spec.md(로컬, git에 없음). 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-06.
v3-115 — 05-investment-lifecycle은 투자자의 여정이다. 동작은 그 주인에게 속한다 ✅ Decided · docs only · 🔴 one fabricated column named
v3-115 — 05가 무엇을 위한 문서인지, 그리고 "파트너 push는 없다"의 집
Date: 2026-08-06
결정 — 05-investment-lifecycle은 질문 하나에 답한다. 투자자에게 무슨 일이 순서대로 일어나는가. 입금 → 수익 → 상환의 단계별 안내이고, 각 단계는 무슨 일이 일어나는지를 한두 문장으로 말하며 그 뒤의 메커니즘은 다시 서술하지 않고 링크한다. 단계 콜아웃 29개가 이 문서의 형태이고 남는다. 나가는 것은 다른 문서가 이미 소유한 산식과 수수료 계산, 패널티 정의, 스케줄러 내부의 두 번째 사본이다. 298 → 209줄이고 문서 전체에서 사라진 사실은 없다.
쓴 소유자 지도(v3-114와 같은 모양): 입금 자금 동작 → 23-money-path §3 · 수익 수수료 계산과 목적지 → 04 → How Yield Works와 23 §4 · 락업 상태, 패널티 타입 넷, 리저브 분기 → 07-redemption · 정합기 컬럼과 스케줄러 → 11-db-schema · "왜 바뀌었나" → 해당 v3-NN · 폐기된 컬럼 → 04 → Migration Notes.
05가 주인으로 유지하는 것은 나중 작업이 이것들까지 링크로 빼지 않도록 적어 둔다. 3단계 여정과 그 역할 배지, Reinvest V1 정책(effectiveNav 가격, 부분, 최소, 같은 풀, 대기 중인 코드 수정), 그리고 더 나은 집이 없는 LP 전송 시 수익 정산이다. 그건 onLpTransfer를 홀더의 눈으로 본 것이고 예시도 포함한다.
이 카드가 이제 "입금 시 파트너 push는 없다"의 집이다. 그것이 05의 ⑦단계에 기록돼 있었고 04와 v3-02가 그것을 가리켰는데, 그러면 안내 단계 하나가 문서 셋의 공식 결정이 된다. 사실 자체는 그대로다. POST {partner_endpoint}/aset-lp-mint와 그 5회 재시도 일정, GET /lp-mints pull 엔드포인트는 문서에 공표됐고 만들어진 적이 없다. apps/infra, apps/web, apps/admin-web에서 aset-lp-mint, lp-mints, partner_endpoint를 grep하면 0건이다. 실제로 일어나는 일은 펀드 매니저가 플랫폼을 읽는 것이다. GET /deposits는 펀드 범위이고 deposit_confirmed_ops 알림의 대상에 fundManagers가 있다. 파트너로 밀어 주는 개념은 대기 중이 아니라 폐기됐다. 파트너에게 가능하다거나 곧 된다고 서술하지 말 것. v3-114의 거버넌스 웹훅과 같은 결함 부류다.
🔴 이 작업이 드러낸 지어낸 컬럼 하나: portfolio_positions.nav_at_investment는 존재하지 않는다. 05의 ⑥단계는 그것이 "정확한 손익 추적을 위해 투자 시점 NAV를 기록한다"고 적었다. grep은 저장소 전체에서 0건이고, 컬럼은 11-db-schema에도 없다. 입금이 실제로 저장하는 것은 effective_value = tokens × nav_per_token(RPC 인자 p_nav_per_token)이고 그건 현재 평가액이라 다음 NAV 이동에서 덮어써진다. 그래서 이 경로에는 입금별 진입 NAV가 전혀 남지 않는다. 근처의 entry_price 컬럼은 진짜이지만 인덱서의 LP 전송 라이터가 처음 보는 포지션을 만들 때만 쓰지 어느 입금 라이터도 쓰지 않는다. 지우지 않고 이름 대어 남긴다. 손익 기능이 그것을 전제로 명세됐을 수 있기 때문이다. 열린 항목이고 결정이 아니라 넘긴 것: 입금이 자기 진입 NAV를 남겨야 하는가? 그것 없이는 원가 기준 손익을 유도할 수 없다. → 제품.
정리하다 정정한 낡은 주장 둘 더.
- 3단계가 여전히 어드민 승인을 게이트로 서술했다. "어드민이 상환 큐를 검토하고… 충분하면
approveRedemption을 호출한다"인데 v3-82가 그것을 뒤집었다. 리저브 검사는requestRedemption안에서 일어나고approveRedemption은 선택적 수동 정산으로만 남는다. 07은 v3-82 이후로 계속 맞았고 05가 뒤처져 있었다. treasuryWallet에는 타임락이 없고proposeTreasuryChange는 존재하지 않는다. 05의 수수료 목적지 단계는 treasury를 "불변이고 타임락이 걸린 목적지(proposeTreasuryChange+ 7일 타임락으로만 변경)"이자 "모든 풀에 걸친 하나의 목적지"라고 적었다.proposeTreasuryChange를grep하면 0건이다. 진짜 setter는GovernanceLib.setTreasuryWallet이고 즉시 실행되는DEFAULT_ADMIN_ROLE쓰기이며 자기 주석이 이유를 적고 있다("수수료는 보류 없이 자동 분배되므로 목적지 변경을 늦추는 것은 잘못된 주소로의 지급을 막지 못하고 수정만 가둔다"). 그리고s.treasuryWallet은 전역 하나가 아니라 풀별 스토리지다. 04 → Fee Destination이 이미 둘 다 옳게 적고 있었으므로 이 단계는 이제 거기로 가는 링크다.
참고: 05-investment-lifecycle · v3-114(04에 대한 같은 작업) · v3-02(웹훅에 대해 여기를 가리킨다) · v3-82(상환 분기) · 08a → reinvest(레거시 1인자 시그니처 설명이 붙었다) · 17-changelog. 명세 Claude-Plan/Active/05-investment-lifecycle-readability-spec.md(로컬, git에 없음). 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-06.
v3-114 — 04-pool-models는 개념 명세이지 보관소가 아니다. 발굴 기록은 주인에게 옮긴다 ✅ Decided · docs only · 🔴 one fabricated API named
v3-114 — 04를 현재형으로 정리하기, 그리고 그 정리가 드러낸 것
Date: 2026-08-05
결정 — 04-pool-models는 풀이 지금 무엇인지를 말한다. 차원을 정의하고 묶고 흐름을 현재형으로 서술한다. 무엇이 왜 바뀌었는지를 설명하거나 마이그레이션 번호나 배포 날짜를 인용하거나 다른 문서가 소유한 메커니즘을 다시 말하는 것은 문단이 아니라 링크다. 1365 → 1134줄이고 문서 전체에서 사라진 사실은 없다. 모든 삭제는 그 소유자에게 있음을 먼저 확인했고(14 / 17 / 05 / 06 / 07 / 08 / 08a / 10 / 11) 04에만 살던 항목 하나는 삭제 전에 v3-106으로 옮겼다. POST /tranche-writedown이 NAV를 어떻게 적용하는지다(propose의 형제, nav_proposals 행 없음, 풀별 24시간 타임락, 부분 fan-out에서 207).
쓴 소유자 지도(다음 문서에서도 재사용할 만하다): NAV와 FX가 NAV에 없다는 것 → 06 · 상환과 회차 동작 → 07 · 컨트랙트 내부 → 08 / 08a · 마이그레이션과 삭제된 컬럼 → 11 / 17 · "왜 / 예전에 / 뒤집혔다" → 해당 v3-NN · 날짜 붙은 배포 기록 → 17. 04 안에서 남은 중복은 각각 집 하나로 합쳤다. 커스터디 표(Core Principles), 수수료 동작(How Yield Works), 청산 산식, 리저브 대 트랜치, 트랜치의 "컨트랙트 영향 없음"이다.
거의 같던 가변성 표 둘이 이제 하나다. "Mutability Matrix (Summary)"는 필드를 메커니즘으로 묶었고 "Editability After ACTIVE"는 무엇이 언제 잠기는지에 답했다. 같은 질문을 두 번, 93줄로 한 것이다. 이제 표 하나가 둘 다 담고 epoch_duration_days와 NAV 안전 경계가 추가됐다(어느 쪽에도 없었는데 옛 제목은 "24개 차원 전부"라고 주장했다). 옛 앵커 둘 다 보존한다. #editability-after-active는 다른 문서에서 오는 링크가 6개이고 #mutability-matrix-summary는 하나라, 헤딩이 앞의 것을 명시적 id로 갖고 뒤의 것을 <a id>로 갖는다.
이 작업이 드러낸 낡은 주장 셋, 전부 코드와 대조해 정정했다.
is_hidden은 투자자에게서 풀을 숨기지 않는다. 04는 풀이 "투자자 목록에서 사라지고 PDP가 404를 낸다"고 적었다.pools.get.list는.eq('is_hidden', false)를 오퍼레이터 분기 안에서만 적용하고isPubliclyVisible은 그 플래그를 보지 않으므로, 투자자 PDP는 숨겨진 풀을 정상적으로 서빙한다. 10-status-machines는 이미 옳게 적고 있었고("어드민 목록만. 결정 텍스트보다 좁다") 04와 가변성 행이 뒤처져 있었다. 좁은 동작이 오히려 옳다고 볼 만하다. 홀더는 상환하고 청구하고 상각 공지를 읽으려면 풀에 도달해야 한다.- v3-02가 만들어진 적 없는 파트너 알림을 주장했다. 카드가 "파트너는 API나 웹훅으로 통지된다"로 끝났고, 같은 날 앞서
05에서 제거한 것과 같은 조작이다. grep 증거와 함께 그 자리에서 정정했다. EXTERNAL_PARTNER는 값이 아니다. 04는 청산을 "EXTERNAL_PARTNER풀의 본질적 위험"이라고 불렀다. 그 식별자는apps/infra/db,apps/infra/lambda,apps/contract/src어디에도 없다. 이제 "파트너 운영 풀"이다.
🔴 그리고 08-smart-contracts → Partner Notification에 지어낸 파트너 API가 하나 더 있었다. POST {partner_endpoint}/aset-governance-action과, 거버넌스 타임락 동안 계약상 이의 제기권이 있음을 함의하는 **can_object_until**을 포함한 전체 예시 payload를 문서화했다. partner_endpoint나 aset-governance-action을 grep하면 저장소 전체에서 0건이고, pools.post.governance.ts는 어떤 알림도 보내지 않는다. 파트너에게도, 인앱으로도, 이메일로도 아니다. 온체인 이벤트는 진짜이므로(GovernanceLib이 FundWalletChangeProposed와 형제 여섯을 낸다) "파트너가 이벤트를 지켜본다"가 정직한 진술이고 이제 두 문서 다 그렇게 말한다. 지어낸 요소 하나하나를 조용히 지우지 않고 그 자리에서 이름 댄다. 파트너가 그 payload에 맞춰 만들었을 수 있기 때문이다. 열린 항목이고 결정이 아니라 넘긴 것: 거버넌스 알림이 필요한가? 7일 타임락은 누군가에게 시작됐다고 알려야만 검토 창이 된다. → 제품.
참고: 04-pool-models · 08 → Partner Notification · v3-106(NAV 적용 동작을 넘겨받았다) · v3-02(정정됨) · 10-status-machines → the hidden axis · 17-changelog. 명세 Claude-Plan/Active/04-pool-models-readability-spec.md(로컬, git에 없음). 스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-05.
v3-113 — 백엔드 문서 정보 구조: 축 하나, 그룹 열 개 ✅ Decided · 🛠 Done
v3-113 — 사이드바는 기능과의 거리가 아니라 문서의 종류로 묶는다
Date: 2026-08-05
결정 — 사이드바에는 축이 하나다. 방향 잡기 → 신원·컴플라이언스 → 자금 개념 → 컨트랙트 → 백엔드 런타임 → 레퍼런스 → 운영 → 프런트엔드 → 메타다. 모든 그룹이 같은 질문에 답한다. 이것은 어떤 종류의 문서인가. 그래서 새 문서의 집이 협상거리가 아니라 유도 가능한 것이 된다.
그룹 열 개. Getting Started · Identity & Compliance · Protocol & Money · Contracts · Platform / Backend · Reference · Operations · Frontend · Decisions & Roadmap · Team. 문서 28개가 전부 그중 정확히 하나에 앉는다.
뻔하지 않은 배치와 그 이유. 각각이 다음에 문서를 더하는 사람이 조용히 되돌릴 만한 종류의 이동이라 여기 기록한다.
- 08과 08a → Contracts(신설). 컨트랙트 구현과 코드 레퍼런스는 Protocol의 개념과 다른 종류의 문서다. 둘 다 Protocol 아래 두면 그 그룹이 한꺼번에 두 가지를 뜻하게 된다
- 20 Joob Pool Config → Reference. 펀드 하나의 설정이다. 일반 프로토콜이 아니라 찾아보는 예시다
- 09a Custody → Protocol & Money. 비수탁과 money-path 불변성은 자금 개념이지 플랫폼 운영이 아니다
- 03 KYC와 21 Holder Verification → Identity & Compliance(신설), Getting Started에서 나온다. 신원 메커니즘이다. 새 독자가 방향을 잡는 데 필요하지 않고, 그것들이 Getting Started에 있던 것이 그 그룹을 배치되지 않은 모든 것의 기본 집으로 만들었다
- 11 + 15 + 24 → Reference(신설). 스키마, API 레퍼런스, 필드 거버넌스 표가 Platform 여기저기 흩어져 있었는데 셋 다 읽는 게 아니라 참조하는 것이다
- 26 Glossary는 의도적으로 Getting Started에 남는다. 조회용 부록이 아니라 처음 읽는 사람의 방향 잡기 도구다. 나중에 대칭성을 이유로 Reference 이동이 여기까지 확장되지 않도록 명시한다
범위: 내비게이션만. 변경은 .vitepress/config.ts에 한정되고 내용과 파일명, 앵커는 diff가 0줄이라 앵커가 움직였을 수 없다. ko도 같은 열 그룹을 같은 순서로 비추고, 조용히 빠뜨리고 있던 항목 넷(26 · 08a · 22 · 25)을 새 번역 스텁으로 얻었다. 기존 ko 파일은 전체 번역본을 포함해 건드리지 않았다. 스텁이 번역을 덮어써서는 절대 안 된다.
커밋 전 확인: EN 문서 28개가 각각 정확히 한 그룹에(누락 0, 중복 0), EN과 ko의 링크 순서 동일, 모든 ko 항목이 실제 파일로 해석, 빌드 통과, check-doc-anchors가 기준선인 1187개 검사 / 2개 깨짐(둘 다 허용 목록) / 예상 밖 0으로 그대로.
참고: docs/vitepress/.vitepress/config.ts · 명세 Claude-Plan/Active/protocol-ia-restructure-spec.md(로컬, git에 없음) · 17-changelog. 내용·스키마·컨트랙트·제품 변경 없음. 출처: JY(제품), 2026-08-05.
v3-112 — hold-back은 잠자는 레버이고 "그 90%"는 설정된 잔여분이다 ✅ Decided · docs only · 🔴 one contract question routed
v3-112 — 만들어졌지만 당겨지지 않은 것에 이름 붙이기, 그리고 파트너가 실제로 받는 것
Date: 2026-08-05
hold-back 메커니즘 코드 감사에서 나온 명명 결정 둘. 감사는 나간 경로에서 버그를 찾지 못했고 제품 변경도 만들지 않았다. 찾은 것은 이 상태의 메커니즘을 부를 말이 문서에 없다는 것과, 설정된 파라미터를 상수처럼 인용하는 습관이었다.
D1 — 만들어졌지만 당겨지지 않은 것의 표준 라벨은 "잠자는 레버"다. 뜻은 이렇다. 스위치가 온체인에 있고 테스트됐고 작동하지만 어떤 제품 표면도 그것을 당기지 않으므로 그 아래 동작이 살아 있는 풀에서 돌지 않는다. 제거됐다거나 고장 났다거나 자동이라는 뜻은 명시적으로 아니다. hold-back 문장은 살아 있는 동작이 아니라 컨트랙트 기능으로 읽어야 한다. 정의는 26-glossary → Status labels에 있다.
D2 — "그 90%"가 아니라 "파트너 잔여분"이라고 쓴다. 분리는 reserveAmountRaw = amount × reserveBps / 10000이고 파트너가 나머지를 가져간다. 90%는 기본값 reserve_bps = 1000에서의 수치일 뿐이다. 리저브 0 출시 가정에서는 잔여분이 **100%**이고, reserve_bps = 2000인 풀은 80%를 보낸다. 90%를 인용하는 것은 풀별 설정 필드를 상수로 바꾸는 일이고, 우리가 먼저 출시할 것으로 보는 풀들에 대해서는 틀리다.
감사가 레버 자체에 대해 확인한 것(다시 유도하지 않도록 기록한다): s.fundingRestricted는 정확히 한 곳에서 쓰이고(GovernanceLib.setFundingRestricted) 초기화자가 없으므로 모든 풀에서 false다. 그 뒤의 채우기 경로 둘(PlatformPool.deposit, YieldLib.reinvest)은 따라서 닿을 수 없고 heldFundReleases는 어디서나 0이다. setFundingRestrictedOnChain은 어떤 lambda나 스케줄러, 어드민 화면에도 호출자가 없다. 소비자 쪽은 완전히 만들어졌고 놀고 있다. 인덱서가 FundingRestrictedSet을 보고, business/holdback-release.ts가 클램프 안내를 정하며, holdback_release_deferred가 카탈로그에 ⏸️ 보류로 앉아 있다. 이것은 v3-100을 다시 말하는 것이지 바꾸는 게 아니다.
⚠️ 2026-08-13에 v3-128이 대체하며 그것을 제거한다. 아래 문단은 삭제 비용에 대해서는 옳았고 유지 비용에 대해서는 틀렸다. 거기를 볼 것.
무엇을 제거하는 결정이 아니다. 제품 경로를 만들지 않는 것은 싸고 되돌릴 수 있다. 컨트랙트에서 메커니즘을 지우는 것은 둘 다 아니다. heldFundReleases는 청산 분자(PlatformPool.sol:1370)와 회차 체결 계산을 포함해 컨트랙트 경로 다섯이 읽으므로, 0으로 읽히는 동안에도 그 항이 모든 산식에 남는다. 그리고 _releaseHeldFunds 안의 클램프는 퍼즈로 찾아낸 진짜 수정이지 발판이 아니다. "우리는 hold-back 제품을 만들지 않는다"와 "컨트랙트에서 빼자"는 다른 결정이고 앞의 것만 내렸다.
🔴 넘겼고 결정하지 않음 — 클램프는 의무를 셋으로 세는데 담보 불변식은 다섯으로 센다. _releaseHeldFunds는 reserveBalance + redemptionCommitted + unclaimedYield를 빼는데, unclaimedYield는 분배 시점에 차감되므로 이미 홀더에게 약속됐고 청구를 기다리는 수익(SUM(pendingYield))은 물리적으로 존재하고 빚졌는데도 차감되지 않는다. freeNorm이 그 합만큼 과대 계산된다. 클램프가 고치려고 쓰인 §9-3-b 버그와 같은 결함 부류이고 의무 하나만큼 더 나아간 것이다. 잠재적이고(레버가 잠자고 있으므로 발동할 수 없다) 실행으로 검증되지 않았다(코드 읽기다. 그 시퀀스에 대해 퍼즈 캠페인을 다시 돌리지 않았다). hold-back 제품 작업이 시작되기 전에 반드시 정리해야 한다. → 컨트랙트 소유자.
지나가며 함께 정정: 26-glossary가 unclaimedYield를 "분배됐는데 아무도 청구하지 않은 수익"이라고 정의했다. 반대다. 입금됐지만 아직 분배되지 않은 수익이다(YieldLib.sol:122가 적립하고 :179가 차감한다). 이름이 그 오독을 부르고, 그것이 애초에 다섯 번째 의무가 사라진 경위다.
참고: 23-money-path §6(이것이 다시 쓴 절) · 26-glossary → Status labels · 22-notifications · 17-changelog. v3-100을 다시 말하고 나간 동작은 바꾸지 않는다. 출처: JY(제품), 2026-08-05.
v3-111 — 재투자는 입금과 마찬가지로 effectiveNav로 가격을 매긴다 ✅ Decided · contract coded, not deployed
v3-111 — 자본을 움직이는 모든 경로에 공지 NAV 규칙 하나
Date: 2026-08-05
결정 — reinvest는 LP를 navPerToken이 아니라 effectiveNav로 가격 매긴다. 평시에는 그것이 현재 NAV이므로 바뀌는 게 없다. 하락이 24시간 타임락에 앉아 있는 동안에는 공지된(더 낮은) NAV이고, 그러면 같은 블록에서 같은 크기의 재투자와 새 입금이 같은 수의 LP를 민팅한다. R2·R3이 입금과 상환 요청에 대해 이미 이 규칙을 세웠고, 이것이 세 번째 경로를 닫아 무조건으로 만든다.
왜 이 방향으로 열린 질문을 닫나. 재투자를 낡은 NAV에 두자는 논거는 재투자되는 수익이 이미 풀 안에 있으므로 손실을 한 번 졌다고 볼 수 있다는 것이었다. 그것은 메커니즘과 부딪히면 살아남지 못한다. 그 수익은 accruedYield에 있고 그것은 풀에 대한 청구권이지 가격 매겨진 LP 포지션이 아니다. NAV 변화로 상각되지 않으므로 투자자를 대신해 흡수된 것이 없다. 공지 전 NAV로 전환을 가격 매기면 재투자자에게 풀의 공지된 장부가가 뒷받침하는 것보다 적은 LP를 주게 된다. 즉 상각을 두 번 물리는 것이다. 기존 토큰에 떨어질 때 한 번, 새 토큰의 교환비에서 한 번이다. 보수적으로 보이는 경로가 벌을 주는 경로다.
반대 틀이 그것을 결정했다. R2·R3이 존재하는 이유는 창 안에서 옛 NAV로 가격 매기는 것이 공짜 옵션이기 때문인데, 그 옵션은 투자자에게 유리한 쪽으로만 가치가 있고 여기서는 잘못된 가격이 반대 방향으로 간다. 그러니 두 경로는 애초에 대칭적인 논거가 아니었다. 하나는 게이밍 방지이고 다른 하나는 벌 방지이며 둘 다 effectiveNav를 가리킨다.
범위. 새 파라미터도, 설정도, 거버넌스 표면도, DB 컬럼도 없다. 실무적으로는 즉시 상환 풀만 해당한다. 회차 풀은 정산 시점에 가격을 매긴다. 재투자 게이트는 그대로이고(allowRollover, minReinvestAmount, v3-64에 따른 같은 풀 전용) E1 money-path 동등성도 그대로다. 이것은 전환의 가격이지 그 외 아무것도 아니다.
✅ 코드는 됐고 배포되지 않았다. YieldLib.reinvest가 이제 PoolCommonLib.effectiveNav(s)로 나누고 입금·상환과 일치한다. 회귀 테스트 test_ReinvestPricesAtAnnouncedNavDuringDecrease가 옛 식(1000 LP)에서 실패하고 새 식(공지 0.80에서 1250 LP)에서 통과한다. 컨트랙트 변경이다. 팩토리의 poolImplementation이 immutable이므로 새 구현체와 팩토리가 배포된 뒤 새로 만들어지는 풀에만 닿는다. 2026-08-04 배포를 포함해 오늘 존재하는 모든 풀은 여전히 낡은 NAV로 재투자를 가격 매긴다.
오프체인 짝도 같은 변경이다. yield.post.reinvest.ts가 LP 교차 검증에서 공지 NAV를 인용한다. 아니면 1% 허용 오차보다 넓은 상각 중의 모든 재투자가 적용된 낡은 NAV 때문일 뿐인 불일치를 로그에 남긴다. 평가(p_nav_per_token → effective_value)는 의도적으로 적용된 NAV에 남는다. 포지션 하나만 풀의 나머지보다 24시간 앞서 상각되지 않게 하려는 것이다.
참고: 05-investment-lifecycle → Reinvest V1 Policy · 06-writedown-nav → R2·R3 · 23-money-path §3b(비대칭이 발견된 곳) · 17-changelog. v3-109 R2·R3을 확장한다. v3-64는 건드리지 않는다. 출처: JY(제품), 2026-08-05.
v3-110 — ARCHIVED는 lifecycle 상태였던 적이 없고 CLOSED에는 들어갈 길이 없다 ✅ Decided · BE + FE shipped
v3-110 — 풀의 마감, 아카이브, 숨김은 하나로 뭉개져 있던 축 셋이다
Date: 2026-08-04
배경. 풀이 어떻게 끝나는지를 감사하다가 문서는 자신 있게 서술하는데 코드는 만들어 낼 수 없는 상태 둘과, 한 단어가 세 가지 일을 하고 있는 것을 발견했다. ARCHIVED는 네 곳에서 lifecycle_status 값으로 적혀 있었는데 enum에 아예 없다. CLOSED는 enum에 있고 상태별로 문서화돼 있는데 저장소의 무엇도 그것을 쓸 수 없다. 그리고 "아카이브 / 삭제 / 숨김"이 레버 하나, 감사 이벤트 하나, 서로 다른 의도 셋이었다.
A — 아카이브를 좁히고, 그것이 오용되던 일은 별도의 is_hidden이 가져간다. 아카이브(deleted_at)는 끝난 풀을 은퇴시킨다는 뜻이다. 종착에 가까운 lifecycle(CLOSED / MATURED / WIND_DOWN과 DRAFT / DEPLOY_FAILED)에서만, 그리고 모든 투자자 포지션이 0일 때만 허용되며, 온체인 pause()를 미러링하므로 아카이브된 풀에는 직접 deposit() 호출로도 닿을 수 없다. 복구에는 사유가 필요하고 감사에 남는다.
오늘의 가드는 중요한 방식으로 그보다 느슨하다. pools.delete.ts는 열린 포지션을 lifecycle_status = 'ACTIVE'일 때만 검사하므로, 살아 있는 투자자 포지션을 가진 CLOSED / MATURED / IMPAIRED / WIND_DOWN 풀을 지금 아카이브할 수 있다. 사람들이 아직 돈을 넣어 둔 채로 풀이 표면에서 사라진다. 그것이 결함이고, 가드가 느슨했던 이유이기도 하다. 운영자에게는 살아 있는 풀을 목록에서 뺄 방법이 필요했고 아카이브가 거기 있던 유일한 레버였다. 그래서 아카이브를 좁히려면 그 필요에 자기 제어를 줘야 한다. **is_hidden**이고, 표시만 바꾸는 자유 토글이다. 기능 변화도, 온체인 효과도, lifecycle 효과도 없고 격식 없이 되돌릴 수 있다.
대안이었던 force 플래그가 붙은 레버 하나는 기각했다. 두 행위는 되돌릴 수 있는지, 온체인 효과가 있는지, 투자자가 영향을 받는지에서 다르다. 공유 엔드포인트의 불리언 파라미터는 "이것을 영구히 은퇴시킨다"와 "오후 동안 이것을 숨긴다"를 같은 버튼 뒤에 두게 되는데, 그것이 지금 없애려는 혼동이지 그것을 간결하게 표현한 것이 아니다.
세 갈래 표류를 멈추려고 명명을 고정한다. 사용자에게 보이는 표면은 Archive(되돌릴 수 있고 사유가 붙는다)라고 말하고, Delete는 영구적인 DRAFT / DEPLOY_FAILED 경로에만 쓴다. 감사가 POOL_ARCHIVE / POOL_RESTORE를 얻어 로그가 행위 셋을 구분할 수 있게 된다. 지금은 하드 삭제와 소프트 아카이브가 둘 다 POOL_DELETE를 쓰고 metadata.mode로만 갈리며, 복구는 일반 POOL_UPDATE로만 기록된다(deleted_at: null을 설정하는 맨 PATCH다). 사유도 전용 타입도 없어 평범한 수정과 구분할 수 없다.
B — CLOSED에 들어갈 길이 생긴다. POST /pools/{id}/close와 end_date의 자동 마감이다. 쓰기 경로가 없는 문서화된 상태는 문서화되지 않은 상태보다 나쁘다. 화면과 배지, 문구가 전부 일어날 수 없는 것에 맞춰 만들어지기 때문이다. CLOSED는 모집이 끝났다는 뜻이다. 입금은 막히고 상환과 수익 청구는 그대로다. 풀이 그 밖으로는 건강하고 end_date가 지나지 않았으면 되돌릴 수 있다(reopen). 청약을 일찍 끝내는 것은 종착이 아니라 운영상의 결정이고, 그것이 MATURED나 WIND_DOWN과 구분되는 지점이다. end_date는 이미 pools에 있고 B가 그것에 자동 트리거라는 두 번째 일을 준다. 그래야 풀이 자기가 말한 마감일을 지나서까지 누군가 기억해 주기를 기다리며 ACTIVE로 앉아 있지 않는다.
C — 전손난 풀의 한국어 용어는 전손이고 영어는 total loss로 남는다. PRD와 DB 주석, 문서에 표현 셋이 돌고 있었다. 여기서 어휘를 고치는 것은 미관 문제가 아니다. 같은 사건이 D3의 필수 에스컬레이션 트리거이고, 일관되게 이름 부를 수 없는 결정은 일관되게 강제할 수도 없다. 처음 쓸 때는 **escalate_flag**와 짝지어서 독자가 단어와 컬럼을 연결할 수 있게 한다.
Q2 — 전손은 온체인 7일 타임락을 거쳐 에스컬레이션한다. 이것은 v3-109의 D3 / T2와 같은 메커니즘이고 두 번째 결정이 아니라 풀 종료 쪽에서 다시 확인한 것이다. rawNav ≤ 0은 플래그만 올리는 게 아니라 proposeImpairment → 7일 → executeImpairment로 풀을 IMPAIRED로 옮겨야 한다(v3-106). 종료 정책이 그 자체로 읽히도록 여기 기록한다. 두 번 구현하지 말 것.
D — 상태 칩 하나, 그리고 그 하나를 정직하게 만드는 자동 해제. 풀의 배지는 지배적인 상태 하나를 보여 준다. pool-badges.tsx는 이미 자기를 "단일 유효 풀 상태 칩"이라고 문서화하고는 지배적인 것 옆에 두 번째 Paused 칩을 렌더한다. 주석과 렌더가 서로 다른 말을 한다. 해법은 단일 칩이다. v3-78의 자동 해제가 이미 한 번에 플래그 하나만 살아 있음을 보장하기 때문이다. 부실 선언이나 동결, 청산이 실행되면 pause가 지워진다. 그것이 성립하면 두 번째 칩은 추가 정보가 아니라 답이 하나인 질문에 대한 두 번째 답이다.
결과 — 2026-08-04 구현. 노션 텍스트 변경 하나만 빼고. 마이그레이션 0123(pools.is_hidden)과 0124(전손 주석)이고 그 밖에는 스키마 변경이 필요 없었다. 나간 코드가 이 카드와 다른 곳이 셋이고 각각 의도적이다.
is_hidden은 어드민 목록 전용이지 "투자자 목록과 PDP"가 아니다. 홀더는 상환하고 수익을 청구하고 상각 공지를 읽으려면 풀 상세에 도달해야 한다. 거기서 숨기면 운영자 편의로 내린 결정 뒤에 그들의 돈을 두게 된다. 투자자 시야에서 빠져야 하는 풀은 표시 플래그가 아니라 lifecycle 문제다(CLOSED, 아카이브).
D의 자동 해제에는 온체인 unpause()가 필요한데 이 카드는 그렇게 적지 않았다. v3-92가 명시적이다. DB만 지우면 paused() == true가 남고 입금이 보이지 않게 revert되며 나중의 pause()가 EnforcedPause()로 실패하고 UI에는 돌아갈 길이 없다. 그것이 Test Pool 260616-base를 묶어 뒀다. 이제 수동 마감과 lifecycle 스케줄러 둘 다 clearOnChainPause()를 호출하고 체인이 동의한 경우에만 is_paused = false를 쓴다. 그리고 스케줄러는 사람 없이 도는데, 그것이 이 항목을 조언에서 요구사항으로 바꾼다.
ACTIVE/UPCOMING → CLOSED이지 "어떤 상태에서든"이 아니다. 10-status-machines는 어떤 상태에서든이라고 적고 있었다. MATURED나 WIND_DOWN 풀을 닫는 것은 이유가 있어 도달한 상태에서 뒤로 움직이는 것이고, 아직 청약을 받고 있는 풀만 닫을 청약이 있다. end_date 스케줄러 패스도 만기 패스 뒤에 도므로 둘 다 지난 FIXED_TERM 풀은 MATURED로 떠난다. 그것을 CLOSED로 강등하면 방금 빠져나갈 자격을 얻은 홀더에게 조기 이탈 패널티가 되살아난다.
G2는 ①안을 택했다(배포 워커가 대기 중인 pause를 새 컨트랙트로 옮긴다). ②(pause를 거부한다)는 운영자가 가장 필요한 창에서 가진 유일한 제어를 없앴을 것이다. 실패하면 DB 플래그를 그대로 두고 감사에 outcome: failure를 기록한다. 체인에 맞추려고 그것을 지우면 운영자의 결정을 조용히 뒤집게 된다.
G4의 파급 범위는 가정하지 않고 측정했다. 감사 로그에 OPERATOR pause 이벤트가 0건이므로 role을 좁혀도 깨지는 워크플로가 없었다. DB가 닫아 준 질문 둘 더. MATURED + PAUSED 유령은 사례가 정확히 하나이고 이미 아카이브돼 있는데, A 아래에서는 그게 유령이 아니라 올바른 상태다(그 컨트랙트도 동의한다). 그리고 아카이브된 DRAFT 행이 없으므로 G6의 묶임 부작용은 치울 것이 없다.
아카이브 게이트는 순수 lib/shared/business/pool-archive.ts에 살고 테스트 16개가 붙는다. 여기 모든 규칙이 거부이고 거부는 열린 채로 실패하기 때문이다. 변이 넷이 테스트가 문다는 것을 확인했다(ACTIVE 전용 가드: 실패 7건, 쿼리 파라미터가 DRAFT를 이김: 1건, WIND_DOWN 제거: 2건, 단일 컬럼 S-18: 3건). 문서(이번 작업): ARCHIVED를 표시 축으로 다시 서술했고, CLOSED 도달 가능성 구멍을 닫았으며, is_hidden과 감사 이벤트 둘을 11-db-schema에 반영된 것으로 문서화했다. 남은 것: 노션 PRD 표현(심각손실 → 전손)이고 저장소 밖이다.
이것을 명세하다 발견해 둘 다 그대로 둔 것 둘. DEPLOY_FAILED 풀의 pause는 DB 전용이고 배포 재시도가 성공한 뒤에 그것을 다시 적용하는 것이 없다(구멍 G2). 진짜 어긋남이지만 여기가 아니라 배포 재시도 경로에 속한다. 그리고 is_hidden과 is_display_only의 이름 충돌은 유감스럽다. is_display_only는 불변이고 커스터디 수준이며 is_hidden은 자유 표시 토글이라, 작업 중간에 하나를 개명하지 않고 둘을 나란히 문서화했다. ⚠️ 2026-08-20 대체: 개명이 실제로 일어났다. 0203이 그 컬럼을 is_showcase로 만들었고(v3-40 마케팅 등급) 커스터디 수준의 의미는 custody_mode로 옮겼다(0199). 이 문단이 고치지 않기로 한 충돌은 해소됐다.
참고: 10-status-machines · 04-pool-models · 15-api-reference · 11-db-schema · 09-rbac · 06-writedown-nav · 17-changelog v3-110. v3-109의 D3/T2를 Q2로 다시 확인한다. D는 v3-78 자동 해제에 의존한다. 부실 선언 타임락은 v3-106이다. 노션 "Pool 상태 종료·보관 정책 재정리". 출처: JY, 2026-08-04.
v3-109 — 리저브는 손실 계층이 아니다. NAV 분모는 totalSupply다 ✅ Decided · BE + contract shipped (new pools only)
v3-109 — NAV 확정: 버퍼가 손실을 흡수하고 리저브는 유동성을 공급한다
Date: 2026-08-04
배경. NAV 로직을 배포된 코드와 전면 대조한 결과, v3-16 이후 문서화돼 온 모델이 손실 워터폴을 틀리게 갖고 있었다. 서류상으로는 투자자에게 유리하고 실제로는 NAV가 흘러가게 만드는 방식으로 틀렸다. 항목 열 개를 정리했고 앞의 셋이 이빨을 가진 것들이다.
✅ 열 개 전부 구현됐다. 백엔드 절반은 커밋 0abe168(2026-08-04)로 나갔고 컨트랙트 절반은 같은 날 배포됐다. 재투자 가격은 2026-08-06에 따라왔다. 카드 전체가 아니라 아래 표의 상태 열을 항목별로 읽을 것. 그리고 컨트랙트 쪽 모든 항목에 적용되는 단서 하나. 풀은 업그레이드 불가한 Clones 프록시이므로 컨트랙트 변경은 새로 만들어지는 풀에만 닿고 기존 풀은 아무것도 이관되지 않는다.
결정 열 개
| # | 결정 | 어디 있나 | 상태 |
|---|---|---|---|
| R8 | 리저브는 손실을 절대 흡수하지 않는다. 순서는 담보 → 버퍼 → Junior → Mezz → Senior → NAV | nav-formula.ts, NavLib.updateNAV | ✅ 반영. BE가 모든 경로에서 reserveConsumed = 0을 보내고 컨트랙트는 그 외의 값에 InvalidAmount로 revert한다 |
| R9 | 평시 NAV 분모는 total_deposited가 아니라 totalSupply | nav-formula.ts:242, nav-suggest.ts | ✅ 반영. pools.lp_total_supply를 읽고, 미러가 NULL이나 0이면 폴백하지 않고 가격 매기기를 거부한다 |
| R10 | 청산 분모는 정산됐으나 미청구인 LP를 제외한다 | PlatformPool.executeWindDown | ✅ 2026-08-04 반영. ⚠️ 새 풀에만 |
| R6 | 매니저 equity 버퍼가 진짜 설정이 된다(요율 + 방향 + 기준) | 마이그레이션 0118 | ✅ 반영. 요율은 모든 풀에서 0이고 대기 중인 값이 아니라 지원되는 정지 상태다 |
| R7 | FX는 NAV의 입력이 아니다. 교차는 비율이지 환율이 아니다 | lossRatioFromFundReport | ✅ 반영 |
| R5 · R5.5 | Aset이 만든 충당 곡선은 없다. 연체만으로는 NAV가 절대 움직이지 않는다 | nav-suggest.ts | ✅ 반영. OJK 사다리는 미뤄진 템플릿으로 남는다(v3-13 수정) |
| R2 · R3 | 공지된 NAV가 24시간 창 안의 모든 것을 가격 매긴다 | PoolCommonLib.effectiveNav | ✅ 반영. 입금과 상환은 2026-08-04, 재투자는 2026-08-06(v3-111) |
| D1 | 수동 NAV 경로가 ADMIN / SUPER_ADMIN이 된다(OPERATOR 제거) | nav-changes 핸들러 | ✅ 반영 |
| D2 | 제안을 사유와 함께 기각할 수 있고 48시간 TTL이 붙는다 | nav-proposals.scheduler.expire | ✅ 반영 |
| D3 / T2 | rawNav ≤ 0은 proposeImpairment → 7일 → executeImpairment로 IMPAIRED로 에스컬레이션한다 | 온체인(v3-106) | ✅ 반영 |
각각의 이유
R8 — 리저브를 손실 흡수에서 뺀다. 모든 입금의 reserve_bps는 Pool 컨트랙트에 남고, 그것은 리저브가 NAV 분모가 가격 매기는 청구권 안에 이미 들어 있는 투자자 돈이라는 뜻이다. 손실에서 그것을 또 빼면 투자자에게 자기 자본을 두 번 인정해 주는 것이다. 리저브 $100k에 대한 $100k 손실이 손실 없음으로 기록됐는데 풀의 자산은 실제로 $100k 줄어 있었다. 흡수 순서는 이제 담보 → 버퍼 → Junior → Mezzanine → Senior → NAV이고 리저브 단계가 없으며 uncoveredLoss = max(0, cumulativeLoss − buffer)다. 리저브는 진짜 일 둘을 유지한다. 상환을 지급하는 것과 청산 하한을 정하는 것이다. 이것이 v3-16과 BD4의 first-loss 틀을 대체한다. 10% 크기는 유지되고 유동성 목표로 재해석된다.
R9 — NAV 분모는 total_deposited가 아니라 totalSupply다. 누군가 상환하는 순간 둘이 갈린다. LP를 태우면 totalSupply가 줄고 total_deposited는 늘기만 한다. 고정된 손실을 늘어나기만 하는 누적 수치로 나누면 새 입금이 기록된 손실을 희석했고 NAV가 아무 회복도 없이 슬금슬금 올라갔다. 반올림 잡음이 아니라 진짜 표류다. totalSupply는 지금 존재하는 청구권의 수이므로 NAV × totalSupply가 풀이 빚진 것과 같게 유지된다. ✅ 반영: 분모는 인덱서의 온체인 미러 pools.lp_total_supply이고, 그 미러가 한 번도 돌지 않았거나 살아 있는 원금에 대해 0으로 읽히면 usableTotalSupply가 null을 반환해 sweep이 그 풀을 건너뛴다. netPrincipal로 폴백하면 이것이 대체한 바로 그 분모가 조용히 되살아나기 때문이다. 이것이 A4에 기록된 2026-07-27의 total_deposited 고정을 뒤집는다는 점에 유의할 것. ⚠️ 분자의 원금 항은 pools.tvl이고 상환 지급을 차감한 값이다. 아래의 "늘기만 한다"는 표현은 라벨이지 컬럼이 아니다(용어집).
R10 — 청산 분모는 정산됐으나 미청구인 LP를 제외해야 한다. v3-100(D-1)이 청산 분자를 고쳐 redemptionCommitted를 빼게 했고 반영됐다. 분모는 totalSupply 그대로였는데 그것은 정확히 그 지급을 받치는 에스크로된 LP를 여전히 센다. 그 USD는 나갔고 토큰은 남아 있으니 남은 모든 홀더가 그 몫만큼 과소 지급된다. 목표는 distributable / (totalSupply − settledUnclaimedLp)이고, 새 카운터는 요청이 아니라 정산 시점에 늘고 청구나 소각에서 준다. ⚠️ 범위를 지킬 것: 이것은 체결됐고 미청구인 LP만 제외한다. 미결과 이월된 LP는 분모에 남는다. 그것들의 USD도 분자에 있으므로 이미 대칭이고, 빼면 회차 설계가 이월된 요청자에게 보장하는 pro-rata 참여가 깨진다. 컨트랙트 변경과 재배포가 필요했고 둘 다 됐다. settledUnclaimedLp가 커밋 0abe168으로 나가 2026-08-04 dev에 배포됐다(⚠️ 새 풀에만). D-1과 합치지 말 것.
⚠️ 분모 셋, 산식 셋. 통일하지 말 것:
| 용도 | 분모 | 결정 | 상태 |
|---|---|---|---|
| 평시 NAV | totalSupply | R9 | ✅ 반영(오프체인. pools.lp_total_supply) |
| 수익 발생 | totalLpSupply − poolHeldLp. 인식된 채무만 | v3-131 (3) | ✅ 2026-08-20 반영. 새 풀에만. ⚠️ 옵션 A(v3-104)와 식은 같고 규칙은 반대다. 옵션 A는 *"에스크로된 LP는 아무것도 벌지 않는다"*였고, 여기서는 홀더가 계속 벌고 장부 반영만 에스크로 경계로 미뤄진다. previewEscrowAccrual(requestId)가 그 지연이다. settledUnclaimedLp는 청산 항이지 이것이 아니다 |
| 청산 NAV | totalSupply − settledUnclaimedLp. 정산 미청구분만 | R10 | ✅ 2026-08-04 반영. 새 풀에만 |
셋이 이제 다 다르고 그게 핵심이다. 평시 NAV는 아무것도 제외하지 않고, 절대 움직여서는 안 되는 것이 그것이다. 수익 발생은 풀이 보유한 LP 전부를 제외한다. 에스크로된 홀더도 계속 벌고 그 몫은 에스크로 경계에서 따로 장부에 잡히기 때문이다. 청산은 정산 미청구 몫만 제외한다. 미결과 이월된 LP는 이미 자기 분자와 대칭이기 때문이다. 따로 논증된 규칙 셋이 모양만 공유할 뿐이고, 둘을 한 항으로 접으면 다른 하나가 기각한 규칙을 되살린다.
R6 — 매니저 equity 버퍼가 진짜 파라미터가 된다. equity_buffer_rule은 자유 텍스트였다("매니저가 NPL 5%까지 흡수"). 어떤 산식도 작동시킬 수 없는 투자자에 대한 약속이었고, 그것이 R8이 리저브를 뺐을 때 단독 풀에 first-loss 계층이 아예 없게 만들 뻔한 이유다(리저브는 설계상 출시 시 0이다). 버퍼는 이제 풀별 설정이다(마이그레이션 0118). bufferCap = total_deposited × buffer_rate_bps / 10000에 **buffer_direction**과 **buffer_basis**가 붙는다. GROSS는 파트너가 총 손실을 보고하고 우리가 상한을 뺀다는 뜻이고, NET은 손실이 이미 그들의 흡수를 반영했으므로 상한을 0으로 강제한다는 뜻이다. 다시 빼면 R8이 방금 없앤 바로 그 이중 계산을 반복하기 때문이다. 기준은 의도적으로 NPL이 아니라 풀 크기다. R5.5에 따라 연체만으로는 NAV가 절대 움직이지 않는다. ⚠️ 줄어드는 bufferBalance를 명세했고 의도적으로 만들지 않았다. bufferBalance = cap − absorbed 다음 uncovered = loss − bufferBalance는 흡수분을 두 번 뺀다(loss = cap = 100에서 0 대신 미충당 100을 보고한다). 계산이 절대값이므로 버퍼는 상태가 없고 buffer_cap이나 buffer_balance 컬럼이 필요 없다. 회귀 테스트가 틀린 판을 막는다. 방향은 Joob에게 물어볼 질문이 아니라 컬럼이 됐다. 요율과 방향은 여전히 외부에서 확정되지 않았지만, 12시간마다 도는 산식에 대해 기한 없는 대기는 안전장치가 아니다. 그리고 두 방향은 같은 숫자로 정반대 책임이다(상한 5%, 손실 8% → 투자자가 3%나 5%를 진다). 기본값이 오늘의 산술을 정확히 재현하므로 확정되지 않은 풀은 bufferCap = 0이고 답이 나오면 UPDATE 하나다.
R7 — FX는 NAV의 입력이 아니다. 자산이 현지 통화인 풀에서 NAV는 그 통화 기준 성과를 잰다. USD 환산은 상환 시점에 그때의 환율로 일어난다. 대안(라이브 USD 기준 NAV)은 기각했다. 자산에 대해 아무것도 바뀌지 않았는데 NAV가 움직이게 만들고, 상각과 통화 효과를 한 숫자에 섞으며, 아무 정보도 담지 않는 움직임에 대해 이탈 상한과 24시간 타임락의 예외를 강요하기 때문이다. 귀결: 비USD 풀의 $1.00 NAV는 현재 USD 가치에 대한 주장이 아니므로 화면이 그 옆에 참조 환산을 보여 줘야 한다. 리뷰의 열린 질문도 함께 정리된다. FX 변동은 절대 타임락을 걸지 않는다. NAV가 움직이지 않았기 때문이다.
R2 · R3 — 공지된 NAV가 타임락 창 안의 모든 것을 가격 매긴다. 대기 중인 하락은 24시간 동안 공개돼 있고, 그 창 동안 새 활동을 옛 NAV로 가격 매기는 것은 양방향 공짜 옵션이다. 내일 $0.98이 온다는 걸 알면서 $1.00에 사거나, $1.00에 나가면서 손실을 남은 사람에게 떠넘기는 것이다. 회차 풀은 면역이다(정산 시점에 가격을 매긴다). 그래서 이것은 즉시 상환 풀의 결함이다. 결정: 창 동안 제출된 입금과 상환 요청은 공지된 NAV로 가격 매긴다. 공지 전에 뜬 스냅샷은 유효하다. 이것은 그 창에 대한 예외이지 forward pricing의 부정이 아니다. 이것이 살아 있던 모순 하나를 해소한다. 2026-07-02의 구두 결정은 타임락 동안 거래가 막힌다고 했고, 이 문서와 06은 투자가 현재 가격에 "열려 있다"고 계속 말했다. 어느 쪽도 구현되지 않았고 답은 둘 다 아니다.
R5 · R5.5 — Aset이 만든 충당 일정은 없다. Aset은 펀드의 상각을 미러링하고 그 위에 부분 충당 곡선을 지어내지 않는다(OJK 5/15/50/100 사다리는 정책이 아니라 미뤄진 자동화 템플릿으로 문서에 남는다). 연체만으로는 NAV가 절대 움직이지 않고 실현된 상각만 움직인다. 버킷 경계는 설정의 동적 배열로 남으므로 어떤 파트너의 일정이든 보고된 대로 수집할 수 있다. Joob의 실제 경계는 설계 질문이 아니라 열린 데이터 질문이다(v3-90).
D1 · D2 · D3 / T2 — NAV 이동을 둘러싼 거버넌스. D1: 수동 경로(POST /nav-changes, POST /tranche-writedown)가 ADMIN / SUPER_ADMIN이 되고 OPERATOR가 빠진다. A4의 승인·덮어쓰기는 이미 ADMIN 전용이었으므로 손으로 입력하는 경로를 OPERATOR에게 열어 두면 약한 통제가 실효 통제가 된다. D2: 제안을 사유를 기록하며 기각할 수 있고 48시간 TTL이 붙는다. 그래서 아무도 손대지 않은 제안이 다음 sweep을 막는 대신 만료된다(중요도 게이트가 두 번째 열린 제안을 거부한다). D3: rawNav ≤ 0은 escalate_flag만 올리는 게 아니라 **IMPAIRED**로 에스컬레이션해야 한다. 전손난 풀이 ACTIVE로 앉아 입금을 계속 받아서는 안 된다. T2: 그 에스컬레이션은 기존 온체인 proposeImpairment → 7일 타임락 → executeImpairment로 돈다(v3-106). 앞서 거론된 2인 승인을 대체한다. 공개된 7일이 동시에 이뤄지는 비공개 서명 둘보다 낫고, 그 경로는 이미 이탈권을 보존한다(입금 차단, 상환 개방). D4: 투자자용 차트는 nav_per_token으로 표준화한다. FE: NAV 하락은 설명 없는 숫자로 나타나지 않고 그것을 일으킨 상각으로 링크한다.
구현 노트 — 리뷰가 예측하지 못한 결함 여섯
만들면서 이것들을 찾았다. 노출 순으로 정렬했고 각각이 위의 결정이 그렇게 읽히는 이유다.
🔴 R7은 표시 작업이 아니었고, 건강한 풀이 승인 하나 차이로 0에 가격 매겨질 뻔했다. 제안 경로가 파트너의 cumulative_loss를 EFIDR 단위로 받아 풀의 USD 원금에서 뺐다. 1,022,311,789에서 573,206을 빼면 −1782이고, 그것이 1e-6 하한으로 잘리며 escalate_flag를 세웠다. dev가 만들어 낸 유일한 NAV 제안은 대출이 전부 정상인 풀에 대한 전손 상각이었다. 교차는 이제 비율이고 의도적으로 환율이 아니다. cumulative_loss ÷ total_subscribed이고 둘 다 같은 리포트에서 오므로 통화가 상쇄되고 어떤 환율도 NAV에 닿을 수 없다. 그것이 R7을 관례가 아니라 강제 가능한 것으로 만든다.
🔴 인덱서가 지난 45일 중 13일 동안 죽어 있었고, 그것이 R9를 그냥 켤 수 없었던 진짜 이유다. 아무도 듣지 않는 체인에서 읽은 분모는 DB의 낡은 값보다 나쁘다. Alchemy가 eth_getLogs를 10블록으로 제한하는데 엔진이 그보다 많이 요청해서 모든 실행이 던졌다. 제공자 자신의 오류에 맞춰 줄어드는 적응형 범위를 넣었고, 리저브와 LP 미러를 이벤트 파이프라인 밖으로 옮겨 로그 실패가 미러를 함께 데려가지 못하게 했다.
🔴 리저브 미러가 1e12만큼 어긋나 있었다. reserveBalance()는 18자리 카운터인데 인덱서가 1e6으로 나눠서 $2.10을 들고 있는 풀을 $2,100,000,000,000로 기록했다. 인덱서도 죽어 있어서 보이지 않았다. 이제 브랜드 타입 RawAmount / NormalizedAmount / NavPrice와 빌드 가드가 축을 주석이 아니라 타입 시스템에 둔다.
🔴 청산이 같은 이탈에 두 번 물렸다. R10은 비대칭 하나로, 분모에 기록됐다. 분자에 거울상이 있었다. _reserveFilledGross가 이미 정산된 gross를 redemptionCommitted에 넣어 두므로, executeWindDown이 그것을 또 빼면 $50을 들고 있는 풀에 대해 max(0, 50 − 100) = 0을 반환했다. 양쪽을 함께 고쳤다.
그리고 테스트 개수가 거짓말을 하고 있었다. tsc가 삭제된 소스의 산출물을 남겨서, 더 이상 존재하지 않는 파일의 테스트 18개가 dist에서 계속 돌았다(보고 284개, 실제 266개). 유령 파일을 심어 재현했고 이제 빌드와 테스트 앞에 rm -rf dist가 붙는다.
R6은 답이 아니라 설정으로 나갔다. 그것은 파트너의 계약 조건에 걸려 있었고, 그건 12시간마다 도는 산식에 대한 기한 없는 대기다. 그 대기가 막아 주는 것은 추측인데 설정은 추측하지 않는다. 0118이 pools에 buffer_rate_bps, buffer_direction, buffer_basis를 더하고 기본값을 두어 산술이 이전과 비트 단위로 같게 만든다. 제 값을 하는 컬럼은 방향이다. 상한 5%에 손실 8%면 FIRST_LOSS는 투자자에게 3%를, EXCESS는 5%를 남기는데, 하나를 조용히 가정하는 산식은 절반의 경우에 틀리면서 어떤 화면에도 증상이 없다. 기준(GROSS/NET)은 R8을 한 층 위에서 반복한 것이다. 이미 순액으로 보고된 수치에서 버퍼를 빼면 이중 계산이므로 NET은 상한을 0으로 강제한다. 0119가 무기력한 버퍼를 표현 불가능하게 만들고(buffer_rate_bps > 0이면 external_fund_id가 필요하다. sweep은 매핑되지 않은 풀을 방문하지 않는다) 0121이 산문을 설정에 종속시킨다. 계층이 없는 곳에는 equity_buffer_rule을 설정할 수 없으므로 투자자에게 보이는 "Manager first-loss commitment"가 더 이상 아무것도 아닌 것을 서술할 수 없다. fund_report_cadence_days와 apy_basis도 같은 방식으로 나간다.
가드 하나가 이틀 만에 들어왔다 나갔다. 0118은 nav_proposals.recovery_flag도 담았다. NAV를 올리는 제안일 때 운영자에게 경고하는 것이고, 상승은 타임락 없이 적용되므로 예고 기간에 잡을 수 없다는 논거였다. 그 논거는 빠진 통제가 아니라 타임락을 서술한다. 승인은 이미 의도적인 사람의 행위이고 같은 클릭에 배너를 하나 더 붙인다고 결정이 바뀌지 않는다. 제품의 판단은 NAV 상승에는 승인 버튼 말고는 필요 없다는 것이다. 적어 두지 않고 0120에서 뺐다. 항상 false인 컬럼은 다음 저자에게 뭔가를 뜻하는 신호로 읽힌다.
Q1은 소스에서 스스로 답했고, 그것이 R2·R3을 온체인으로 옮겼다. deposit()과 requestRedemption()은 external이고 KYC로 게이팅되므로 어떤 홀더든 자기 지갑에서 바로 호출한다. 백엔드 게이트는 UI를 쓰는 사람만 구속하고 나머지는 구속하지 않았을 것이고, 그건 정확히 잘못된 절반이다. PoolCommonLib.effectiveNav()가 하락이 큐에 있는 동안 공지 값을 반환하고 가격 지점 넷이 그것을 가져간다. deposit(창 안의 매수자는 즉시 손실이 아니라 더 낮은 가격에 더 많은 토큰을 받는다), 즉시 상환 게이트, navAtRequest 스냅샷, 그리고 settleNav다. 마지막 것은 결정에 없었다. 결정은 회차 풀이 정산 시점 가격으로 이미 충족된다고 봤다. 예고 창 동안 저장된 NAV는 구조상 낡았으므로, 그때 정산하는 회차는 남은 홀더가 여전히 소유한 유동성에서 상각 전 가격을 이탈자에게 지급했다. 같은 위험, 같은 수정이다.
이 카드 자신의 블로커 둘은 진짜가 아니었다. nav_proposals와 마이그레이션 0090/0091/0093이 전부 dev에 있다. (적용 대기) 표시가 낡았던 것이고, 정확히 11-db-schema가 경고하는 실패 양상이다. T1/T2/D-1도 마찬가지로 미결로 기록돼 있었지만 v3-106에서 이미 나갔다.
그리고 R6은 풀 하나짜리 기능으로 드러났고, 그것을 보호하던 제약이 그 상태를 영구화하고 있었다. 버퍼를 설정으로 낸 것은 일의 절반이었다. buffer_rate_bps는 정확히 한 곳 computeSuggestedNav에서 읽혔고 sweep은 그것을 외부 매핑된 풀에 대해 호출한다. 단독 풀 13개 중 1개다. 나머지 12개에서는 운영자가 first-loss 계층을 설정하고 저장되는 것을 보고도 아무것도 흡수되지 않았다. 0119는 거기서 그 값을 금지하는 것으로 대응했는데, 아무것도 그것을 읽을 수 없는 동안에는 옳았고 정지 상태로는 틀렸다. 버퍼를 풀 하나짜리 기능으로 얼려 버렸다.
나머지 12개에 버퍼가 닿을 수 없던 이유는 수동 경로가 가격을 받기 때문이다. 손으로 입력한 가격은 계산한 사람이 이미 손실을 적용한 값이라, first-loss 계층이 뺄 것이 남아 있지 않다. 그래서 POST /nav-changes가 이제 new_nav의 대안으로 cumulative_loss를 받고 sweep이 쓰는 것과 같은 진입점 computeNavFromPoolLoss로 가격을 유도한다. 호출자 둘, 조합 하나, 버퍼가 조용히 적용되지 않을 두 번째 자리 없음이다. 0119는 buffer_not_on_tranche_pools로 좁아지고(트랜치 그룹은 워터폴 엔진으로 가격을 매기므로 정말로 적용할 자리가 없다) fund_report_cadence_days는 매핑 요구를 유지한다. 버퍼와 달리 그것은 리포트를 보낼 파트너의 의무를 서술하고 피드가 없는 풀은 아무것도 받지 않기 때문이다.
가드 셋이 함께 왔고 각각 이미 기록된 실패다. total_deposited를 넘는 손실은 거부한다(이 경로에서 R7 통화 혼동을 잡는 것도 그것이다. 루피아 수치는 자릿수 단위로 넘치고 그러지 않으면 하한으로 잘려 정당한 전손처럼 읽힌다). 계산된 전손은 승인 경로에서와 마찬가지로 여기서도 proposeImpairment로 간다(D3). 그리고 어드민 미리보기는 서버의 dry_run이라 산식과 온체인 시뮬레이트를 함께 돌린다. 브라우저에 산식 사본을 두면 둘이 어긋날 두 번째 자리가 됐을 것이다. nav_history가 loss_amount(버퍼를 지난 미충당분)와 loss_as_of를 얻어서 NAV 하락이 자기 원인을 이름 댈 수 있다. 스펙 수준에 머물러 있던 하락 원인 메타데이터다.
반영됨: R6(모든 풀에), R7, R8, R9, D1, D2, D3과 위의 결함 여섯. forge test 375/375이고 불변식 스위트를 포함한다. infra 307/307. PlatformPool은 24,576B 한도에 대해 20,835B로 여유 3,741B다. dev 배포 대기: R10과 R2·R3은 쓰이고 테스트됐지만 컨트랙트에 산다. R9는 이제 미러 컬럼(pools.lp_total_supply, 0117)을 갖고 있고 그 미러가 도는 것이 확인되면 전환한다. 분자를 DB에, 분모를 체인에 두면 인덱서 지연이 전부 NAV 점프가 되는데, 이번 라운드가 이미 겪은 실패다. R9는 반영됐고, 상환 경우가 결정이 기록한 입금 경우보다 나빴다. 카드는 결함을 새 입금이 기록된 손실을 희석하는 것으로 서술한다(0.900 → 0.947). 미러가 드디어 채워진 상태로 대수를 따져 보니 반대 방향이 진짜 돈이었다. 0.90으로 상각된 $1,000 풀에서 500토큰 상환이 $450을 지급하면 옛 산식은 0.8182를 남겼다. 떠난 사람의 상각이 남은 모두에게 두 번째로 청구된 것이고, 다음 입금이 그중 일부를 0.9310으로 되돌려 줬다. 어느 움직임도 자산과 무관했다. lp_total_supply로 나누면 둘이 정확히 상쇄된다. p = (V − L)/S일 때 상환은 V를 V − T·p로, S를 S − T로 만들므로 가격이 p(S − T)/(S − T) = p로 다시 계산된다.
🔴 그리고 그 상쇄는 분자가 상환을 차감한 값이라는 데 달려 있는데, 이는 이 카드 자신의 표현과 모순된다. complete_redemption_atomic은 tvl = tvl − payout을 한다. 위 텍스트는 그 항을 total_deposited라 부르고 "늘기만 한다"고 말한다. 그렇지 않고 그래서도 안 된다. 누적 수치라면 이탈한 투자자의 원금이 분자에 남는데 그 토큰은 분모를 떠나므로, 이탈할 때마다 NAV가 부풀려진다. 틀린 것은 서술이지 컬럼이 아니다.
없는 공급량은 0인 공급량이 아니다. usableTotalSupply()는 NULL과 0, 음수를 거부하고 호출자는 원금으로 폴백하지 않고 그 풀을 건너뛴다. 그 폴백이 옛 분모이고 어떤 화면도 그것을 구분하지 못한다. dev에는 그런 상태의 풀이 넷 있고 그중 하나는 온체인 공급량 0에 대해 $7.1m을 들고 있다. 수동 경로는 다른 곳에서 수치를 얻은 운영자를 위해 new_nav를 계속 제공하고, 그래서 폴백이 눈에 보이는 사람의 행위로 남는다.
⚠️ R9는 원장의 어긋남도 가격으로 바꾼다. 옛 형태는 데이터 품질과 무관하게 손실 0에서 모든 풀에 정확히 1.0을 반환했으므로 아무것도 드러낼 수 없었다. 토큰 19개에 $2.30을 들고 있는 dev 풀 하나가 이제 $0.121로 가격 매겨진다. 그 불일치는 늘 거기 있었다. 살아 있는 풀 13개 전체 dry run: 8개는 1.0으로 그대로, 4개는 가드로 건너뜀, 1개가 드러남이다. 그리고 제안 sweep은 그중 아무것도 방문하지 않으므로 전환은 무해하게 착지한다.
마지막 테스트되지 않은 표면이 닫혔고, 그것이 테스트되지 않고 남아 있던 이유는 기록된 것과 달랐다. computeSuggestedNav에는 분기가 열다섯이고 모든 거부가 같은 null을 반환하므로, "이 풀을 가격 매길 수 없다"를 "잘못 가격 매겼다"로 바꾸는 버그는 어디에도 드러나지 않는다. 그 공백을 설명하던 메모는 저장소에 Supabase 모킹 패턴이 없다고 적었다. 진짜 걸림돌은 더 조잡했다. db/supabase.ts가 env 변수가 없으면 모듈 로드 시점에 던지므로, 테스트 대상 모듈을 정적으로 임포트하면 테스트 본문이 돌기 전에 죽는다. 자리표시자 env와 동적 임포트가 그것을 해결하고, test 스크립트가 아니라 테스트 파일 안에 의도적으로 뒀다. 스크립트에 가짜 URL을 전역으로 두면 다른 모든 곳에서 진짜로 빠진 설정도 침묵시킨다. 이제 함수가 클라이언트를 기본값 있는 인자로 받으므로 프로덕션에는 캐스팅이 없고 가짜는 테스트에 갇힌다.
⚠️ 방향 테스트의 첫 판은 엉뚱한 이유로 통과했다. 상한 50에 손실 100이면 FIRST_LOSS와 EXCESS가 둘 다 미충당 50을 남긴다. 틀린 분기를 포함해 어느 쪽과도 맞는 케이스였다. 손실 80으로 다시 썼고 거기서 둘이 0.97과 0.95로 갈린다. 변이 셋이 스위트가 문다는 것을 확인한다. 방향을 뒤집으면 15케이스가 실패하고, 공급량 가드를 원금 폴백으로 만들면 4케이스, R9 이전 분모를 되살리면 4케이스가 실패한다.
파트너를 기다리는 것은 없다. 한때 "Joob 없이는 진행 불가"로 분류됐던 조건 다섯은 시스템에 없던 입력이 아니었다. 정의된 기본값을 가진 설정이고 미설정 상태는 구멍이 아니라 지원되는 동작이다. 그 근거로 핸드오프 문서를 은퇴시켰다. 그 기본값이 무슨 뜻인지를 안전한 것으로 읽히지 않도록 한 번 적어 둔다. buffer_rate_bps = 0은 실현된 손실과 투자자 사이에 담보 말고는 아무것도 없다는 뜻이고 그것이 확인된 출시 조건이다. NULL인 fund_report_cadence_days는 제품이 다음 갱신이 언제 오는지 투자자에게 말하지 않는다는 뜻이다. 관측된 리듬(7개월에 리포트 7건, 불규칙)은 누가 한 약속이 아니기 때문이다. apy_basis = GROSS_DEPOSIT은 리저브가 사실상 0인 동안에는 대안과 구분되지 않고 0이 아니게 되면 다시 봐야 한다. NULL인 write_off_policy는 어떤 산술도 바꾸지 않는다. 상각 시점은 파트너가 정하고 Aset은 그 수치를 미러링하기 때문이다. 어느 파트너와든 진짜 버퍼를 합의하는 것은 PATCH 하나이고, 요율뿐 아니라 방향도 담아야 한다. 같은 5%가 8% 손실에서 투자자에게 3%를 남길지 5%를 남길지가 그것에 달려 있다.
v3-108 — 알림 재구축: 두 일을 하던 행 하나가 event → notification → delivery가 된다 ✅ Decided · shipped
v3-108 — 알림 시스템을 현재 요건에 맞춰 다시 만든다
Date: 2026-07-31
왜 확장이 아니라 재구축인가. 그 시스템은 초기 구현 위에 요건이 켜켜이 얹힌 것이었고, 어색한 부분이 전부 결정 하나로 거슬러 올라갔다. notification_logs가 인앱 수신함 항목이자 동시에 이메일 전달 로그인 행 하나였다는 것이다. 그것이 투자자가 읽을 수 있는 항목이 status = 'FAILED'를 달게 만들었고(상태가 이메일을 서술했다), "이메일은 건너뛰고 인앱은 정상"을 뜻하는 SUPPRESSED를 지어내야 했던 이유이며, WEBHOOK enum 값을 쓸 수 없었던 이유이고(channel 컬럼이 하나라 두 번째 채널은 payload 복제를 뜻했다), recipient_id가 행에 따라 user_id이거나 풀 id이거나 펀드 id였던 이유다. 그것이 발송 워커에 80줄짜리 해석기를 밀어 넣었고 fund_id 없는 풀의 FM 공지가 조용히 아무에게도 닿지 않게 했다. 오늘의 요건에 맞춰 처음부터 설계하라고 하면 답은 관례적인 3테이블 분리이고, 그래서 그것이 나갔다.
모델. notification_events(무슨 일이 있었나, idempotency_key UNIQUE) → notifications(누구에게 알리나. 수신함 항목이고 상태가 없다) → notification_deliveries(어떻게 나갔나, 채널마다 행 하나)에 email_suppressions가 붙는다. 마이그레이션 0110이 백필 없이 notification_logs를 삭제한다. 그 행들은 출시 전 운영 이력이고, 레거시 테이블을 나란히 두면 이 분리가 없애는 바로 그 모호함이 그대로 보존됐을 것이다.
fan-out을 생산 시점으로 옮겼고, 그것이 가장 파장이 큰 결정이다. ADMIN 알림은 이메일이 나갈 때 대상이 정해지는 브로드캐스트 행 하나였는데 이제 생산자가 쓰는 사람별 행 하나다. 그래서 읽음 상태가 사람별이고(동료가 알림을 열어도 팀 전체가 지워지지 않는다. CH의 판단으로 여기서 "모델 A: 공유 읽음"을 대체한다), 환경설정이 채널을 고르는 자리에서 적용되며(수신 거부는 전달 행이 없다는 뜻이므로 실패 집계에 나타날 수 없고 SUPPRESSED가 필요 없다), 해석할 수 없는 대상은 호출 지점에서 실패한다. 그리고 어드민 피드의 펀드 격리 로직을 아예 지웠다. FM은 다른 펀드의 행을 볼 수 없다. 그 행들이 다른 사람에게 주소가 붙어 있기 때문이다.
대상이 이벤트의 속성이 됐다. 카탈로그 항목마다 audience: ['opsTeam', 'fundManagers']를 선언하고 생산자는 파라미터만 넘긴다({ poolId }). 전에는 "누가 받는가"를 생산자가 마침 어느 헬퍼를 호출했는지가 정했으므로, 문구 시트의 recipient 열은 코드가 반박할 수 있는 의도를 기록했고 실제로 반박했다. 생산자 32개 전부에서 대상을 기계적으로 유도해 카탈로그를 만들면서 살아 있는 사례 하나가 드러났다. fund_member_changed("귀하의 역할과 권한이 변경되었습니다")가 발송 워커가 주소를 담은 ADMIN 행을 특수 처리하지 않는 한 운영 팀 전체로 해석됐다. 이제 adminUser이고, 역할이 바뀐 그 한 사람이다.
중복 제거가 제약이 됐다. idempotency_key UNIQUE가 열린 실패로 동작하던 조회 넷을 대체했다(dedupByEntity, recentlyNotified, 손으로 짠 notification_logs 쿼리 둘). 각각이 조회 자체가 오류를 내면 중복을 통과시켰다. 반복 알림은 주기를 키에 담는다. epoch_gated:<pool>:<epoch>:2026-07-31은 호출자가 넘기기를 기억해야 하는 창이 아니라 키의 속성으로서의 "하루 한 번"이다.
렌더 시점이 이제 채널마다 다르고 그건 의도적이다. 인앱은 쓰기 시점에 렌더해 저장한다(피드가 카탈로그에 의존하지 않고, 문구를 고쳐도 누군가 이미 읽은 것을 다시 쓰지 않는다). 이메일은 보내는 시점에 카탈로그와 저장된 변수로 렌더하므로, 문구 수정이 아직 큐에 있는 것에 닿고 홀더 천 명 분배가 같은 HTML 문서 천 벌 대신 변수 천 행을 저장한다.
전달이 큐가 됐다. SQS와 DLQ가 3분 cron을 대체한다. 그 cron은 100건 배치를 폴링하고 claimed_at 컬럼으로 겹치는 실행의 이중 발송을 막았는데, SQS가 그것을 공짜로 주고 홀더 천 명 분배를 30분 걸리게 만들던 시간당 약 2,000건 상한도 없앤다. 이제 백오프가 있다(1/5/15/60/240분). 옛 워커는 실패한 행을 즉시 풀어서 9분 만에 시도 셋이 타 버렸고 짧은 SES 장애에 걸린 것들이 영구 실패로 남았다. 5분 sweep이 전달이 커밋됐지만 큐에 들어가지 않은 경우의 아웃박스 릴레이 폴백이다.
🔴 반송 구멍을 닫았다. SendEmail이 성공했다는 것은 SES가 그 메시지를 받아들였다는 뜻일 뿐이다. 반송을 듣는 것이 없었다. 구성 세트는 존재했고 notification_failure_type에 BOUNCED와 SPAM_FILTERED가 이미 있었는데 SendEmailCommand가 그 세트를 지목한 적이 없다. 그래서 SES가 만든 모든 반송이 버려졌고 DELIVERED는 시스템이 뒷받침할 수 없는 주장이었다. 이제 SNS 웹훅이 provider_message_id로 피드백을 대응시키고 영구 반송과 신고를 email_suppressions에 더한다. 차단된 주소는 critical 이벤트보다도 우선한다. 메일이 도착하지 않을 것이고 계속 보내면 도메인 전체가 공유하는 발신 평판이 상하기 때문이다. 토픽과 그 Bounce·Complaint 이벤트 목적지가 둘 다 구성 세트 옆 SesStack에 살아서 배포가 그것들을 배선한다. ApiStack에 토픽을 만들면 그 연결 하나가 환경마다 수동 단계로 남는데, dev는 완전히 배포된 것처럼 보이면서 모든 반송을 버리는 파이프라인으로 돌고 있었다.
의도적으로 문구를 보존한 절충 하나. 풀 업데이트 경로(운영자가 쓴 제목과 본문)가 마지막 남은 직접 notification_logs insert였고 자기 이메일을 직접 렌더했다. 이미 보내고 있던 문자열 그대로를 담은 카탈로그 항목 둘로 옮겼고 칩 라벨도 포함한다. NotificationEventDef가 선택적 chipLabel을 얻어서 재구축이 살아 있는 공지를 조용히 다르게 쓸 수 없게 했다. pool_update_material은 critical이고(부실 선언이나 청산 공지는 억제 가능해서는 안 된다) pool_update_important는 optional이다. 그것이 옛 하드코딩 payload가 주장했지만 아무도 읽지 않던 priority: 'optional'을 드디어 실제로 만든다.
시트 커버리지. 시트 행 54개 중 52개가 구현됐다. epoch_demand_finalized와 over_funding_detected는 회차 마감 동결 수요 스냅샷(A1·A2)에 막혀 있다. 그것 없이는 확정된 지급 총액이 없으므로 공지가 아직 바뀔 수 있는 수치를 말하게 되고, 그건 침묵보다 나쁘다. 나중에 어느 쪽이든 추가하는 것은 카탈로그 항목 하나와 notify() 호출 하나다. 시트와 코드의 수신자 불일치 넷을 찾았고 의도적으로 바꾸지 않았다(fm_shortfall, holdback_release_deferred, yield_distribution_escalation, nav_change_*_ops). 구조 리팩터 중에 누가 이메일을 받는지를 바꾸는 것은 잘못된 위험이고, 그중 하나는 리저브 부족에 대한 어드민 가시성을 없앴을 것이다. 제품과 논의 중.
API 호환은 의도적이다. 읽기 엔드포인트는 늘 반환하던 모양을 새 테이블에서 다시 만들어 반환한다. 저장 방식 변경이 클라이언트 변경이 되어서는 안 된다. 운영 화면은 SENT → DELIVERED, SKIPPED → SUPPRESSED, FAILED·BOUNCED·COMPLAINED → FAILED로 매핑한다(구분은 failure_type이 담는다). NotificationLog 위젯이 공유 디자인 킷에서 오기 때문이다. 거기서 반송과 신고를 네이티브로 노출하는 것은 프런트엔드 후속이다.
검증: infra tsc 깨끗함 · 175/175 · 문구와 생산자, 단위 가드 깨끗함 · cdk synth 깨끗함(큐, DLQ, SNS 토픽, 함수 셋 존재). 생산자 가드가 재구축 중에 제 일을 했다. fm_notification_failed의 유일한 생산자가 삭제되고 있던 발송 워커임을 잡아냈고 새 워커가 넘겨받자 초록불이 됐다. 2026-07-31에 dev에 배포하고 마이그레이션 0110을 적용했으며, 그다음 반송 경로를 살아 있는 SES에 대고 끝에서 끝까지 확인했다(시뮬레이터 주소 둘이 발송 → SNS → 웹훅을 왕복하며 provider_message_id로 대응되고 올바른 사유로 차단됐다).
참고: 22-notifications(아키텍처) · 11-db-schema · 17-changelog v3-108. v3-44의 공유 읽음 모델과 v3-97 후속의 SUPPRESSED 상태를 대체한다. v3-106 다음 번호를 가져간 이유는 v3-103이 이 재구축이 대체한 레지스트리를 정합했기 때문이다. 그 카드의 미완 em dash 정리 작업은 이제 catalog/에 적용된다. 출처: CH, 2026-07-31.
v3-107 — 앵커 기반 회차 일정은 온체인에 절대 설치되지 않는다. 펀딩일 출처는 체인 읽기가 아니라 컬럼이다 ✅ Decided · shipped (contract deployed 2026-08-04)
v3-107 — 모델 B는 설계되고 머지됐고 배포되지 않았다. 그것을 설치하는 쓰기 경로가 없다
📌 2026-08-05 상태 갱신 — 모든 계층이 닫혔다. 따로 착지했으므로 나눠서 적는다.
항목 그때 지금 출처 컬럼 둘과 마이그레이션 없음 → FE 하드 블로커 ✅ dev 적용됨( 0111_epoch_funding_date_provenance)redemption_epochs정산 기한— ✅ dev 적용됨( 0112)주기별 setter 엔드포인트 둘 호출자 없음 ✅ 만들었다 — pools.post.epoch-schedule.ts인덱서 라이터 셋과 ABI 조각 없음 ✅ 만들었다 — lib/shared/indexer/writers/epoch-schedule.ts(이벤트는 컨트랙트 배포 뒤에야 흐르기 시작한다)그 호출자들이 부르는 온체인 함수 — ✅ 2026-08-04 dev 배포(팩토리 0xE1E2E974…DA90→ 구현체0x27D9948F…f968, 커밋0abe168) — 새 풀에만그래서 아래의 "아무것도 구현되지 않았다"는 이제 어느 것에도 해당하지 않는다. 컨트랙트 절반도 닫혔다.
setEpochSchedule/setEpochFundingDate/setEpochSettleAfter가 2026-08-04 dev에 배포된 구현체에 있다. 새 팩토리0xE1E2E9743fd3d00F6f50eBFCb765A91411D6DA90→ 새 풀 구현체0x27D9948FD2A3aF7b035e99d23440A3A877aef968이고 커밋0abe168에서 빌드했으며 selector로 확인했다. ⚠️ 새로 만들어지는 풀에만. 팩토리의poolImplementation이immutable이므로 새 구현체는 새 팩토리를 뜻하고 기존 clone은 업그레이드되지 않는다. 배포 전에 만들어진 모든 풀은(가장 최근 2026-07-23) 여전히 옛 구현체로 돌고, 새 팩토리로 만들어진 풀은 아직 없다. 그리고setEpochSchedule은 생성 전용이므로 이미 존재하는 풀은 절대 앵커될 수 없다. 다시 만들어야 한다. 아래 카드 본문은 쓰인 그대로 보존한다. Date: 2026-07-31
배경. v3-105가 회차 설계 질문을 닫았고 2026-07-31 동기화 작업이 재설계 이전 엔진을 여전히 서술하던 문서를 고쳤다. 이것은 남은 것에 대한 작업이다. 쓰기 경로, 프런트엔드로 가는 읽기 경로, 그리고 동기화 작업이 닿지 않은 문서 넷이다. 회차 FE 작업이 시작되기 전인 2026-07-31에 apps/contract/src, apps/infra, apps/web과 대조해 확인했다.
🔴 계획을 바꾸는 발견 — setEpochSchedule에 호출자가 없어서 앵커된 풀이 없다. (fundingAnchor, recallLeadDays, requestWindowDays)를 설치하는 setter가 어디에서도 호출되지 않는다. 배포 설정 호출 목록(lib/shared/contract/create-pool.ts)에도, 어떤 lambda에도, 어드민 앱에도 없다. 그 인자 셋은 CreatePoolOnChainParams의 필드도 아니다. 백엔드는 값을 모으고(pools.post.create가 검증하고 pools.patch.update가 B 부류로 다룬다) 그것들은 데이터베이스에서 그냥 멈춘다. 그래서 모든 풀이 fundingAnchor == 0으로 배포되고, PoolCommonLib.hasAnchoredSchedule이 그것을 "앵커된 일정 없음"으로 읽는다. 요청 창 게이트가 건너뛰어지고 지연 시계가 돌아오며 공백 구간에서 요청이 받아들여진다. v3-100은 그 예외를 "모델 B는 새 풀의 속성이지 플랫폼 전체의 속성이 아니다"로 틀 잡았는데, 이것이 없으면 어떤 풀의 속성도 아니고 레거시 풀 7개를 지우면 모델 B 풀만 남는다는 FE의 전제가 거짓이다.
결정: setEpochSchedule을 배포 순서에 배선하고, 그 불일치를 지원할 상태가 아니라 결함으로 다룬다. DB에는 일정 조건이 있는데 체인은 fundingAnchor == 0으로 읽히는 풀은 배포 결함이다. 배포 경로가 설정 호출 뒤에 앵커를 단언해야 하고, 어드민 풀 화면은 강제되지 않는 일정을 렌더하는 대신 앵커되지 않은 회차 풀을 드러내야 한다. 제약 둘이고 둘 다 봐주지 않는다.
- 순서가 무게를 진다.
setEpochSchedule은recallLeadDays + requestWindowDays < epochDurationDays를 검증하므로setEpochDurationDays뒤에 붙어야 하고 아니면InvalidSchedule로 revert한다 - 생성 전용이다. LP가 존재하면
ConfigImmutableAfterDeposit으로 revert한다. 앵커 없이 첫 입금을 받은 풀은 절대 앵커될 수 없다. 레거시 풀 7개를 레거시로 남게 하는 것과 같은 영구성이다. 그러니 이것은 FE만이 아니라 풀 생성을 막는다
결정 — 펀딩일 출처는 컬럼 둘이고 FE 배지는 그것에 막혀 있다. v3-105는 확정 / 예정 배지가 데이터베이스를 읽는다고 결정했고 어디인지는 말하지 않았다. pools.next_funding_date_confirmed_at과 next_funding_date_set_by이고, 온체인 setEpochFundingDate tx가 확정될 때만 쓰이며 정산이 주기를 전진시킬 때 비워진다. 다음 주기의 날짜는 유도된 상태로 시작하므로 출처가 주기마다 다시 무장하지 않으면 배지가 앞 주기에서 "확정"을 물려받는다. 배지는 표시하는 주기에 대해 둘 다 있을 때만 확정이고, 없으면 예정이다. v3-105가 명시한 그대로다. 두 컬럼 다 없으므로 이것은 previewEpochClaim과 나란히 하드 FE 블로커이지 있으면 좋은 것이 아니다.
결정 — setEpochSettleAfter도 같은 묶음에서 배선한다. 그것도 호출자가 없고, 그 말은 semi-auto 손잡이의 나머지 절반("발행자가 늦으니 정산을 옮긴다")이 제품에서 닿을 수 없다는 뜻이다. 오류는 그대로 매핑한다. 값이 펀딩일보다 이르면 InvalidSchedule(손잡이는 미루는 방향만 된다)이고, SettleAfterTooLate(cap)에서 cap = fundingDate + recallLeadDays다. 4xx에 그 상한을 노출할 것. 운영자가 그것을 알 다른 방법이 없다.
결정 — 인덱서에 일정 이벤트 셋 전부의 라이터가 필요하다. EpochScheduleSet(실제로 설치된 조건을 기록한다. 즉 위의 단언이다), EpochFundingDateSet(유일한 정확한 확정 신호이고 출처 컬럼을 쓴다), EpochSettleAfterSet(지연 배지)다. 셋 다 GovernanceLib에 선언돼 있어서 PlatformPool.abi.json에는 없으므로 각각 디코더에 ABI 조각을 더해야 한다. v3-105가 적었듯이 그것이 배포된 풀에 대해서도 컨트랙트 변경 없이 소급해 그것들을 되찾아 준다. v3-92에 따라 DB는 우리 의도가 아니라 체인을 미러링한다. 이 라이터들이 없으면 pools.next_funding_date는 체인이 공유하지 않을 수도 있는 의도다.
결정 — GET /pools/{id}가 일정을 반환해야 한다. pools.get.list.ts의 DETAIL_SELECT가 회차 일정 컬럼 여섯을 전부 빠뜨리는데 LIST_SELECT는 담고 있다. 그래서 투자자 풀 상세 페이지가 request_window_days / recall_lead_days / next_funding_date를 null로 읽고 날짜도 배지도 카운트다운도 역산 타임라인도 렌더할 수 없다. 한 줄 수정이지만, 날짜가 존재하는 것과 날짜를 보여 줄 수 있는 것의 차이라서 적어 둔다.
처음 기록 — C7: 청구는 만료되지 않는다. 2026-07-24에 결정됐고 문서 어디에도 적힌 적이 없다. 정산된 몫은 기약 없이 청구 가능하다. 기한도, sweep도, 남은 홀더에게 되돌아가는 것도 없다. 시간이 흘렀다는 이유로 투자자의 돈을 몰수하는 것은 RWA와 규제 맥락에서 지탱될 수 없고, 그 예약은 redemptionCommitted에 격리돼 있어서 미청구 지급이 다른 주기의 체결이나 수익에 쓰일 수 없기 때문이다. 받아들이는 비용은 redemptionCommitted가 스스로 0으로 돌아가지 않는다는 것이고, 그것이 청산 분자를 영구히 줄이고 hold-back 클램프를 그만큼 조인다. 나중에 누군가 만료를 뻔한 정리로 더하지 않도록 기록해 둔다. 그러려면 실효 처리와 무주물 재산 법무 항목이 먼저 답해져야 한다.
문서 넷이 여전히 대체된 엔진을 서술하고 있었고(2026-07-31 동기화 작업은 07-redemption / 23-money-path / 04-pool-models를 덮고 멈췄다) 여기서 전부 고쳤다.
| 문서 | 그때 | 지금 |
|---|---|---|
08a-contract-reference의 executeEpoch | "돈이 움직이지 않는다" · "구현 대기(CH)" · "회차별 항아리". 세 가지가 다 틀렸다 | 이월 우선 체결, redemptionCommitted 스칼라 차감, settlementAllowedAt 게이트. 그리고 함수 표가 아예 나열한 적 없던 회차 setter 셋(setEpochSettleAfter는 문서 어디에도 없었다) |
| 10-status-machines의 회차 흐름 | "⚠️ 엔진 재설계 결정됨, 구현 대기"와 회차별 항아리 | 반영됨. 아직 남은 것으로 v3-105의 컨트랙트 항목 둘과 이 항목의 배포 구멍을 이름 댄다 |
15-api-reference의 POST /pools/{id}/freeze | propose_extend / execute_extend / cancel_extend가 살아 있는 거버넌스 동작으로 나열됨 | 410 Gone을 반환한다. 본문의 에스컬레이션 경로 둘(pause, impairment)을 문서화했다 |
| 08-smart-contracts의 이벤트 | FreezeExtendProposed / FreezeExtended / FreezeExtendCancelled가 살아 있는 타임락 경로로 | 제거됨으로 표시하고 이유(7일 타임락 == 7일 동결 수명)와 남은 것(pendingFreezeExtend* 스토리지, unfreeze에서 지워짐)을 적었다 |
결과: BE 작업은 배포 배선(create-pool.ts와 CreatePoolOnChainParams), 주기별 setter 엔드포인트 둘, 인덱서 라이터 셋과 ABI 조각, 출처 컬럼 둘과 마이그레이션, DETAIL_SELECT 한 줄이다. 아무것도 구현되지 않았다. 컨트랙트: 이 항목으로 바뀌는 것 없음(v3-105의 ClaimBeforeCancel과 previewEpochClaim은 그대로다). FE: 회차 날짜와 배지 작업이 출처 컬럼과 DETAIL_SELECT 수정에 막혀 있고, 부분 체결 분해는 previewEpochClaim에 막힌 채로 남는다.
참고: v3-105를 완성한다. v3-100의 문서 절반을 정정한다. 일정 이벤트에 v3-92를 적용한다. 07-redemption → Funding date: confirmed vs derived · 08a-contract-reference · 11-db-schema · 17-changelog v3-107. 노션 "Epoch 재설계 — 기획 확정 & FE 작업" §5~6과 "Epoch Redemption — 코드 검수 정리". 출처: JY, 2026-07-31.
v3-106 — 트랜치 상각은 ADMIN의 행위다. IMPAIRED는 온체인에만 있고, 지워진 트랜치만 부실이다 ✅ Decided · 🛠 Implemented (BE)
v3-106 — 트랜치 상각 거버넌스와, 한 번도 수행되지 않던 IMPAIRED 에스컬레이션
Date: 2026-07-31
배경. v3-50이 손실 워터폴을 POST /tranche-writedown으로 냈다. 그룹 수준 손실 하나를 후순위에 따라 나누고 풀마다 새 NAV를 적용한다. NAV 쓰기 주변에서 무엇을 하는지는 명세된 적이 없고, 2026-07-31에 확인한 코드는 그것을 우리가 의도하지 않은 세 가지 방식으로 답하고 있다.
결정 1 — 엔드포인트가 ADMIN / SUPER_ADMIN으로 옮겨 간다. 지금은 OPERATOR를 받으므로(withRole('SUPER_ADMIN', 'ADMIN', 'OPERATOR')) 오퍼레이터 한 명이 호출 한 번으로 그룹의 모든 풀을 상각할 수 있다. 이것이 A4 승인 흐름의 구멍은 아니라는 점에 유의할 것. nav-changes.post.approve와 .override가 ADMIN 전용인 이유는 그것들이 스케줄러가 제안한 것을 해소하기 때문이고, propose와 tranche-writedown은 둘 다 두 번째 눈 없이 직접 쓴다. 그룹을 상각하는 것이 셋 중 가장 크므로 가장 높은 기준을 받는다. A4 승인을 여기로 확장하지는 의도적으로 않는다. 워터폴은 손실 하나를 NAV N개로 분해한 것이고, 각 갈래를 승인 큐로 보내면 그룹이 절반만 승인된 채로 정산될 수 있다.
결정 2 — 지워진 Junior는 DB 쓰기가 아니라 온체인 부실 경로로 에스컬레이션한다. 오늘 wipedOut 분기는 pools에 lifecycle_status = 'IMPAIRED'와 impairment_proposed_at = now()를 바로 쓰고 온체인에서는 아무것도 호출하지 않는다. 그것이 다시 v3-92 결함 부류이고, 여기서는 더 나쁘다. 제품으로 복구가 불가능하기 때문이다.
- 온체인
lifecycleStatus가ACTIVE로 남고hasImpairmentProposal이 false로 남으므로, IMPAIRED로 판단하는 모든 게이트가 계속 ACTIVE처럼 동작한다. 구체적으로:RedemptionLib의 락업과 패널티 면제가 적용되지 않으므로, 지워진 Junior의 투자자가 여전히 락업에 막히거나 조기 이탈 패널티를 물면서 UI는 부실 조건이 적용 중이라고 말한다. 그리고 온체인 입금 차단도 적용되지 않는다(BE는 입금을 막지만 직접 컨트랙트 호출은 막지 못한다) - 그다음
pools.post.impairment의 propose는 두 겹으로 409를 낸다(impairment_proposed_at이 설정돼 있고lifecycle_status가 더 이상 ACTIVE가 아니다). execute는 자기 DB 쪽 타임락 사전 검사를 통과한 뒤 온체인에서NoImpairmentProposal로 revert해 502가 되고,setLifecycleStatus는 설계상IMPAIRED를 거부한다(_isValidLifecycleTransition). 돌아갈 길이 없다
그래서 wipedOut은 proposeImpairmentOnChain을 호출하고 DB는 제안됨만 기록하며, lifecycle_status는 7일 타임락 뒤 executeImpairment에서 IMPAIRED로 뒤집힌다. 다른 모든 부실 선언이 타는 것과 같은 경로다. 타임락이 지나기 전에 입금을 멈춰야 한다면 lifecycle 쓰기가 아니라 is_paused를 쓸 것(온체인 pause()와 함께, v3-92에 따라). lifecycle 라벨에 7일 지연을 받아들이는 것이 핵심이다. IMPAIRED는 투자자에게 보이는 면제가 붙은 공개 지급 능력 신호이고, 타임락이 그것을 검토 가능하게 만든다.
결정 3 — 완전 소진만 IMPAIRED다. Junior가 손실 전체를 먼저 흡수하고, Junior가 지워지면 Junior 풀만 부실이 된다. 부분 손실을 흡수한 Senior나 Mezzanine은 NAV < 1.0인 ACTIVE로 남는다. 상각이지 lifecycle 변화가 아니다. 기존의 상각 대 lifecycle 구분(v3-12)을 트랜치에 적용한 것이고, 그래서 한 그룹이 IMPAIRED인 Junior와 ACTIVE인 Senior를 동시에 가질 수 있다.
정정 — "NAV 하한" 메커니즘은 없다. NAV > 0 동작은 가드 하나처럼 읽히는데 독립적인 셋이다. 컨트랙트가 newNav == 0을 거부하고(InvalidNav) $1.00 위를 자른다. DB에 CHECK (nav_per_token > 0)이 있다. 그리고 NAV_FLOOR = 0.000001은 tranche.post.writedown.ts에만 있는 로컬 상수다. nav-changes.post.propose에는 그런 상수가 없다. 회차 정산 자신의 settleNav == 0 → InvalidNav는 또 다른 네 번째다(오라클 손상 방지 가드이고 v3-100에 문서화돼 있다). 붙들 귀결: "NAV 0"은 어디에서도 표현할 수 없으므로 지워진 트랜치는 1e-6이고, "사실상 0"이라고 말하는 것은 숫자가 아니라 lifecycle 전이다.
⚠️ 잠재 충돌: 이탈 상한이 전손을 금지한다. navDeviationCapBps > 0이면 1.0 → 1e-6이 어떤 상한이든 넘으므로 _checkNavBound가 revert하고 핸들러의 사전 시뮬레이트가 그것을 깔끔한 4xx로 바꾼다. 올바른 동작이지만, 상한을 내리기 전까지는 Junior 전액 상각을 아예 적용할 수 없다는 뜻이다. 오늘은 살아 있지 않다. setNavDeviationCap을 호출하는 것이 없고 pools.nav_deviation_cap_bps 컬럼은 쓰이지 않아서 모든 풀이 상한을 끈 채로 돈다. 상한을 켜는 사람이 이것을 떠안는다. 운영 흐름에 상한 해제 → 상각 → 복원 단계를 명시하거나, 상한에 전손에 대한 문서화된 예외가 필요하다.
함께 기록 — 청산 분자 항목(D-1)은 열려 있는 게 아니라 이미 반영됐다. 노션 페이지에 동반 수정으로 옮겨졌는데, executeWindDown은 이미 reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted로 청산 가격을 매긴다(v3-100 정정 2, 머지된 코드에). ⚠️ R10이 일부 대체했다(0abe168, 2026-08-04 배포). − redemptionCommitted 항이 이중 계산으로 분자에서 제거됐고(세 버킷이 이미 그것을 차감한 값이다) 그 제외는 이제 분모에 totalSupply − settledUnclaimedLp로 산다. 거기서 아직 남은 것은 계산이 아니라 문구다. pools.post.wind-down의 알림 텍스트와 PlatformPool.sol:762의 독블록이 여전히 옛 리저브 전용 산식을 서술하고, 투자자용 표현 판단은 v3-95와 10-status-machines에 이미 기록된 열린 제품 항목이다.
구현됨(2026-08-03, tranche.post.writedown.ts). 결정 셋 다 머지된 코드에 있으므로 위의 "지금 / 오늘" 표현은 수정 전 핸들러를 서술하지 지금 도는 것을 서술하지 않는다. D1: 게이트가 withRole('SUPER_ADMIN', 'ADMIN')이고 OPERATOR 호출은 403이다. D2: wipedOut 분기가 proposeImpairmentOnChain을 호출하고 DB 쓰기는 impairment_proposed_at을 담으며(그리고 junior_depleted_at, 마이그레이션 0116. 운영자 배너가 제안이 나타난 이유를 말할 수 있게) lifecycle_status는 담지 않는다. 라벨은 7일 뒤 executeImpairment에서 뒤집히고, 응답이 impairment_proposed와 deposits_open_during_timelock을 반환해 클라이언트가 타임락이 열어 두는 수동 pause를 안내할 수 있다. D3에는 자기 분기가 필요 없다. wipedOut만 에스컬레이션하므로 부분만 맞은 Senior는 NAV 쓰기만 받고 ACTIVE로 남는다. 회귀 커버는 lib/shared/tranche/__tests__/에 있다. loss-waterfall.test.ts가 후순위와 wipedOut 경계를 못 박고, writedown-governance.test.ts는 핸들러를 텍스트로 읽어 role 목록과 lifecycle_status 쓰기의 부재가 회귀하지 않게 한다(둘 다 TypeScript가 담을 수 있는 값이 아니고 둘 다 한 번 회귀했다). 이 문서에서 아직 남은 것: 아래의 청산 문구 항목이고 계산이 아니라 텍스트다.
엔드포인트가 NAV를 정확히 어떻게 적용하나(2026-08-05에 여기 기록했다. 04-pool-models에만 적혀 있었는데 그건 개념 문서이고 이것의 집으로는 틀렸다). POST /tranche-writedown은 nav-changes.post.propose의 형제이지 그것의 호출자가 아니다. 영향받는 모든 풀에 대해 updateNAV를 사전 시뮬레이트한 뒤 각각을 updateNavOnChain으로 직접 쓰고 source = 'tranche_waterfall'인 nav_history 행을 기록한다. nav_proposals 행을 만들지 않고 A4 승인·덮어쓰기를 거치지 않는다. 그것들은 스케줄러가 제안한 값을 해소하는데 워터폴은 그런 값이 아니다(위 D1과 같은 논리다). 하락은 여전히 풀마다 표준 24시간 온체인 타임락을 건다. 풀 간 원자성은 없다. 순차 트랜잭션 N개이고, fan-out 중간에 실패하면 운영자가 맞춰 볼 수 있도록 풀별 상세와 함께 **207**을 반환한다.
참고: v3-50을 수정한다. 트랜치 경로에 v3-92를 적용한다. 04-pool-models → Loss Waterfall · 06-writedown-nav · 10-status-machines → Lifecycle transitions · 17-changelog v3-106. 노션 "NAV·트랜치 — 후속 결정 & 버그수정 (2026-07-31 코드검수)". 출처: JY, 2026-07-31.
v3-105 — 부분 체결된 취소는 먼저 청구해야 한다. 펀딩일 배지는 체인이 아니라 DB를 읽는다 ✅ Decided · shipped (deployed 2026-08-04)
v3-105 — 회차 취소 고아를 닫고, 펀딩일 확정은 오프체인 사실이다
📌 2026-08-05 상태 갱신 — ✅ 반영됨(2026-08-04 배포).
컨트랙트 항목 둘 다 살아 있다.
ClaimBeforeCancel(RedemptionLib.sol:626)과previewEpochClaim(PlatformPool.sol:317→RedemptionLib.sol:703)이고 2026-08-04 dev에 배포됐다. 새 팩토리0xE1E2E9743fd3d00F6f50eBFCb765A91411D6DA90→ 새 풀 구현체0x27D9948FD2A3aF7b035e99d23440A3A877aef968이고 커밋0abe168에서 빌드했다. 그 구현체에서 selector로 온체인 확인했다(previewEpochClaim=9ccd0389).⚠️ 새로 만들어지는 풀에만. 팩토리의
poolImplementation이immutable이므로 새 구현체는 새 팩토리를 뜻하고 기존 clone은 업그레이드되지 않는다. 배포 전에 만들어진 모든 풀은(가장 최근 2026-07-23) 여전히 옛 구현체로 돌고, 새 팩토리로 만들어진 풀은 아직 없다.아래의 모든 "대기 / 막힘 / 필요"는 과거형으로 읽을 것. 전부 만들어졌고 배포됐다. 이 카드가 열어 둔 펀딩일 출처 컬럼도 ✅ dev에 적용됐다(
0111). 카드 본문은 쓰인 그대로 보존한다. Date: 2026-07-31
v3-100의 🔴 열린 항목을 닫고 "예정 / 확정" 배지가 필요로 하는 펀딩일 질문에 답한다. 결정 전에 둘 다 머지된 코드와 다시 대조했다.
고아가 왜 생기고 왜 그것이 평범한 경로인가. executeEpoch는 체결된 gross를 유동성 버킷에서 redemptionCommitted로 옮기지만 LP를 태우지 않는다. 소각과 지급 둘 다 청구 시점에 일어난다(_settleEpochClaim). cancelRedemption은 ep.lpRemaining을 돌려주는데 그건 여전히 포지션 전체이고, claimRedemption이 거부하는 상태로 만든다. redemptionCommitted를 줄이는 유일한 것이 _payCommitted이므로 그 예약은 청구자 없이 영구히 남는다. 그리고 이건 예외적 경우가 아니다. 취소는 창으로 게이팅되고(C10) 정산은 마감 뒤인 펀딩일에 일어나므로, 부분 체결된 투자자가 할 수 있는 유일한 취소는 자기가 청구하지 않은 정산 뒤의 취소다.
실제로 누가 손해 보나. 투자자가 아니다. 그들은 자기 포지션 전체만큼의 LP를 들고 나간다. 손해는 풀이 두 겹으로 본다. 현금은 정산 때 이미 reserveBalance / heldFundReleases / 톱업에서 나갔고, 부풀려진 redemptionCommitted가 청산 분자에서 영구히 차감되며(그래서 남은 홀더가 정확히 그만큼 과소 지급된다) hold-back 해제 클램프를 영구히 조인다(그래서 그만큼이 파트너에게도 절대 풀릴 수 없다).
결정 — 청구 가능한 것이 남아 있는 동안 취소를 거부한다. cancelRedemption의 회차 분기가 _epochFillMath를 계산하고 filledLp > 0이면 ClaimBeforeCancel(requestId)로 revert한다. 새 상태도, 새 요청별 회계도 없다. UX는 트랜잭션 둘이다. 청구 후 취소이고 그것은 언제나 가능하다. claimRedemption에는 창 게이트가 없어서 요청 창 밖에서도 첫 단계가 된다. 이미 청구한 요청은 다음 정산까지 filledLp == 0으로 읽히므로(이월 구간이 시작하는 자리에서 끝난다) 흔한 경우는 막힘 없는 단일 취소다.
기각 — 미체결 잔량만 돌려주고 체결분은 청구 가능하게 남기기. 이 모양에서는 구현할 수 없다. _epochFillMath는 체결분을 principalLp × epochNewFillRatio[vintage]에 이월 구간을 더해 유도하므로, principalLp를 체결분으로 줄이면 이미 그 비율이 반영된 수치에 vintage 비율을 다시 적용하게 된다. 그것을 바로잡으려면 요청을 최근 정산 경계에 대해 순수 이월 형태로 다시 앵커해야 하는데, 그것이 정확히 _settleEpochClaim이 이미 하는 일이다. 체결분을 먼저 정산하지 않는 판본은 없으므로 이 안은 상태만 더한 채 위의 결정으로 붕괴한다.
기각 — 취소 안에서 자동 청구. 트랜잭션이 하나이고 바로 그 점 때문에 솔깃하지만, 청구는 canRedeem과 requestHeld로 게이팅된다. 그것을 접어 넣으면 KYC가 폐기됐거나 보류된 투자자가 아예 취소할 수 없게 된다. 그리고 취소는 아무도 풀 수 없는 LP를 든 채 갇히지 않도록 의도적으로 게이팅하지 않는다. 명시적인 ClaimBeforeCancel revert는 투자자에게 무엇을 해야 하는지도 알려 준다. 취소 안에서 삼켜진 KYC 실패는 그러지 못한다.
결정 — previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD)는 필수 뷰이고 FE를 막는다. 프런트엔드는 공개 상태에서 그 분해를 계산할 수 없다. epochRequests는 (epochId, principalLp, lpRemaining, yieldAccSnapshot)만 반환하고 gBase / hBase / generation이 없으며, epochH와 epochCarryGen, generationCloseH에는 getter가 없다(epochG에만 있다). 이 뷰 없이는 "60 체결 / 40 이월" 표시도, 정직한 취소 버튼 문구도, 청구 우선 게이트가 왜 발동했는지 투자자에게 말할 방법도 없다. _epochFillMath가 이미 view이므로 이것은 래퍼다.
결정 — "확정 / 예정" 배지는 체인이 아니라 DB를 읽는다. 온체인 확정 플래그를 더하지 않는다. 이유 둘이고 첫 번째가 본질이다. 저장된 온체인 날짜가 확정된 날짜를 뜻하지 않는다. 정산이 열린 실패 날짜를 바로 그 슬롯(epochFundingDate[id+1], 이벤트 없음)에 실체화하고 회차 1은 fundingAnchor에서 백필되므로, 날 스토리지 getter는 아무도 확정하지 않은 날짜를 "확정"으로 보고한다. 진짜 플래그는 새 스토리지와 새 뷰를 뜻하고, 풀은 immutable 구현체를 가진 EIP-1167 clone이므로 새 풀만 덮게 된다. 프런트엔드는 이미 pools.next_funding_date를 읽는다. 출처는 그 옆에 속한다.
선결 조건 — 어드민 확정 동작을 setEpochFundingDate에 배선할 것. apps/infra의 무엇도 그것을 호출하지 않는다(lib/shared/contract/ 아래 참조 0건). 어드민 수정 경로는 pools.next_funding_date를 쓰고 거기서 멈춘다. 그래서 지금 모든 온체인 주기 날짜가 열린 실패 유도값이고, 정직한 배지는 영원히 "예정"을 읽을 것이다. 확정 동작이 체인에 닿아야 배지가 무슨 뜻이든 갖게 되고, 그것이 SEMI_AUTO 리마인더(pools.scheduler.epoch-funding-date)에게 실제로 무엇을 상기시킬지를 준다. 감사와 정합에서 EpochFundingDateSet은 온체인의 유일한 정확한 확정 신호다(정산의 실체화는 조용하다). 그것은 GovernanceLib에 선언돼 있어 PlatformPool.abi.json에 없지만 로그는 풀 주소 아래 발생하므로, ABI 조각을 더하면 이미 배포된 풀에 대해서도 소급해 컨트랙트 변경 없이 되찾을 수 있다.
FE 계획에서 이어받은 전제: 앵커되지 않은 레거시 회차 풀 7개는 출시 전에 삭제되므로 프런트엔드는 모델 B만 다루고 새 뷰가 새 풀 전용인 것도 받아들일 만하다. 그 전제가 바뀌면 v3-100의 fundingAnchor == 0 예외가 다시 범위에 들어온다.
🔴 열린 항목 — 회차 요청에 대한 어드민 되돌리기가 없다. rejectRedemption은 REQUESTED와 PENDING_RESERVE만 받으므로 QUEUED와 PARTIALLY_FILLED는 아예 거부할 수 없다(v3-100 2번). 청구 우선 게이트가 붙으면서 이것이 더 날카로워진다. KYC가 폐기된 회차 투자자는 청구도 못 하고(canRedeem이 막는다) 취소도 못 하며(새 게이트가 막는다) 어떤 어드민 동작도 그 요청을 닫을 수 없다. 예약을 푸는 어드민 쪽 되돌리기나, 게이트의 명시적 예외가 필요하다. P1.
참고: v3-100의 열린 항목을 닫는다. v3-93 C10과 v3-91을 수정한다. 07-redemption → Cancellation · 08a-contract-reference · 17-changelog v3-105. 노션 "Epoch 재설계 — 기획 확정 & FE 작업" §5. 출처: JY, 2026-07-31.
v3-104 — 도래했으나 기록되지 않은 수익 기간은 프런트엔드 조인이 아니라 읽기 모델 행이다: 어드민 Yield 목록에 include_due가 붙는다 ✅ Implemented (BE + FE, 2026-08-03) · migration 0113
v3-104 — Yield의 Pending 큐가 읽기 모델로 들어간다
Date: 2026-07-31
Pending 탭이 실제로 보여 주는 것. yield_distributions 행은 오퍼레이터나 FM이 gross를 기록할 때만 생긴다(POST /yield-distributions). 그전까지 그 의무는 pools의 일정으로만 존재한다. 매일 도는 pools.scheduler.yield-due sweep이 관리하는 next_yield_due / yield_overdue / yield_frequency다. 예정된 기간 테이블은 없다(수익 테이블 넷은 yield_distributions, yield_distribution_investors, yield_claims, yield_funding_events이고 전부 이미 일어난 일의 기록이다). 그래서 어드민 Yield의 "Pending" 탭은 행이 없는 풀을 나열하고, 그것이 나머지 네 탭과 같은 출처를 읽을 수 없던 이유다.
오늘 하는 일(문제). apps/admin-web/app/routes/yield.tsx가 GET /pools(yield_overdue)와 GET /yield-distributions?status=PENDING을 브라우저에서 조인하고, 결과를 "기록 필요"와 "기록됐으나 정체됨"으로 나누며, 돈을 직접 계산한다.
estimateExpectedGross—tvl × apy/100 × periodDays/365. 백엔드가 명세한 적 없는 산식이다. v3-20 수익 도래 명세는 추정 금액을 명시적으로 범위 밖에 뒀고, 프런트엔드가 하나를 지어냈다computePoolNet/YieldCalcBreakdown—computeYieldFees를 흉내 낸 수수료 분해의 두 번째 사본이다(플랫폼 몫, AUM × 요율 × 기간에 대한 SPC·풀 관리, 허들 초과 성과). 백엔드에서 수수료 정책이 바뀌면 이 숫자들이 조용히 낡는다- 한 화면에 "pending" 집계가 셋이다. 탭 배지는 기록이 필요한 풀을 세고, 표는 그것들에 정체된 것을 더해 보여 주며, Overview 패널의
Pending은 PENDING 기록을 센다 - 정렬과 검색, 페이징이 클라이언트 쪽이고 pending 조회의
limit: 200에 갇혀 있다. 그 탭의 CSV Export는 화면의 큐가 아니라 기록을 내보낸다
결정 — 읽기 모델을 확장한다(옵션 B). GET /yield-distributions가 include_due=true를 받아 실제 기록과 합성된 due 행을 payload 하나로 반환하고, **source: 'RECORD' | 'DUE'**로 구분한다. DUE 행은 풀, 그것이 들어갈 기간 라벨, next_yield_due, 추정 gross와 추정 net(서버가 실제 분배와 같은 수수료 코드로 계산한다)을 담고 id는 없다. 서버 쪽 정렬·검색·페이징이고 role 범위는 다른 모든 탭과 같다. 그러면 어드민 프런트엔드가 다섯 탭 전부에 대해 출처 하나를 읽고 표시만 담당한다.
기각 — 옵션 A: 기간이 도래하면 DUE 행을 실체화한다. 그것이 문자 그대로 "테이블 하나"이고 그게 문제다. 돈을 담지 않은 행을 원장에 넣는다. yield_distributions에 대한 모든 집계(platform_stats의 총 수익), 투자자용 목록, 인덱서의 tx_hash 정합, /{id}/distribute 가드가 각각 새 상태를 제외해야 하고, 한 군데만 놓쳐도 홀더에게 지급된 액수가 부풀려진다. 원장은 사실을 기록한다. 사실과 의무를 섞는 목록은 읽기 모델이다.
함께 결정: 이것이 나가면 추정 산식과 수수료 분해는 백엔드 소유다(구현 하나를 추정과 실제 정산이 함께 쓴다). 그리고 기간 라벨의 권한도 백엔드다. 프런트엔드의 defaultPeriodLabel 미리 채움은 미리 채움으로 남는다.
추정 기준은 TVL에 대한 APY이고 행에 estimate_basis: 'APY_ON_TVL'이 붙어 나중 변경이 보이게 한다. per_period = tvl × apy_rate/100 × periodDays/365다. 직전 기간의 실제 gross_amount에 앵커하는 것도 검토했다가 기각했다. 풀의 첫 분배에서는 비어 있고 TVL이 움직이면 낡는다.
풀마다 행 하나이고 그 위에 놓친 횟수가 붙는다. 풀에는 열린 기간이 최대 하나이므로(매일 도는 sweep이 놓친 next_yield_due를 앞으로 굴리지 않는다) 4개월 늦은 풀은 **missed_periods: 4**를 담은 행 하나다. 그리고 그 추정은 놓친 모든 기간을 덮는다(per_period × missed_periods). 4개월 늦은 풀에 한 기간치 수치는 빚진 것을 4배 과소 표현하기 때문이다. 중복 제거 키는 pool_id이고, 이미 PENDING이나 PROCESSING 기록이 있는 풀은 due 행을 내지 않는다.
이것을 명세하다 발견한 PENDING 고아 — sweeper가 아니라 생산자를 지워서 해결했다(2026-08-03 개정). yield_distributions에 넣는 라이터는 둘뿐이다. 생성 엔드포인트와 인덱서다. FM 경로는 PROCESSING을 넣고 /{id}/distribute를 기다린다(그래서 진짜 "기록됐으나 아직 분배 안 됨" 상태는 PENDING이 아니라 PROCESSING이다). 레거시 서버 경로가 PENDING을 넣고 같은 호출 안에서 정산했으므로 PENDING이 1초도 안 되는 insert 상태였다. 인덱서는 DISTRIBUTED만 쓴다. 그래서 PENDING에 머물러 있는 행은 insert와 settleYield 사이의 크래시 고아였고 아무것도 그것을 고치지 않았다. 인덱서는 tx_hash로 정합하는데 그 행에는 아직 그것이 없고, yield.scheduler.reconcile은 claimable_yield만 미러링한다.
원래 계획은 sweeper였다(오래된 PENDING → 온체인을 읽고 → DISTRIBUTED로 고치거나 FAILED로 표시. 절대 cron에서 정산하지 않고 모든 전이에서 알린다). 핸드오프 티켓 2였다. 그것은 대체됐다. 빈도 논거가 답의 전부로 드러났다. PENDING을 넣은 적 있는 유일한 라이터가 레거시 서버키 경로인데 그것은 제품에서 닿을 수 없다. 어드민 UI가 fund_wallet이 있는 풀에 대해 거부하고(fmSignBlocked), display-only가 아닌 것에는 생성 시 fund_wallet이 필수이며(v3-26), display-only 풀에는 분배할 pool_address가 없어서 핸들러가 먼저 400을 낸다. sweeper였다면 죽은 코드 경로만 만들어 낼 수 있는 상태를 유지하는 영구 기계가 됐을 것이다.
그래서 대신 그 경로를 지웠다. POST /yield-distributions가 이제 deposit_tx_hash 없이 400을 낸다. FM이 서명한 depositYield가 분배를 기록하는 유일한 방법이다. 그리고 마이그레이션 0113이 그 상태를 표현 불가능하게 만든다. yield_distributions.status가 PROCESSING을 기본값으로 하고(전에는 PENDING이 기본값이라 컬럼을 그냥 빠뜨린 INSERT가 고아를 조용히 되살렸을 것이다) CHECK (status <> 'PENDING')이 붙는다. 같은 마이그레이션의 일회성 백필: tx가 아예 없는 PENDING 행은 FAILED / ORPHANED_LEGACY_PATH로, tx_hash나 deposit_tx_hash가 있는 PENDING 행은 마이그레이션을 중단시킨다. 온체인에서 정산됐을 수 있고 그것을 FAILED로 표시하면 지급된 기간을 미지급으로 기록하게 되는데, 그건 SQL이 내릴 판단이 아니다.
⚠️ yield_status enum 값은 삭제하지 않는다. 그 타입은 yield_distribution_investors.status와 공유하고 거기서는 PENDING이 정당하다(아직 청구되지 않은 배정). 금지는 테이블 수준 CHECK이다.
이 선택의 비용: 서버키 수익 분배를 영구히 포기한다. 백엔드 키가 온체인 depositYield 권한을 쥐는 풀 운영 모델은 이것을 뒤집지 않고는 다시 쓸 수 없다. v3-26이 이미 비수탁 fund_wallet 풀을 요구하므로 받아들일 만하다. 뒤집는 것은 코드 변경이 아니라 컨트랙트 권한 결정이 된다.
여전히 사람이 필요한 것과 그것이 어디에 드러나나. 레거시 경로를 지우면 진짜 실패 양상 하나가 남는다. 입금은 도착했는데 /distribute가 돌지 않은 PROCESSING 행이다. 수익은 풀에 있고 홀더는 지급받지 못했다. 복구 경로는 이미 있다(POST /{id}/distribute와 어드민 표의 행 동작). 빠져 있던 것은 아무도 알아채지 않는다는 점이었다. 그래서 dashboard_alert_counts.pending_yield를 stalled_yield로 이름을 바꾸고 24시간을 넘긴 PROCESSING을 가리키게 했으며(영원히 조용한 알림은 작동하는 안전망처럼 읽히고 그건 틀린 것보다 나쁘다) 어드민 대시보드 알림이 **"Stalled Distributions"**가 되고 Yield 화면의 Failed 탭이 그것들을 Stalled 아래 나열한다. 같은 전이에 대한 ADMIN 대상 알림은 만들지 않았고 문구에 막혀 있다. notifications/catalog/는 제품 시트의 미러이므로("여기서 문구를 지어내지 말 것", v3-103) 생산자가 존재하려면 시트 항목이 먼저 필요하다(제안 이름 yield_distribution_stalled).
막힌 행이 어디 사나(FE). Pending 탭 밖이다. 그 탭은 기록할 기간의 큐이고 이제 배지가 그 행 수와 같다. 그리고 주의 탭인 Failed 안으로 간다. FAILED 기록에 Stalled 표가 붙는다. 원래 그 표는 PENDING 고아를 나열했고 sweeper가 나오면 사라질 예정이었는데, PENDING이 이제 불가능하므로 정체된 PROCESSING을 나열하고 표 자신의 행 동작을 유지한다(검증할 deposit_tx_hash가 있으면 Distribute, 없으면 사유와 함께 비활성). Overview 분해의 Pending 행도 마찬가지로 **Stalled**다.
✅ 2026-08-03 구현(BE + FE). BE: GET /yield-distributions에 include_due=true와 source/due_at/overdue_days/missed_periods/estimated_gross/estimated_net/estimate_basis, due 행에 적용되는 FM 펀드 범위, due_at 정렬 열, 두 행 종류에 걸치는 X-Total-Count다. 새 lib/shared/yield/due-row-math.ts(순수 함수. 추정과 행 모양, 병합 정렬. 단위 테스트 21개)와 due-rows.ts(쿼리)를 만들었고, 읽기 엔드포인트가 Supabase나 viem, notify를 끌어오지 않고 하나의 수수료 구현을 재사용할 수 있도록 computeYieldFees를 lib/shared/yield/fees.ts로 뽑았다. FE: estimateExpectedGross와 computePoolNet, YieldCalcBreakdown 안에서 계산되던 모든 금액을 지웠고, 풀과 기록의 조인을 useYieldDuePage로 대체했으며, Pending 탭을 manualPagination으로 옮기고 그 CSV 내보내기가 이제 화면의 큐를 내보낸다. 알려진 축소: 운영자가 입력하는 gross에 대한 실시간 net 미리보기가 없다. 서버는 자기가 도래했다고 아는 기간만 가격 매길 수 있고, 임의의 gross를 가격 매기려면 두 번째 구현(이 항목이 없애는 결함)이나 preview 엔드포인트(만들지 않았다)가 필요하다. FM이 gross로 depositYield에 서명하므로 없는 net이 결정을 막지는 않는다. 최종 net은 영수증에 있다.
핸드오프 명세에서 의도적으로 벗어난 것 둘이고 둘 다 호출 지점에 적어 뒀다. (1) 새 응답 필드는 include_due=true일 때만 붙는다. 명세의 §3은 "모든 행"이라고 했지만 §5는 플래그 없이 바이트 단위로 동일한 응답을 요구했고, 회귀 가드 쪽이 더 안전한 해석이다. (2) due 행은 PROCESSING만이 아니라 열린 PENDING 또는 PROCESSING 기록으로 억제된다. 그래서 Lambda가 마이그레이션 0113보다 먼저 배포되는 구간에서도 이중 계산이 없다.
알려진 한계: 병합된 페이지(status 없이 include_due)는 1000행으로 제한된 기록 prefix에 대해 메모리에서 정렬되므로, 병합 목록의 아주 깊은 페이지는 그 안에 정렬돼야 할 기록을 빠뜨릴 수 있다. due 행을 요청하는 유일한 소비자가 Pending 큐인데 그쪽은 구조상 기록 쪽이 비어 있다. 정확한 페이징에는 SQL UNION 뷰가 필요하다.
참고: 13-operations → Yield Overdue Tracking · 15-api-reference · 11-db-schema → next_yield_due · 05-investment-lifecycle · v3-20의 날짜 전용 범위를 확장한다 · 17-changelog v3-104. 출처: JY, 2026-07-31.
v3-103 — 알림 문구의 기준은 시트가 아니라 코드다. 레지스트리를 56개 키로 맞췄고 전달은 여전히 발송 워커에서 멈춰 있다 ✅ Decided · 🔴 F1 delivery blocked (BE)
v3-103 — 문구의 기준과 레지스트리 정합
Date: 2026-07-31
배경. 알림 레지스트리(copy.ts)와 Notification Copy 시트를 손으로 함께 편집해 왔고 양방향으로 흘러갔다. 시트에는 어떤 생산자도 책임지지 않는 행이 있었고, 코드에는 시트가 뺀 이벤트가 있었으며, 이벤트 12개는 텍스트가 다른데 어느 쪽이 이기는지 규칙이 없었다. 그동안 문서는 여전히 2026-07-28 감사를 서술하고 있었다. 레지스트리를 우회하는 생산자 여덟, 미구현 동결 이정표, 강제되지 않는 투자자 환경설정이다. 그 셋 다 ch/product 작업에서 고쳐졌고 어느 것도 문서에 닿지 않았다.
결정 1 — 알림 문구의 기준은 코드다. copy.ts와 시트가 다르면 코드를 채택한다. 감사에서 나온 실제 차이는 전부 코드가 옳았고(각각 뒤에 문서화된 이유나 버그 수정이 있었다) 실제로 렌더하는 것이 코드다. 시트는 제품용 뷰로 남고, 지속되는 해법은 둘을 손으로 유지하는 대신 레지스트리에서 시트를 생성하는 것이다. 손으로 맞춘 미러는 다시 흘러갈 것이고 그것이 이 항목이 존재하는 이유 전부다.
결정 2 — 미결이던 문구 판단 셋.
holdback_release_deferred는critical이다. optional이 아니다(심각도info, 분류REDEMPTION). FM이 그러지 않으면 오류로만 읽을 수 있는 금액 불일치를 설명하는 유일한 메시지이므로 끌 수 있어서는 안 된다fund_member_changed는 관점 둘을 유지한다. 이메일은 2인칭으로 접근 권한이 바뀐 그 멤버에게 가고, 인앱 요약은 3인칭이다({memberName} is now {role} on {fundName}). 모델 A에서ADMIN행은 개인 수신함이 아니라 공유 운영 피드다. 불일치가 아니라 옳은 것이다redemption_rejected는{reason}을 렌더해서는 안 된다. 운영자의 필수 메모는 나중 검토자를 위해 쓰이고 AML이나 검증 상세를 담을 수 있다. 그것을 투자자 공지에 넣는 것은 PII 유출이자 tipping-off 위험이다. 시트 행에는 아직 그것이 있고 고쳐야 한다
결정 3 — 오퍼레이터 범위 분리는 명시적으로 미룬다. recipient_type에 OPERATOR가 없고 오퍼레이터는 ADMIN 피드를 읽는다. 나누려면 새 enum 값과 어드민 앱 키 32개 전부에 대한 이벤트별 대상 결정, 그리고 두 번째 피드 범위가 필요한데 그 값을 치를 오퍼레이터 전용 이벤트가 아직 없다. 공유 피드가 실수가 아니라 결정으로 읽히도록 기록한다.
결정 4 — em dash 금지 규칙이 알림 문구에도 적용된다. 프런트엔드의 AI 티 방지 지침이 사용자 대상 문자열에서 —를 금지하는데, 최근 레지스트리 항목들이 그것을 쓰고 있었다. 규칙이 이제 copy.ts도 덮는다. 알림 문구는 플랫폼을 떠나는 사용자 대상 텍스트이므로, 그것을 면제하면 제품에서 가장 많이 읽히는 문자열만 기계가 쓴 것처럼 들려도 되는 셈이다. 대체는 공백을 둔 하이픈이다. 시트는 이미 변환됐고 레지스트리 항목 전체에 대한 코드 정리는 BE 작업이지(dev 핸드오프 §B3) 문구 결정이 아니다. 여기서의 결정은 그것이 적용된다는 것뿐이다.
레지스트리를 56개 키로 맞췄다(2026-07-31 생산자와 대조 확인, 빌드 가드 깨끗함). 문서가 서술하던 07-28 스냅샷 이후 바뀐 것:
- 레지스트리를 우회하던 생산자 여덟이 전부
queueNotification을 거친다. 그것들은notification_logs에 직접 썼으므로payload가 null이었고 인앱 카드가 긴 문장 하나를 제목으로 렌더하며 본문도 CTA도 없이SYSTEM으로 잘못 분류됐다. 회차 실행 어드민 알림은 하루 단위로(pool, event_type)마다 중복을 제거한다. 끈적한 상태에 대해 매시간 도는 cron이 모든 어드민에게 하루 24번 메일을 보내고 있었다 - 동결 자동 만료 sweep이 존재하므로
freeze_exit_window_open과freeze_expired가 만들어졌다. 만료된 동결이 입금을 계속 잠가 두던 살아 있는 결함을 닫는다(v3-97 반영) freeze_extended가 사라졌고 그것이 보고하던 온체인 연장도 함께 사라졌다. 연장의 7일 타임락이 7일 동결 수명과 같아서, 동결이 끝난 뒤에만 발동하거나 72시간 이탈 차단을 소급해 다시 시작시킬 수 있었다impairment_executed와winddown_executed에 진짜 생산자가 생겼고 엔드포인트에 하드코딩된 문장 대신 작성된 문구를 보낸다(그 문구만이 락업 해제와 패널티 면제를 말한다)._ops변형이 FM과 어드민에 닿는데 전에는 그들에게 아무것도 가지 않았다.createSystemPoolUpdate는 더 이상priority: 'optional'을 하드코딩하지 않고MATERIAL_EVENT는critical로 강제된다- 새로 생긴 것:
redemption_exit_gate_blocked(v3-99),epoch_funding_date_due,holdback_release_deferred(v3-100),pool_lifecycle_active - 환경설정 네임스페이스를 두 대상 모두
event_key로 통일했고(0101 어드민 / 0108 투자자) 투자자 강제가 살아 있다(2026-07-30).pool_lifecycle_active가 첫 optional 투자자 이벤트다. 그전에는 토글 가능한 집합이 비어 있어서 강제할 것이 없었다. 그 수신자 목록은 명시적 옵트인이다(pool_follows, 0109). 문구가 "알려 달라고 요청하셨습니다"라고 말하는데 그러지 않은 사람에게는 거짓이기 때문이다 INVALID_RECIPIENT를 실패 알림 KPI에서 제외했고(0107) 대상 전원이 수신 거부한 경우를FAILED가 아니라SUPPRESSED로 표시한다(0100). 둘 다 운영 큐가 0에 도달하고 읽을 만하게 유지되도록 하려는 것이다- 가드 둘이 이제 이것을 붙들어 둔다.
check-notification-producers.mjs(문구와 생산자 짝짓기, 인용된 리터럴 정확 매칭)와 infra까지 확장된check-copy.mjs다
🔴 열린 항목 — F1: 실제로 아무것도 전달되지 않는다. 발송 워커가 SENDING 행을 소비하지 않고 있고 마지막 DELIVERED 행이 2026-07-03이다. EventBridge 규칙은 api-stack.ts에 있으므로 첫 질문은 어느 커밋이 배포됐는가다. 런타임이 아직 옛 V1이면 이건 망가진 워커가 아니라 낡은 배포다. 밀린 것의 위험은 해소됐으므로(1139 → 8행. 중복된 epoch_execute_failed 1005건을 손으로 SUPPRESSED로 옮겼다) 폭주 없이 배출을 켤 수 있다. BE·운영.
스키마 문서도 함께 다시 맞췄다(CLAUDE.md 규칙 6). 11-db-schema는 0107까지 확인돼 있었고 그래서 알림 마이그레이션 둘이 빠져 있었다. 0108(투자자 환경설정을 event_key로 다시 키잉. 결정에 인접한 강제를 가능하게 만든 변경)과 0109(pool_follows, +1 → 테이블 47개 · enum 26개)다. 문서 자신의 개수 줄 둘도 서로 어긋나 있었다. 푸터는 여전히 테이블 40개 / 마이그레이션 0050이라고 주장하고 있었다. 이제 둘 다 0109까지 47/26으로 읽히고, 0100~0107의 _(dev 적용 대기)_ 표시를 지웠다. 그 배치는 2026-07-30에 dev에 갔다.
함께 열린 것: 레지스트리에서 redemption_funded와 lp_mint_failed 삭제(시트는 이미 둘 다 뺐다) · copy.ts에 대한 em dash 정리(dev 핸드오프 §B3) · epoch-execute가 여전히 MATURED 풀을 후보 집합에 넣고(lifecycle_status 필터가 없다) 무해한 상태를 실패로 알리는데 건너뛸지 알릴지 미정 · epoch_funding_date_due의 LEAD_DAYS 3 → 7(v3-100) · redemption_exit_gate_blocked와 epoch_funding_date_due의 시트 행 · epoch_demand_finalized와 over_funding_detected는 회차 머지로 풀렸는데 레지스트리 항목이 아예 없어서 생산자 가드가 덮지 못한다. 레지스트리 항목이 먼저다 · redemption_rejected / redemption_returned_unfunded 문자열은 법무 승인 대기다(노션 "Legal Review Required" #25. 분기와 변수, failure_type 라우팅은 확정이고 문자열만 바뀔 수 있다).
참고: 22-notifications(레지스트리에 맞춰 다시 씀) · 13-operations → Notification rules(이벤트별 표 제거) · 11-db-schema(0108/0109, 개수 재동기화) · v3-97의 문서 항목을 닫는다. v3-99와 v3-100의 알림 작업을 낸다. CLAUDE.md 규칙 2-b와 6. 17-changelog v3-103. 노션 "Notification System" 핸드오프 §6. 출처: JY, 2026-07-31.
v3-102 — distributeYield와 withdrawFees가 settleYield 하나가 된다: 반쯤 정산된 기간은 이제 표현 불가능하다 ✅ Decided · shipped
v3-102 — settleYield: 수익 기간마다 tx 하나
Date: 2026-07-31
이것이 없애는 실패. runYieldDistribution이 distributeYield(net)를 호출한 뒤 withdrawFees(treasury, poolMgmt)를 호출하고 두 번째를 .catch()로 콘솔 한 줄로 만들었다. 그래서 수수료 갈래가 실패하면 홀더는 적립되고 수수료는 걷히지 않으며 행은 fee_amount와 pool_mgmt_fee_amount가 걷힌 것처럼 기록된 채 DISTRIBUTED로 마무리되고 복구가 없다. POST /yield-distributions/{id}/distribute는 DISTRIBUTED 행에서 조기 반환하고 PROCESSING이 아닌 것에는 400을 내며, withdrawFees에는 호출자가 정확히 하나였다. 제품 밖에서 컨트랙트를 직접 호출해야만 걷을 수 있었다. 돈이 사라지는 것은 아니지만(unclaimedYield에 남는다) DB가 일어나지 않은 지급을 주장하고, 그 잔여가 unclaimedYield를 영구히 부풀려 _releaseHeldFunds가 파트너에게 풀 금액을 줄인다. 현실적인 트리거는 pool_mgmt_bps는 있고 fund_fee_wallet이 없는 풀(FundFeeWalletNotSet. 즉시 실행되고 타임락 없는 setter라 설정 누락이 쉽다)과 평범한 RPC나 tx 실패였다.
결정: 둘을 합친다. settleYield(address stablecoin, uint256 netAmount, uint256 treasuryAmount, uint256 poolMgmtAmount)가 둘을 대체하고 distributeYield와 withdrawFees는 제거된다. 그래서 기간을 정산하는 방법이 하나이고 잘못 배선할 두 번째 진입점이 없다. 결과 셋이 따라온다.
- 반쯤 된 상태가 없다. 기간이 정산되거나 온체인에서 아무 일도 없거나다. 백엔드의 실패 분기가 하나로 붕괴한다
- 보존 검사가 하나다.
netAmount + normalize(treasury + poolMgmt) ≤ unclaimedYield를 어떤 상태도 움직이기 전에 평가한다. 전에는 갈래마다 따로 상한이 걸려서 net이 기간 전체를 소비하고 수수료 호출이 그다음 revert할 수 있었다. 이것은 09a-custody가 Lambda 호출 순서 관례로 기록해 둔 불변식(withdrawFees보다distributeYield가 먼저)을 온체인 경계로 승격시키기도 한다 - 더 작아진다. 풀 래퍼 둘이 하나가 됐다. PlatformPool이 21,064 → 20,929 B다(EIP-170 여유 3,512 → 3,647).
YieldDistributed와 FeesWithdrawn은 시그니처를 유지하므로 인덱서는 손대지 않는다. 자격 있는 LP가 없으면 net은 unclaimedYield에 남고(기존 동작) 수수료 갈래는 그대로 지급된다. 그것들은 누군가에게 적립될 수 있는지와 무관하다.
시그니처가 의도적으로 축을 섞고 있고 그게 예외가 아니라 규칙이다. netAmount는 원장만 움직이므로 NORMALIZED(18)이고, treasuryAmount와 poolMgmtAmount는 각각 safeTransfer를 감싸므로 RAW다. 그것이 정확히 v3-101의 규칙이고, 백엔드의 브랜드 타입이 둘을 컴파일 시점에 바꿔 쓸 수 없게 만든다. 여기서 그게 중요한 이유는 이 경로에서 늘 그렇듯 틀린 배율이 revert하지 않기 때문이다.
컨트랙트 변경이 애초에 받아들일 만했던 이유: 풀은 poolImplementation이 immutable인 팩토리 뒤의 불변 EIP-1167 clone이므로, 이것은 새 구현체와 새 팩토리, 그리고 모든 풀을 다시 만드는 것을 요구한다. 출시 전에만 싼 일이다. 분배가 한 번도 돈 적이 없다. 미루면 삼켜지는 실패 경로를 영구히 프로덕션까지 지고 가게 된다.
담보 불변식의 구멍도 함께 닫혔다. 그것이 분배됐지만 미청구인 수익을 빠뜨리고 있었다. 그래도 _releaseHeldFunds는 안전했다. 그 해제가 heldFundReleases로 잘리고 그것 자체가 불변식 안에 있기 때문이다. 그래서 이것은 검증 구멍이지 누수가 아니었고, 앞서 이것을 살아 있는 누수로 읽은 것은 틀렸다. 원인은 이름이었다. unclaimedYield()의 natspec이 "입금되고 분배됐지만 홀더가 아직 청구하지 않은"이라고 적었고 Foundry 불변식의 독블록도 그 속성이 "분배됐지만 미청구인 수익"을 보호하려고 존재한다고 적었는데, 그 필드는 분배에서 차감된다. 그래서 모두가 덮여 있다고 생각한 항이 애초에 그 합에 없었다. 분배된 수익은 사용자별 accruedYield 매핑에만 살고 집계가 없으므로 수정은 컨트랙트가 아니라 하네스에 있다. 이제 불변식이 고정된 actor 집합에 대해 Σ pendingYield(investor)를 더한다. 공허하지 않음을 확인했다(더한 항이 실행 중 약 49e18에 이르고, 그것이 0으로 유지된다고 임시로 단언해 실패하는 것을 보고 확인했다). 오해를 부르던 주석 둘도 정정했다. 새 스토리지는 없다. 온체인 distributedUnclaimedYield 카운터는 이것과 중복이고 의도적으로 추가하지 않았다.
via_ir에서만 실패하던 테스트를 확인하다 찾아 고쳤다. PlatformKYCSoulbound.test_RenewExtendsExpiryInPlace가 dev와 prod 프로파일에서 실패하고 기본 프로파일에서 통과했다. 원인: solc가 호출 안에서 block.timestamp를 상수로 다룰 수 있는데, 실제 트랜잭션에서는 참이고 시간을 워프하는 테스트에서는 아니다. 그래서 via_ir의 강한 CSE와 함께 두 번째 block.timestamp + ONE_YEAR가 첫 번째로 접혔고 테스트가 이미 만료된 날짜로 갱신했다. 이 작업 전부터 빨간불이었고 이 작업과 무관하지만, make test-prod가 한 번도 초록불이 아니었다는 뜻이다. 목표를 expiresAt에서 유도하면 고쳐진다. 스위트는 이제 두 프로파일에서 369/369다.
검증: forge test가 dev와 prod 프로파일 모두에서 369/369다. 원자성 회귀 셋을 새로 넣었고(수수료 갈래 실패 시 아무것도 적립되지 않음 · 합쳐진 인출에 상한이 한 번만 걸림 · 자격 있는 홀더가 없어도 수수료는 지급됨) 담보 불변식도 강화했다. ABI를 다시 생성했다(263 → 262 항목). infra tsc 깨끗함, 162/162, 가드 넷 다 깨끗함. 두 프런트엔드도 줄어든 ABI에 대해 타입체크를 통과한다(어느 쪽도 제거된 함수를 온체인에서 호출하지 않는다). ⚠️ 배포되지 않았다. 그리고 settleYield는 이미 배포된 clone에 존재하지 않으므로, 새 구현체와 팩토리가 나가고 풀을 다시 만들 때까지 수익 정산이 막힌다.
참고: 08-smart-contracts → Amount Units. 09a-custody. 05-investment-lifecycle. 23-money-path. 17-changelog v3-102. v3-101을 잇는다. 출처: CH, 2026-07-31.
v3-101 — 금액의 축은 주석이 아니라 타입이다. 컨트랙트는 그대로 두고, 살아 있던 1e12 버그 둘을 고쳤다 ✅ Decided · shipped
v3-101 — Raw와 Normalized를 타입 경계로
Date: 2026-07-31
배경. runYieldDistribution이 distributeYield에 6자리 netAmount를 넘겼는데 컨트랙트는 18자리 원장 축을 기대한다. 1e12만큼 작다. JY의 핸드오프는 백엔드를 고치거나 수익 3단계 전부가 raw 금액을 받도록 컨트랙트를 바꾸는 안을 제시하며 어느 쪽이냐고 물었다. 뒤이은 감사가 답을 바꿨다.
컨트랙트는 이미 일관돼 있다. 바꾸지 말 것. 금액을 받는 진입점 여덟 전부가 규칙 하나를 예외 없이 따른다. 이번 tx에서 움직이는 토큰을 이름 대는 인자는 Raw이고(그 스테이블코인의 소수 자릿수), 풀의 원장에 있는 수치를 이름 대는 인자는 Normalized(18)다. depositYield / withdrawFees / fundRedemption / deposit은 이체를 감싸므로 Raw다. distributeYield / claimYield / reinvest / updateNAV.reserveConsumed / setHardCap과 PoolConfig의 상한들은 원장 카운터를 만나므로 Normalized다. 그래서 핸드오프가 수익 흐름을 읽은 방식("가운데 단계만 튀니까 함정이다")은 오독이었다. depositYield와 withdrawFees는 이체 함수이고 distributeYield는 회계 함수다. raw로 통일하면 통화 없는 집계(unclaimedYield는 받아들이는 모든 스테이블코인을 합산한다)에 통화를 강요하게 된다. 균일해 보이게 하려고 규칙을 깨는 것이다. 표는 08-smart-contracts → Amount Units에 있다.
근본 원인은 ABI가 아니라 백엔드 경계다. 틀린 축은 revert하지 않는다. 이 인자들에 대한 온체인 검사는 전부 상한이므로 1e12만큼 작은 값이 통과하고 tx가 성공하며, 오프체인 기록은 따로 계산되므로 여전히 맞아 보인다. 불일치는 투자자의 claimYield에서만 드러난다. 그리고 축은 문서화돼 있었다. PlatformPool.sol에 @param netAmount ... (normalized to 18 decimals)가 있고 백엔드에 YIELD_NORMALIZED_DECIMALS = 18 상수가 있는데도 실수가 났다. 여기서 문서는 메커니즘이 아니다.
결정: TypeScript에서 축을 브랜딩한다. lib/shared/contract/units.ts의 RawAmount / NormalizedAmount / NavPrice이고 변환기가 그것을 만드는 유일한 길이다. lib/shared/contract/*의 모든 온체인 금액 파라미터가 이제 자기 축을 선언하므로 틀린 축의 값은 컴파일 오류다. scripts/check-onchain-units.mjs(infra build와 test에 붙었다)가 그 모듈 밖의 parseUnits를 금지하고 브랜드 캐스팅을 lib/shared/contract/에 가둔다. 그때 덮지 않은 것: 읽는 방향(formatUnits, 호출 지점 12곳)이다. 거기서 틀린 배율은 조용한 체인 손상이 아니라 눈에 보이는 표시 오류이고, 첫날부터 금지하면 탈출구 12개를 만들어야 했다. v3-102에서 닫혔다. 12개를 전부 변환했고 가드가 이제 예외 없이 양방향을 덮는다.
살아 있던 버그 둘. 둘 다 타입을 적용하다 찾았고 둘 다 1e12만큼 작았으며 둘 다 조용했다.
distributeYield(netAmount)— 보고된 것이다. 실제 피해는 없다. 진짜 분배가 한 번도 돈 적이 없어서(yield_claims= 0,yield_distribution_investors= 0,DISTRIBUTED행 둘은 seed 데이터다) 온체인accumulatedYieldPerShare가 왜곡되지 않았고 마이그레이션도 필요 없다.updateNAV(reserveConsumed)— 전에 보고되지 않았다.nav-changes.post.propose와nav/apply-proposal이 둘 다 6자리로 파싱했는데 컨트랙트는 그것을 18자리 카운터인s.reserveBalance에서 바로 뺀다. 그래서 상각이 리저브를 거의 태우지 않고 성공을 보고했으며, 상환 체결과 청산 NAV에 대해 리저브가 과대 표시된 채로 남았다.
YieldDistributed.netAmount도 같은 변경에서 18로 다시 디코딩해야 했다. 인덱서가 그것을 6으로 읽어서 버그를 상쇄했고 기록된 숫자가 맞아 보이게 만들었다. 한쪽만 고치면 기록이 깨진다. yield_per_share는 상쇄된 적이 없고 그냥 1e12만큼 작았다.
컨트랙트에서 여전히 할 만한 일이 있고 이유는 다르다. 2단계와 3단계를 settleYield 하나로 합치는 것은 단위가 아니라 원자성으로 정당화된다. run-distribution.ts가 withdrawFees 실패를 삼키므로 net은 분배되고 수수료는 미수인 상태가 조용히 남을 수 있고 재시도가 이중 분배한다. 단위 수정이 아니다. 따로 결정할 것. 같은 릴리스 후보로: 분배됐지만 미청구인 수익이 담보 불변식에서 빠져 있으므로(그것에 대한 집계 카운터가 없다) _releaseHeldFunds가 투자자의 accruedYield를 받치는 잔액을 풀 수 있다. 둘 다 열려 있고 어느 것도 막지 않는다.
참고: 08-smart-contracts → Amount Units. apps/infra/lib/shared/contract/units.ts. scripts/check-onchain-units.mjs. 17-changelog v3-101. JY의 yield-distribute-scale-mismatch-handoff.md에 있던 A/B 선택을 대체한다. 출처: CH, 2026-07-31.
v3-100 — 회차 엔진: 항아리가 스칼라가 됐다. v3-93의 열린 항목 여섯을 닫았고 살아 있는 회계 누수 하나를 찾았다 ✅ Decided · 🔴 1 open (CH)
v3-100 — 회차 재설계 마무리
Date: 2026-07-30
배경. v3-91과 v3-93은 엔진이 만들어지기 전에 쓰였고 의도적으로 질문 여섯을 열어 뒀다. 그다음 ch/product 머지가 엔진을 냈으므로 그 질문들이 이제 의도가 아니라 코드에 대고 답할 수 있게 됐다. 그리고 그중 하나는 틀린 방향으로 답해져 있었다. 여기 기록하는 셋 중 둘은 새 제품 결정이 아니라 우리 앞선 항목에 대한 정정이다.
처음 기록 — 🔴F, 지수 붕괴. 감사 전체에서 가장 우선순위가 높았던 결함인데 어떤 문서 페이지에도 항목이 없었고, 더 나쁘게 07-redemption이 깨진 산식을 설계로 공표하고 있었다. G[id] = G[id−1] × (1 − fillRatio)는 첫 전량 체결에서 0에 도달하는데 fillRatio = 1이 운영 목표다. 그래서 유동성 게이팅이 첫 건강한 주기에 죽었고, 그다음 종착 청구 분기가 유동성 검사 없이 남은 잔액을 선착순으로 지급했다. 기존 회차 테스트 27개가 그동안 내내 통과했다. 요청 시점 (gBase, hBase) 스냅샷은 필요하지만 충분하지 않다. 이미 0인 지수의 스냅샷은 여전히 0이다. 해법은 세대별로 사다리를 재시작하는 것이다. 전량 체결은 그 세대의 미결 요청을 전부 끝내므로 사다리가 붕괴하는 대신 1e18에서 다시 시작할 수 있고, generationCloseH[gen]이 청구하지 않은 낙오자가 받을 것을 보존한다. demand == 0은 명시적으로 전량 체결이 아니다. 같은 재시작이 LadderPrecisionExhausted revert도 대체했는데, 그것은 평범한 운영에서 도달 가능한 것으로 드러났다(부족분을 정확히 펀딩하고 세 회차 연속). 정산을 막으면 자금이 갇히는데, 그건 지급 하한이 이미 버리는 wei 미만 먼지를 버리는 것보다 나쁘다.
배포 전에 알아 둘 하위 호환 예외 둘. fundingAnchor == 0은 "앵커된 일정 없음"을 뜻하고 창 게이트를 아예 건너뛰어 옛 지연 동작을 보존한다. 그것이 이미 배포된 회차 풀 7개가 벽돌이 되지 않게 하는 것이고, setEpochSchedule이 생성 전용이므로 그 풀들은 영구히 레거시 경로에 남는다. 그래서 모델 B는 플랫폼 전체의 속성이 아니라 새 풀의 속성이다. 별개로 ep.gBase == 0은 스냅샷이 존재하기 전에 만들어진 요청에 대해 라이브 지수 읽기로 폴백한다. 라이브 DB에 진행 중인 회차 요청이 2건 있고, 둘 중 하나라도 옛 전량 체결 뒤에 앉아 있는지는 배포 뒤가 아니라 전에 확인해야 한다.
정정 1 — 정산 에스크로는 스칼라이고 이미 나갔다. v3-91은 회차별 epochClaimable[id] 항아리를 명시했다. 그것은 2026-07-27에 철회됐고 이 문서에 반영되지 않았다. 그것이 07-redemption 상태 표가 이중 커밋 수정이 착지했는지 CH에게 물은 이유 전부다. 설계에서 빠진 필드를 찾고 있었던 것이다. 청구는 회차를 가로지르므로(H[latest] − H[vintage]) 회차별 항아리를 비우는 것은 vintage 수에 대해 O(V)이고 사다리가 제공하려는 O(1) 청구를 파괴한다. 실제로 나간 방식은 redemptionCommitted 스칼라 하나다. executeEpoch가 체결된 gross를 T[id] → H → R 순서로 차감하고(회차 전용 돈부터) 그것을 스칼라에 더한다. 회차 청구는 _payCommitted로 그 스칼라에서만 가져가고 살아 있는 리저브에서는 절대 가져가지 않는다. 즉시 상환은 _drawDown을 유지한다. 불변식은 physicalBalance ≥ reserveBalance + heldFundReleases + redemptionCommitted + unclaimedYield이고 온체인 assert가 아니라 Foundry 불변식 테스트로 강제한다(모든 경로에서 stablecoinList를 도는 것은 가스와 revert 위험이다). 함께 나간 후속 둘: _debitLiquidity와 _debitPrincipal을 나눠서 유동성 차감을 정산으로 옮겨도 totalDeposited가 앞으로 끌려가 게이팅 상한이 자기 참조가 되지 않게 했고, setFundingRestricted(false)가 해제를 physicalBalance − (reserve + committed + unclaimedYield)로 잘라서 hold-back 해제가 이미 약속된 채무의 담보를 걷어 갈 수 없게 했다.
정정 2 — 청산 분자(🔴W). executeWindDown이 reserveBalance만으로 청산 가격을 매기고 heldFundReleases와 epochFundTopUp을 무시했다. 회수된 돈을 실제로 담고 있는 버킷 둘이다. 그래서 투자자가 풀이 회수한 것보다 적게 지급받았고, 위의 스칼라가 그 간극을 넓힌다. 정산이 이제 리저브를 먼저 비우기 때문이다. 분자는 reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted이고, 약정된 채무는 이미 이름 붙은 청구자에게 빚진 현금이므로 제외한다. 반영됐고, 요청 게이트가 분자와 함께 움직여야 했다. 아니면 청산된 풀이 그다음 정산을 거부할 이탈에 가격을 매긴다. ⚠️ R10이 일부 대체했다(0abe168, 2026-08-04 배포). − redemptionCommitted 항이 이중 계산으로 분자에서 제거됐고(세 버킷이 이미 그것을 차감한 값이다) 그 제외는 이제 분모에 totalSupply − settledUnclaimedLp로 산다. 상세는 04-pool-models에 있다.
v3-93의 열린 항목 여섯을 닫는다(2026-07-27 결정, 여기 기록):
- 과다 펀딩 잔여는 다음 회차로 이월되고 FM에게 과다 펀딩 안내가 간다.
T → H → R차감 순서가 톱업의 대부분을 이미 소비하므로 진짜 초과분만 남고, 반환 경로를 만들 필요가 없다.🔴 "이월된다"는 틀렸고 v3-137이 정정했다(2026-08-17). 버킷이 주기로 키잉되고 정산은
reserveBalance + epochFundTopUp[currentEpochId]만 읽는다(RedemptionLib.sol:955). 그래서 n+1 주기는 n 주기가 남긴 것을 볼 수 없다. 그리고reserveBalance +=는 저장소에 정확히 한 번 나오므로(PoolLedgerLib.sol:73) 그리로 옮기는 것도 없다. 남은 것은 자기 주기에 묶인다.totalEpochTopUp에 남아 청산 분자를 통해 홀더에게 닿으므로 사라지는 것은 아니지만 나중 정산이 쓸 수는 없다. 항목의 뒤 절반은 유효하다. 반환 경로는 없다. 이 문장이 그 오류의 출처다.over_funding_detected알림 본문과epoch-redemption.ts의 문서 주석이 둘 다 이 결정을 인용하며 각자 충실히 재현했다. ✅ 둘 다 정정됐다. 주석은 2026-08-17에, 알림 본문과 그것을 붙들고 있던 테스트 단언은 2026-08-18에다(22-notifications). - 어드민 거부에는 마감 게이트가 없다. 현행 그대로. 게이팅할 것이 없다.
rejectRedemption은REQUESTED와PENDING_RESERVE만 받고 그것들은 즉시 상환 전용 상태이므로 회차 요청은 아예 거부할 수 없다. 회차 거부를 언젠가 연다면 가장 먼저 고칠 것은rejectRedemption이 수요를 줄이지 않는다는 점이다. - 규제상 강제 취소 경로를 따로 두지 않는다.
holdRequest(청구를 막는다)와 청구 시점의canRedeem게이트가 이미 KYC 폐기와 제재를 덮는다. ⚠️ 추적 중인 후속: 보류된 요청도 여전히 수요에 잡히므로 체결 여력을 소비한다. demand == 0회차는 별도 항목이 아니라 🔴F에 합쳐진다. 수요 0은 전량 체결로 읽히고 그것이NAV > 0가드와 같은 실패이며, 요청 시점(gBase, hBase)스냅샷 수정이 그것을 덮는다.- 즉시
PENDING_RESERVE취소에는 게이트가 필요 없다. 거짓으로 만들 확정 총액이 없고, 이미 들어온 파트너 펀딩은fund_wallet으로 돌아간다. 돈이 왕복하고 새는 것이 없다. WIND_DOWN시점의 미결 회차 수요는 일관돼 있다. 에스크로된 LP는 소각되지 않으므로totalSupply에 남고, 기다리는 투자자도 다른 모두처럼 pro-rata 몫을 가져간다. 진짜 문제는 창 게이트에 갇히는 것인데, 그건 부실 예외가 닫는다(WIND_DOWN과IMPAIRED는_requireRequestWindow를 건너뛴다).
🔴 열린 항목 — 정산 뒤 취소가 예약된 지급을 고아로 만든다. 위를 확인하다 발견했고 머지된 코드에 살아 있다. cancelRedemption이 ep.lpRemaining을 돌려주는데 lpRemaining은 청구 시점에만 줄어든다. 그래서 청구하지 않은 부분 체결 투자자가 에스크로된 LP를 전부 돌려받고 체결분의 USD는 청구자 없이 redemptionCommitted에 남는다. 정정 2의 청산 분자에서 영구히 차감되므로 남은 홀더가 정확히 그만큼 과소 지급된다. C10 창 게이트가 이것을 막지 못한다. v3-93의 4번은 막을 것이라 가정했는데, 이월 예외(부분 체결된 투자자는 다음 창에서 취소할 수 있다)가 바로 그것에 닿는 경로이고 현재 동작은 test_C8_CancelRolledOver_ClearsCarryBucket이 고쳐 놓은 게 아니라 못 박아 두고 있다. CH에게 던진 질문은 취소가 미체결 잔량만 돌려주고 체결분을 청구 가능하게 남길지, 아니면 예약을 버킷으로 되돌릴지다. 결정되지 않았고 CH와 논의 중.
작은 핸드오프 판단 셋(2026-07-30):
펀딩일 리마인더 선행 시간 3일 → 7일.
pools.scheduler.epoch-funding-date의LEAD_DAYS이고, 3일은 파트너가 정산일을 확정하기에 부족하다. 상수만 바뀌고 로직 변경은 없다. ✅ 알림 파이프라인 재구축과 함께 2026-07-31 반영.hold-back 해제 잔여에 대해 FM에게 알린다(결정). 정정 1의 클램프 때문에 hold-back을 풀어도 해제의 일부가 보내지지 않고 버킷에 남을 수 있는데, FM에게는 그 이유를 볼 방법이 없다. 그들 쪽에서는 해제가 그냥 기대보다 적게 지급된 것이다. 그래서 그 클램프에 목소리가 필요하다. 트리거는 정산됐으나 미청구인 채무로 잘린
setFundingRestricted(false)해제이고, 대상은 FM이며 인앱과 이메일이고, 내용은 "해제분 중 $X는 대기 중인 상환 청구를 받치는 동안 유예되며 그 청구들이 정산되면 지급됩니다"다. (⚠️ 결정된 내용에 자동으로라는 말이 들어 있었고 문구로 나갔다. 2026-07-31에 제거했다. 해제를 다시 돌리는 것이 없으므로 존재하지 않는 메커니즘을 약속했다. 17-changelog와 23-money-path 참조.) 잃는 것도 없고 필요한 조치도 없다. 바로 그래서 침묵이 오류로 읽힌다. ✅holdback_release_deferred로 반영됐다.FundingRestrictedSet(restricted=false)에 대한 인덱서 라이터이고, 잔여를 그 이벤트의 블록에 고정해 다시 읽으며(이벤트가 금액을 담지 않고_releaseHeldFunds가 그 트랜잭션 안에서 돌았다), 잔여가 0이거나 읽을 수 없으면 침묵하고, 하루 단위로 다시 무장한다. 상세는 22-notifications에 있다.그것을 만들다 다른 자금 안내 넷에서 배율 버그가 드러났다. 풀은 모든 USD 수치를 18자리로 정규화하는데(
PoolCommonLib.normalizeAmount) 인덱서가 그중 몇을 USDC의 6으로 디코딩해서 1e12배 과대 표시했다. 영향받은 것:epoch_funding_needed와 "Awaiting Funding" KPI 뒤의funding_shortfall컬럼,epoch_settlement_complete의 지급액(그것이redemption_requests.payout_amount와 모든redemption_fills행도 썼다),redemption_pending_reserve의 부족분, 그리고NAV_PRECISION대신LP_PRECISION으로 나눠 1e6 수요를 18자리 잔액과 비교하던readEpochShortfall이다. 실제 풀에서는 부족분이 약 0으로 계산됐고, 그 말은 D-2 / D-12h 파트너 펀딩 리마인더가 아예 발동한 적이 없다는 뜻이다. 앞선 G4 패널티 수정과 같은 부류다. raw 이체 금액(Deposited.amount,YieldDeposited.grossAmount)과의 구분이 분명해지도록NORMALIZED_DECIMALS라고 이름 붙였다.🔴 같은 정리에서 하나 더 찾았고 고치지 않았다. 온체인 동작을 바꾸기 때문이다.
runYieldDistribution이netAmount를parseUnits(…, 6)으로 변환해distributeYield(netAmount)에 바로 넘기는데, 그것은 18자리 정규화 배율을 기대한다(unclaimedYield에서 빼고yieldPerShare = netAmount × 1e18 / totalLPSupply를 계산한다). 그 양옆의depositYield와withdrawFees는 raw 스테이블코인 단위를 받고, 그것이 가운데 호출을 틀리기 쉽게 만든다. 확인되면 이것은 수익을 1e12배 과소 분배하고 배포된 풀은 이미 왜곡된accumulatedYieldPerShare를 갖게 된다. 그래서 한 줄 수정이 아니라 CH와 라이브 상태 확인이 필요하다. 인덱서의 짝인YieldDistributed.netAmount디코딩은 의도적으로 6자리로 둔다. 지금은 그것이 버그를 상쇄하고 있고, 한쪽만 뒤집으면 기록된 수치가 틀려진다. 둘은 함께 움직여야 한다.
✅ 2026-08-20에 수정이 아니라 제거로 닫혔다. v3-131 (3)이 netAmount 인자와 지분당 누적기를 함께 삭제했으므로, 이 호출 지점에 틀릴 배율이 남지 않고 디코딩할 YieldDistributed 이벤트도 없다(YieldPeriodSettled(funded, fees, undistributed)다). 홀더가 빚진 것은 이제 시간에서 온체인으로 유도한다. 일반적인 교훈은 유효하고 08 → argument axes에 기록돼 있다. 유일한 가드가 상한인 인자는 검증할 수 없고, 그것을 문서화하는 것보다 제거하는 것이 낫다.
- 72시간을 넘는 출금 차단은 현행 유지, 변경 없음. v3-28이 유효하다. 투자자별 제한은
holdRequest와 KYC 게이트가 이미 덮고, 72시간을 넘는 풀 전체 차단은 입증된 규제 사유와 법무 검토가 먼저 필요하다.
참고: v3-91과 v3-93을 수정한다. 07-redemption → Epoch-Based Redemption. 04-pool-models → Emergency Wind-Down. 17-changelog v3-100. 노션 "Epoch Redemption — 코드 검수 정리"(JY 핸드오프 회신, 2026-07-30). 출처: JY, 2026-07-30.
v3-99 — 상환 출구 게이트. "reject" 대신 Return position이고, ADMIN 전용이며 사유가 필수다 ✅ Decided · shipped
v3-99 — 출구 게이트와 `Return position` 명명
Date: 2026-07-30
배경. 무엇이 출금을 멈출 수 있는지를 문서화한 것이 없어서 규칙을 컨트랙트를 읽어야만 복원할 수 있었다. 그것이 이름 붙일 만한 잘못된 믿음 둘을 낳았다. 회차 풀에는 AML 게이트가 없다는 것(있다), 그리고 오퍼레이터의 거부 버튼이 AML 강제 수단이라는 것(아니다)이다.
결정 — 구별되는 게이트 셋. 07-redemption에 문서화했다:
- 락업과 만기 — 자동이고 3단계 모델이다
- 검증 — 자동이고 온체인이며
canRedeem을 통한다. 요청 시점과 지급 시점 둘 다에서 검사한다. REVOKED는 이탈을 막고 EXPIRED는 통과한다(v3-31). 만료로 막으면 투자자 자기 자본이 만료된 서류 뒤에 갇히는데 그것이 v3-28이 막으려고 존재하는 실패 양상이다. 회차 풀에도 똑같이 적용되고, 그것이 회차에 요청별 오퍼레이터 판단이 필요 없는 이유다 - 오퍼레이터 조치 — 즉시 상환 풀 전용이고
REQUESTED와PENDING_RESERVE만 대상이다
결정 — CTA는 Return position이고 버튼 하나이며 사유가 필수다. "Reject"는 자격을 부인하는 것을 서술하는데, 이 동작은 에스크로된 LP를 돌려주고 홀더에게 포지션을 남기므로 부인이 아니라 되돌림이다. Release와 Freeze는 쓸 수 없었다(이미 hold·release와 풀 동결이 쓰고 있다). Block for compliance 버튼을 하나 더 두는 안도 검토했다가 버렸다. 주된 정당화가 권한 분리였는데 동작이 ADMIN 전용이 되면서 그것이 사라졌다. 남은 차이인 투자자용 표현과 보고 분류는 대신 사유에 실린다. 시스템이 그것을 미리 고르고(정기 검사가 검증을 표시했으면 compliance, 정체된 부족분이면 unfunded) 오퍼레이터가 바꿀 수 있으며 자유 텍스트 메모가 필수다. NAV 승인·덮어쓰기와 같은 제안 후 확인 모양이다. DB REJECTED는 그대로다. 구분은 failure_type이 담고, 그것이 자발적 취소가 거부율을 부풀리던 문제도 고친다.
결정 — ADMIN / SUPER_ADMIN만. FM은 제외된다. 단정함의 문제가 아니다. 이 동작의 주된 용도는 펀드가 끝내 채우지 않은 부족분을 닫는 것이고 FM이 바로 그 펀드다. 그것을 그들에게 남기면 돈을 빚진 쪽이 투자자의 이탈 요청을 끝낼 수 있다. LP는 돌아오지만 이탈은 닫힌 채로 남고, 반복되면 사실상 거절이다. 이것이 세 갈래 충돌을 노션 FM 패널 PRD 쪽으로 정리한다. 그 문서는 FM에게 그런 게이트가 없다고 했는데 코드와 이 문서는 허용하고 있었다. 09-rbac과 핸들러가 맞춰 움직인다. FM이 승인자가 아니라는 원칙으로 FM_ACCEPTED를 없앤 v3-04와 일관된다. FM은 부족분 알림과 읽기 접근, 자기 지갑에서 fundRedemption에 서명하는 것을 유지한다.
이것이 드러낸 구멍과 그 수정 — ✅ 2026-07-30 반영. _executeRedemptionPayout에는 호출자가 셋인데 claimRedemptionFallback만 먼저 canRedeem을 검사했다. approveRedemption과 fundRedemption 안의 파트너 펀딩 자동 정산은 그것을 직접 호출했으므로, 요청 시점에 통과하고 PENDING_RESERVE에서 기다리는 동안 SBT가 폐기된 요청이 펀딩이 도착하면 지급됐다. 결과가 어느 경로로 정산되느냐에 달려 있었다. (fundRedemption의 checkExitNotBlocked는 이 게이트가 아니라 동결 게이트다.)
- 호출자마다가 아니라
_executeRedemptionPayout안에서 검사한다. 검사 하나가 세 경로를 다 덮고, 호출자마다 땜질하면 다음 호출자가 그것을 놓칠 것이 보장된다. 그것이 정확히 이 구멍이 생긴 경위다. 폴백 자신의 사전 검사는 남겨 뒀다. 중복이지만 무해하고 정책 주석을 담고 있다 RedemptionBlockedByKyc로 revert하고 요청을 자동 전이시키지 않는다. revert가 파트너의 돈을 묶지 않는다. 이체와 지급이 같은 트랜잭션이므로 함께 롤백된다. (쪼갠 펀딩이면 앞선 톱업이 안에 남을 수 있지만 그건 이 변경 이전 문제이고Return position이 이미 그것을fund_wallet으로 돌려준다.) 자동 전이는 기각했다. 파트너의 펀딩 트랜잭션이 Aset의 컴플라이언스 결정을 집행하게 되고 파트너가 행위자로 기록되기 때문이다- 실패한 트랜잭션을 지켜보는 대신 매시간 sweep과 짝짓는다. revert는 Aset에게 조용하므로,
PENDING_RESERVE요청을 훑는 sweep이 홀더가 더 이상 통과하지 못하면 어드민에게 알린다. 파트너의 revert된 tx를 지켜보면 놓치는 경우가 있고 그들이 이미 가스를 쓴 뒤에야 발동한다. sweep은 상태를 바꾸지 않고(요청을 닫는 것은 명시적인 어드민 행위로 남는다) 체인을 읽을 수 없을 때 의도적으로 닫히는 쪽으로 실패하지 않는다. 지급도 없고 응답을 기다리는 사람도 없는데, 모든 RPC 딸꾹질에 알리면 어드민이 알림을 건너뛰도록 훈련시킨다 - 아직 열린 것: sweep의 발견이 요청에 기록되지만 어드민 대화상자가 아직 그것을 읽지 않으므로 위의
COMPLIANCE사전 선택이 배선되지 않았다. 분류는 여전히 요청 상태만으로 유도된다
여기서 함께 정정: 09-rbac이 FM에게 "상환 승인·거부"를 부여하고 역할 표에 둘 다 나열했다. v3-82가 모든 role에서 수동 승인을 없앴으므로(리저브로 감당되면 요청 시 정산되고 부족분은 펀딩이 오면 정산된다) 패널에 남은 유일한 상환 결정이 Return position이고 이 결정이 그것을 ADMIN / SUPER_ADMIN에게 유보한다. 역할 표 행의 이름을 바꾸고 FM 접근을 뺐다.
참고: 07-redemption → Exit gates. 09-rbac. v3-31, v3-28, v3-82, v3-84. 노션 "상환 출금 게이트 & Reject 재정비" 핸드오프. 출처: JY, 2026-07-30.
v3-98 — KYB(법인 온보딩) 폐기: 플랫폼은 개인만 온보딩한다 ✅ Decided
v3-98 — KYB는 범위 밖
Date: 2026-07-29
결정: 플랫폼은 개인만 온보딩한다. KYB(법인 온보딩)는 미룬 것이 아니라 폐기한다. 목표 시점이 없고, v3-75의 정책이 의존하던 2026년 8월 SumSub Enterprise KYB 구독을 하지 않는다. v3-75는 대체됐다. 그 관할 조사는 로드맵이 아니라 참고 자료로 03-kyc-identity에 남는다.
왜 상태 표시 수정이 아니라 자기 항목이 필요한가. "미뤘다"와 "폐기했다"는 표에서 같아 보이고 실제로는 완전히 갈린다. 미뤘다면 모든 INSTITUTION 분기가 데워 둘 만한 발판이고 "곧 지원"이 정직하다. 폐기했다면 같은 분기가 죽은 경로이고 같은 문구는 제품이 지키지 않을 약속이다. 문서가 앞의 해석으로 흘러가 있었다. 03-kyc-identity가 "아직 쓸 수 없다 … 2026년 8월 예정 … KYB는 별개 작업 흐름이고 먼저 나간다"고 적었는데, 그것이 독자가 법인 온보딩이 오고 있다고 결론 내리게 되는 정확한 방식이다.
의도적으로 남는 것:
kyc_levelenum(INDIVIDUAL/INSTITUTION)과SUMSUB_LEVEL_MAP— SBT가 레벨을 저장하고 v3-63(마이그레이션 0063)이 이미 레벨 기반 풀 게이팅을 없앴으므로, 잠자는 INSTITUTION 값은 비용이 없고 그것을 뜯어내면 SBT 컨트랙트를 건드리게 된다. 그것이 있다는 것이 기능이 존재한다는 증거가 아니다. 그 추론이 이 항목이 막으려는 바로 그 실수다users.company_name과company_country— SumSub 웹훅이 쓴다.company_country는 남아 있는 레거시 INSTITUTION 행의 관할 유도를 여전히 채운다
🔴 열린 항목 — 투자자용 문구(반드시 바뀌어야 하는 유일한 것). KYCModal이 VITE_KYB_ENABLED 게이트 뒤에서 "법인 인증은 곧 지원됩니다"를 렌더한다. KYB가 폐기된 지금 그 문장은 제품이 지키지 않을 약속이고, CLAUDE.md 규칙 2-b가 막으려고 쓰인 실패 양상이다. 선택지는 둘이다. (a) 법인 계정을 지원하지 않는다고 표현을 바꾸거나 (b) 개인·법인 선택기를 없애 그 선택 자체가 나타나지 않게 하거나다. 아직 결정되지 않았다.
이 결정의 범위 밖: Reg S 적격투자자 모델(v3-74)은 영향이 없다. 애초에 개인 우선이었고 독립적으로 나간다. v3-75의 "대형 사업체 2-of-3" 법인 테스트는 KYB가 빠져 있는 동안에만 무의미해진다.
참고: v3-75를 대체한다. 03-kyc-identity → KYB. v3-74는 영향 없음. 17-changelog v3-98. 노션 "KYB(법인 온보딩) — 현황 및 활성화 계획"(단계 = 드롭). 출처: JY, 2026-07-29.
v3-97 — 동결 이정표를 알린다: 날짜를 미리 알리고 재개 안내도 두 번 보내며, 중복 제거는 동결 주기로 키잉한다 🔧 Decided · BE unimplemented (CH)
v3-97 — 동결 알림 정책
Date: 2026-07-28
배경. 동결은 이정표가 트랜잭션도 이벤트도 없이 지나가는 유일한 상태다. v3-28의 비대칭 아래에서 가치 유출은 freeze_started_at 72시간 뒤 다시 열리고 동결 전체는 7일에 소멸하는데, 둘 다 읽을 때 계산되고 어느 쪽도 이벤트로 나오지 않는다. 컨트랙트가 아무것도 알리지 않으므로 플랫폼이 그 안내의 유일한 출처인데, 오늘 아무것도 보내지 않는다. pool_unfrozen은 EmergencyUnfrozen 이벤트에서만 큐에 들어가고 자동 만료는 그것을 내지 않는다. 그동안 pool_frozen은 투자자에게 "활동이 재개되는 즉시 알려 드리겠습니다"라고 말했다. 뒤에 생산자가 없는 약속이다. 그리고 출금이 72시간에 다시 열린다는 말 없이 입금 과 출금이 정지됐다고도 적었다. 이것이 정확히 v3-96이 결과가 가장 무겁고 감사되지 않았다고 표시한 표면이다.
결정 — 날짜를 미리, 그리고 재개 안내 둘. 양자택일이 아니다.
pool_frozen이 이미 아는 시각 둘을 말한다. 동결 시작이 이정표 둘을 고정하므로, 문구가 앞으로의 메시지를 약속하는 대신withdrawalsResumeAt과haltLiftsBy를 담는다. 지키지 않은 약속과 "출금이 정지됐다, 끝" 오류를 한 번에 없앤다. FE 배너는 v3-95에서 이미 그렇게 했고 이메일은 맞춘 적이 없다.freeze_extended도 같은 처리를 받는다(연장이 72시간 차단을 다시 시작시키므로 출금이 언제 다시 열리는지 말해야 한다).- +72시간에
freeze_exit_window_open(신규). 예상 시각을 미리 말했다고 재개를 확인해 줄 의무가 면제되지 않는다. 상환 중단에 대한 업계 관행은 상환이 재개될 때 투자자에게 알리는 것을 그 자체로 의무로 다룬다. 그리고 72시간이 투자자가 자기 돈에 대해 행동할 수 있게 되는 순간이다. 이 묶음에서 가치가 가장 큰 단일 안내다. - +7일에
freeze_expired(신규). 동결 안내의haltLiftsBy는 확인이 아니라 상한이고, 자동 만료는 투자자가 확인할 흔적을 남기지 않는다.pool_unfrozen과 의도적으로 구분해 유지한다. 자동 만료는 운영자의 판단이 아니라 설계가 작동한 것이므로 문구가 "자동으로 해제됨"으로 읽히고 로그가 두 출처를 구분해 둔다("unfrozen"을 읽은 투자자는 누가, 왜 그렇게 정했나를 묻는다). - 중복 제거는 동결 주기로 키잉한다.
(event_type, related_entity_id = pool, created_at >= freeze_started_at)이고partner-fundingsweep 패턴이다.queueNotification의dedupByEntity는 아니다. 그건 풀당 영원히 한 번이라 첫 동결 뒤의 모든 동결을 침묵시킨다. 연장이freeze_started_at을 다시 잡으므로 이 키는 안내 둘을 자동으로 다시 무장시키는데, 그건 부작용이 아니라 올바른 동작이다. - 동결 문구에는 UTC 라벨이 명시된 날짜·시각 포맷터가 필요하다.
formatDate()는YYYY-MM-DD로 자르므로 72시간 이정표를 표현할 수 없다. 날짜만 읽은 투자자는 02:00에 열리는 창을 09:00에 시도한다. 이메일에는 브라우저가 없으므로 FE의milestoneLabel()처럼 지역화할 수 없다.
기각: 채널 분리(72시간은 인앱만, 만료는 이메일). queueNotification이 channel: 'EMAIL'을 하드코딩하고 발송 워커가 모든 SENDING 행을 가져가므로 인앱 전용에는 채널 필터 인프라가 필요하다. 그리고 그것은 가장 중요한 안내를 가장 약한 채널로 보내게 된다.
순서. 새 이벤트 둘 다 자동 만료 DB 수정에 의존한다. is_emergency_frozen이 절대 지워지지 않으므로 오늘 7일 지점이 지났음을 아는 것이 없다. 이정표 sweep과 이 안내 둘이 함께 나간다. 그러니 그 sweep은 "만료된 플래그를 지운다"보다 넓다. 여전히 동결된 풀도 72시간 지점을 위해 살펴야 한다. sweep 주기가 안내 지연을 정한다(pools.scheduler.lifecycle에서 매시간 → 1시간 이내).
이것을 명세하다 함께 발견: freeze_extended가 새 동결 시작을 Until이라고 라벨된 행에 넣으므로(indexer/writers/freeze.ts) 연장 이메일이 지금 오늘 날짜를 동결의 끝으로 보여 준다. 진짜 값은 시작 + FREEZE_MAX_DURATION(7일, 상수)이다.
참고: 22-notifications → Freeze notifications. v3-96의 열린 항목에 대응한다. v3-28(동결 비대칭). v3-95(FE 배너 선례). 10-status-machines. 17-changelog v3-97. 노션 "Freeze auto-expiry" BE 핸드오프. 출처: JY, 2026-07-28.
A4 — NAV 제안·승인·덮어쓰기 (자동 제안은 절대값 재계산을 쓴다) ✅ Decided
v3-73 — FE는 비율 설정을 percent로 표시한다. 저장·입력·API·컨트랙트는 bps 그대로다 (단방향 표시 포맷터) ✅ Decided
v3-73 — bps 비율 설정에 대한 FE percent 표시
Date: 2026-07-13
배경: 규칙 7번(마이그레이션 0068)이 모든 비율 값을 FE 입력과 표시를 포함해 끝에서 끝까지 bps로 통일하고 percent와 bps 사이의 어떤 다리도 금지했다. 제품 리뷰: bps는 저장과 전송 단위로 옳지만(컨트랙트가 정수 전용이고 amount × bps / 10000이다) 투자자에게 "1000 bps"를 보여 주는 것은 "10%"보다 나쁜 경험이다.
결정:
- 저장, 입력, API payload, Lambda, 컨트랙트는 bps로 남는다(변경 없음). 마이그레이션도, ABI 변경도, 건드린 계산도 없다
- FE는 비율 설정(
reserve_bps,redemption_gating_bps,penalty_rate_bps)을 표시 전용으로 percent로 렌더한다. 단방향formatBpsAsPercent(bps)헬퍼를 쓴다(1000 → "10%",250 → "2.5%", 끝의 0은 제거) - 어드민 운영자 입력은 bps로 유지한다(숫자 bps 필드와
isBps검증). 운영자는 온체인 단위로 일하고, 입력을 bps로 두면 틀릴 양방향 변환 자체가 없다. 어드민 생성·수정에 bps 입력마다 실시간 "= X%" 힌트가 붙고 리뷰 요약에 percent를 보여 준다(admin-web FE. 노션 FE-UI-v3에서 추적) - 여전히 금지: 저장·입력·전송 계층의 어떤 양방향 percent↔bps 변환이든(7번이 금지한 그것). 읽기 경로의 표시 포맷터만 허용한다. percent가 소수 2자리로 제한되므로(
isPercent) 정수 bps와의 정밀도가 무손실이다 - 수수료 요율은 영향 없음 —
pool_mgmt_pct/spc_mgmt_pct/platform_yield_take_pct는 이미 끝에서 끝까지 percent였다(isPercent). (대체됨: 마이그레이션 0075가 나중에 수수료 요율도 bps로 통일했다.*_pct→*_bps, ×100,×bps/10000으로 부과. 17-changelog 참조.)
범위: apps/web의 표시 문자열(shared/api/pools.ts의 리스크 등급 사유와 조기 패널티, PCWidgets, invest-sidebar, risk-disclosure, overview-tab)과 어드민 리뷰 요약(step-save-publish)이 이제 포맷터를 쓴다. 어드민 입력의 percent 힌트는 FE-UI-v3 항목이자 Claude Design 목업이다. CLAUDE.md 규칙 7번을 다시 썼다(표시는 percent, 저장·입력·API·컨트랙트는 bps, 양방향 다리 없음, 표시 포맷터는 허용).
참고: CLAUDE.md 규칙 7번. 24-field-governance. 17-changelog v3-73. 노션 FE-UI-v3.
v3-72 — 펀드 데이터 DPD/NPL 모델: NPL은 장부상 연체(선행), 누적 손실은 실현 상각(NAV에 반영). 동적 버킷과 리스크 배지 신호 ✅ Decided
v3-72 — DPD / NPL 데이터 모델과 리스크 배지 신호
Date: 2026-07-13
배경: 인턴의 동남아·OJK 연체 기준 조사다. 결과: 대손 분류 비율 규제는 인도네시아와 베트남의 은행에만 존재하고(태국·필리핀·말레이시아·싱가포르는 추정), OJK는 은행과 P2P를 다르게 다루며 동남아 P2P에는 공통 비율 기준이 없다. 상각 시점은 재량이고(P2P는 대체로 90일 이상 연체를 부실로 본다) 연체 비율의 분모는 미상환 잔액이다. → Aset은 각 펀드가 보고하는 것을 수집하고 충당 표를 강요하지 않는다.
결정:
- NPL은 장부상 연체 잔액이다.
npl_threshold_days(펀드가 보고하고 기본 90)를 넘겼지만 아직 장부에 있는 대출이다. 선행하는 미실현 신호다. NPL 비율의 분모는total_outstanding_principal(잔액)이다 - 누적 손실은 실현 상각이고 별개 지표다. 펀드의
write_off_policy를 지나 장부에서 제거된 원금이다(재량이고 동남아 P2P는 약 180 DPD다). 실현됐으므로 NAV에 반영된다(상각 항). NPL 수치가 아니고 충당금도 아니다. (DB 컬럼cumulative_loss이고 마이그레이션 0074에서cumulative_impairment에서 이름을 바꿨다.) - 임계값 둘, 생애 하나:
npl_threshold_days(부실이지만 장부에 있음) →write_off_policy(제거됨 → 누적 손실)다. 한 시점에 절대 겹치지 않는다. 다이어그램은 06-writedown-nav에 있다 - DPD 버킷은 동적이다. 펀드가 보고하는 대로 버킷마다 행 하나다(임계값이 관할마다 다르다). 고정된 30/60/90이 아니다
- 리스크 배지 — 구별되는 신호 셋:
nav_per_token(실현 손실),npl_ratio(선행·미실현. S2에서 신규), 구조적 신호(lifecycle·트랜치·reserve_bps)다. NPL> 5% → High,2~5% → Medium이다.cumulative_loss는 표시 전용이다(이미 NAV에 있고 배지 입력이 아니다). 앞서의 "NPL이 NAV에 반영돼 있다"는 설명을 정정한다. 파트너 데이터가 없으면 1단계 신호로 우아하게 폴백한다. 04-pool-models Risk Levels 참조
범위(S2): 동적 DPD 표시, NPL 비율, 누적 손실 표시, 배지에 NPL% 반영, sector_breakdown 표시다. 미룬 것: 집중도·평균 만기·소진율(3단계), 자동 NAV 상각(v3-13. Joob의 대출별 데이터가 필요하다).
펀드 데이터 수집: npl_threshold_days와 write_off_policy를 더하고, dpd_bucket_standard(버킷이 lower_days를 담는다)와 dpd_denominator(고정이고 미상환 원금이다)를 뺀다. OJK collectibility_categories도 뺀다.
DB·저장소(CH — 마이그레이션 0073 추가분과 0074 이름 변경. 0071/0072는 이미 쓰였다): 스냅샷이 accrued_income / total_outstanding_principal / active_loan_count / total_overdue_loans / total_overdue_exposure / sector_breakdown을 얻는다. 새 자식 테이블 external_pool_dpd_buckets. pools.npl_threshold_days(기본 90)와 write_off_policy. cumulative_impairment → cumulative_loss 이름 변경(0074이고 BE 독자와 함께 나간다). BE: 펀드 데이터 동기화의 버킷 저장, 읽기 시점의 npl_ratio, 어드민 폼 필드, computePoolRiskTier의 NPL 분기다. (JY가 SQL 에디터로 추가 컬럼을 라이브 DB에 이미 적용했고 11-db-schema 문서는 CH의 마이그레이션 커밋과 동기화한다.)
열린 항목: Joob의 실제 NPL·상각 기준(없는 것인지 동기화 누락인지 확인), 누적 손실률의 분모(현재 잔액인지 누적 집행액인지), 배지 히스테리시스(미룸. 지금은 최신 스냅샷에서 다시 계산한다).
참고: 04-pool-models, 06-writedown-nav. 17-changelog v3-72. 노션 핸드오프 "DPD/NPL S2".
v3-71 — 필드 거버넌스 표를 컨트랙트와 대조해 정정. 온체인 재분류와 열린 타임락·법무 질문 🔧 Decided · items pending
v3-71 — 필드 거버넌스 표 정정
Date: 2026-07-13
배경: 24-field-governance를 컨트랙트(PlatformPool, libraries/GovernanceLib, PlatformKYCSoulbound)와 한 줄씩 대조하다 잘못된 분류 몇과 애초에 필드가 아닌 것 하나가 드러났다.
정정(코드로 확인. 표에 적용했고 문서만 바뀐다):
min_investment와capacity는 오프체인 정책이 아니라 온체인에서 강제된다. 둘 다 온체인PoolConfig에 살고 입금 시 강제된다.minInvestment에는 setter가 없고(배포 후 불변 → §5 B 부류),capacity/hardCap에는 **타임락 없는 올리기 전용setHardCap**이 있다(어떤 감소도 거부하고 무제한0상한을 좁히는 것도 거부한다 → §2 A 부류). 그 "잠김 / 올리기만" 동작은 백엔드 정책이 아니라 컨트랙트의 것이다. 옛 §6(C-lock)에서 뺐고 이제 그 절에는 구성원이 없다accepted_currencies는 불변이 아니다. 온체인addStablecoin/removeStablecoin(어드민)이 있지만 풀에 LP가 없는 동안만 작동하고(totalSupply() == 0) 첫 입금 뒤 얼어붙으며(ConfigImmutableAfterDeposit), 제거는 사용 중인 코인을 뺄 수 없도록 보호된다. 앞으로의 정책은 추가만이다- "오라클 / 주변부 주소" 행을 제거했다. 거버넌스 대상 필드가 아니다. 오라클은 role이고(
ORACLE_ROLE, "서비스 키") AccessControl로 즉시 부여·회수되며, 타임락이 아니라 좁은 범위로 제한된다(상환 처리, NAV 표시, pro-rata 수익 회계. 임의 주소로 자금을 옮길 수 없다). "주변부"는 KYC 구현체 주소이고 아래에 자기 행이 있다 perf_fee_pct를 제거했다. 성과 보수 몫은 구현되지 않았다. 만들어질 때까지 그 필드와perf_hurdle_pct를 표에서 뺀다- 수익 청구 수취인 표현을 명확히 했다. 거버넌스할 수취인 필드가 없다.
distributeYield는 아무도 지목하지 않고(전역 지분당 누적기만 올린다)claimYield는 언제나 자기 호출자에게 지급한다. 다른 홀더의 수익을 돌리는 것은 구조적으로 불가능하다 - 낡은
kyc_level_required행을 제거했다. 풀 수준의 KYC/KYB 레벨 게이팅은 이미 마이그레이션 0063에서 폐기됐다(requires_institutional도). 풀은 이제 관할로만 게이팅한다. 표에서는kyc_jurisdiction_whitelist와enforce_jurisdiction항목이 대신한다. (이 결정의 앞선 초안은 "그 타임락을 제거한다"고 잘못 제안했다. 그 필드는 더 이상 존재하지 않는다.) 적격투자자·QP(investor_tier) 게이팅은 별개의 백엔드 쪽 계획이다. KYC/KYB와 투자자 등급 명세를 볼 것
확인된 사실(변경 없고 이제 문서화됨): reserve_percentage에는 방향 제약이 없다. 7일 타임락으로 올릴 수도 내릴 수도 있고 ≤ 100%로만 제한된다.
제안:
- ✅ KYC 컨트랙트 구현체 — 즉시 업그레이드(7일
UPGRADE_TIMELOCK제거). 2026-08-14 채택, 코드에 반영(UPGRADE_TIMELOCK = 0). 나중에 다시 발견하지 않도록 그 비용을 기록해 둔다. 이탈 경로가 이 컨트랙트를 읽는다.PoolCommonLib.sol:61과RedemptionLib.sol:537/639/1048이requestRedemption,claimYield, 정산 지급, 허가 없는claimRedemptionFallback을kycContract.canRedeem(...)으로 게이팅한다. 모두에게false를 답하는 구현체는 모든 풀의 모든 출금을 막고, 불변 money-path는 도움이 되지 않는다. 바뀐 것이 자격 판정이기 때문이다. 7일은 홀더가 그것이 오는 것을 보고 먼저 나갈 수 있는 창이었다. 남는 것: 제안과 실행은 여전히 필수이고(_authorizeUpgrade가 직접upgradeToAndCall을 거부한다)cancelUpgrade도 그대로이며 이벤트 셋도 그대로 발생한다. 그러니 바뀐 것은 대기 시간이지 인가 게이트나 감사 기록이 아니다 nav_per_token의 하락 시 24시간 지연 — 이것이 법적 요구인지 운영상의 가드인지 확인할 것
범위: 정정은 문서만이다(표와 03-kyc-identity, 04-pool-models, CLAUDE.md가 이제 코드를 그대로 반영한다). NAV 24시간의 법적 확인은 대기 중이고, KYC 즉시 업그레이드는 2026-08-14에 채택돼 구현됐다.
참고: 24-field-governance, 03-kyc-identity, 04-pool-models. 컨트랙트 PlatformPool.sol, libraries/GovernanceLib.sol, PlatformKYCSoulbound.sol. 17-changelog v3-71.
v3-70 — money-path 용어 통일 (자본 축과 수수료 축). 새 표준 문서 23-money-path ✅ Decided
v3-70 — money-path 명명 통일과 표준 문서
Date: 2026-07-10
배경: 지갑과 은행 계좌 용어가 일관되지 않은 축으로 갈려 있었다. 지갑은 fund_wallet과 fund_fee_wallet을 쓰는데 펀드 데이터 시트는 그것들을 pool_bank_account(자본)와 fund_bank_account(수수료)에 짝지었고, 산문은 treasury와 treasury_wallet을 둘 다 썼다. 그래서 입금 money-path 지갑을 수수료 지갑과 혼동하기 쉬웠다(v3-69 뒤의 근본 우려다). 끝에서 끝까지의 자금 지도가 없었고 흐름이 04, 07, 09a에 흩어져 있었다.
결정 — 모든 지갑과 그 법정통화 출구를 축 하나(자본 대 수수료)로 이름 붙이고 접두사를 공유한다:
- 투자자 자본:
fund_wallet(온체인, 변경 없음) →fund_bank_account(법정통화) - FM 풀 관리 수수료:
fund_fee_wallet(v3-69. 온체인과 DB에 살아 있다) →fund_fee_bank_account(법정통화) - Aset 수수료:
treasury_wallet(표준이고 느슨한 "treasury"를 대체한다) - 리저브: 지갑도 컨트랙트도 아니다.
PlatformPool안의reserveBalance다("리저브 컨트랙트"는 없다.PlatformEscrow는 v3-11에서 제거됐다) - **
pool_wallet**은 폐기됐다(죽은 레거시 AS_POOL 컬럼). 자본 지갑이었던 적이 없다
코드 변경 없음: DB 마이그레이션도 온체인 변경도 필요 없도록 일부러 이 체계를 골랐다. 살아 있는 식별자(fund_wallet, treasury_wallet, reserve_balance)가 이미 맞는다. 펀드 데이터 시트만 맞춘다(pool_bank_account → fund_bank_account로, 옛 fund_bank_account → fund_fee_bank_account로) 그리고 문서 산문을 맞춘다. admin_fee_pct → platform_yield_take_pct JSONB 이름 변경은 v3-69 아래에서 나갔다(마이그레이션 0067, 그다음 0075에서 *_pct → *_bps).
온체인과 플랫폼 밖의 경계: Aset은 끝에서 끝까지 스테이블코인(USDC)이고, 온체인 경로는 fund_wallet이나 Pool에서 시작하고 끝난다. 법정통화 전환(OTC → 은행 계좌)은 플랫폼 밖이고 파트너 쪽이다. Aset은 정합을 위해 계좌 정보만 기록한다. 상환 펀딩은 대칭이고 role로 게이팅된다(YIELD_DEPOSITOR_ROLE = fund_wallet을 통한 fundRedemption). 고정되거나 하드코딩된 주소는 없다(풀에 설정된 목적지는 withdrawFees의 두 갈래 → treasury_wallet과 fund_fee_wallet뿐이고 둘 다 v3-69에 따라 어드민이 설정할 수 있다). 원천징수세는 money-path 문서의 범위 밖이다.
범위: 세 흐름과 명명 표, 경계를 모은 새 표준 문서 23-money-path(와 ko). 04와 09a에서 상호 링크 추가. 사이드바 Protocol 아래 항목. 그리고 24-field-governance(와 ko)도 더한다. 거버넌스 관련 펀드·풀 필드를 한눈에 보는 표(저장 · 부류 · 편집자 · 타임락)이고, 08의 타임락 기준을 상호 참조하는 파생 뷰다.
참고: 구글 시트 "a-set Fund Data List (Internal)". 23-money-path, 04-pool-models, 09a-custody. v3-69 위에 얹는다.
v3-69 — 수수료 구조: 수취자 셋(Aset / 펀드 / 운용). 풀 관리 수수료는 분배 시점에 fund_fee_wallet으로 간다 ✅ Decided · 🛠 Done
v3-69 — 수취자별 수수료 분리, 풀 관리 수수료는 fund_fee_wallet으로
Date: 2026-07-09
배경: BD가 내년 상장 상품을 위한 앞으로의 수수료 설계를 확정했다(현재 FJO의 Aset 수수료는 0이다). 옛 단일 admin_fee_pct → treasury 모델은 수수료마다 수취자가 다르다는 것을 담지 못한다.
결정 — 수취자 셋:
- ① Aset(treasury) ← 플랫폼 몫(수익 take)과 SPC 관리(그리고 쓰인다면 성과)
- ② 펀드(별도 수수료 지갑) ← 풀 관리만이다. 펀드 매니저의 수수료이고 분배 시점에 별도의 펀드 수수료 수령 주소로 직접 보낸다.
fund_fee_wallet이고 입금 money-path의fund_wallet과 구분된다(수수료 수익을 집행된 자본에서 분리한다. 이름은 CH와 정할 것). Aset을 거치지 않는다. 요율은 거래마다 다르고(DBpool_mgmt_pct) 수수료 금액은 오프체인(Lambda)에서 계산해 컨트랙트에 넘긴다. 현재 수수료 아키텍처와 일관된다. 요율 변경은 즉시 적용된다(타임락 없음). (온체인bps저장과 컨트랙트 쪽 계산, 온체인 타임락은 선택적인 신뢰 불필요 변형이고 필수가 아니다.) - ③ 운용(오프체인이고 투자자 수수료가 아니다) ← 투자자 소싱(유통 파트너의 몫)과 온·오프 램핑(거래마다 Aset이 OTC 비용을 낸다). BE와 컨트랙트 범위 밖이다. 옛 진입 수수료(
deposit()에서 떼는) 설계는 폐기했다. 소싱은 운용 비용으로 재분류한다
온체인 변경 — 반영됨: 분배 시점에 withdrawFees가 목적지 둘로 나뉜다. treasury(Aset 수수료)와 별도 fund_fee_wallet(풀 관리)이다. 이 수수료 주소는 입금 money-path의 fund_wallet이 아니다. 둘은 별개다. 입금 money-path(90% fund_wallet / 10% 리저브이고 v3-27에 따라 불변)는 그대로다. 분배 단계만 바뀐다.
타임락 정책: 수수료 쪽 변경, 즉 요율과 수수료 목적지 주소 둘(treasury, fund_fee_wallet)은 거버넌스가 통제하지만 타임락은 없다. 수수료 분배는 보류 없이 자동이므로 타임락은 지연 창 동안 수수료를 낡은 목적지에 묶어 둘 뿐이다. 입금 fund_wallet(투자자 자본)만 타임락을 유지한다.
DB: net_yield_fee_config에서 admin_fee_pct를 platform_yield_take_pct로 바꾸고 관리 수수료를 spc_mgmt_pct(→ Aset treasury)와 pool_mgmt_pct(→ 별도 fund_fee_wallet)로 나눈다. 수취자가 다르므로 나누는 것이 필요하다.
⚠️ 메커니즘은 v3-102가 대체했다. 분리 자체는 유효하다
아래의 목적지 둘 분리와 요율, 타임락 없음 정책은 전부 여전히 유효하다. 바뀐 것은 진입점이다. withdrawFees는 더 이상 함수로 존재하지 않는다. 그 두 갈래가 **settleYield(stablecoin, netAmount, treasuryAmount, poolMgmtAmount)**로 합쳐졌으므로, 수수료가 투자자에게 적립하는 것과 같은 트랜잭션에서 지급된다. 별도 수수료 호출도, 반쯤 정산된 기간도 없다. 아래의 withdrawFees는 "settleYield의 수수료 갈래"로 읽을 것. FeesWithdrawn 이벤트는 이름을 유지했다.
결과 — 끝에서 끝까지 구현됨:
- 컨트랙트:
PlatformPool.withdrawFees(stablecoin, treasuryAmount, poolMgmtAmount)→YieldLib.withdrawFees가 합친 인출을unclaimedYield에서 한 번 차감한 뒤 갈래마다(treasuryWallet,fundFeeWallet) 같은 tx에서 이체하고 갈래마다FeesWithdrawn을 하나씩 낸다. 0인 갈래는 건너뛴다.poolMgmtAmount > 0인데 지갑이 설정되지 않았으면address(0)으로 태우는 대신FundFeeWalletNotSet으로 revert한다.fundFeeWallet은PlatformPoolStorage에 공개 getter와 함께 저장되고GovernanceLib.setFundFeeWallet은 타임락 없는 즉시 어드민 setter다(아래 정책에 따라) - DB(0067):
pools.fund_fee_wallet(≠fund_wallet)과yield_distributions.pool_mgmt_fee_amount.admin_fee_pct→platform_yield_take_pct에 새spc_mgmt_pct/pool_mgmt_pct키. 요율은 나중에 0075에서 bps로 통일됐다(*_bps) - Lambda:
lib/shared/yield/run-distribution.ts가 플랫폼 몫(gross × bps/10000)과 SPC·풀 관리(aum × bps/10000 × days/365)를 오프체인에서 계산하고,yield-distributions.post.distribute가withdrawFeesOnChain을 통해poolMgmtAmount를 분리 호출로 넘긴다
의도적으로 만들지 않은 것: 온체인 bps 저장과 컨트랙트 쪽 수수료 계산, 온체인 타임락이다. 선택적인 신뢰 불필요 변형이다. 성과 보수는 잠자는 채로 남는다(v3-96).
참고: 노션 — 플랫폼 Fee(설계 기준)와 CH 핸드오프(온체인·BE 스펙). 04-pool-models → net_yield_fee_config / Fee Destination, 23-money-path, 17-changelog v3-69. :::
v3-68 — NAV 안전장치(서킷 브레이커·이탈 상한·신선도)는 운영용이다. 투자자 UI는 없다 ✅ Decided
v3-68 — NAV 안전장치에는 투자자용 표시가 없다
Date: 2026-07-09
배경: 가드 커버리지 감사(#12)가 투자자 앱이 온체인 서킷 브레이커와 NAV 이탈 상한, 신선도 가드를 노출해야 하는지 물었다. 온체인 NavLib._checkNavBound가 모든 updateNAV에서 서킷 브레이커와 양방향 이탈 상한을 강제하고, RedemptionLib.executeEpoch가 회차 정산에서 신선도를 강제한다.
결정: 이 안전장치들은 운영용이지 투자자용이 아니다. 투자자 앱은 브레이커나 신선도 표시를 하지 않는다.
- 강제와 앱 경로(백엔드):
nav-changes.post.propose가simulateUpdateNav(viemsimulateContract/eth_call드라이런)로 사전 점검하고 커스텀 오류(CircuitBreakerActive/NavDeviationExceeded/InsufficientReserve등)를 깔끔한 4xx로 디코딩한다. 낭비되는 가스도, DB 드리프트도 없다. 이탈 상한과 신선도 임계값에는 온체인 getter가 없으므로(setter만 있다) 시뮬레이트가 기준이다. 의도적으로 DB에 미러링하지 않는다 - 왜 투자자 UI가 없나: 브레이커 리셋과 상한 설정은 PAUSER·ADMIN 멀티시그 행위다. 투자자가 행동할 수 있는 상태가 없다. 정산 시점의 신선도는 회차 스케줄러의
epoch_circuit_breaker/epoch_execute_failed운영 알림이 다룬다. 투자자는 상각 배너와 NAV 이력으로 NAV 움직임을 보고, 그것이 의미 있고 행동 가능한 신호다
범위: 컨트랙트·DB·프런트엔드 변경 없음. 이미 강제되고 있는 불변식에 대한 문서상 명확화다.
참고: 06-writedown-nav → NAV Guard Rails, 17-changelog v3-68. NavLib._checkNavBound, RedemptionLib.executeEpoch, nav-changes.post.propose.ts.
v3-67 — 회차 상환 게이팅 상한의 기준은 TVL이 아니라 `totalDeposited`다 (문서·주석 정정) ✅ Corrected
v3-67 — 상환 게이팅 상한의 기준은 totalDeposited
Date: 2026-07-08
배경: 문서(04-pool-models, 07-redemption)와 컨트랙트 자신의 주석과 테스트까지 회차 상환 게이팅 상한을 gatingBps × TVL로 서술했다. 실제 온체인 코드(RedemptionLib._epochFillRatio)는 gatingCap = totalDeposited × redemptionGatingBps / 10000을 계산한다. 기준은 TVL이 아니라 **totalDeposited**다.
명확화(동작 변화 없음): 회차별 게이팅 상한은 redemptionGatingBps/10000 × totalDeposited이고, 체결률 계산 전에 available(리저브 + heldFundReleases + 회차 톱업)에 적용된다. totalDeposited는 온체인의 입금 자본 누적기다. deposit에서 += 입금, reinvest나 YieldLib에서 += 재투자된 수익, redeem에서 −= 지급액이다. 즉 장부 자본이지 TVL(LP 공급량 × NAV, 시장 가치)이 아니다. 상각(NAV < 1)이나 미분배 수익이 있으면 둘이 갈리므로 게이팅은 장부 자본에 대고 잰다.
수정: 04-pool-models와 07-redemption의 "TVL" 라벨과 컨트랙트 주석(PlatformPoolStorage, RedemptionLib, PlatformPool), 테스트 주석(PlatformPoolEpoch.t)을 정정했다. 코드는 그대로다.
참고: 04-pool-models, 07-redemption, 17-changelog v3-67. RedemptionLib.sol의 _epochFillRatio.
v3-66 — `NO_EARLY`는 락업 없음을 뜻한다 (`lockup_days = 0`) ⤳ Superseded by v3-83
v3-66 — `NO_EARLY` = 락업 없음
Date: 2026-07-08
⚠️ v3-83이 대체했다(2026-07-20). 락업과
penalty_type은 이제 독립이다.NO_EARLY풀도lockup_days > 0을 가질 수 있다(락업 중에는 LOCKED이고 그다음 무패널티 이탈이다). 아래의 설정 불변식과 그 온체인 가드(NoEarlyRequiresZeroLockup)는 제거됐다. 이 카드의 나머지는 이력용으로 남긴다.
배경: 문서(04-pool-models, 05-investment-lifecycle, 07-redemption, CLAUDE.md)가 penalty_type = NO_EARLY를 "조기 상환에 패널티가 없으므로 투자자가 락업 기간에도 상환할 수 있다"로 틀 잡았다. 락업이 있지만 무패널티라는 함의이고, NO_EARLY + LOCKED 배지와 상환 상태까지 문서화하고 있었다. 이는 의도를 오독한 것이다.
결정: NO_EARLY ⟺ 락업 없음. penalty_type = NO_EARLY인 풀은 lockup_days = 0이므로 LOCKED 상태가 없다. 투자자는 입금 순간부터 언제든 무패널티로 상환할 수 있다. 락업(lockup_days > 0)은 패널티 타입(FLAT_FEE / PRINCIPAL_BASED / YIELD_BASED)과 함께일 때만 의미가 있다. 상환 메커니즘("lockup_days = 0이면 LOCKED 상태를 건너뛴다")과 이미 일관된다.
설정 불변식: penalty_type = NO_EARLY ⟺ lockup_days = 0.
강제(완료): 문서와 CLAUDE.md를 정정했다. 불변식은 풀 생성에서 거부되고(pools.post.create → penalty_type NO_EARLY requires lockup_days = 0) 온체인에서도 거부된다. GovernanceLib.setLockupDays가 NO_EARLY 풀에 양수 락업을 설정하면 NoEarlyRequiresZeroLockup으로 revert한다(2026-07-16). 그래서 팩토리 설정을 직접 호출해도 우회할 수 없다.
참고: 04-pool-models, 05-investment-lifecycle, 07-redemption, 17-changelog v3-66.
v3-65 — Fund Data 근거 확보: total_subscribed, 펀드 수준 서비스 제공자, NPL과 상각 연체 ✅ Decided · 🛠 Done
v3-65 — Fund Data 근거 확보
Date: 2026-07-07
배경: PDP Fund Data 검토에서 뒷받침하는 컬럼이 없는 지표("Total subscribed")와, Aset이 받지도 않는 전체 대출 장부 DPD 분포를 함의하는 연체 블록을 발견했다.
결정:
- Total subscribed — 펀드가 보고하는 누적 약정 원금(이미 펀드 데이터 요청 필드이자
summary.fund.totalSubscribed다)을external_pool_data_snapshots.total_subscribed로 저장한다(0054). Aset 자신의 약정액은deposits에서 계산할 수 있다. 펀드 수준 수치는 펀드가 보고하는 것이고 동기화 때 스냅샷 행에 찍힌다 - 서비스 제공자 — 풀별이 아니라 펀드 수준으로 모델링한다(
fund_service_providers, 0054). 어드민의 펀드 생성·수정에서 입력하고 풀의 Fund Data 탭에 읽기 전용으로 노출한다. 그 펀드의 풀들이 물려받는다 - 연체 = NPL과 상각 — Fund Data의 연체 블록은
loan_writeoffs(대출 단위로 실제로 받은 상각된 NPL 건)를 중심으로 틀 잡는다. 정상 대출의 전체 DPD 분포가 아니다. 없으면 빈 상태를 보여 준다
상태: ✅ 만들었다(마이그레이션 0054. 펀드 제공자 CRUD와 GET /pools/{id}/fund-data/writeoffs, 어드민의 펀드 생성·수정 제공자 편집기, 투자자 Fund Data 탭의 상각 대출과 제공자 절).
v3-64 — 재투자는 같은 풀에만 한다 (다른 풀로 재투자 불가) ✅ Decided · 🛠 Done
v3-64 — 재투자는 같은 풀 전용
Date: 2026-07-04
배경: 문서가 "다른 풀로의 재투자"를 앞으로의(V2) 기능으로 틀 잡았고, 투자자 포트폴리오 UI가 모든 활성 풀을 나열하는 "Target Pool" 드롭다운을 내보냈다. 풀 A의 수익을 풀 B에 재투자할 수 있다는 함의다. 그것은 구조적으로 불가능하고 심각하게 오도하는 기능 표시였다.
결정: 재투자는 설계상 같은 풀 전용이다. 온체인 reinvest(uint256)은 호출자의 그 풀 안의 accruedYield를 소비해 그 풀의 LP를 민팅한다. 출처와 대상 파라미터가 없고, /yield/reinvest 핸들러도 주어진 풀 자신의 발생 수익만 재투자한다. "다른 풀로의 재투자" 기능은 없다. V2도, 로드맵도 아니다. 수익을 다른 풀에 배분하려면 투자자가 claimYield()로 지갑에 받은 뒤 그 풀에 평범한 deposit()을 한다.
범위: FE는 apps/web/app/routes/portfolio.tsx에서 "Target Pool" 선택기와 다른 풀 분기를 제거했다(재투자는 언제나 원 풀을 대상으로 한다. tsc -b 통과). 문서는 02-core-concepts와 05-investment-lifecycle의 표현을 고쳤다("다른 풀 재투자는 V2" 줄을 뺐다). 백엔드와 컨트랙트는 이미 같은 풀 전용이었고 변경이 없다.
참고: 02-core-concepts, 05-investment-lifecycle, 08a-contract-reference, 17-changelog v3-64.
v3-63 — 리저브 회복은 홀더별이 아니라 풀 단위다 ✅ Decided · 🛠 Done
v3-63 — 리저브 회복은 풀 단위
Date: 2026-07-02
배경: PDP의 "리저브 회복" 바가 홀더별로 틀 잡혀 있었고 그것은 오도한다. 청산에서는 같은 트랜치의 모든 홀더가 같은 pro-rata 비율로 상환받는다. 홀더별 회복률이라는 것은 없다.
결정: 회복을 풀 단위로 보여 준다. nav_per_token과 액면을 비교하는 회복 바(reserve_percentage / nav_history.reserve_consumed와 함께)에 보는 사람 자신의 예상 금액(portfolio_positions.effective_value × 회복 NAV)과 "모두가 같은 비율로 회복한다"는 설명을 더한다. 새 백엔드는 없다. (홀더별 틀이 다시 나오지 않도록 여기 기록한다.)
상태: ✅ 만들었다(position-card.tsx의 WIND_DOWN 분기).
참고: 07-redemption, 06-writedown-nav, 17-changelog v3-63. FE-UI PDP-6(노션).
v3-62 — 숫자·USD·날짜 표시 관례 ✅ Decided
v3-62 — 숫자·USD·날짜 표시 관례
Date: 2026-07-02
결정: 숫자와 값 표시를 앱 전체에서 표준화한다.
- 숫자: 모든 지표와 표가
tabular-nums를 쓰고 소수 자릿수를 일관되게 하며 소수점 기준 오른쪽 정렬한다 - USD: 카드와 지표는 축약형(
$28.5M), 표와 툴팁, 상세는 전체 표기($142,400)다. USD 전용(v3-24)은 유지한다 - 날짜: 카운트다운과 lifecycle은 상대 표기(
15 days left), 만기와 고정 날짜는 절대 표기(Jul 15, 2026)다
공유 formatUsd()와 날짜 헬퍼를 한 번 정의해 앱 전체에 적용하고, 공유 SectionCard / StatCard 토큰과 함께 토큰화했다(무분별한 전역 치환 회귀를 피하려고 함께 묶었다). FE 표현만이고 스키마나 컨트랙트 영향은 없다.
참고: 17-changelog v3-62, 디자인 NOTES.md. FE-UI DC-4(노션).
v3-61 — 투자자별 상한(`max_investment`)은 숨긴다. 컨트랙트 필드는 잠자는 채로 둔다 ✅ Decided
v3-61 — `max_investment` 숨김, 필드는 잠자는 상태
Date: 2026-07-03
배경: max_investment는 온체인의 투자자별 누적 상한이다(PlatformPool.sol의 positions[msg.sender].totalInvested. 0이면 검사 꺼짐). 벤치마크 플랫폼(Centrifuge, Maple, Ondo, Securitize)과 견주면 비표준 원시 요소다. 그쪽에서는 투자자별 통제가 KYC와 자격 게이팅에서 오고 풀 전체 크기는 capacity다.
결정: 풀 생성·수정 폼에서 max_investment를 숨기고 언제나 0(무제한)을 보낸다. 폼에는 capacity(풀 전체)만 남는다. 컨트랙트 필드는 잠자는 채로 남기고 제거하지 않는다. Clone은 불변이므로 제거하려면 새 구현체와 팩토리 재배포가 필요하고, 0은 비용 없는 무동작이다. 나중에 고래 방지나 공정 배분, 규제상 투자자별 한도가 필요해지면 UI를 다시 붙이면 된다. 컨트랙트 변경은 필요 없다.
참고할 점: 규제 적합성(풀별 고정 상한 대 실제 투자자별 소득·순자산·연간·플랫폼 합산 한도)은 별개의 법무 확인 사항이다.
참고: 11-db-schema(max_investment), 04-pool-models(수정 표. B 부류 불변), 17-changelog v3-61. FE-UI CF-3(노션).
v3-60 — 국가 코드의 표준 형식은 ISO alpha-3다 ✅ Decided (reversed from alpha-2)
v3-60 — 표준 국가 코드는 ISO alpha-3
Date: 2026-07-07 (뒤집힘. 원래는 2026-07-02에 alpha-2로 결정했다)
배경: 관할 게이팅은 풀의 kyc_jurisdiction_whitelist를 SBT의 온체인 countryCode와 비교한다. SumSub은 alpha-3을 반환하고(IDN, KOR) resolveApplicantCountry가 그것을 그대로 저장하므로(sumsub/client.ts) 민팅된 SBT는 이미 alpha-3을 담고 있으며, 온체인 거버넌스 경로(pools.post.governance.ts)도 이미 alpha-3을 검증한다(/^[A-Za-z]{3}$/). 그러니 SumSub → DB → SBT → 온체인 화이트리스트로 이어지는 살아 있는 파이프라인은 이미 끝에서 끝까지 alpha-3이다. 어긋난 곳은 오프체인뿐이다. 어드민 화이트리스트 UI 힌트가 alpha-2를 입력하라고 안내하고("예: KR, SG") 오프체인 kyc-gating.ts 비교가 변환 없이 대문자 매칭만 하며, 컨트랙트 독스트링(08a §KYCData)이 countryCode를 alpha-2로 라벨한다. 그래서 alpha-2 화이트리스트 항목(KR)이 저장된 alpha-3 국가(KOR)와 절대 맞지 않고, 게이팅이 켜져 있으면 모두를 조용히 거부한다.
결정: 어디서나 ISO 3166-1 alpha-3으로 표준화한다("KR"이 아니라 "KOR"). 이것이 2026-07-02의 alpha-2 결정을 뒤집는다. 앞선 방향은 끝내 나오지 않은 BE 정규화 계층을 요구했고, alpha-3은 SumSub과 DB, SBT, 온체인 검증 전반에서 이미 현실이다. BE 정규화는 필요 없다. 대신 오프체인의 alpha-2 가정을 alpha-3으로 고친다.
남은 작업: ① 어드민 UI — pool-create와 pool-edit의 step-risk-penalty.tsx에서 화이트리스트 힌트와 자리표시자를 alpha-3으로 바꾼다("예: USA, KOR, SGP"). ② 핸들러 — 생성·수정에서 alpha-3 형식 검증(정확히 대문자 3자)을 더해 온체인 거버넌스 검사와 맞춘다. ③ 컨트랙트 독스트링 — 08a §KYCData의 countryCode를 alpha-2 → alpha-3으로 정정한다(governance.ts:41의 타입 주석도). ④ 남아 있는 레거시 alpha-2 행이나 SBT를 alpha-3으로 이관한다(SumSub의 새 쓰기는 이미 alpha-3이다). country.ts의 ['US','USA'] 이중 형태 임시방편은 남겨 둬도 된다(무해하다).
참고: 04-pool-models, 03-kyc-identity, 08a-contract-reference, 17-changelog v3-60. B1(노션).
v3-59 — DPD 연체 버킷을 일반화한다 (고정 30/60/90 → 관할이 정하는 동적 버킷) ✅ Decided · 🚧 To build
v3-59 — 동적 DPD 버킷
Date: 2026-07-03
배경: PDP 연체 절은 실제 파트너 데이터를 보여 주는데 수집 모델이 30/60/90일 버킷을 하드코딩한다. 다른 규제 당국은 다르게 나눈다. 예를 들어 인도네시아 OJK 대손 분류는 90/120/180 경계로 다섯 범주(정상 / 요주의 / 고정 / 회수의문 / 추정손실)를 쓴다. 고정된 {30, 60, 90} 객체는 그것을 표현할 수 없으므로 동남아와 OJK 파트너 데이터를 충실히 받을 수 없다. 이 일반화는 인턴의 동남아·OJK 조사가 착지하기 위한 선결 조건이다.
결정:
- 버킷은 고정
{30/60/90}객체가 아니라 동적이다. 펀드마다 자기 경계를 보고한다 - DB는 자식 테이블(옵션 A):
pool_dpd_buckets(snapshot_id, lower_days, loan_count, exposure)이고 버킷마다 행 하나다. 부모 리스크 스냅샷이dpd_bucket_standard(예:"OJK collectibility"/"30/60/90 internal")와npl_threshold_days(NPL 기준선, 기본 90)를 얻는다. 선택적인dpd_denominator가 비율의 기준(미상환 원금인지 활성 대출 수인지)을 기록한다 - FE는 변경 없음: 연체 렌더러(PDP-1)가 이미 동적이라 API가 반환하는 어떤 버킷 배열이든 흡수한다
왜 JSON이 아니라 자식 테이블인가: 정규화된 자식 테이블은 버킷을 질의·정렬 가능하게 유지하고(ORDER BY lower_days) 검증 가능하게 하며(겹침이나 음수 없음) 시계열에 적합하다. 관할마다 스냅샷 행을 넓히지 않고도 그렇다.
범위(🚧 아직 만들지 않음) — 이음매 셋: ① 파트너 구글 시트("Data Mapping" 탭): dpd_buckets 고정 객체 → 배열 [{lower_days, count, exposure}]과 새 dpd_bucket_standard / dpd_denominator / npl_threshold_days 필드. ② DB: pool_dpd_buckets와 스냅샷 컬럼 둘 추가(새 마이그레이션). ③ 수집과 API 매퍼: 배열을 받고 lower_days로 정렬하고 검증한 뒤 FE가 순회하도록 그대로 노출한다. 온체인 영향은 없다. DPD 데이터는 수집과 표시 전용이고 NAV에 쓰이지 않는다(그건 별개의 v3-13 대출별 파이프라인이다). 인턴의 동남아·OJK 조사(표준 버킷, NPL 기준선, 상각 시점, 분모 관례)와 짝이다.
참고: 06-writedown-nav, 20-joob-pool-config, 11-db-schema(external_pool_data_snapshots, loan_writeoffs), 17-changelog v3-59. FE: PDP-1(동적 렌더, 완료).
v3-58 — 허가 없는 LP 전송: 화이트리스트 게이트 제거 (검증은 가치 경계에서 강제한다) ✅ Decided · 🛠 Done
v3-58 — 허가 없는 LP 전송
Date: 2026-07-02
배경: PlatformLPToken이 투자자 간(유통) 전송을 어드민이 관리하는 whitelist 매핑 뒤에 두고 있었다. from과 to 둘 다 허용 목록에 없으면 전송이 NotWhitelisted로 revert했다. 그런데 어떤 백엔드나 앱 코드도 setWhitelist를 호출한 적이 없었다(컨트랙트 테스트만 호출했다). 그래서 실제로 매핑이 비어 있었고 유통 전송이 사실상 모두에게 막혀 있었다. 이것은 5상태 홀더 모델과도 충돌했다. 그 모델의 **State C(미검증 홀더)**는 Aset에 등록하지 않고 전송으로 LP를 받은 지갑을 위해 명시적으로 존재하는데, 아무도 올라 있지 않은 화이트리스트 아래에서는 불가능하다. 화이트리스트는 살아 있는 컴플라이언스 통제가 아니라 구현 관성이었다.
결정: 화이트리스트 게이트를 전부 제거한다. 유통 LP 전송은 허가가 필요 없다. 어느 쪽도 허용 목록 항목이 필요 없다. 검증은 가치 경계에서 강제하고, 그것은 온체인에서 이미 참이었다. deposit()은 requiresKYC를 달고 requestRedemption()과 claimYield()는 requiresRedeemableKyc(canRedeem)를 단다. 그래서 지갑은 자유롭게 LP를 받고 보유할 수 있지만(→ State C) KYC를 마치기 전까지 입금도 상환도 수익 청구도 할 수 없다(→ State A). 어드민 pause()는 비상시 모든 유통 전송을 여전히 얼린다(N-3/N-6). 민팅과 소각, 상환 잠금 경로는 영향이 없다.
왜 안전한가(컴플라이언스 후퇴가 아니다): LP는 증권을 대표하지만 중요한 게이트는 가치의 유입과 유출이지 토큰의 이동이 아니다. 플랫폼 밖에서 취득한 LP(유통 시장, P2P)도 우리 플랫폼에서 상환하거나 청구하려면 KYC를 통과해야 한다. 출구가 게이팅되므로 미검증 홀더는 절대 가치를 빼낼 수 없다. "들어오는 가치는 막되 나가는 가치는 절대 가두지 않는다"는 원칙과 맞는다(v3-19).
범위와 안전성: PlatformLPToken.sol에서 whitelist 매핑과 setWhitelist, NotWhitelisted 오류, WhitelistUpdated 이벤트, _update의 검사 둘을 제거했다. _requireNotPaused()와 onLpTransfer 수익 정산은 유지했다. 테스트를 갱신했다(화이트리스트 전용 테스트 3개 제거, revert 테스트 2개를 허가 없는 성공 테스트로 뒤집음, 스위트 6개에서 셋업의 setWhitelist 호출 제거). 291/291 통과. 어떤 백엔드나 앱도 화이트리스트를 참조하지 않았다. 기존 풀은 소급 적용되지 않는다(clone은 불변이다). 새로 만들어지는 풀에 이 동작이 적용되려면 새 LP 구현체와 팩토리를 재배포해야 한다.
참고: 01-overview, 08-smart-contracts, 08a-contract-reference, 21-holder-verification, 05-investment-lifecycle, 17-changelog v3-58.
v3-57 — Pool Updates(WO-6) RBAC 정식화: 오퍼레이터 옵트인, 소프트 삭제 보존, MATERIAL_EVENT 개정 감사 ✅ Decided · 🛠 Done
v3-57 — Pool Updates·공지의 권한
Date: 2026-07-02
배경: 풀별 Updates·공지 피드(v3-22 / WO-6)가 나갔다. 마이그레이션 0012(pool_updates와 pool_update_revisions)와 엔드포인트 POST /pools/{id}/updates, GET /pool-updates, PATCH·DELETE /pool-updates/{id}다. 그런데 권한 모델이 문서가 아니라 코드에만 있었다. 여기서 정식화한다(동작 변화는 오퍼레이터 하나다).
결정(권한):
- 생성·수정·삭제 = SUPER_ADMIN, ADMIN, FUND_MANAGER, 그리고 어드민이 부여한
pools페이지 권한을 통한 OPERATOR 옵트인이다(보기와 같은 게이트다. 전에는 오퍼레이터가 강하게 제외돼 있었다). 권한을 받은 오퍼레이터는 플랫폼 전체에서 어드민처럼 행동한다 - FM은 자기 펀드의 풀에만 올리고(
requireFundAccess) 자기 글만 수정·삭제할 수 있다. Admin과 Operator, Super Admin은 어떤 항목이든 수정·삭제할 수 있다 - 분류는
INFO/IMPORTANT/MATERIAL_EVENT다.IMPORTANT와MATERIAL_EVENT는 발행 시 모든 홀더에게 이메일을 보내고INFO는 인앱 피드만이다. FM의 중대 사건 발송 전에 별도의 어드민 검토 게이트는 없다. FM이 자기 펀드의 공시를 소유하고, Admin과 Operator는 사후 수정·삭제 권한을 유지한다 - 삭제는 소프트 전용이다(
deleted_at). 행은 보존되고(피드에서 숨겨진다) 절대 하드 삭제되지 않는다. 영구 감사 기록이다.MATERIAL_EVENT를 수정하면(또는 그것으로 분류를 바꾸거나 그것에서 빼면) 수정이 반영되기 전에 이전 버전을pool_update_revisions에 스냅샷으로 뜬다 DRAFT풀에는 업데이트를 만들 수 없다
범위와 안전성: 오프체인만이고 컨트랙트나 스키마 변경이 없다. pools 페이지 권한 게이트는 admin_user_permissions를 재사용하고 requireFundAccess는 FM이 아닌 role에서 아무 일도 하지 않는다. 코드: pool-updates.post.create / .patch.update / .delete(OPERATOR와 requirePagePermission('pools') 추가)와 .get.list다. tsc 통과.
참고: 09-rbac → Pool Updates, 11-db-schema(pool_updates / pool_update_revisions, 0012), 15-api-reference → Pool Updates, 17-changelog v3-57, v3-22(피드 명세).
v3-56 — 전액 LP 민팅: 리저브 분리가 더 이상 투자자의 청구권을 희석하지 않는다 ✅ Decided · 🛠 Done
v3-56 — 전액 LP 민팅
Date: 2026-07-02
배경: 첫 컨트랙트(2026-02-03, EmergeFiPool) 이래로 deposit()과 reinvest()가 LP를 리저브를 뺀 금액에 대해 민팅했다. lpAmount = (amount − reserve) × NAV_PRECISION / nav다. 기본 10% reserve_percentage에서 $100 입금은 LP 90개만 민팅했고, 투자자의 상환 가능한 청구권(LP × NAV)이 조용히 입금보다 10% 낮았으며 리저브 몫은 개별적으로 되돌려 적립된 적이 없었다(WIND_DOWN에서 pro-rata로만). 이것은 기록된 제품 결정이 아니라 구현 관성이었다.
결정: LP를 전액에 대해 민팅한다. lpAmount = amount × NAV_PRECISION / navPerToken이고 입금과 재투자 둘 다다. 리저브 분리는 자금 흐름으로는 그대로이지만(리저브는 reserveBalance에 남고 나머지는 fund_wallet으로 나간다) 이제 풀 수준의 유동성 배분일 뿐이다. 리저브와 LP는 독립이다. NAV 1.0에서 $100 입금은 LP 100개를 민팅한다.
범위와 안전성: PlatformPool.deposit()과 reinvest()(2줄), 백엔드 미러 calculateLpTokens()(입금 교차 검증과 재투자 DB 적립에 쓰이므로 온체인과 일치해야 한다), 그리고 테스트 26개 갱신(295/295 통과)이다. 기존 풀은 소급 적용되지 않는다(clone은 불변이다). 옛 Sepolia 테스트 풀은 리저브 차감 민팅을 유지하므로 아카이브하거나 다시 만들어야 한다. 옛 풀에 대한 재투자 추정은 reserve_percentage만큼 과다 적립된다. 새 PoolImpl과 팩토리로 재배포했다(apps/contract/sepolia.md 참조).
참고: 05-investment-lifecycle(④단계 산식), 08-smart-contracts, apps/contract/sepolia.md(2026-07-02 라운드), 17-changelog v3-56.
v3-55 — 파트너 펀드 데이터 지표를 자산군 중립 이름으로 변경 (total_rni/total_npl → realized_income/cumulative_impairment) ✅ Decided · 🛠 Done
v3-55 — 파트너 펀드 데이터 지표의 중립적 명명
Date: 2026-06-30
배경: external_pool_data_snapshots의 파트너 데이터 수집 필드 total_rni("Realized Net Income")와 total_npl("Non-Performing Loans")은 신용 펀드에 특화돼 있고(Joob) 일부는 오도한다. (1) total_rni는 "Net"이라고 라벨돼 있지만 실제로는 수수료 차감 전이고(rni_gross_or_net 확인 사항에 따라 Aset이 수수료를 뗀다), (2) total_npl의 "90일 부도" 틀은 대출 장부를 전제하고 부동산이나 매출채권, 펀드 오브 펀드에 맞지 않는다. 플랫폼은 여러 자산군을 온보딩하므로(모든 풀이 fund_id를 가진 완전한 온체인 투자 가능 풀이다) 표준 이름은 자산군에 중립적이어야 한다.
결정: DB 컬럼과 우리 도메인 타입, API 응답, FE를 중립적 이름으로 바꾼다. total_rni → realized_income(실현 소득, 수수료 차감 전)과 total_npl → cumulative_impairment(누적 손상 원금)이다. 신용 특화 용어(RNI/NPL, 90일 부도)는 파트너 수집 시트에 예시로만 남고 표준 식별자로는 남지 않는다.
범위와 안전성: 이 컬럼들은 수집과 표시 전용이다. NAV를 채우지 않고(NAV는 DPD 버킷과 OJK 상각 기준을 쓴다. v3-13/14) 온체인 호출에 닿지 않으므로 이름 변경은 파괴적이지 않다. 파트너(Joob)의 와이어 필드 이름 totalRni/totalNpl은 그대로다. 프로바이더 매퍼(lib/shared/fund-data/providers/joob/mapper.ts)가 파트너 명명을 우리 표준 컬럼으로 옮기는 이음매이므로 파트너는 영향받지 않는다. 마이그레이션 0043(ALTER TABLE … RENAME COLUMN)이다. 파트너 수집 시트 표현("Net" 수정 포함)은 따로 갱신했다.
참고: 11-db-schema(0043), external_pool_data_snapshots, 17-changelog v3-55. 파트너 수집 시트(구글 시트).
v3-54 — FM 지갑 서명 보강: fund_wallet 노출 + 클라이언트 서명 강제 + 바인딩된 지갑 표시 (W8/W9) ✅ Decided · 🛠 BE+FE done (deploy pending)
v3-54 — FM 지갑 서명 보강 (W8/W9)
Date: 2026-07-01
배경: v3-53이 자금 유입을 FM 클라이언트 서명으로 옮겼지만 dev의 fund_wallet == adminWallet 임시 처리를 끄지 못하게 막는 구멍 셋을 남겼다. FM이 자기 풀의 fund_wallet을 볼 수 없고, 맞는 지갑이 연결되지 않으면 수익과 상환 경로가 조용히 서버 키로 폴백했으며(비수탁 보장이 샌다) SIWE로 바인딩된 지갑이 보이지 않고 다시 묶을 길도 없었다. 이 결정이 그것들을 닫는다(노션 W8a/W8b/W9).
결정(W8a — fund_wallet 노출): fund_wallet 전체 표시(주소, 탐색기, 복사)가 어드민 전용이었다(Controls 탭). 공유 FundWalletDisplay 컴포넌트로 뽑고 **Overview 탭의 "Pool Config Summary"**에 읽기 전용 행으로 더했다(FM 포함 모든 role). API 변경 없음. PoolView.fundWallet이 이미 있었다.
결정(W8b — 클라이언트 서명 강제, 조용한 폴백 없음): depositYield(yield.tsx)와 fundRedemption(redemptions.tsx) 경로가 더 이상 조용히 서버 키로 폴백하지 않는다. 규칙: fund_wallet이 설정된 풀은 클라이언트 서명을 요구한다. 맞는 지갑이 연결되지 않았으면 명시적 오류로 동작을 막는다(서버 키 경로 없음). 모든 풀이 fund_wallet을 가지므로(v3-26) 그리고 서버 키는 임시 처리가 아닌 풀에서 이 두 함수에 온체인 권한이 없으므로, 온체인 현실과 일관된다. 원래 계획 대비 단순화: fund_wallet == adminWallet으로 분기하지 않는다(admin-web에는 어드민 지갑 주소가 없다). dev와 어드민의 서버 키 테스트 편의는 포기하고, dev 테스트는 풀의 fund_wallet을 직접 연결한다. FE만이고 플로우 훅은 이미 불일치에서 막고 있었다.
결정(W9 — 바인딩된 지갑 표시와 재바인딩, A안): 새 **GET /admin/me/wallets**가 호출자 자신의 SIWE 바인딩 지갑을 반환한다(auth.sub로 범위가 잡히고 다른 사용자를 읽지 않는다). My Settings의 Signing Wallet 카드가 검증된 지갑을 나열하고 연결과 재검증(재바인딩. EOA와 Safe)을 기존 바인딩 흐름을 재사용해 제공한다. 실제 온체인 fund_wallet 변경은 **어드민 거버넌스 행위(7일 타임락)**로 남는다. FM은 그것을 요청하라는 안내를 받고 FM이 거버넌스를 실행하지는 않는다(BE 권한 변화 없음). 바인딩은 표시와 감사 전용으로 남고, 인가의 기준은 온체인 msg.sender == pool.fund_wallet이다.
구현: BE는 admin-user-wallets.get.list.ts와 라우트 /admin/me/wallets다(마이그레이션 없음. admin_user_wallets가 이미 있다). FE(admin-web)는 FundWalletDisplay 추출과 Overview 행, 수익·상환 경로의 fmSignBlocked 가드, admin-user-wallets api와 훅, 쿼리 키, WalletBindCard의 바인딩 목록과 재바인딩, My Settings 거버넌스 안내다. 컨트랙트 변경 0, 마이그레이션 0. 배포 순서는 그대로다. **W8 머지 → dev 임시 처리 제거 → 실제 fund_wallet**이다.
참고: 15-api-reference → Admin Wallets, v3-53, 17-changelog v3-54. 노션: "🛄 Fund Manager 지갑 추가"(W8a/W8b/W9).
v3-53 — FM 지갑 서명: depositYield와 fundRedemption을 클라이언트에서 서명 (B3 + 2단계 분리) ✅ Decided · 🛠 BE+FE done (deploy pending)
v3-53 — FM 지갑 서명 (클라이언트가 서명하는 자금 유입)
Date: 2026-06-30
배경: 비수탁 원칙상 Aset은 유저 자금을 움직이는 키를 쥐어서는 안 된다. 컨트랙트가 파트너에게 게이팅하는 온체인 함수는 둘뿐이다(풀의 fundWallet에 걸린 YIELD_DEPOSITOR_ROLE). depositYield와 fundRedemption이고(PlatformPool.sol:931/774) 둘 다 msg.sender에게서 USDC를 끌어오므로 서명자가 자금을 쥐고 있어야 한다. dev에서는 fund_wallet == adminWallet 임시 처리로 Aset 서버 키가 서명했는데, 그건 수탁이고 프로덕션에서 깨진다. 이 결정이 둘을 FM의 자기 지갑에서 하는 클라이언트 서명으로 옮긴다.
결정(커스터디는 모델 A): FM이 연결한 지갑이 풀의 온체인 fundWallet이다(별도 운영 지갑도, 중간 단계도, 컨트랙트 변경도 없다). EOA든 Safe든 펀드가 고른다. 키 분리를 위해 Safe를 권하지만 강제하지 않는다.
결정(FM 지갑 신원은 B3): FM이 SIWE로 지갑 소유를 증명하고 증명된 지갑을 새 admin_user_wallets 테이블에 저장한다(FM 하나에 지갑 N개다. 풀마다 fund_wallet이 다를 수 있다). 인가의 기준은 온체인에 남는다(msg.sender == pool.fund_wallet이고 컨트랙트가 강제한다). 테이블은 사전 서명 가드와 표시, 감사에 쓰는 증명된 지갑 집합이다(DB와 체인의 어긋남을 피한다). SIWE 바인딩은 세션과 무관하다. FM은 구글 OAuth 어드민 세션을 유지하고, POST /admin/wallet/nonce와 POST /admin/wallet/verify가 siwe.ts를 재사용해 바인딩만 기록한다(JWT를 발급하지 않는다). 지갑 연결 계층은 덧붙인 "서명 연결"이고(wagmi/RainbowKit, Safe 커넥터) 로그인 방법이 아니다.
결정(2단계 분리와 인덱서 인계):
- 수익:
POST /yield-distributions가 새deposit_tx_hash본문 필드로 분기한다. 있으면(FM 경로) 행이PROCESSING으로 생성돼(쓰이지 않던yield_status값을 재사용한다. enum 변경 없음) FM의 온체인depositYieldtx를 기록하고 서버 키 단계는 미룬다. 새 **POST /yield-distributions/{id}/distribute**가 온체인에서 입금을 검증하고(인덱싱된yield_funding_events행이나YieldDeposited의 tx receipt 디코딩)distributeYield와withdrawFees, 적립, 알림을 돌린다(공유lib/shared/yield/run-distribution.ts이고 그대로인 레거시·dev 경로도 쓴다) →DISTRIBUTED다. 멱등이고 확정 전까지는 409를 반환한다 - 상환: FM이
fundRedemption을 클라이언트에서 서명한다. **POST /redemption-requests/{id}/record-funding**은 tx만 기록한다(funding_status='PENDING_FUND_CONFIRMATION'이고funding_status는 자유 텍스트 TEXT라 마이그레이션이 없다). 컨트랙트가 지급을 자동 완료하고 인덱서가RedemptionCompleted→complete_redemption_atomic으로 정산한다(LP 소각, TVL,COMPLETED). 별도/complete엔드포인트는 명시적으로 기각했다. 인덱서와 경쟁해 이중 소각이 된다 - 기각: 서버 키
/fund엔드포인트에FUND_MANAGER를 더하는 것. 필요 없다. FM이 더 이상 그것을 거치지 않는다
구현: 마이그레이션 0041(admin_user_wallets)과 0042(yield_distributions.deposit_tx_hash)이고 둘 다 dev에 적용됐다. BE는 admin-wallet.post.{nonce,verify}.ts, yield-distributions.post.distribute.ts, redemption-requests.post.record-funding.ts와 라우트다. FE(admin-web)는 wagmi/RainbowKit 프로바이더, SIWE 바인딩 UI(my-settings), use-deposit-yield-flow와 use-fund-redemption-flow(승인→동작 2-tx), use-network-guard, fund_wallet 전체 표시와 복사·탐색기, pool-create의 fund_wallet 입력, Record Distribution의 FM 표시 게이트 제거다. 컨트랙트 변경 0. 1단계(기반) → 2단계(수익) → 3단계(상환)로 나눴다. CDK 배포와 admin-web 배포가 필요하고, 실제 money-path가 작동하려면 dev 임시 처리(fund_wallet==adminWallet)를 올바른 순서로(FE·BE·배포) 제거해야 한다.
후속(미룸): Safe 비동기 실행(제안 → 인덱서가 실행을 감지)은 오늘 EOA 우선이다. 서명 특화 알림 딥링크(풀별). Safe 주소가 체인마다 갈리면 admin_user_wallets.chain_id.
참고: fm-wallet-signing-spec, 11-db-schema(0041/0042), 09-rbac → Off-chain↔On-chain, 17-changelog v3-53. 노션: "🛄 Fund Manager 지갑 추가".
v3-52 — 투자자 표시 이름은 KYC 법적 이름이다 (수정 불가) + SumSub 백필 ✅ Decided · 🛠 BE done (deploy pending)
v3-52 — 투자자 표시 이름 = KYC 법적 이름
Date: 2026-06-29
배경: 투자자 Settings 페이지가 모두에게 "Anonymous Investor"를 보여 줬다. users에 사람 이름이 없고 email과 company_name뿐이었다. 마이그레이션 0040이 first_name과 last_name을 더했고 apply-review(GREEN)가 이제 SumSub이 검증한 이름을 그것들에 미러링한다. 열린 질문 둘이 남았다. (1) 표시되는 이름이 엄격히 KYC 법적 이름이어야 하는가, 아니면 투자자가 통제하는 수정 가능한 별칭이어야 하는가. (2) 0040 이전에 KYC를 마친 기존 승인 투자자는 여전히 이름이 NULL이라 "Anonymous"다.
결정(A — 법적 이름만, 수정 불가): 표시 이름은 SumSub에서 오는 KYC 법적 이름이고 투자자가 수정할 수 없다. 별도의 display_name이나 별칭 컬럼은 없다. 근거: 이름은 장식이 아니다. WO-4에 따라 펀드 매니저가 KYC·AML 목적으로 투자자 이름과 지갑을 보므로, 자기가 주장한 별칭은 그 컴플라이언스 가치를 무너뜨린다(FM은 법적 상대방이 누구인지 봐야 한다). Settings는 읽기 전용으로 렌더한다. 투자자 자기 화면에만 적용되는 수정 가능한 별칭은 실제 UX 필요가 나타나면 앞으로 더할 수 있지만 지금은 명시적으로 범위 밖이다. 법적 이름과 별칭을 섞으면 노출 지점마다 "FM은 법적 이름, 본인은 별칭" 분기를 강요하고 사용자를 혼란스럽게 한다(자기 수정이 FM과 어드민이 보는 것을 바꾸지 않는다).
결정(백필): 이름이 NULL인 기존 승인 투자자를 소급해 채운다. 재인증 없이 기존 5분 sweep인 kyc.scheduler.reconcile-sweep이 한다. 새 backfillMissingNames()가 kyc_status=APPROVED이고 sumsub_applicant_id가 있으며 first_name·last_name·company_name이 전부 NULL인 것을 조회해 getApplicantInfo를 부르고 개인이면 first_name·last_name, KYB면 company_name을 쓴다. 새로운 PII 수집은 없다. SumSub이 이미 가진 데이터를 미러링할 뿐이다. 스스로 마른다(모든 이름이 생기면 아무것도 반환하지 않는다). GREEN 웹훅이 0040보다 앞섰던 개별 사용자도 함께 고친다. 실행당 25건 배치이고, SumSub이 이름을 주지 않으면 건너뛴다(다음 실행에서 재시도하고 완료로 표시하지 않는다).
구현: kyc.scheduler.reconcile-sweep.ts와 getApplicantInfo다. 새 마이그레이션은 없다(0040에 얹는다). CDK 배포와 마이그레이션 0040 적용이 필요하다. 그전까지는 이름이 NULL로 남고 페이지가 설계대로 "Anonymous Investor"를 보여 준다.
참고: 11-db-schema(0040), 17-changelog v3-52, WO-4(FM PII 범위). 추적: 투자자 Settings.
v3-51 — 수익 정합기: claimable_yield는 온체인 pendingYield다 (미청구 + 적립) ✅ Decided · 🛠 BE done (deploy pending)
v3-51 — 수익 정합기 (claimable_yield 미러)
Date: 2026-06-29
배경: 앱이 보여 주던 투자자의 "청구 가능 수익"은 portfolio_positions.accrued_yield만 읽었다. yield-distributions.post.create가 쓰는, 정산되고 어드민이 분배한 미러다(순 distributeYield의 pro-rata). 그 값은 세 경우에 온체인 진실과 갈린다. (1) 파트너가 온체인에서 직접 분배할 때(distribution_type=ONCHAIN이고 accrued 쓰기가 없다), (2) 투자자가 온체인에서 직접 claimYield()를 부를 때(DB가 높게 낡는다), (3) LP 잔액이 바뀌어(전송이나 재투자) 지분당 yieldDebt가 다시 계산될 때다. 컨트랙트는 이미 권위 있는 총액 pendingYield(investor)를 노출하는데 아무도 그것을 되읽지 않았다. (추적 #7.) ⚠️ 그 getter 뒤의 산식이 바뀌었다. v3-131 (3)에서 이제 claimableYield + unfundedYield이고, 열린 상환 요청에 붙은 수익(previewEscrowAccrual(requestId))을 제외한다. 그래서 투자자의 진짜 총액을 원하는 독자는 열린 요청을 더해야 한다.
결정: 수익 정합기를 더한다(BE와 인덱서만이다. 컨트랙트에 이미 다 있으므로 컨트랙트 변경 없음).
- **
portfolio_positions.claimable_yield**와claimable_yield_synced_at(마이그레이션 0038). 정합된 온체인pendingYield(정산분 + 미정산분)이고 사람이 읽는 USD다.synced_at이 NULL이면 정합된 적 없다는 뜻이고 FE가accrued_yield로 폴백한다 yield.scheduler.reconcile(매시간 EventBridge). 홀더가 있는 배포된 풀마다 홀더별로pendingYield(walletAddr)를 읽어claimable_yield를 upsert한다. 온체인은 읽기 전용이고(어드민 키 없음) DB 쓰기만 한다YieldClaimed(investor, amount)인덱서. 새로 감시하는 이벤트다(writeYieldClaimed). 온체인 직접 청구가 있으면 그 홀더의claimable_yield를 체인에서 새로 읽고yield_claims원장을 정산한다(API 행을 완료하거나 COMPLETED 감사 행을 넣는다)
표시: FE의 총 청구 가능액은 claimable_yield_synced_at이 설정돼 있으면 claimable_yield, 아니면 accrued_yield다(첫 sweep 전이나 RPC 오류 시 우아하게 낮춘다). FE 배선은 후속이고 API 변경은 없다. portfolio-positions가 이미 모든 컬럼을 반환한다.
단위: 수익은 온체인에서 18자리로 정규화된다(distributeYield의 netAmount, accruedYield, pendingYield). 리더는 accrued_yield와 맞추려고 formatUnits(value, 18)로 사람이 읽는 USD로 바꾼다.
참고: v3-20(수동 수익), 05-investment-lifecycle, 11-db-schema, 17-changelog v3-51. 추적: FE-UI-v3 #7.
v3-50 — 트랜치 손실 워터폴: BE 기능을 지금 만들되 손실만 다루고, 생성 플로우는 방식 A ✅ Decided · 🛠 BE + admin create-FE done
v3-50 — 트랜치 손실 워터폴: BE 기능과 생성 플로우(방식 A)
Date: 2026-06-29
배경: v3-14가 트랜치 구조를 정의했고 v3-39가 오프체인(Lambda) 신뢰 모델을 골랐는데 실제 손실 워터폴 계산은 만들어진 적이 없다. nav-changes.post.activate.ts는 대상 풀 자신의 nav_per_token만 갱신한다. 그룹 조회도, Junior 우선 흡수도, Junior→IMPAIRED 트리거도 없다. (생성 시 그룹 제약 검증은 만들어져 있다. 같은 fund_wallet·operating_currency·maturity_days, 역할 유일성, 최대 3개다. pools.post.create.ts L438~475.) FE는 0%다. 그래서 오늘의 트랜치 풀은 이름만 존재한다. Senior·Junior 그룹을 만들어도 손실이 Junior 우선으로 흡수되지 않는다. 파이프라인에 확정된 Senior 트랜치 거래가 있으므로 지금 BE 기능을 완성하기로 했다. 컨트랙트와 무관하므로(v3-39에 따라 Lambda가 풀별 NAV를 계산한 뒤 각 풀에서 updateNAV()를 호출한다) 스마트 컨트랙트 감사의 임계 경로에 놓이지 않는다.
결정 — 거래 단위가 아니라 플랫폼 기능으로 만든다. 어떤 트랜치 그룹이든 끝에서 끝까지 작동하도록 BE를 완성한다. FE는 거래별이고 미룬다.
결정 — 범위는 손실 워터폴만(v1): Junior NAV가 먼저 흡수하고 Mezzanine, Senior 순이다(04 → Tranche Group Waterfall에 따라). 그리고 Junior NAV가 0이 되면 Junior→IMPAIRED 자동 전이다(v3-12 재사용). 수익(이익) 워터폴(Senior가 목표 APY를 먼저 가져가는 것)은 어떤 거래가 명시적으로 Senior APY 우선을 요구할 때까지 미룬다. 손실 보호가 트랜치의 핵심이고, 안전 구조로 작동하는 데 이익 순서는 필요 없다.
결정 — 역할 배정: tranche_role(SENIOR/MEZZANINE/JUNIOR)과 tranche_group_id는 풀 생성 시에만 운영자가 명시적으로 고른다. 자동 유도가 아니다. 생성 후 불변이다(Senior가 후순위 약속에 기대고, 오늘 풀 수정 경로가 없어서 자연히 강제된다. 그리고 그룹에 입금이 하나라도 생기면 강하게 얼려야 한다). Senior 풀의 apy_rate가 Junior 풀 이상이면 막지 않고 경고한다(Senior가 수익률이 낮아야 한다).
결정 — 생성 플로우는 방식 A(점진적)이고 한 번에 끝내는 위저드가 아니다: 기존 풀 생성 폼이 Standalone / Tranche 토글을 얻고, 트랜치면 그룹(새로 만들거나 기존 선택)과 역할 라디오(그룹에서 이미 쓰인 역할은 비활성)를 고르며 공유 필드(fund_wallet·통화·maturity_days)가 그룹의 첫 풀에서 잠긴다. 다중 풀 위저드(방식 B)보다 이것을 고른 이유는 BE가 이미 풀별 생성과 그룹 검증을 지원하고 각 풀이 자기 온체인 배포이기 때문이다. 2~3개 풀을 원자적으로 배포하는 것은 취약하다(부분 실패 시 롤백). 받아들이는 절충: 그룹이 잠깐 불완전할 수 있다(Junior 없는 Senior). 아래 가드가 다룬다.
결정 — 완성도 가드: 트랜치 그룹은 방패가 자리 잡기 전까지 입금을 받을 수 없다(Senior와 Junior가 최소한 있어야 한다). 불완전한 그룹(예: Senior만)은 표시되고 완성될 때까지 ACTIVE가 아닌 상태로 유지된다. Senior가 first-loss를 흡수할 Junior 없이 살아나는 것을 막는다.
결정 — Junior의 소매 투자자 차단은 FE에서 숨기는 것이고 거래별이다. 온체인 입금 허용 목록은 없다. 온체인 허용 목록은 컨트랙트 변경을 요구하므로 "스마트 컨트랙트 영향 0"이 깨지고 트랜치가 다시 감사 임계 경로로 끌려온다. v3-39와 일관된다. 신뢰가 필요 없는 온체인 변형은 앞으로의 비파괴적 풀 타입으로 남는다.
명시적 범위 밖: 수익·이익 워터폴 · 온체인 허용 목록 · 풀 수정 시 그룹 검증(수정 경로가 없다). (어드민 생성 UI와 투자자 #18/#20, 불완전 그룹 배지는 이제 완료다. 아래 상태 참조.)
FE 상태(완료):
- 어드민 생성 플로우 — 풀 생성 3단계 "Models"에 Standalone / Tranche group 토글이 있고, 그룹을 고르면(새로 만들면 새 UUID, 아니면 기존에 붙인다) 역할 라디오가 나오며(그룹에서 이미 쓰인 역할은 비활성) 그룹 첫 멤버에서 펀드와 만기가 자동으로 잠긴다. Senior APY가 Junior 이상이면 부드러운 경고가 뜬다
- 어드민 풀 목록 — 불완전한 그룹의 풀은
Tranche · {role}알약과 앰버색 "Incomplete · needs Senior/Junior" 배지를 보여 준다(pools.post.lifecycle의 발행 가드가 이미 라이브 전환을 막는다) - 투자자(#18/#20) — 풀 카드와 상세 헤더에 Senior/Junior/Mezz 알약. 리스크 공시에 트랜치 행(Senior는 손실 보호, Junior는 first-loss). 리스크 등급이 이제 v3-25 명세에 따라
tranche_role을 읽는다(Junior → High, Senior와 Mezzanine → Medium, 단독은 Low 유지) - 두 앱의 데이터 계층(
PoolRow/PoolView/payload와CreatePoolPayload)이tranche_group_id와tranche_role을 담는다
여전히 미룬 것(진짜로 거래별): 특정 Junior 풀을 투자자 앱에서 숨기는 것이다. 거래가 Junior를 지정할 때(예: LINE) 건별로 하지 일반 기능으로 만들지 않는다.
구현: 순수 워터폴 엔진 apps/infra/lib/shared/tranche/loss-waterfall.ts(문서 예시에 대한 단위 테스트 포함. Junior $300K, 그룹 손실 $500K → Junior NAV 0, Senior가 $200K 흡수) → 그룹 상각 진입점이 손실을 풀별 NAV 변경으로 뿌린다. 기존 제안→24시간 타임락→활성화→applyPendingNavOnChain 경로를 거친다(단일 풀 제안은 건드리지 않는다 = money-path 격리). → Junior→IMPAIRED 트리거 → 완성도 가드다. CDK 배포가 필요하고(사용자 소유) money-path에 인접하므로 QA가 필요하다. 스마트 컨트랙트 변경은 0이다.
참고: v3-14(트랜치 구조), v3-39(오프체인 신뢰 모델), v3-12(IMPAIRED), v3-32(NAV 가드), 04 → Tranche Group Waterfall, 06 → Step 2, 17-changelog v3-50.
v3-49 — FM 수익 분배 도래와 연체 알림 (스케줄러 생산자) ✅ Decided · 🛠 BE done (deploy pending)
v3-49 — 수익 분배 도래·연체 알림
Date: 2026-06-26
배경: 알림 목록에 yield_distributed(분배 뒤에 보낸다)는 있었는데 예정된 분배가 도래했음을 FM에게 알리는 것이 없었고 연체 에스컬레이션도 없었다. 수익은 수동 발동이므로(v3-20) 알림이 없으면 FM이 주기를 조용히 놓칠 수 있다. yield_overdue는 pools.scheduler.yield-due sweep이 설정하는 DB 플래그였고 아무도 아무에게 알리지 않았다.
결정: FM·운영 이벤트 둘을 기존의 매일 도는 pools.scheduler.yield-due sweep이 생산한다(ACTIVE 풀만이다. UPCOMING에는 홀더가 없고 IMPAIRED와 WIND_DOWN은 정상 일정을 돌리지 않는다).
yield_distribution_due—next_yield_due가 연체로 넘어가는 전이에서 한 번 발동한다(도래한 순간이다). "이제 분배를 기록할 때다."yield_distribution_overdue— 도래 후 3일 이상 여전히 기록되지 않으면 에스컬레이션한다. 연체가 이어지는 동안 최대 7일마다 재발송한다
멱등성(스키마 변경 없음): due는 기존 yield_overdue의 false→true 전이를 탄다(주기당 한 번이고 분배가 next_yield_due를 앞으로 옮기면 초기화된다). overdue는 dedupByEntity를 쓸 수 없다(notification_logs.related_entity_id가 UUID라 주기를 가로질러 영원히 중복 제거된다). 그래서 최근 행 조회를 쓴다(event_type과 풀, 7일 창).
수신자: FM과 ADMIN 대상이다(recipient_id는 풀 id이고 FM 읽기 엔드포인트가 자기 펀드로 범위를 잡는다). 분류는 YIELD이고 어드민 딥링크는 /yield다.
참고할 점: FM V1 알림 범위를 확장한다(원래 목록에 없었다). 문구를 copy.ts에 더했다. Notification Copy 시트와 Notification UX/Design의 Yield 표에 행 둘을 더해야 한다. 새 lambda나 CDK는 없다(기존 cron을 탄다). 배포는 v3-44/45처럼 알림 파이프라인에 걸려 있다.
참고: v3-20(수동 수익), v3-44/45(알림 시스템), 17-changelog v3-49.
v3-48 — BE와 FE 간극 점검: 펀드 멤버 수정과 NAV 제안 배선, retry-lp-mint 제거 ✅ Decided · 🛠 FE done
v3-48 — BE·FE 간극 점검과 마무리
Date: 2026-06-26
배경: v3-47(어드민 사용자 수정·삭제 간극)의 후속으로, apps/admin-web 전체를 같은 결함 부류(BE 기능은 만들어졌는데 FE UI가 없거나 닿을 수 없거나 스텁인 것)에 대해 점검했다. 방법: 모든 API 클라이언트 함수와 훅을 컴포넌트 계층과 grep해 진짜로 사용이 0인지 확인했다(가정하지 않았다).
닫힘(순수 FE. 핸들러와 훅이 이미 있었고 CTA만 없었다):
- 펀드 멤버 수정 —
PATCH /fund-members/{id}와useUpdateFundMember가 쓰이지 않았고fund-detail.tsx에는 목록과 Remove만 있었다. 행별 수정 모달을 더했다(이름·지갑·primary). 권한은 기존 멤버 관리 게이트를 따른다(FM 제거와 같다) - NAV 수동 제안 —
POST /nav-changes와useProposeNavChange가 쓰이지 않았고 패널에는 오라클이 만든 행에 대한 Activate와 Cancel만 있었다. "Propose NAV Change" 버튼과 폼을 더했다(source: admin_override). 하락이면 24시간 타임락 안내를 보여 준다.yield페이지 권한으로 게이팅한다(핸들러가 강제한다)
해결 — retry-lp-mint 제거(선택지 b):
deposits.tsx와dashboard.tsx의 "Retry LP Mint"와 "Bulk Retry LP" 버튼이POST /deposits/{id}/retry-lp-mint를 호출했는데 핸들러가 없다(404가 났을 것이다). 결정: 제거한다. 재시도 UI와retryLpMint클라이언트 함수,useRetryLpMint훅을 뺐다. 근거: 비수탁 v3 모델에서 LP는 투자자 자신의deposit()tx 안에서 민팅된다(원자적이고 v3-03). Aset은 다시 민팅할 키를 쥐고 있지 않으므로 어드민의 "LP 민팅 재시도"는 안 만든 것이 아니라 구조적으로 불가능하다. 대조적으로 SBT 민팅은 Aset의 키가 발급하므로 어드민의 "SBT 재시도"는 진짜이고 남는다(kyc.post.mint-sbt와 FIFO 워커). 실패한 입금은 계속 보인다(Error Details와 대시보드의 "Failed Deposits" 항목) 다만 CTA가 View로 낮아진다(Failed Redemptions·Yield와 맞춘다). 라우트와 핸들러는 만들어진 적이 없고 필요하지도 않다
함께 표시(변경 없음): BE가 필요한 스텁들(어드민 알림 환경설정 · 수익 distribute·retry·notify · 풀 이전 · 플랫폼 설정 · send-test-notification. 전부 올바르게 비활성으로 표시돼 있다)과 죽은 코드(triggerAutoRedemption은 양쪽 다 고아다. createAdminUser와 createFundMember는 쓰이지 않는 대체 경로다)다. 전부 FE-UI-v3의 "🔎 BE-FE 갭 감사" 절에서 추적한다.
참고: v3-47, 09-rbac, 15-api-reference, 17-changelog v3-48. 추적: FE-UI-v3(🔎 BE-FE 갭 감사 2026-06-26).
v3-47 — 어드민 사용자 관리: 이름 수정과 삭제를 모든 role에서 접근 가능하게 (권한은 삭제 규칙이 정한다) ✅ Decided · 🛠 FE done
v3-47 — 어드민 사용자 이름 수정과 삭제 접근성
Date: 2026-06-26
배경: 어드민 설정의 "Admin Users" 패널(FE-UI #13 / A-9. 완료로 표시돼 있었다)이 오퍼레이터 권한 수정을 냈는데 QA가 부딪힌 간극이 둘 있었다. (1) 이름 수정 UI가 어디에도 없었다. updateAdminUser(PATCH /admin-users/{id})와 useUpdateAdminUser 훅은 있었는데 한 번도 배선되지 않아서, 이름이 빈 채로 초대된 어드민을 고칠 수 없었다. (2) 삭제가 오퍼레이터에게만 닿을 수 있었다. Delete 버튼이 권한 모달 안에 있었고 그 모달은 오퍼레이터 전용 "Permissions" 버튼으로만 열렸다. 그래서 ADMIN, SUPER_ADMIN, FUND_MANAGER 행은(정적인 "Full Access"를 보여 준다) 수정도 삭제도 할 수 없었다. 이 간극은 FE-UI-v3 추적에 따로 열거되지 않았다(#13/A-9가 완료로 간주됐다).
결정(선택지 A — 권한은 삭제를 따라간다): 모든 행이 "Manage User" 모달을 연다. 이름 수정 권한 == 삭제 권한(canDeleteUser)이다. 어드민은 오퍼레이터와 FM을 수정·삭제할 수 있고, 어드민을 수정·삭제하는 것은 슈퍼 어드민만이며, 슈퍼 어드민은 아무도 수정·삭제할 수 없다. 권한이 있으면 이름이 인라인 저장이 붙은 수정 가능한 입력이고 아니면 읽기 전용 텍스트다. 오퍼레이터 권한 토글과 Delete가 같은 모달에 산다. 백엔드는 withRole과 기존 PATCH·DELETE 핸들러로 이미 이것을 강제하고 있었다. 이 결정이 FE 간극과 표의 누락을 닫는다.
왜 A인가("누구나 자기 이름은 수정" 대신): 기존 제거 권한 행과의 일관성 때문이다(어드민 초대·제거는 슈퍼 어드민, 오퍼레이터와 FM은 어드민 이상). 조건자 하나이고 자기 수정만을 위한 특수 경로가 없다.
참고: 09-rbac → Permission Matrix, 15-api-reference → Admin Users, 17-changelog v3-47. 추적: FE-UI-v3(#13/A-9 후속).
개정(2026-06-29): PM에 따라 권한을 완화했다. ADMIN이 다른 ADMIN의 이름을 수정하고 삭제할 수 있다(전에는 슈퍼 어드민 전용이었다). SUPER_ADMIN은 보호된 채로 남고(절대 삭제할 수 없고 슈퍼 어드민만 수정할 수 있다) 마지막 어드민 가드도 그대로다. ⚠️ 정정: 원래 항목은 BE가 이미 이것을 강제한다고 주장했는데, DELETE와 PATCH /admin-users/{id}는 withRole('SUPER_ADMIN')이고 대상 role 검사가 없었다. 그래서 ADMIN은 403을 받았다. 이제 withRole('SUPER_ADMIN','ADMIN')에 서버 쪽 대상 가드(SUPER_ADMIN 대상 차단, SUPER_ADMIN role 부여 차단, 마지막 어드민 가드)를 붙여서 완화된 FE 규칙을 API로 우회할 수 없다.
v3-46 — 상환 모델(즉시 대 회차)은 게이팅 구조의 선택이고 만기와 직교한다 ✅ Decided (clarification)
v3-46 — 즉시와 회차는 만기의 속성이 아니라 게이팅 선택이다
Date: 2026-06-26
배경: v3-26 회차가 회차를 REVOLVING 모델로, 즉시를 FIXED_TERM 모델로 틀 잡았고 그것은 "REVOLVING에 만기가 없기 때문에 회차가 존재한다"로 읽힌다. 그런데 FIXED_TERM 풀도 만기 전 조기 이탈을 받고, 그것들은 같은 동시 이탈 실패 양상에 부딪힌다(FIFO 불공정, 요청 시점 NAV 차익, 기약 없는 PENDING_RESERVE). 설계 리뷰 질문: 기간제 풀도 뱅크런이 날 수 있는데 왜 회차가 REVOLVING에 묶여 있나?
결정(명확화. 스키마나 코드 변경 없음): redemption_epoch_days는 maturity_model과 직교이고 둘 사이에 검증 결합이 없다(pools.post.create는 0~90으로만 제한하고 FIXED_TERM + > 0을 금지하는 것이 없다). 타입에 따른 기본값(FIXED_TERM → 0, REVOLVING → 회차)은 제약이 아니라 생성 시점 미리 채움이다. FIXED_TERM 풀도 redemption_epoch_days > 0으로 만들 수 있다. 진짜 선택 축은 뱅크런 위험이다. "동시 이탈 수요가 가용 유동성을 넘어서 타이밍과 순서가 불공정해질 수 있는가"이지 풀에 만기일이 있는지가 아니다.
- REVOLVING: 모든 이탈 물량이 일정 밖이고 계속 노출된다 → 회차가 기본이다
- FIXED_TERM: 이탈 수요가 계획된 만기에 몰린다(파트너가 대출이 만기되는 대로 자본을 돌려준다). 만기 전 조기 이탈 꼬리는 드물고 패널티로 게이팅된다 → 즉시와
PENDING_RESERVE로 충분하다. 집중되거나 뱅크런 위험이 있는 풀이나 유의미한 상각 위험이 있을 때만 생성 시 회차를 고른다
범위와 한계: (1) 살아 있는 동안은 여전히 불변이다(v3-38). 모델은 생성 시 고르고 v1에는 라이브 즉시↔회차 전환이 없다. (2) 회차는 압박 아래에서 이탈을 공정하고 질서 있게 만들지 뱅크런을 멈추지 않는다. 유동성이 모자라면 여전히 pro-rata 부분 체결이다. 멈추는 것은 is_paused / IMPAIRED / is_emergency_frozen이고, 유출 속도 상한은 redemption_gating_pct다(오늘은 회차 전용이고 즉시는 앞으로의 선택지다). 풀별 라이브 즉시→회차 스트레스 전환은 v3-38의 라이브 변경 거버넌스 경로가 필요하다(v2, 감사 대상).
참고: v3-26 회차, v3-38, 07 → Why epoch instead of instant, 04 → redemption_epoch_days, 17-changelog v3-46.
v3-45 — 투자자 이메일 등록과 인증 (알림 이메일 채널) ✅ Decided · ✅ Built · ⚖️ legal follow-up
v3-45 — 투자자 이메일 수집과 인증
Date: 2026-06-25
배경: 투자자는 지갑으로 로그인하므로(SIWE) users.email은 가입 시 설정되는 합성 자리표시자다(<address>@wallet.aset.io). 전달 가능한 주소가 아니다. 그래서 알림의 이메일 채널이 투자자에게는 아무 일도 하지 않았다(어드민과 FM은 초대에서 온 진짜 이메일이 있다). 그런데 v3-44 결정이 중대 공지(NAV 상각, 부실 선언, 청산)를 법적으로 반드시 보내야 하는 것으로 못 박았고, 인앱만으로는 법적 "통지" 의무를 충족하지 못한다. 진짜로 전달 가능한 채널이 필요하다. 그래서 투자자 이메일은 있으면 좋은 것이 아니라 컴플라이언스 요구사항이다.
결정: 투자자 이메일 등록과 인증 흐름을 더한다.
POST /users/me/email이pending_email과 일회용 토큰(24시간)을 저장하고 SES로 인증 링크를 보낸다.POST /auth/verify-email(공개이고 토큰으로 게이팅한다)이 그것을 활성email로 승격하고email_verified_at을 찍는다. 마이그레이션 0033- 새
GET /users/me투자자 자기 프로필 엔드포인트가 Settings UI를 받친다(FE는 그것을 호출하고 있었는데 어드민 전용/users/{id}만 있었고me를 거부했다) - 발송 워커가 합성
@wallet.aset.io주소를 건너뛴다. 투자자 이메일은 진짜 주소가 인증된 뒤에만 발동한다. 인앱 행은 어느 경우든 언제나 전달된다
⚖️ 법무 후속(열려 있고 중대 공지의 임계값·시점과 함께 묶는다): (1) 투자하기 전에 인증된 이메일이 있어야 하는가(중대 공지 도달 가능성을 보장하려면)? (2) 중대 공지에 입증 가능한 전달이 필요한가(SNS를 통한 SES 전달·반송 이벤트로 "SES가 접수했다"가 아니라 진짜 전달을 반영하는 것)?
임시 자세: 투자자가 이메일 수집을 받아들이기 전까지 투자자 중대 공지는 인앱뿐이고 법적 통지로 충분하지 않다. 그것을 공식 통지로 의지하지 말 것.
참고: v3-44, 11-db-schema → users, 17-changelog v3-45. 노션: Notification — UX/Design(§📧 투자자 이메일 채널).
v3-44 — 알림 시스템: 코드 문구 레지스트리 + Direction-B 이메일 템플릿 + payload jsonb 스키마 ✅ Decided · 🛠 BE build (in progress)
v3-44 — 알림 구현: 문구 레지스트리, 템플릿, payload 스키마
Date: 2026-06-24
배경: 알림 PRD(노션)가 서로 어긋나던 기준 셋을 맞췄다. docs 13의 알림 이벤트 표, 실제 코드 생산자, FE 환경설정 UI다. 그리고 D1~D8과 채널(V1은 이메일과 인앱, V2는 Slack), 발신자(no-reply@aset.finance), 법무에 따른 수신 거부(V1은 전부 거래성이라 면제)를 확정했다. 생산자를 배선하기 전에 구조적 결정 둘이 필요했다.
결정:
- 문구는 DB가 아니라 코드에 산다. 타입이 붙은 레지스트리(
lib/shared/notifications/copy.ts, 이벤트 28개)가 Notification Copy 시트의 코드 미러다. 각 항목이 두 채널(인앱 제목·요약과 이메일 제목·프리헤더·히어로·본문·상세·CTA)과 딥링크 경로, 변수 목록을 담는다. V1은 영어 전용이고 i18n 구조를 갖춘다 - 이메일 템플릿 하나에 슬롯을 채운다(Direction B).
lib/shared/email/notification-email.ts가 이벤트별 문구로 모든 이벤트를 렌더한다. 심각도 강조 바(info#6999fa/ critical#ef5a3c), 히어로(자금 이벤트는 금액, 상태 이벤트는 상태 단어), 2칸 상세 카드, 브랜드 보라 CTA다. 로고는 호스팅된EMAIL_LOGO_URLPNG다(Gmail이 인라인 SVG를 막는다) notification_logs.payload(jsonb)로 구조화된 인앱, 추가 방식. 새 타입 컬럼 대신payloadjsonb 하나가in_app(피드 렌더 슬롯),email(SES 워커를 위한 발송 준비된 subject/html/text),variables를 담는다.read_at(과 부분 인덱스)이 미읽음 배지를 받친다. 마이그레이션 0032. 생산자가related_entity_type과related_entity_id를 설정해 FE 딥링크가 해석되게 한다
빌드 상태: 문구와 렌더러, 생산자 헬퍼(notify.ts), 마이그레이션 완료(tsc 깨끗함). 첫 생산자를 배선했다(인덱서 freeze.ts → pool_frozen/unfrozen/freeze_extended). 열린 것: 나머지 생산자, SES 발송 워커(실제 발송과 재시도·백오프·스로틀), 인앱 읽기 엔드포인트(GET /notifications 등), (c) 환경설정 저장과 POOL·ACCOUNT FE 분류다. 0단계 SES 도메인 인증과 프로덕션 접근은 BE 의존이다.
참고: 11-db-schema → notification_logs, 17-changelog v3-44, v3-41(감사 피드. 형제 이벤트 출처). 노션: Notification — UX/Design(초안)과 Notification Copy 시트.
v3-43 — 풀 필드 편집 가능성: 단계별로 다르다(DRAFT 대 ACTIVE). capacity는 올리기만, min_investment는 잠금 ✅ Decided · 🛠 BE + FE build
v3-43 — 풀 필드 편집 가능성: 단계 인식과 capacity·min_investment 분류
Date: 2026-06-24
배경: 온체인과 DB의 경계는 정해져 있었고(v3-18) 편집 가능성도 사실 이미 명세돼 있었다. 08 → Field Sync (v3-29)의 A/B/C 부류와 04 → Editability After ACTIVE다. 그런데 어드민 풀 생성의 초안 저장·수정 버그(노션 QA)를 분류하다 간극 둘이 드러났다. (1) v3-29 표가 capacity와 min_investment를 **B 부류 "온체인 불변"**으로 잘못 라벨해서 같은 문서의 온체인 상태 표(그것들을 포함하지 않는다)와 DB 전용 표(그것들을 Lambda가 강제하는 소프트 한도로 나열한다)와 모순됐다. (2) A 부류 행이 is_paused만 열거해서 온체인에서 바꿀 수 있는 다른 필드들이 산문 속에 숨어 있었다.
결정:
capacity와min_investment는 오프체인이다(입금 게이팅에서 Lambda가 강제하고 온체인 대응물이 없다). B 부류가 아니고 컨트랙트 불변도 아니다- ACTIVE 이후:
min_investment는 잠긴다(PATCH 거부. 공표된 투자자 조건이다. 바꾸려면 새 풀을 배포한다).capacity는 올리기만 된다.new_capacity >= current_capacity일 때만 PATCH를 받는다(올리는 것은 더 많은 투자자를 받아들이고 기존 홀더에게 해가 없다. 낮추는 것은 거부한다). 둘 다 오프체인에서 강제하고 재배포가 없다 - A 부류를 열거했다: 온체인에서 바꿀 수 있는 것은
is_paused·is_emergency_frozen(즉시),reserve_percentage·fund_wallet·kyc_level_required·kyc_jurisdiction_whitelist(7일 타임락 거버넌스),nav_per_token(/nav-changes),lifecycle_status(/lifecycle)다. 전부 전용 엔드포인트를 거치고 절대 PATCH를 거치지 않는다
편집 가능성 = (저장 위치) × (lifecycle 단계). DRAFT에서는 배포된 것이 없으므로 모든 필드가 DB 행이고 자유롭게 수정된다(lockup_days·maturity_days·penalty_type 포함). 불변성은 DRAFT → ACTIVE 전이(배포)에서 걸리지 그 전이 아니다.
풀 수정 버그 해결(노션 QA. 막혀 있던 것): 결정이 없어서 막힌 게 아니었다. 명세는 있었고 수정 UI가 그것을 구현하지 않았을 뿐이다. 수정 폼은 단계를 알아야 한다. DRAFT에서는 모든 필드를 수정 가능하게 보여 주고(Maturity (days) 필드 포함. 버그 2), ACTIVE 이후에는 B 부류와 min_investment를 "발행 후 잠김. 바꾸려면 새 풀을 배포"라는 사유와 함께 읽기 전용으로 렌더하며(숨기지 말 것. 버그 2의 기대 동작이다), A 부류에는 "멀티시그·7일 타임락 필요" 안내를, capacity에는 올리기 전용을 보여 준다.
빌드: (BE) pools.patch.update.ts에서 배포된 풀의 min_investment 거부를 유지하고 capacity 올리기 전용 가드를 더한다(new < current면 거부). (FE) 어드민 풀 수정 폼이 단계별 편집 가능성과 잠금 사유를 렌더한다.
참고: 08 → Field Sync (v3-29 정정본), 04 → Editability After ACTIVE, v3-18, v3-29, v3-38. 노션: Admin Pool Create — 초안 저장·수정(QA).
v3-42 — 펀드 매니저의 활동 접근: 펀드 범위의 운영 뷰. 컴플라이언스 Audit Log는 어드민 전용으로 남는다 ✅ Decided · ✅ Built
v3-42 — FM 활동 접근 (펀드 범위 운영. 컴플라이언스는 어드민 전용)
Date: 2026-06-24
배경: 대시보드의 "Recent Activity (48h)" 위젯과 Audit Log 페이지는 같은 GET /activity-events 엔드포인트이고 시간 창만 다르다. 그래서 "FM은 대시보드 활동은 받고 감사 로그는 못 받는다"는 일관된 구분이 아니었다. 엔드포인트가 하나이고 진짜 질문은 FM을 위해 그것을 펀드 범위로 잡을 것인가였다. 09-rbac이 자기모순이었다. L227은 FM이 펀드 범위의 /activity-events를 받는다고 했고 L184/L231/L167/L104는 Audit Log가 어드민 전용이라고(FM은 403) 했다. 조사에 따르면 다중 테넌트 SaaS의 관례는 테넌트 범위 감사이고 펀드 운영 도구에서는 GP가 자기 펀드의 기록을 본다. 즉 범위를 잡아 보여 주는 것이 표준인데, 그건 그 role이 데이터 관리자일 때만이다. Aset의 FM은 명시적으로 처리자이고 독립적인 KYC·AML 의무가 없다(L206). 그래서 컴플라이언스 감사 로그(KYC 결과, PII 접근, 플랫폼 전체 어드민 행위)는 FM이 아니라 관리자의 기록이다.
결정 — 페이지가 아니라 무엇으로 나눈다:
- 운영 활동 → FM이 펀드 범위로 본다. 입금, 상환, 수익, NAV와 FM 자기 풀에 대한 풀 어드민 행위다.
audit_feed를related_entity_type = 'pool'이고 풀이 FM의 펀드들의 풀에 속하는 것으로 걸러 구현한다. 컴플라이언스 이벤트(PII_ACCESS,KYC_*)는 사용자를 대상으로 하므로 자동으로 빠진다. 별도 제외 목록이 필요 없다 - 컴플라이언스 Audit Log → 어드민 전용. 플랫폼 전체 피드와 CSV 내보내기는 ADMIN·SUPER_ADMIN으로 남는다(내보내기는 관리자의 도구다)
- 같은 라우트, role에 따라 바뀌는 껍데기.
/audit-log가 새activityPageKey를 통해 FM에게 열리되 FM에게는 **"Activity"**로(제목과 사이드바 라벨) 어드민에게는 **"Audit Log"**로 렌더된다. FM에게는 Export를 숨긴다. 이것이 문서 모순을 정리한다. L227은 엔드포인트 데이터 범위였고(맞다) L184/L231은 전체 컴플라이언스 페이지 접근이었다(정신적으로 여전히 어드민 전용이다. FM의 화면은 범위가 잡힌 Activity이지 컴플라이언스 로그가 아니다)
해결: FM의 Recent Activity 위젯이 []를 반환하고 있었다(범위 잡기가 구현되지 않았다, L161). 이제 FM 펀드의 활동을 보여 주고 "View all activity →"가 범위가 잡힌 Activity 페이지로 간다.
빌드(완료, jy/product): get.list의 FM 분기가 getFundManagerFundIds와 getFundPoolIds로 펀드 범위를 잡는다. activity PageKey와 FM 부여. /audit-log 라우트 가드 → page/activity. 사이드바 role 라벨. 대시보드의 canViewAuditLog에 FM 포함. audit-log 페이지가 role에 따라 바뀐다(제목, Export 숨김, 푸터). 마이그레이션 없음(풀 행이 이미 related_entity_id = pool_id를 담는다).
참고: v3-41, 09-rbac → FM panel scoping, activity-events.get.list.ts, audit-log.tsx, sidebar.tsx
v3-41 — 감사 로그는 두 스트림을 합친 피드다 (사람의 행위 + 온체인 미러). 보관 5년 ✅ Decided · 🔧 BE build
v3-41 — 활동·감사 로그: 두 스트림 통합 피드
Date: 2026-06-23
배경: 13-operations는 activity_events를 **"V1의 단일 감사 출처"**로 정의한다(48시간 대시보드, 보관, 어드민 전용 CSV). 실제로는 테이블과 GET /activity-events API, audit-log.tsx UI가 전부 존재하는데 유일한 라이터가 PII_ACCESS를 기록한다(어드민이 투자자의 PII 사이드바를 여는 것). 한편 온체인 인덱서(onchain-indexer.scheduler)는 이미 모든 컨트랙트 이벤트(Deposited, Redemption*, Yield*, Nav*, EmergencyFrozen/Unfrozen, LP Transfer)를 전용 테이블(deposits, redemption_requests, yield_distributions, nav_history, redemption_epochs, portfolio_positions)에 tx_hash와 log_index 출처와 함께 미러링한다. 그러니 감사 데이터는 존재한다. 없는 것은 (a) 통합된 조회 창과 (b) 권한 있는 사람의 행위에 대한 누가 했는지의 귀속이다.
결정 — 감사 피드는 스트림 둘이고 읽을 때 합친다(두 번째 사본이 아니다):
- 온체인 이벤트는 기존 인덱서 테이블에서 읽는다(UNION 뷰). 인덱서가 이미 미러이고 감사 로그 API가 그 행을 노출한다. 행위자는 시스템이나 지갑이다(서비스 키, 멀티시그). 그것들을
activity_events에 다시 INSERT하지 말 것. 이중 쓰기가 되고 두 번째 기준이 생긴다. - 사람의 권한 행위는
activity_events에 쓴다. admin-web → API 경계에서 쓰고 검증된 JWT에서actor_id를 가져온다(PII_ACCESS가 이미 쓰는 패턴이다). 체인이 귀속할 수 없는 사람의 재량 행위다.KYC_APPROVE·KYC_REVOKE,FREEZE·UNFREEZE·PAUSE,IMPAIR_*·WINDDOWN_*(제안·실행),REDEMPTION_APPROVE/REJECT/HOLD/RELEASE,FEE_WITHDRAW, 거버넌스 제안(fund_wallet·리저브·treasury),PII_ACCESS(완료)다.
보관은 5년이다("2년"이었다). 근거: 싱가포르 MAS Notice PSN02(DPT 서비스 제공자. 고객·거래 기록 5년 이상)와 태국 AMLA §22(5년 이상이고 처음 2년은 즉시 조회 가능). 말레이시아는 SC 지침에 따라 6~7년이다(말레이시아 법인이 해당하면 법무 확인). "2년"은 보관 하한이 아니라 즉시 조회·핫 내보내기 창이었다. → 아카이브 작업은 5년을 유지한다. 대시보드의 핫 창은 그대로다(기본 48시간, 기간 지정 내보내기).
메인넷 블로커인가? 아니다(코드로 확인). 모든 lifecycle 이벤트가 온체인에서 발생하고 인덱서가 온체인 출처와 함께 저장한다 → 감사 기록은 이미 존재한다. 간극은 통합(UX)과 사람 행위자 귀속이다. 우선순위: 통합 읽기 뷰와 CSV·아카이브는 P1(막지 않음)이고, 권한 행위에 대한 사람 행위자 라이터는 P0-soft(메인넷 전 권장)다. AML 감사가 "어느 직원이 이것을 했나"를 기대하기 때문이다.
빌드:
- (BE, P0-soft) 위 권한 행위 목록에 대해 어드민 API 경계에
activity_events라이터를 더한다(JWT에서actor_id) - (BE, P1)
GET /activity-events가activity_events와 온체인 인덱서 테이블의 UNION을 반환한다(ActivityEventView모양으로 정규화) - (BE, P1) 어드민 전용 CSV 내보내기 엔드포인트(UI 내보내기는 있고 전체 기간을 뽑으려면 서버 쪽이 필요하다)
- (BE, P1) 보관·아카이브 작업 — 5년(2년 아님)
- (FE)
audit-log.tsx가 이미 있으므로 API가 합친 피드를 반환하면 렌더된다
참고: 13-operations → Audit Log, 활동·감사 간극 페이지(노션), apps/infra/lib/shared/indexer/, activity-events.post.create.ts, admin-web/app/routes/audit-log.tsx
v3-40 — 미리보기(마케팅·쇼케이스) 풀 등급과 관심 등록 CTA ✅ Decided · 🛠 FE build
v3-40 — 미리보기 풀 등급(쇼케이스)과 관심 등록 CTA
Date: 2026-06-23
배경: 파트너(예: Joob/FJL)가 Aset을 통해 투자를 받지 않을 다른 펀드들을 운용하는데, 우리는 그것들을 플랫폼에 보여 주고 싶다("여기서 이만큼 거래가 돈다") 앞으로의 라운드(예: FJL 5)에 관심을 끌기 위해서다. 기존 상태 중 맞는 것이 없다. UPCOMING은 "여기서 곧 열린다(날짜와 함께)"이고 CLOSED는 "여기서 열렸다가 모집이 끝났다"인데, 게다가 CLOSED는 지금도 전체 실적을 렌더한다(펀드 데이터, NAV, DPD). 쇼케이스에는 그것을 원하지 않는다. (출처: 6/23 Grab×JOOB 마무리, 옵션 B "순수 온보딩 / API 연동, 비용 X". 최소 동의는 규모와 구조를 개괄 수준으로, 현재 상태는 없이.) 앞선 "표시 전용 풀" 항목(FE-UI #19)을 다듬은 것이고 그건 "그냥 숨기자"였다.
결정: preview 풀 등급(플래그나 상태)을 더한다. 보이지만 Aset에서 절대 투자할 수 없다.
- Overview만 보여 준다(개괄: 이름, 매니저, 자산군, 만기, 거래 구조·목표 수익률). 실적과 Fund Data는 숨긴다(라이브 NAV, 펀드 가치, DPD, 상태 없음)
- 관심 등록 CTA("알려 주세요")가 Aset에 리드 알림을 보낸다. 같은 펀드의 다음 라운드를 위해서다. 이 CTA는
CLOSED풀에도 나타난다(같은 펀드가 다시 돌 수 있다). 알림 트랙과 이어진다 - 필드 수준이 아니라 탭 수준으로 명세한다. 규칙은 "preview → Overview 탭만"이다. FE는 바뀔 수 있으니 필드 목록을 박아 두지 말 것
표시 등급 → 필요한 파트너 API(Joob 데이터 요청 시트를 굴린다):
| 등급 | 보여 주는 것 | 필요한 API |
|---|---|---|
| Preview(쇼케이스) | Overview만(정적 프로필·거래 구조)과 관심 등록 CTA | 정적 프로필만. Overview 필드가 확정되지 않았더라도 파트너에게 필수로 넓게 요청한다. 지속적인 가치나 수익률, 리스크나 시계열은 없다 |
| Display(공개, 기본) | + 현재 펀드 가치 · NAV · APY · 상태 | 1차 데이터 요청(지속 표시 필드) |
| Full(공개, 리스크 배지) | + DPD · 집중도 · 만기와 시계열 | 2차 데이터 요청(리스크)과 시계열 |
왜 새 등급인가(CLOSED나 UPCOMING 재사용이 아니라): 의미가 다르고(외부이고 여기서는 절대 투자할 수 없으며 관심을 등록한다) 게다가 CLOSED는 지금 전체 실적을 노출한다.
구현(FE, 컨트랙트 영향 없음): preview 플래그나 상태로 Overview만 렌더하고 Performance와 Fund Data 탭을 숨기며 투자 사이드바를 없애고 관심 등록 CTA를 둔다(preview와 closed). FE-UI #19(노션)에서 추적한다.
참고: 6/23 Grab×JOOB 마무리(옵션 B), v3-12 lifecycle, v3-29(is_paused와 가시성), 04-pool-models → lifecycle_status, 10-status-machines. 노션: Joob 데이터 요청 시트(등급별 필수), FE-UI-v3 #19.
v3-39 — 트랜치 first-loss는 v1에서 오프체인(Lambda) 신뢰 모델이다. 온체인 워터폴은 미룬다 ✅ Decided · v1, no contract change
v3-39 — 트랜치 first-loss: 신뢰 모델(v1), 온체인 워터폴은 미룸
Date: 2026-06-23
배경: 트랜치 상품(v3-14)에서 손실과 수익 워터폴은 Aset 오라클 Lambda가 오프체인에서 적용한다. 각 트랜치가 별개의 SINGLE 풀이고("스마트 컨트랙트 영향 0") Lambda가 Junior 풀의 NAV를 먼저 내린 뒤 각 풀에서 updateNAV()를 호출한다(04 → Tranche Group Waterfall). 그래서 Senior 투자자는 Aset이 후순위를 올바르게 적용하는 것에 의존한다. 컨트랙트가 강제하는 것이 아니라 신뢰 모델이다. 질문: 출시에 이것을 받아들일 것인가, 아니면 신뢰가 필요 없는 온체인 후순위를 먼저 만들 것인가?
결정(v1): 신뢰 모델을 유지한다(v3-14에 따른 오프체인 Lambda 워터폴).
- Junior는 파트너가 제공하는 first-loss 자본이고 자기 Junior 풀로 배포되며 소매 투자자에게서 차단된다. 투자자 앱에 노출하지 않고 KYB·화이트리스트 입금을 건다(FJL·LFC가 Junior를 제공하고 투자자는 Senior를 가져간다). 온체인의 강한 입금 제한은 입금 허용 목록이고 별개 항목이다. v1은 UI 숨김과 KYB 게이팅에 의존한다.
- first-loss 두께는 거래별·풀별 설정이고(파트너마다 풀 생성 시 정한다) 플랫폼 상수가 아니다
왜 받아들일 만한가: 평가는 업계 전반에서 오프체인이다(Centrifuge V2/V3는 Chronicle 오라클로 NAV를 밀어 넣고 Maple, Goldfinch, Huma는 매니저나 에이전트를 쓴다). 온체인 후순위는 일부가 나중에 더하는 선택적 정교화다(TrueFi: 원래 풀에는 트랜치가 없었고 나중의 Credit Vaults가 온체인 A/B/C 워터폴을 더했다). 신뢰는 한계가 있다. v3-32의 이탈 상한과 서킷 브레이커, 신선도가 Lambda가 NAV를 얼마나 움직일 수 있는지를 제한하고, money-path 불변성(v3-27)은 최악이 절도가 아니라 잘못 표시하는 것이라는 뜻이다.
혼합 자본에 대한 참고: 우리 풀이 더 큰 펀드의 일부일 때(외부 공동 투자자가 우리 체인 밖에 있다) 펀드 수준의 "총액 대비 %" 버퍼는 어차피 우리 컨트랙트에서 온체인으로 강제할 수 없다(총 분모를 볼 수 없다). Lambda는 워터폴을 우리 Senior와 Junior 풀에만 적용하고, 그것이 정확히 신뢰 모델이 하는 일이다.
앞으로의 선택지(비파괴적): 기관 Senior 투자자가 신뢰 없는 후순위를 요구하면 온체인 워터폴 풀 타입을 더한다(Centrifuge·TrueFi식 단일 풀 2토큰이나 풀 간 캐스케이드). 나란히 배포하면 되고 신뢰 모델 풀을 이관할 필요가 없다(트랜치는 별개 풀이고 money-path는 불변이다). 계기는 기관 수요나 규모다.
참고: v3-14(트랜치 구조), v3-15(Lambda가 유일한 NAV), v3-32(NAV 가드), v3-27(money-path 불변), 04 → Tranche Group. 벤치마크: Centrifuge Tinlake 대 V2/V3, TrueFi Credit Vaults, Maple pool cover.
v3-37 — 콜드 role에 Gnosis Safe 멀티시그 도입 (Admin 3-of-5 + 별도 Pauser 2-of-3) ✅ Decided · ⚙️ Config, no contract change
v3-37 — Gnosis Safe로 콜드 키 멀티시그 (Admin과 Pauser 분리)
Date: 2026-06-23
배경: 비수탁이 멀티시그를 요구한 적은 없다. money-path가 불변이면 콜드 키 하나로도 자금을 돌릴 수 없으므로(v3-32) 멀티시그는 선택적 다층 방어로만 남겨 뒀다. 2026-06-23 외부 보안 검토 이후 채택한다. 한 사람이 키를 통제하는 것은 확장 위험이고 Gnosis Safe(ERC 표준)가 그 답이다.
결정: 사람이 통제하는 콜드 role 둘을 Gnosis Safe 멀티시그 지갑으로 옮긴다. 컨트랙트 변경은 없다(각 role이 이미 주소가 쥐는 별개 role이므로 그것을 Safe에 부여하기만 하면 되고, 컨트랙트는 hasRole만 검사하지 EOA인지 Safe인지 신경 쓰지 않는다). 콜드 role 둘을 임계값이 다른 별개 Safe로 나눈다.
DEFAULT_ADMIN_ROLE(거버넌스·lifecycle·role 부여) → 콜드 Safe, 3-of-5. 영향이 크고 드물며 이미 타임락이 걸려 있으므로(7일/30일) 임계값을 높여도 운영 비용이 없다. "콜드 키 하나가 자기에게 role을 줄 수 있다"는 우려도 닫는다PAUSER_ROLE(정지·비상 동결) → 별개 콜드 Safe, 2-of-3. 비상 동결은 빨라야 하고 낮은 임계값이 속도와 안전의 절충이다. 안전한 이유는 동결이 멈추는 것뿐이고 자동 만료되기 때문이다(v3-28: 이탈은 72시간에 풀리고 동결 전체는 7일에 만료된다). 침해된 Pauser는 훼방(DoS)만 할 수 있고 절대 훔칠 수 없다- **
ORACLE_ROLE**은 핫 Lambda 키로 남는다(무인 자동화이고 고정된 목적지와 v3-32 NAV 경계로 제한된다). 여기에 콜드 서명을 요구하면 자동화가 깨진다 - **
YIELD_DEPOSITOR_ROLE**은 파트너의fund_walletSafe로 남는다(자기수탁)
임계값(잠정): 3-of-5와 2-of-3을 출발점으로 하고 나중에 바꿀 수 있다(비용, 서명자 가용성). 서명자는 팀원 사이에 물리적으로 분리한다(단일 실패 지점과 1-of-1 오설정을 피한다).
새 role도 재배포도 없다: Admin과 Pauser는 이미 별개의 온체인 role이므로 그것들을 Safe 둘로 나누는 것은 설정뿐이다. (진짜로 새로운 서명자 조합 등급, 예를 들어 특정 운영을 위한 "콜드 1 + 핫 1"은 새 role과 재배포가 필요할 것이고 명시적으로 하지 않는다.) 배선은 풀 배포 시 Safe 주소를 넘기거나 배포 후 grantRole(PAUSER, pauserSafe)와 renounceRole(PAUSER, adminSafe)를 하는 것이다(Admin이 PAUSER의 role 관리자다).
참고: v3-32(멀티시그 불필요 기준선), v3-28(기간 제한 동결 → Pauser 위험을 제한한다), 09a-custody → Key model, 09-rbac → Key custody, 2026-06-23 보안 검토 회의(노션). 감사 후속: Zellic Korea.
v3-38 — 회차 기간은 풀 생성 시에만 정한다. 운영 중 변경은 v2 타임락 거버넌스로 미룬다 ✅ Decided · 🛠 FE + deploy wiring
v3-38 — 회차 기간: 생성 시에만(v1), 타임락 거버넌스(v2)
Date: 2026-06-23
배경: 회차 컨트랙트 리팩터가 setEpochDurationDays(uint256)를 맨 DEFAULT_ADMIN_ROLE setter로 냈다. 상한도, 이벤트도, 타임락도, 생성 전용이나 라이브 가드도 없었다(s.epochDurationDays = newDurationDays;, PlatformPool.sol). 그런데 epoch_days는 투자자가 언제 어떻게 나가는지를 직접 통제한다. reserve_percentage보다 투자자에게 민감한 레버인데(그것은 7일 타임락이 걸려 있다, v3-18) 맨 라이브 setter는 money-path 불변과 시간 제한 통제 철학(v3-27~v3-33, v3-28, v3-31)과 어긋난다.
결정(v1): epoch_days는 풀 생성 시에만 설정한다. 배포 멀티콜에 접어 넣고(createPoolOnChain → setEpochDurationDays) 타입에 따라 미리 채운다(REVOLVING → 7, FIXED_TERM → 0). 라이브 변경 UI를 노출하지 않고 컨트랙트가 신뢰 경계에서 생성 전용을 강제한다. 풀에 투자자가 생기면(totalLPSupply > 0) setEpochDurationDays가 revert하므로 배포 시(입금 전. 아래 보강 참조)에만 설정할 수 있다. 입금 뒤에는 어떤 기존 투자자의 상환 조건도 절대 바뀌지 않으므로 타임락과 커스터디, 공시 문제를 통째로 비켜 간다. "money-path 불변"과 일관된다.
왜 생성 전용이 안전한 절단면인가:
- 살아 있는 풀의 유동성을 조이는 것(즉시→회차, 또는 더 긴 N)은 공시되지 않은 상환 게이트다. 투자자 보호와 규제의 적신호다
- N에 상한이 없는 것은 자금을 가두는 뒷문이다(어드민이 36500일로 설정할 수 있다). 비수탁 이탈 보장(v3-31)과 시간 제한 동결 원칙(v3-28)을 깨뜨린다
- 투자자는 풀의 상환 모델을 알고 입금한다. 그것이 발밑에서 움직이지 않는다
컨트랙트 보강 — ✅ 구현·테스트 완료(jy/product. 메인넷 배포와 Zellic 감사 대기): setEpochDurationDays가 이제 (a) 상한 MAX_EPOCH_DURATION_DAYS = 90을 강제하고(revert EpochDurationTooLong) 그래서 절대 자금을 가둘 수 없으며, (b) 인덱서와 모니터링을 위해 **EpochDurationChanged(oldDays, newDays)**를 내고, (c) 온체인에서 생성 전용을 강제한다. 풀에 투자자가 생기면(totalLPSupply > 0) EpochImmutableAfterDeposit으로 revert한다. 배포 멀티콜은 입금 전(LP = 0)에 설정하므로 통과하고 그다음 잠긴다. 회차 중간이나 대기 수요 가드보다 강하다. 그것들은 상환 활동 중에만 보호하는데, 조건은 어떤 기존 투자자에 대해서도 바뀌면 안 되기 때문이다. 그래서 트리거가 "대기 중인 상환이 있는가"가 아니라 "투자자가 있는가"이고, 그것이 "살아 있는 동안 불변"을 컨트랙트가 강제하는 보장으로 만든다. PlatformPoolEpoch.t.sol이 덮는다(test_SetEpochDuration_RevertsAboveMax / _EmitsEventWhileEmpty / _RevertsAfterFirstDeposit / _AllowsMaxBoundaryWhileEmpty. 회차 스위트 전체 23/23 통과). ⚠️ Sepolia(2026-06-22 배포)는 보강 이전 빌드라 재배포가 필요하다. (GitHub #6, 닫힘. MD와 Slack으로 인계.)
v2(출시 후. 감사 필요): 라이브 변경이 언젠가 필요하면 setEpochDurationDays를 fund_wallet·리저브·KYC와 같은 7일 타임락 거버넌스로 보낸다(제안→실행→취소). 방향을 구분해서 조이는 것은 온전한 예고와 옛 조건에서의 이탈 창을, 푸는 것은(즉시로, 더 짧은 N으로) 낮은 예고를 준다. 컨트랙트 변경이므로 감사 사이클이 필요하고 명시적으로 v1에는 없다.
FE 영향: 라이브에서 불변인 설정 필드에 epoch_days가 이제 B 부류 집합(capacity, min/max investment, penalty, redemption_type, lockup/maturity, currencies)에 합류하므로, 어드민 풀 수정에서 잠김과 짧은 사유로 렌더해야 한다("생성 시 설정. 살아 있는 투자자의 상환 조건을 바꾸려면 새 풀이 필요하다"). FE-UI-v3(노션)에서 추적한다.
참고: v3-26 회차 상환, 04 → redemption_epoch_days, 07 → Epoch-Based Redemption, 08 → Field Sync, v3-18, v3-28, v3-31, v3-32. 노션: Epoch UX/Design v3-26 §3, FE-UI-v3 추적.
v3-35 — collateral_type enum 삭제 (v3-07 마무리) ✅ Decided
v3-35 — collateral_type enum 제거
Date: 2026-06-18
결정: collateral_type enum(FULLY_COLLATERALIZED / PARTIALLY_COLLATERALIZED / UNSECURED)을 완전히 제거하고 v3-07을 마무리한다(그것은 collateral_description을 더했지만 enum을 살려 둔 채 절반만 이관했다). 담보는 **collateral_description(자유 텍스트)과 collateral_ratio(NUMERIC, 표시 전용)**로 모델링한다. collateral_ratio는 표시 전용 메타데이터이고 온체인으로 보내지 않으며 100%를 넘을 수 있다(초과담보, 예를 들어 150%). Fully/Partially/Unsecured 일관성은 강제하지 않는다.
이유: v3-25가 collateral_type 배지를 무의미하게 만들었다(리스크 신호가 자동 계산 Risk Tier로 갔다). 담보가 더 이상 리스크 입력이 아니고 온체인에도 없으므로, 현실의 담보 구조가 들어가지 않는 뻣뻣한 3칸 enum(v3-07)과 그 enum↔비율 검증은 가치를 더하지 않는다. 이것이 풀 생성 4단계 버그 3/4(Partially=0~100% / Unsecured=0의 엄격 강제)를 무의미하게 만든다. 그것들은 정확히 이 모델 질문에 막혀 있었다.
정리 범위: collateral_type enum과 컬럼을 collateral_description 백필과 함께 삭제(마이그레이션) · 어드민 풀 생성·수정의 select를 설명 입력으로 · 풀 목록·상세의 CollateralBadge·CollateralLabel을 설명 텍스트로(또는 제거. Risk Tier가 신호를 담는다) · VALID_COLLATERAL_TYPES API 검사 제거 · 02-core-concepts / 04-pool-models / 11-db-schema / CLAUDE.md 동기화다. (FE-UI #32와 DB 마이그레이션)
참고: v3-07(enum→설명), v3-25(Risk Tier 배지), Pool Risk Tier 결정 로그(노션), FE-UI #32
v3-11 — PlatformEscrow를 PlatformPool에 흡수 (컨트랙트 5개 → 4개) ✅ Decided
v3-11 — PlatformEscrow 제거
Date: 2026-06-02
결정: PlatformEscrow.sol을 삭제한다. 입금의 10/90 분리(리저브와 fund_wallet)를 PlatformPool.deposit() 안으로 직접 옮긴다. v3.0의 컨트랙트 수는 활성 4개다(v2.x의 6개에서 줄었다).
이유: Receipt NFT가 제거되고(v3-03) 입금이 원자적이 되면서 Escrow에 남은 유일한 역할은 얇은 10/90 분배기, 즉 풀 안의 5줄짜리 인라인 코드다. 별도 컨트랙트를 유지하면 이런 것이 붙는다.
- 외부 호출 하나 추가(가스와 복잡성)
- 감사할 컨트랙트 하나 추가
- Lambda와 프런트엔드 ABI에서 추적할 별도
escrow_address - 실제 관심사 분리 이득 없음(어차피 입금이 원자적이다)
"앞으로의 MULTI_SIG escrow"는 PlatformPool의 어드민을 멀티시그 지갑으로 만들면 이룰 수 있다. 같은 결과에 움직이는 부품이 적다.
백엔드 개발 작업:
apps/contract/src/PlatformEscrow.sol삭제- 분리 로직을
PlatformPool.deposit()에 인라인 - PoolConfig와 팩토리 배포에서
escrow_address제거 - Lambda 핸들러와 프런트엔드의
escrow_address참조 제거(DB 컬럼은 폐기 표시로 남는다)
v3-12 — 청산을 NAV 메커니즘과 통합하고 IMPAIRED 상태를 추가 ✅ Decided
v3-12 — 손실과 종료 lifecycle 통합
Date: 2026-06-02
결정: 청산 분배를 표준 NAV·상환 메커니즘에 합친다. ACTIVE와 WIND_DOWN 사이에 IMPAIRED lifecycle 상태를 더한다(Maple 패턴의 부실 선언).
lifecycle 흐름:
HEALTHY → WRITEDOWN (NAV < 1.0) → IMPAIRED (deposits paused, redemptions open) → WIND_DOWN (terminal) → TERMINATED메커니즘 변화:
- ❌
PlatformPool.claimWindDown()제거. 표준redeem()이 모든 경로를 처리한다 executeWindDown()이 이제navPerToken = reserveBalance / totalSupply를 설정한다(즉시다. 30일 타임락이 이미 지났으므로 24시간 타임락이 없다)redeem()이lifecycle_status가WIND_DOWN이나IMPAIRED면 락업과 패널티를 면제한다- 새
IMPAIRED상태: 입금은 정지되고 상환은 현재 NAV로 허용되며 락업이 면제된다. 파트너가 부실 징후를 보일 때 어드민 제안(7일 타임락)으로 발동한다 - DB:
lifecycle_statusenum이IMPAIRED값을 얻는다
이유: NAV = reserve / totalSupply일 때 수학적으로 (LP/totalLP) × reserve는 LP × NAV와 같다. 같은 계산을 하는 함수 둘은 중복이다. Centrifuge도 비슷하게 통합하고 Maple은 중간의 IMPAIRED를 더한다.
IMPAIRED가 왜 중요한가: 파트너의 부실은 대개 점진적이다(NAV 갱신 지연, 늦은 수익, DPD 급등). ACTIVE에서 WIND_DOWN으로 바로 뛰면 이분법적 선택을 강요한다. IMPAIRED는 새 입금을 멈추면서 기존 홀더가 상환할 수 있게 풀을 열어 두고, 아직 종착 청산을 확정하지 않게 해 준다.
참고: Status Machines, Pool Models → Lifecycle, Writedown & NAV
v3-13 — Joob DPD API로 NAV 상각 자동화 (OJK 기준) ✅ Decided
v3-13 — DPD 기반 자동 상각
Date: 2026-06-02
⚠️ R5 · R5.5(v3-109)가 수정했다. 둘이 바뀌었다. (1) 연체만으로는 NAV가 절대 움직이지 않고 실현된 상각만 움직인다. 그래서 아래의 OJK 사다리는 오늘의 입력이 아니라 미뤄진 자동화 템플릿이다. 지금의 입력은 파트너가 보고하는 누적 손실이다. (2) 자동화는 자율이 아니다. 계산된 NAV는 어드민이 승인하는 제안이고, 계산된 전손은 적용 대신
proposeImpairment로 간다. 이 카드가 한때 채우던reserveConsumed는 이제 언제나 0이다(R8). 기준 자체를 위해 남긴다. 이 카드의 자동 적용 루프는 구현하지 말 것.
결정: Aset Lambda가 Joob의 대출별 DPD 데이터를 API로 수집하고 대출별 상각 기준을 적용해 풀 수준 NAV로 합산한 뒤 Pool.updateNAV()를 자동 호출한다(평소처럼 24시간 타임락).
초기 기준 — OJK(인도네시아 규제 당국) 표준, POJK No.40/2019:
| DPD 구간 | OJK 분류 | 상각률 |
|---|---|---|
| 1~90 | DPK(요주의) | 5% |
| 91~120 | Kurang Lancar(고정) | 15% |
| 121~180 | Diragukan(회수의문) | 50% |
| 181 이상 | Macet(추정손실) | 100% |
왜 OJK가 먼저인가: Joob은 인도네시아에서 OJK 규제 아래 영업한다. 그들의 내부 충당 정책이 이미 이것과 맞을 가능성이 크다. Joob과 확인 대기 중이고, 정책이 다르면 기준을 조정한다.
앞으로의 일반화: 기준은 풀별 writeoff_schedule 차원으로 승격될 예정이다(v3-13.1, 미룸). 그러면 인도네시아 밖 풀이 IFRS 9이나 자체 기준을 코드 변경 없이 쓸 수 있다.
구현:
- 새 Lambda
joob-dpd-sync. 매일 cron이고 Joob eNote API를 호출해 합산 상각을 계산한 뒤Pool.updateNAV(newNav)를 부른다 - 새 테이블
loan_writeoffs. 대출별 상각의 과거 감사 기록이다 - NAV 갱신 사유:
nav_history.source의JOOB_DPD_AUTO출처 코드
🟡 비즈니스 결정
제품의 재무·운영 모델을 결정하는 항목이다. 각각 권고안이 있지만 논의는 열려 있다.
BD1 — 수익 분배 모델 ✅ Resolved
BD1 — 수익 분배 모델
질문: 수익을 투자자에게 어떻게 전달할 것인가?
→ 결정: 수동 청구(MANUAL_CLAIM)
검토한 선택지:
- 분배형 ★ — 수익을 USDC나 LP 토큰으로 따로 지급한다. LP 가격은 $1에 고정된다. 원금과 수익이 분명히 분리된다
- 자동 복리 — 수익을 자동으로 풀에 재투자한다. LP 가격이 누적 수익을 반영해 오른다
- 수동 청구 — 수익이 컨트랙트에 쌓이고 투자자가 청구 트랜잭션을 일으켜야 받는다
막고 있는 것: 🟡 BD5 · 참고: Core Concepts, Investment Lifecycle, Triggers & Rules
BD2 — 조기 상환 정책 Open
BD2 — 조기 상환 정책
질문: 투자자가 락업이 끝나기 전에 나가면 어떻게 되나?
★ 권고: 풀별로 설정하는 패널티
선택지:
- 풀별로 설정하는 패널티 ★ — 풀마다 자기 조기 이탈 패널티를 정한다(예: 발생 수익의 50%). 자산군마다 유연하다
- 플랫폼 전체 정률 — 모든 풀에 같은 패널티율이다. 단순하지만 자산 기간이 달라도 뻣뻣하다
- 패널티 없음 — 투자자가 언제든 자유롭게 나간다. 위험: 시장 압박 시 뱅크런
- 완전 락업(조기 이탈 불가) — 만기까지 상환할 수 없다. 구현이 가장 단순하고 경험은 가장 나쁘다
막고 있는 것: 🟡 BD6 · 참고: Redemption, Triggers & Rules
BD3 — 토큰 발행·손실 모델 Open
BD3 — 토큰 발행·손실 모델
질문: 플랫폼이 자산 손실을 투자자에게 어떻게 반영해야 하나?
★ 권고: NAV 토큰 가격
선택지:
- NAV 토큰 가격 ★ — 토큰 가격이 $0.00~$1.00 사이에서 움직인다(하한 없음). 손실 중에도 투자가 열려 있다. 새 투자자는 현재 NAV 가격으로 공정하게 들어온다
- 원금 계수(PF) — 토큰 가격을 $1로 고정하고 별도 PF 승수를 둔다. PF < 1.0이면 투자가 자동으로 막힌다
막고 있는 것: 🟡 BD4 🔵 PD1 🔵 PD2 🔵 PD3 🔵 PD4 🔵 PD5 · 참고: Core Concepts, Writedown & NAV, Redemption, Database Schema, Triggers & Rules
BD4 — 리저브 펀드 메커니즘 ⚠️ Superseded — v3-109
BD4 — 리저브 펀드 메커니즘
⚠️ R8(v3-109)이 대체했다. 이 카드의 전제, 즉 리저브가 "NAV가 내려가기 전에 손실을 덮는" first-loss 계층이라는 것은 폐기됐다. 리저브는 투자자 입금에서 떼어 낸 것이므로 이미 NAV가 가격 매기는 청구권 안에 있다. 손실과 상계하면 이중 계산이었다. 리저브는 상환 유동성 버퍼일 뿐이다. first-loss는 단독 풀에서는 매니저 equity 버퍼(R6)이고 묶인 풀에서는 Junior 트랜치다. 10%라는 크기 결정 자체는 유동성 목표로 여전히 유효하다. 이력용으로 남긴다. 이 카드를 보고 구현하지 말 것.
질문: first-loss 리저브를 어떻게 구성해야 하나?
★ 권고: 풀마다 10% first-loss 리저브
선택지:
- 풀마다 10% first-loss 리저브 ★ — 입금의 10%를 리저브에 배분한다. NAV가 내려가기 전에 손실을 덮는다. 생성 시 풀마다 설정할 수 있다
- 풀별 설정 비율 — 풀마다 자기 리저브 비율을 정한다. 유연성이 크고 운영 복잡성도 크다
- 플랫폼 전체 고정 비율 — 모든 풀에 같은 비율이다. 관리가 단순하다
- 혼합 — 플랫폼이 최소 리저브 비율을 정하고 풀은 그 이상은 되지만 아래로는 못 간다
의존: 🟡 BD3 · 참고: Core Concepts
BD5 — 재투자 메커니즘 ✅ Resolved
BD5 — 재투자 메커니즘
질문: 투자가 만기되면 어떻게 되나?
→ 결정: 수동 재투자 V1
검토한 선택지:
- 옵트아웃이 있는 자동 갱신 ★ — 만기에 투자가 자동 갱신된다. 정해진 창 안에서 투자자가 빠질 수 있다
- 수동만 — 투자자가 능동적으로 재투자를 선택해야 한다. 아무것도 하지 않으면 만기에 자금이 지갑으로 돌아간다
- 옵트아웃 없는 자동 갱신 — 언제나 재투자된다. 나가려면 투자자가 상환을 시작해야 한다
의존: 🟡 BD1 · 참고: Investment Lifecycle
BD6 — 락업 기간 구조 Open
BD6 — 락업 기간 구조
질문: 투자 락업 기간을 어떻게 굴려야 하나?
★ 권고: 풀별 설정
선택지:
- 풀별 설정 ★ — 풀마다 생성 시 자기 락업을 정한다. 현실 자산의 유동성 성격과 맞는다
- 플랫폼 전체 표준 — 모든 풀이 같은 락업을 쓴다(예: 90일). 단순하지만 뻣뻣하다
- 락업 없음 — 투자자가 언제든 상환한다. 위험: 비유동 자산과의 유동성 불일치
의존: 🟡 BD2 · 참고: Redemption, Triggers & Rules
🔵 제품 결정
기술 아키텍처 선택이다. 일부는 관련 비즈니스 결정이 나올 때까지 막혀 있다.
PD1 — NAV 오라클 컨트랙트 ✅ Resolved
PD1 — NAV 오라클 컨트랙트
질문: NAV 갱신 로직이 온체인 어디에 사나?
→ 결정: PlatformPool에 통합(2026-02-20)
검토한 선택지:
- PlatformPool에 통합 ★ — updateNAV()와 ORACLE_ROLE을 PlatformPool에 직접 넣는다. 배포가 적고 컨트랙트가 하나라 단순하다
- 별도 NAVOracle 컨트랙트 — NAV 갱신 전용 컨트랙트다. 분리가 깔끔하지만 상호작용이 늘어난다
- 제3자 오라클(Chainlink) — Chainlink 커스텀 데이터 피드를 쓴다. 사모 RWA NAV에는 복잡하다
의존: 🟡 BD3 · 막고 있는 것: 🔵 PD2 🔵 PD3 · 참고: Core Concepts, Smart Contracts, Writedown & NAV
PD2 — 오라클 role 메커니즘 ✅ Resolved
PD2 — 오라클 role 메커니즘
질문: 오라클이 온체인에서 어떻게 인증하나?
→ 결정: PlatformPool의 ORACLE_ROLE(2026-02-20)
검토한 선택지:
- PlatformPool의 ORACLE_ROLE ★ — AccessControl에 ORACLE_ROLE을 더한다. 그 role을 가진 지갑만 updateNAV()를 호출할 수 있다
- 별도 오라클 컨트랙트 인증 — 오라클 컨트랙트가 권한을 쥔다. 격리가 낫지만 상호작용이 늘어난다
- 멀티시그 오라클 — 갱신마다 서명자 여럿이 필요하다. 가장 안전하지만 잦은 갱신에는 비현실적이다
의존: 🔵 PD1 · 참고: Smart Contracts
PD3 — 오라클 운영 주체 ✅ Resolved
PD3 — 오라클 운영 주체
질문: 오라클 서비스를 누가 운영하나?
→ 결정: Aset이 운영(2026-02-20)
검토한 선택지:
- Aset 운영 ★ — Aset이 오라클을 돌린다. 펀드 운영자가 NAV를 보고하면 Aset이 검증해 온체인에 올린다
- 제3자 서비스 — 오라클 제공자에게 맡긴다. 부담은 줄지만 신뢰 가정이 늘어난다
- 탈중앙 — 독립적인 NAV 보고자 여럿이다. 가장 신뢰가 필요 없지만 V1에는 복잡하다
의존: 🔵 PD1 · 막고 있는 것: 🔵 PD4 · 참고: Core Concepts, Writedown & NAV
PD4 — 오라클 중단 시 폴백 ✅ Resolved
PD4 — 오라클 중단 시 폴백
질문: 오라클이 갱신을 멈추면 어떻게 되나?
→ 결정: 폴백 없음(2026-02-20)
검토한 선택지:
- 어드민 수동 덮어쓰기 — 어드민이 NAV 갱신을 손으로 올린다. nav_history에 admin_override로 기록된다
- 풀 자동 정지 — X시간 갱신이 없으면 풀이 자동 정지된다. 가장 안전하지만 방해가 크다
- 폴백 없음 — 오라클이 재개할 때까지 NAV가 마지막 값에 머문다. 심각한 경우 어드민이 손으로 정지할 수 있다
의존: 🔵 PD3 · 참고: Writedown & NAV, Admin & RBAC
PD5 — NAV 타임락 기간 ✅ Resolved
PD5 — NAV 타임락 기간
결정(2026-06-08): NAV 하락에 24시간이고 상승은 즉시 적용된다. 기존 투자자에게 상각 전에 반응할 시간을 주면서 NAV가 낡지 않게 한다.
전체 타임락 승인의 일부로 확정했다.
- 24시간 — NAV 하락
- 7일 — 어드민 파라미터 변경(fund_wallet · 리저브 · KYC 레벨 · 관할)과 부실 선언 실행
- 30일 — 청산 실행(60일 무응답 뒤)
- 타임락 없음(즉시) — 비상 동결
값은 구현된 컨트랙트 상수와 일치하고 벤치마크(Compound 2일, MakerDAO GSM 16~30시간) 대비 보수적이다.
⚖️ 법무 결정
규제와 법적 구조에 대한 결정이다. 확정 전에 법률 자문이 필요하다.
L7 — SPV 소유 모델 Open
L7 — SPV 소유 모델
질문: 풀 자산을 보유하는 SPV(법인)를 누가 만들고 소유하나?
★ 권고: 유연하게, 둘 다
선택지:
- 펀드 매니저가 SPV를 만든다 — Centrifuge 모델이다. 펀드 파트너마다 자기 법인을 세운다. Aset은 법률 템플릿과 스마트 컨트랙트 인프라를 제공한다. Aset의 라이선스 부담이 최소다
- Aset이 풀마다 SPV를 만든다 — Aset이 풀마다 전용 법인을 세운다. 투자자 경험이 일관되고 컴플라이언스를 완전히 통제한다. 규제 부담이 크고 수탁 라이선스가 필요할 가능성이 높다
- 유연하게, 둘 다 ★ — 플랫폼이 두 모델을 다 지원한다. 어떤 파트너는 자기 SPV를 가져오고 어떤 파트너는 Aset이 제공하는 것을 쓴다. 유연성이 가장 크고 시스템 설계가 더 복잡하다
막고 있는 것: L8 · 참고: Core Concepts, Smart Contracts
L8 — SPV 관할 Open
L8 — SPV 관할
질문: SPV 법인을 어디에 설립해야 하나?
선택지:
- 델라웨어 Series LLC — 설립 약 $210과 법률 비용, 연간 약 $350. 기간은 법인 2~5일에 전체 설정 약 30일. 미국 중심 투자자에 가장 좋다(Reg D/Reg S). 새 series마다 $0이라 법인 하나 아래 여러 풀을 둘 수 있다
- 케이맨 SPC — 설립 중간~높음, 연간 상승 중(AML/KYC). 기간은 몇 주에서 몇 달. 미국 밖 글로벌 기관 자본에 가장 좋고 세제 중립적이다. 표준 역외 펀드 수단이다
- BVI Business Company — 설립 $1,500~$3,000, 연간 $1,700~$8,000. 기간 약 1주. VASP 라이선스 없이 토큰을 발행하는 데 가장 좋다. 프라이버시 친화적이고 세금이 없다
의존: L7 · 참고할 점: 제품 결정이 아니다. 법률 자문과 목표 투자자층에 달려 있다 · 참고: Core Concepts
L9 — 에스크로와 커스터디 모델 Open
L9 — 에스크로와 커스터디 모델
질문: Aset이 자기 에스크로 스마트 컨트랙트를 운영하나, 아니면 라이선스를 가진 수탁사와 제휴하나?
★ 권고: 경로 1: 자체 관리 에스크로 (A+B)
선택지:
- 경로 1: 자체 관리 에스크로 (A+B) ★ — 에스크로가 없는 풀에는 비수탁이 기준선이다. Aset이 에스크로 스마트 컨트랙트를 배포하고 관리한다. 비용이 낮고 자금 흐름을 더 통제한다. VASP·수탁 요건을 유발할 가능성이 높다
- 경로 2: 제3자 수탁사 (A+C) — 같은 비수탁 기준선이다. 라이선스를 가진 수탁사가 자금을 보유하고 Aset이 조율한다. 컴플라이언스 경로가 가장 깔끔하고 비용이 크다. Fireblocks는 월 약 $200에 거래당 0.23%, Copper는 최소 금액이 높고, Anchorage는 기업 전용이다
의존: L7 · 참고: Smart Contracts, Escrow, Redemption