상태 머신
작업 진행 중
이 페이지는 v3.0 상태 흐름을 반영한다. 구현이 진행 중이다. FM_ACCEPTED는 제거됐고(v3-34), 회차 상환이 QUEUED와 PARTIALLY_FILLED를 추가한다(v3-26).
시스템 전체의 상태 필드와 그 유효한 전이를 다룬다. v3.0은 많은 플로우를 통합했다. 설정과 무관하게 모든 풀이 같은 로직을 쓴다.
입금 상태 deposits.status
v3.0의 통합된 입금 플로우다. Aset이 항상 입금 시점에 LP를 민팅한다.
PENDING → PROCESSING → COMPLETED
PROCESSING → FAILED(전송 또는 민팅 오류)
v3.0 통합 플로우
USDC가 Pool 컨트랙트로 들어가고 → 투자자에게 LP가 민팅되고(Aset의 PlatformLPToken) → reserve가 남고 → 파트너 잔여분이 fund_wallet으로 가고 → COMPLETED가 된다.
전부 한 트랜잭션이다. Receipt NFT도 없고 D+7 환불 메커니즘도 없다.
상태 정의
| 상태 | 설명 |
|---|---|
PENDING | 입금 트랜잭션이 제출됐고 확정을 기다린다. |
PROCESSING | 블록 확정 진행 중이다(드물다. 보통 PENDING과 atomic하다). |
COMPLETED | LP가 민팅됐고 reserve가 남았으며 fund_wallet이 자기 몫을 받았다. |
FAILED | 전송 또는 민팅 오류다. failure_type과 error_message가 기록된다. |
폐기됨 (v2.x → v3.0)
REFUNDED상태(D+7 메커니즘): 제거됨. v3.0에는 환불 플로우가 없다. enum 값은 마이그레이션 0049에서deposit_status에서 삭제됐다.- AS_POOL/FUND_POOL 분리 플로우: 제거됨. 통합됐다.
- Receipt NFT 타임라인(PENDING → MATCHED): 제거됨.
폐기된 상태를 가진 기존 데이터는 남아 있고, 새 입금은 PENDING/PROCESSING/COMPLETED/FAILED만 쓴다.
KYC 상태 users.kyc_status
NOT_STARTED → IN_REVIEW → APPROVED
IN_REVIEW → REJECTED
상태 정의
| 상태 | 설명 |
|---|---|
NOT_STARTED | 기록된 검증이 없다. SDK를 열고 아무것도 제출하지 않은 홀더도 포함한다. 그 경우 kyc_level만 기록된다. |
IN_REVIEW | 신청자가 제출됐고 판정을 기다린다. |
APPROVED | KYC를 통과했고 SBT가 온체인에 민팅됐다. |
REJECTED | 검증에 실패했다. reject_type(RETRY / FINAL)을 볼 것. |
**
IN_REVIEW의 라이터는 하나뿐이다.kyc/enter-review.ts→markApplicantInReview**이고, SumSub의applicantPending/applicantOnHoldwebhook이나POST /kyc/sync가 신청자의reviewStatus를 되읽는 것으로 구동된다. SDK 접근 토큰 발급은 이 값을 쓰지 않는다. 예전에 그렇게 했다가 잠금 사고가 났다. 상태 카드의 PENDING 분기에는 돌아갈 길이 없고, SumSub가 보고한 적 없는 상태는 어떤 sweep으로도 해소할 수 없다. KYC & 신원 → KYC 상태 흐름 참조.
상환 요청 상태 redemption_requests.status
플로우가 둘이고 풀별로 epoch_duration_days가 고른다(상환 참조). redemption_status enum의 현재 값은(v3-34) REQUESTED, QUEUED, PARTIALLY_FILLED, PENDING_RESERVE, PROCESSING, COMPLETED, REJECTED, FAILED다.
즉시 플로우 (epoch_duration_days = 0)
요청 트랜잭션 안에서 정산되고, 부족할 때는 파트너와 조율하는 상태인 PENDING_RESERVE가 된다. 어드민 승인 단계는 없다(v3-82).
REQUESTED → PROCESSING → COMPLETED
REQUESTED → PENDING_RESERVE → PROCESSING → COMPLETED(파트너가 자금을 보내면)
REQUESTED / PENDING_RESERVE → REJECTED · PROCESSING → FAILED
즉시 플로우
- 투자자가
requestRedemption을 호출하면 NAV 스냅샷과 패널티가 계산되고, 같은 트랜잭션이 총 지급액에 대해 reserve를 확인한다. - reserve가 충분하면 투자자에게서 LP를 바로 소각하고 USDC를 지급하며 패널티를
fund_wallet으로 보내고COMPLETED가 된다. 에스크로도 대기열도 없고 어드민이 할 일도 없다. - 모자라면 LP가 에스크로되고 상태가
PENDING_RESERVE가 되며, 부족액과 함께RedemptionPendingReserve가 발생한다. 파트너의fundRedemption이 잔액이 충당되는 순간 그 요청을 자동으로 정산한다. - 수동 경로가 둘 남아 있지만 어느 것도 게이트가 아니다.
approveRedemption은 reserve가 나중에 늘어난PENDING_RESERVE요청에 대한 선택적 정산이고(REQUESTED요청에는 revert한다),claimRedemptionFallback은 예고 기간 이후의 무권한 이탈이다. 진짜 어드민 결정은 거부뿐이다.
회차 플로우 (epoch_duration_days > 0, v3-26)
요청이 회차 창으로 묶이고 마감에 pro-rata로 정산된 뒤 claimRedemption으로 가져간다.
엔진 재설계가 반영됐다. 아래가 그것이 만들어 내는 상태다
v3-91 / v3-93이 머지됐으므로 아래 흐름이 현행이다. 요청은 요청 창 안에서만 접수되고(밖에서는 RequestWindowClosed), 마감에 수요가 고정되며, 정산은 그 주기의 펀딩일에 일어나고 체결된 총액이 redemptionCommitted 스칼라에 차감된다(v3-91이 처음 명시한 회차별 항아리가 아니다. 철회됨, v3-100). 체결은 신규보다 이월분을 먼저 채우고, 수익 발생은 요청 시점이 아니라 정산까지 이어진다(v3-131 (3), 2026-08-20 반영, 온체인은 2026-08-21. 신규 풀만 해당되고 아직 새 팩토리로 만든 풀은 없다). 07-redemption → Model B 참조.
동작 둘은 반영 완료다(v3-105). 미청구 체결분이 있는 상태의 취소는 먼저 claim해야 하고(ClaimBeforeCancel, RedemptionLib.sol:626), previewEpochClaim(PlatformPool.sol:317)이 체결·미체결 분해를 제공한다. dev에는 2026-08-04 배포됐다(팩토리 0xE1E2E974…DA90 → 풀 구현체 0x27D9948F…f968, 커밋 0abe168). ⚠️ 신규 풀만 해당된다. 풀은 생성 시 구현체가 고정되는 Clones라 그 배포 이전에 만들어진 풀은 전부 옛 구현체로 돌고, 거기서는 취소가 여전히 포지션 전체를 돌려주며 뷰는 revert한다.
스케줄 설치 경로는 이제 처음부터 끝까지 완성됐다(v3-107). 호출자가 있고(pools.post.epoch-schedule.ts), 펀딩일 출처 컬럼이 dev에 적용됐고(마이그레이션 0111), setEpochSchedule / setEpochFundingDate / setEpochSettleAfter가 2026-08-04 배포된 구현체에 있다. ⚠️ 다만 setEpochSchedule은 생성 시에만 호출 가능하고 기존 풀은 옛 구현체에서 복제됐다. 그래서 그 배포 이전에 만들어진 풀은 앵커를 설정할 수 없고 창 없는 레거시 지연 시계를 계속 쓴다.
REQUESTED → QUEUED → COMPLETED(전량 체결 후 claim)
QUEUED → PARTIALLY_FILLED → … → COMPLETED(여러 회차에 걸친 부분 체결. 잔량은 전량 청구될 때까지 이월된다)
QUEUED / PARTIALLY_FILLED → (취소: LP 반환, epochTotalDemandLp 차감)
어드민의 holdRequest/releaseRequest로 이상한 요청(또는 회차)을 자동 정산에서 빼낼 수 있다. 보류된 요청은 이후 회차로 넘어가거나 어드민 결정을 기다린다(releaseRequest로 다시 포함하거나 REJECTED).
회차 플로우
- 투자자가 요청하면 LP가 잠기고
currentEpochId에 등록되며 상태가QUEUED가 된다(NAV 스냅샷 없음) - 마감에
executeEpoch()가fillRatio와 정산 NAV를 계산한다(O(1)이고 자금 이동 없음). 이상치가 보류되지 않는 한 100% 자동이다 - 투자자(또는 대리 청구하는 키퍼)가
claimRedemption()을 호출하면 체결분이 지급되고 잔량은PARTIALLY_FILLED로 이월된다 - 잔량이 0이 되면
COMPLETED가 된다
상태 정의
| 상태 | 설명 |
|---|---|
REQUESTED | 투자자가 요청을 제출했다. 즉시 풀은 nav_at_request가 고정되고, 회차 풀은 등록 중이다. LP 전송이 막힌다. |
QUEUED(회차) | 회차 창에 등록됐고 정산을 기다린다. NAV는 정산으로 미뤄진다. |
PARTIALLY_FILLED(회차) | pro-rata 부분 체결분을 청구했고 잔량이 다음 회차로 이월됐다. |
PENDING_RESERVE(즉시) | reserve가 부족해 파트너의 fundRedemption 보충을 기다린다. |
PROCESSING | 자금이 전송되는 중이다. |
COMPLETED | 투자자가 USDC를 받았다. payout_tx_hash가 기록된다. |
REJECTED | Return position(ADMIN 전용)으로 요청이 닫혔거나 투자자가 취소했다. rejection_reason이 기록되고, 어느 쪽인지는 failure_type이 말해 준다(아래 참조). |
FAILED | 전송 오류다. failure_type과 error_message가 기록된다. 어드민이 재시도할 수 있다. |
REJECTED는 네 가지 뜻을 갖고 failure_type으로 구분된다(v3-99). DB enum에 CANCELLED가 없어서 투자자 본인의 취소도 같은 상태로 뭉개진다. 그래서 리포팅은 이 컬럼으로 갈라야 하고, 그러지 않으면 자발적 취소가 거부율을 부풀린다.
failure_type | 기록 주체 | 뜻 |
|---|---|---|
UNFUNDED | Return position(어드민) | 파트너 자금이 끝내 오지 않았다. 투자자 잘못이 아니다. 에스크로된 LP가 돌아간다 |
COMPLIANCE | Return position(어드민) | 홀더가 더 이상 검증을 통과하지 못한다(SBT 취소, AML) |
OTHER | Return position(어드민) | 그 외 전부다. 필수 자유 입력 메모가 상세를 담는다 |
INVESTOR_CANCELLED | POST /{id}/cancel(투자자) | 투자자가 자기 요청을 철회했다. 운영자는 절대 설정할 수 없다. 그러면 결정 주체를 잘못 귀속하게 된다 |
failure_type은 DB 제약이 없는 순수 TEXT라, 이 버킷을 깨끗하게 유지하는 것은 핸들러의 화이트리스트뿐이다. 이 분리 이전의 행은 레거시 값 REJECTED를 가질 수 있고 소급 재분류하지 않는다. 인덱서가 미러링하는 온체인 직접 거부는 failure_type이 NULL로 들어온다(이벤트가 그 값을 담지 않는다).
폐기됨 (v2.x → v3.0)
FM_ACCEPTED: 완전히 제거됨(v3-34). FM 사전 확인 단계가 없다. 파트너 조율은 워크플로 단계가 아니라 상태다(즉시는PENDING_RESERVE, 회차는 이월).- AS_POOL/FUND_POOL 분리 플로우: 통합됐다.
- 멀티시그 에스크로 플로우: 제거됐다.
FM_ACCEPTED enum 값과 POST /redemption-requests/{id}/fm-accept 엔드포인트는 삭제됐다.
RECOMMENDED/APPROVED/CANCELLED에 대해: 이들은 플로우나 온체인 개념(예: operator recommend → admin approve, 투자자 취소)이고 DB enum 값으로 저장되지 않는다. DB는 진행 중인 요청을REQUESTED/PENDING_RESERVE/QUEUED로, 종료 결과를COMPLETED/REJECTED/FAILED로 뭉친다.
풀 상태 플래그 pools table
플래그 셋이 런타임 풀 동작을 통제한다.
| 플래그 | 설명 |
|---|---|
is_paused | soft pause다. 신규 입금이 막히고 상환은 정상 처리된다. 어드민이 즉시 토글한다. |
is_emergency_frozen | hard freeze다. 모든 활동이 막힌다(입금, 상환, 수익 청구). 투자자가 자금에 접근하지 못하므로 아껴서 쓴다. |
nav_per_token | NAV 값이다(기본 $1.00, 상한 $1.00, 하한 없음). 오라클이 updateNAV()로 갱신하고 하락에는 24시간 타임락이 붙으며 상승은 즉시 적용된다. |
2단계 정지 (v3.0)
v2.x에는 is_paused 플래그 하나뿐이었다. v3.0은 심각한 경우를 위해 is_emergency_frozen을 추가했다.
- soft pause(
is_paused = true): 신규 입금이 없고 투자자는 여전히 상환할 수 있다. 일시적 문제에 쓴다. - 긴급 동결(
is_emergency_frozen = true): 모든 활동이 멈춘다. 컴플라이언스 이슈, 보안 사고, 컨트랙트 버그에 쓴다.
펀드 상태 funds.status
ACTIVE ↔ INACTIVE
상태 정의
| 상태 | 설명 |
|---|---|
ACTIVE | 펀드가 운영 중이고 풀이 보이며 투자를 받는다. |
INACTIVE | 펀드가 중단됐다. 기존 포지션은 남는다. 어드민이 되돌릴 수 있다. |
NAV 갱신 상태 nav_history.status
PENDING → APPLIED
PENDING → CANCELLED(어드민이 타임락 중에 취소)
상태 정의
| 상태 | 설명 |
|---|---|
PENDING | NAV 하락이 제출됐다. 24시간 타임락이 걸려 있고 그동안 어드민이 취소할 수 있다. |
APPLIED | 타임락이 끝났거나(하락) 즉시 적용됐다(상승). 새 NAV가 풀에 반영됐다. |
CANCELLED | 타임락이 끝나기 전에 어드민이 대기 중인 갱신을 취소했다. |
수익 분배 상태 yield_distributions.status
수익 분배 플로우다.
PENDING → PROCESSING → DISTRIBUTED
PROCESSING → SETTLED_NO_HOLDERS
PROCESSING → FAILED
v3.0 플로우
- 파트너가
Pool.depositYield(gross)를 호출하면 상태가PENDING이 된다 - Aset Lambda가 이벤트를 받아 오프체인에서 net을 계산한다
- Lambda가
Pool.settleYield(stablecoin, net, treasury_fee, pool_mgmt_fee)를 호출하면 상태가PROCESSING이 된다 - LP 홀더가 청구할 수 있게 되고 수수료는 이미 지급됐으므로 상태가
DISTRIBUTED가 된다
⚠️ 3단계는 v3-102 이전에는 호출 둘이었다. 분배가 이미 성공한 뒤 수수료 호출이 실패할 수 있었는데, 그래도 행은 DISTRIBUTED로 마무리됐다.
상태 정의
| 상태 | 설명 |
|---|---|
PENDING | 마이그레이션 0113 이후 yield_distributions에서 금지된다(v3-104). CHECK (status <> 'PENDING')이고 컬럼 기본값이 PROCESSING으로 옮겨 갔다. 이 값은 제거된 레거시 서버키 생성 경로에서만 나왔고, insert와 settleYield 사이에서 크래시가 나면 어떤 정합기도 고칠 수 없는 고아가 남았다. ⚠️ enum 값 자체는 살아 있다. yield_distribution_investors.status에서 "아직 청구되지 않은 배분"을 뜻한다. |
PROCESSING | FM의 depositYield가 온체인에 올라갔고(deposit_tx_hash가 기록됨) Aset의 settleYield가 남아 있다. PENDING이 아니라 이것이 "기록됐지만 아직 분배되지 않음" 상태다. 24시간이 넘으면 정체다. 돈은 풀에 있는데 홀더가 지급받지 못했다. 복구는 POST /{id}/distribute이고, dashboard_alert_counts.stalled_yield와 Yield 화면의 Stalled 목록으로 노출된다. |
DISTRIBUTED | LP 홀더가 청구할 수 있다. |
SETTLED_NO_HOLDERS | 정산은 됐는데 아무에게도 크레딧되지 않았다. 자격 있는 LP가 없었기 때문이다(빈 풀이거나, 모든 지분이 상환을 위해 에스크로된 풀). 실패가 아니다. 수수료는 지급됐고 net 수익은 unclaimedYield에 남아 홀더가 생기면 다음 분배에서 청구할 수 있다. 이 행에는 종착 상태다. |
FAILED | 트랜잭션 오류다. failure_type과 error_message가 기록된다. |
🔴 상태는 체인이 한 일에서 파생하지, 무엇이 없는지에서 파생하지 않는다 (0185)
money_distribution_list.status는 저장된 컬럼이 아니라 원장에 대한 CASE다. 예전에는 PROCESSING을 폴백으로 읽었다. "기록된 실패도 없고 관측된 이벤트도 없으니 아직 진행 중일 것"이라는 뜻인데, 부재에는 두 가지 의미가 있다.
자격 있는 LP가 없으면 settleYield는 아무에게도 크레딧하지 않으면서 수수료 갈래는 지급한다(크레딧이 아니라 기간에서 끌어간다. test_SettleYieldWithNoEligibleHoldersStillPaysFees가 이를 고정한다). 트랜잭션은 성공했고 돈이 움직였는데 YieldDistributed가 발생하지 않았다. 그래서 행이 영원히 PROCESSING에 앉아 있었고, 단방향 distribution_started_at 선점 때문에 재시도도 안 됐으며, 24시간 sweep은 운영자에게 이미 실행된 분배를 실행하라고 알렸다.
수정은 온체인이다. YieldDistributed를 모든 정산에서 발생시켜 무엇이 기록됐는지 보고하게 했다. 그래서 아무에게도 크레딧하지 않는 것은 침묵이 아니라 (0, 0)이다. 시그니처가 그대로라 topic0도 그대로이고 재인덱싱이 필요 없다. 온체인이어야만 했다. 수수료 갈래도 없는 무홀더 정산은 아무것도 발생시키지 않아서, 어떤 뷰도 복구할 수 있는 증거가 남지 않았다.
오프체인 패치를 둘 먼저 시도했는데 둘 다 틀렸다. failure_type = 'NO_ELIGIBLE_HOLDERS'를 기록하는 것은 성공한 정산에 실패 채널을 빌려 쓰는 것이고, 핸들러에서 호출을 거부하는 것은 컨트랙트 테스트가 금지하는 바로 그 가드를 오프체인에 다시 세우는 것이었다.
도래했으나 기록되지 않은 기간은 아예 행이 아니다(v3-104). 그것은
pools.next_yield_due로만 존재하고,GET /yield-distributions?include_due=true가 서버 계산 추정치로 읽기 모델에 합성해 넣는다.status='DUE'행을 실제로 만드는 안은 기각됐다. 돈 없는 행이 원장에 있으면platform_stats의 총 수익, 투자자 목록, 인덱서의tx_hash정합,/{id}/distribute가드가 전부 그것을 제외해야 하고, 한 군데만 놓쳐도 홀더에게 지급된 금액을 과대 표시하게 된다.
SBT(Soulbound Token) 상태 users.sbt_status
NOT_MINTED → MINTED
NOT_MINTED → FAILED(민팅 tx가 revert됐거나 타임아웃)
FAILED → MINTED(재시도 성공)
MINTED → NOT_MINTED(로그인 정합화에서 온체인에 토큰이 없음을 확인. 아래 참조)
상태 정의
| 상태 | 설명 |
|---|---|
NOT_MINTED | KYC가 승인되지 않았거나, 민팅이 큐에 있고 아직 온체인에서 확정되지 않았다. |
MINTED | 온체인에서 확정됐고 sbt_tx_hash가 저장됐다. |
FAILED | 큐 재시도를 거친 뒤에도 민팅 tx가 revert됐거나 타임아웃됐다. sbt_error가 기록된다. sweep이나 어드민 재시도로 다시 큐에 들어간다. |
비동기 민팅: 민팅이 FIFO
sbt-mint큐에서 돌기 때문에sbt_status는kyc_status = APPROVED보다 잠깐 뒤처진다. 어드민 KYC 페이지는 행이 큐에 있는 동안 일시적인 MINTING 배지를 보여 준다(프론트엔드 전용이고 DB 값이 아니다). KYC & 신원 → SBT 민팅과 복구 참조.
온체인과 DB는 다르다:
users.sbt_status(이 3값 컬럼)는 우리 민팅 파이프라인을 추적한다. 출구 게이트는kycStateOf로 별개의 온체인 4상태 모델(NONE/VALID/EXPIRED/REVOKED)을 읽는다. 03-kyc-identity 참조. 취소된 홀더는sbt_status = MINTED를 유지하면서(토큰은 여전히 존재한다) 온체인에서는REVOKED다.
MINTED→NOT_MINTED전이는 파이프라인 단계가 아니라 정합화다.POST /auth/verify가 행을kycStateOf와 비교하고, 확정적인NONE(토큰 없음)일 때만role = GUEST와 함께 이 값을 지운다.EXPIRED와REVOKED는 지우지 않고 토큰과 id를 유지한다. 온체인 읽기가 실패하면 아무것도 쓰지 않는다. 전체 표와 여기서 booleanisValidKYC를 읽는 것이 왜 버그였는지는 KYC & 신원 → 로그인 시점 정합화에 있다.
풀 Lifecycle 상태 pools.lifecycle_status
DRAFT → UPCOMING → ACTIVE → CLOSED(모집 종료) → MATURED(FIXED_TERM)
DRAFT → UPCOMING → ACTIVE → MATURED(FIXED_TERM, 별도 모집 마감 없음)
ACTIVE → IMPAIRED(파트너 부실) → WIND_DOWN(종착). v3-12
ACTIVE 또는 UPCOMING → CLOSED → 다시 ACTIVE(어드민 POST /pools/{id}/close, 되돌릴 수 있다)
🔴 CLOSED는 이제 MATURED의 형제가 아니라 그리로 가는 도중의 정거장이다(0204 / v3-141). subscription_end_date가 있는 풀은 그날 모집을 닫고 기간이 끝나면 만기가 되므로 그 순서로 둘 다 지난다. 0204 이전에는 두 날짜가 같은 컬럼이었고 만기 패스가 먼저 돌아서 자동 마감 패스가 아예 발동하지 않았다. 이제 만기 패스는 ACTIVE와 CLOSED를 함께 고른다. 일찍 닫히고 만기가 되지 못하는 풀은 온체인에서는 패널티 없이 상환되는데도 알림, 배지, 부분·전량 규칙, 수익 도래 상한 등 모든 곳에서 만기가 안 온 것으로 읽히기 때문이다. 다만 subscription_end_date를 가진 CLOSED 풀만 고른다. 그래야 손으로 닫은 풀이 되돌아올 수 없는 상태로 쓸려 가지 않고 reopen 경로를 유지한다. ✅ 체인도 여기에 맞춰졌는데 한 번이 아니라 두 라운드에 걸쳐서다. LifecyclePolicy가 CLOSED를 종착으로 취급해서 온체인 호출이 revert됐고, 스위퍼는 컨트랙트가 공유하지 않는 상태를 쓰는 대신 행을 건너뛰었다(v3-92). b619d9a가 CLOSED → MATURED를 추가하고 MATURED → CLOSED를 제거했으며(2026-08-20 배포, 구현체 0xCfe751fd…), 3edf75b가 CLOSED → ACTIVE를 추가했다(2026-08-21 배포, 구현체 0x405E89Ec…, v3-142). reopen 결정이 첫 라운드가 이미 만들어진 뒤에 나와서 하루 차이로 그 배포를 놓쳤다. ⚠️ Clones 때문에 컷오프가 하나가 아니라 둘이다. 풀은 생성 시점 구현체의 규칙을 갖고 가고 poolImplementation은 immutable이다. 08-20 이전 풀은 CLOSED에서 MATURED로 갈 수 없고, 08-21 이전 풀은 reopen할 수 없다. 만기 쪽은 둘 다 무해하다. 0204 이전에 만들어진 풀은 subscription_end_date가 없어서 sweep으로 CLOSED에 도달하지 않는다. 하지만 reopen 쪽은 어느 풀에서든 손으로 도달할 수 있어서, 아래 전이 표에서 다시 짚는다. 2026-08-21 점검 결과 dev에 배포된 아홉 개 풀 중 현재 구현체를 쓰는 것은 하나도 없다(apps/contract/sepolia.md의 Live pool generations).
⚠️ 이전 초안은 이를 *어느 상태에서든 → CLOSED*로 적었다. 그렇지 않다. MATURED, IMPAIRED, WIND_DOWN 풀을 닫는 것은 이유가 있어 도달한 상태에서 뒤로 되돌리는 것이고, 핸들러가 이를 거부한다(409). 아직 모집을 받고 있는 풀만 닫을 모집이 있다.
🔴 ARCHIVED는 lifecycle 상태가 아니다
pools.lifecycle_status의 값이 아니고 그리로 가는 전이도 없다. DB enum에 ARCHIVED 멤버가 없다. 어드민 매퍼(admin-web/app/shared/api/pools.ts → mapPoolRowToView)에서 deleted_at으로부터 파생되는 표시 라벨이고, deleted_at이 설정돼 있으면 'ARCHIVED'를 반환해 실제 lifecycle을 가린다.
그래서 아카이브된 풀도 그 아래에서는 여전히 CLOSED거나 MATURED거나 DRAFT다. 아카이브는 lifecycle 안의 단계가 아니라 lifecycle과 플래그 위에 겹쳐진 세 번째 축이다. lifecycle_status를 읽는 모든 것(온체인 가드, 입금 게이트, 상환 경로)은 아카이브에 영향받지 않는다. is_paused와 같은 부류로 다룰 것. 라벨이 배지를 차지할 뿐인 플래그다. 두 축 → 숨김 축과 v3-110 참조.
상태 정의
| 상태 | 설명 |
|---|---|
DRAFT | 저장됐고 배포되지 않았다. 수정과 삭제가 가능하다. |
UPCOMING | 배포됐고 투자자에게 보이며 start_date 이전이다. |
ACTIVE | 투자가 열려 있다. start_date에 도달했다. NAV 변동이 자동으로 막지 않는다. |
IMPAIRED | 파트너 부실이 감지됐다(DPD 급증, NAV 갱신 지연, 수익 연체). 입금은 멈추고 상환은 락업과 패널티가 면제된 채 열려 있다. 회복 가능하며, 파트너가 안정되면 어드민이 ACTIVE로 되돌릴 수 있다. |
CLOSED | 모집이 끝났다. 신규 입금은 없지만 상환과 수익 청구는 정상적으로 계속되고 분배 일정도 이어진다(들어올 때 next_yield_due를 더 이상 지우지 않는다. 닫힌 풀도 남은 쿠폰을 다 갚아야 한다). 재투자는 이어지지 않는다. reinvest가 온체인에서 whenActive이기 때문이다. 어드민 수동 마감(POST /pools/{id}/close)이나 subscription_end_date 도달 시 자동으로 진입한다(0204 / v3-141. 예전에는 만기인 end_date를 봤고 그 패스는 발동한 적이 없다). 풀이 다른 면에서 정상이면 ACTIVE로 되돌릴 수 있다(reopen). 모집을 일찍 닫는 것은 종착이 아니라 운영상의 판단이기 때문이다. ✅ 두 경로 다 2026-08-04에 반영됐다. 그전까지 이 상태는 제품 곳곳에서 읽히기만 하고 아무것도 쓰지 않았다. 🔴 "되돌릴 수 있다"는 온체인에서 참이 되기 전에 여기 쓰였다. 엔드포인트와 버튼은 2026-08-04부터 있었지만 LifecyclePolicy가 CLOSED → ACTIVE를 허용한 적이 없어서, 배포된 풀에 대한 모든 reopen이 502를 냈다. 이 행은 오프체인 절반만 주장하고 체인을 확인하지 않았다. 2026-08-21부터 참이고(v3-142) 그 구현체 이후에 만들어진 풀에만 해당된다. |
MATURED | maturity_days가 경과했다(FIXED_TERM 풀). 모든 상환에 패널티가 없다. |
WIND_DOWN | 종착 상태다(파트너 60일 이상 무응답 또는 IMPAIRED에서 에스컬레이션). nav_per_token이 distributable / (totalSupply − settledUnclaimedLp)로 설정되고, 여기서 distributable은 reserve 더하기 회수한 자금(회차 top-up)이며 정산됐지만 미청구인 채무를 빼지 않는다. 그 채무는 대신 분모에서 빠진다(v3-100이 분자, R10이 분모. 06 → R10 참조). pro-rata 지분은 requestRedemption → claim으로 청구한다(온체인에 전용 redeem() 함수는 없다). claimWindDown은 없다(v3-12에서 제거). |
ARCHIVED는 이 표에 의도적으로 없다. lifecycle 값이 아니다. 위 경고와 숨김 축 참조.
자동 전이(스케줄러)
| 전이 | 트리거 |
|---|---|
DRAFT → UPCOMING | 🔴 어드민이 "Publish"를 누르면 published_at이 설정된다. start_date가 이미 지났다면 같은 요청 안에서 바로 ACTIVE로 승격된다(pools.post.create / pools.post.lifecycle). 어드민 위저드가 확인 전에 어느 결과가 되는지 알려 준다 |
UPCOMING → ACTIVE | 🟢 pools.scheduler.lifecycle(매시간). 시작일보다 먼저 발행된 풀에 대해 start_date에 도달하면 자동 전이한다 |
ACTIVE 또는 CLOSED → MATURED | 🟢 maturity_days가 경과하면(FIXED_TERM) 자동 전이한다. 0204부터 CLOSED가 필터에 들어 있다(v3-141). 기간이 끝나기 전에 모집이 닫힌 풀은 이미 CLOSED 상태로 만기를 맞는데, 모든 화면과 알림과 패널티 없는 상환 경로가 달력이 아니라 MATURED를 기준으로 삼기 때문이다. 🔴 subscription_end_date가 있는 CLOSED 풀만 해당된다. 손으로 닫은 풀은 되돌릴 수 있는 reopen을 빼앗기지 않도록 제외한다. ✅ b619d9a 이후 온체인에서도 가능하다(2026-08-20 배포). 같은 변경에서 MATURED → CLOSED가 제거돼 MATURED가 setter의 종착이 됐다. ⚠️ Clones 컷오프: 그 구현체 이전에 만들어진 풀은 CLOSED가 종착으로 남는다. 그런 풀은 sweep으로 거기 도달하지도 않으므로(0204 이전에는 subscription_end_date가 없다) 컷오프가 물리는 것은 손으로 닫은 풀뿐이다 |
ACTIVE → IMPAIRED | 🔴 어드민 proposeImpairment() → 7일 타임락 → executeImpairment()(v3-12). 이것이 유일한 경로다. setLifecycleStatus는 IMPAIRED를 목표로 받지 않고, 트랜치 상각도 이 경로를 써야 한다(v3-106) |
IMPAIRED → ACTIVE | 🔴 어드민 cancelImpairment()(파트너가 회복되고 부실이 해소됨) |
ACTIVE 또는 IMPAIRED → WIND_DOWN | 🔴 60일 침묵 후 어드민 proposeWindDown() → 30일 타임락 → executeWindDown(). 참고: 온체인에서 강제되는 것은 30일 타임락(WIND_DOWN_TIMELOCK)뿐이다. 60일 파트너 침묵은 자동 추적기가 없는 오프체인 전제조건이고, proposeWindDown()은 수동 어드민 조작이다. |
ACTIVE → CLOSED | ✅ 어드민 POST /pools/{id}/close 또는 **subscription_end_date**가 지나면 pools.scheduler.lifecycle이 처리한다(0204 / v3-141. v3-110 B는 만기인 end_date를 읽었다). 입금만 막히고 상환과 청구는 계속된다. 온체인 setLifecycleStatus가 먼저이고 DB가 나중이다. ⚠️ 스케줄러 패스는 여전히 만기 패스 뒤에 돌기 때문에, 두 날짜를 다 지난 풀은 MATURED로 남고 강등되지 않는다. 강등하면 이미 자유 이탈 자격을 얻은 홀더에게 조기 이탈 패널티를 되살리게 된다. subscription_end_date가 없는 풀은 여기서 아예 스윕되지 않는다 |
CLOSED → ACTIVE | ✅ 어드민 reopen(같은 엔드포인트, action: reopen). 풀이 긴급 동결 중이면 거부된다. 동결된 풀은 reopen이 광고하는 입금을 받을 수 없기 때문이다. is_paused를 다시 설정하지 않는다(v3-78: 완화는 자동 복원하지 않는다). subscription_end_date는 과거일 때만 지운다. 미래 날짜는 유지되므로 풀이 그날 다시 닫히고, reopen 다이얼로그가 다른 탭에서 찾게 두지 않고 그 날짜를 알려 준다. 🔴 이 행은 2주 반 동안 ✅로 적혀 있었지만 그동안 모든 reopen이 502를 냈다. 엔드포인트는 2026-08-04에 나왔고 LifecyclePolicy는 그 전이를 허용한 적이 없다. 온체인에서는 3edf75b / 구현체 0x405E89Ec…, 2026-08-21부터다(v3-142). ⚠️ Clones 컷오프: 옛 풀은 절대 reopen할 수 없고, 풀이 어느 구현체에서 왔는지를 오프체인에 기록하는 것이 없어서 어느 화면도 둘을 구분하지 못한다 |
아카이브는 이 표에 없다.
deleted_at을 설정할 뿐lifecycle_status는 건드리지 않는다.
🔴 DB에 IMPAIRED를 쓰는 것은 전이가 아니다 (v3-106)
온체인 executeImpairment 없이 pools.lifecycle_status = 'IMPAIRED'만 쓰는 것은 v3-92와 같은 부류의 결함이다. 체인은 ACTIVE로 남으므로 IMPAIRED가 부여하는 락업·패널티 면제와 입금 차단이 전혀 작동하지 않는다. UI가 컨트랙트는 지키지 않는 부실 조건을 약속하게 된다. 게다가 스스로를 잠근다. pools.post.impairment의 propose는 409를 내고(제안 타임스탬프는 설정됐는데 상태가 더 이상 ACTIVE가 아니다), execute는 DB 쪽 타임락 검사를 통과한 뒤 온체인에서 NoImpairmentProposal로 revert해 502가 되며, setLifecycleStatus는 그 목표를 거부한다. 이를 되돌리는 제품 경로가 없다.
tranche.post.writedown이 소진된 Junior에 대해 v3-106 전까지 정확히 이렇게 했다(2026-08-03 수정). 이제는 proposeImpairmentOnChain을 호출하고 제안됨만 저장하며 executeImpairment가 라벨을 바꾸게 둔다. 타임락이 끝나기 전에 입금을 멈춰야 한다면 그건 is_paused와 온체인 pause()다(아래 자동 해제 표 참조). lifecycle 쓰기가 아니다.
복합 풀 상태: 기능 매트릭스 lifecycle × flags
앞 절들은 각 상태 필드를 독립적으로 정의했다. 이 절은 그것들을 합쳐, UI와 온체인 가드를 실제로 좌우하는 질문에 답한다. 풀의 복합 상태가 주어졌을 때 투자자는 무엇을 할 수 있는가?
축은 둘이지 긴 목록 하나가 아니다
투자자에게 보이는 풀 상태는 평평한 enum이 아니다. 이렇게 구성된다.
- Lifecycle(6개):
UPCOMING/ACTIVE/CLOSED/MATURED/IMPAIRED/WIND_DOWN. 풀의 단계와 건강 상태다(pools.lifecycle_status). - 플래그(독립적이고 위에 겹친다):
is_paused(입금 차단),is_emergency_frozen(전부 차단). - 노출(세 번째 축, v3-110):
deleted_at(아카이브. 종착에 가깝고 조건이 걸린다)과is_hidden(운영 중 숨김. 자유롭게 되돌린다). 둘 다 lifecycle 값이 아니고 투자자가 풀을 볼 수 있는지만 정한다. - 수식어(상태가 아니라 축): 상각(
nav_per_token < 1.0), 만석(tvl >= capacity), 제안된 타임락(impairment / NAV / wind-down 제안됨. 여전히ACTIVE다), 회차 정산(상환 메커니즘).
그래서 "Active + 상각", "Impairment 제안됨", "만석"은 별개의 lifecycle 상태가 아니라 ACTIVE × 수식어다. 투자자는 한 번에 하나의 복합 상태를 본다.
숨김 축: deleted_at과 is_hidden
서로 다른 두 요구를 레버 하나가 처리하고 있었고, 그래서 "아카이브"가 "끝난 풀을 정리한다"와 "잠깐 내린다" 둘 다를 뜻하게 표류했다. v3-110(결정 A)이 이를 나눈다.
deleted_at(아카이브) | is_hidden(숨김) | |
|---|---|---|
| 의도 | 끝난 풀을 운영자의 작업 목록에서 정리 | 풀이 계속 돌아가는 채로 투자자 화면에서 일시적으로 내림 |
| 허용 조건 | 모든 투자자 포지션이 0이고, DRAFT를 뺀 모든 lifecycle(DRAFT는 하드 삭제로 보낸다) | 어떤 상태에서든 언제든 |
| 되돌릴 수 있나 | 가능. 복원을 쓰지만 사유가 필수이고 감사 대상이다 | 가능. 평범한 토글이다 |
| 온체인 효과 | 온체인 pause()를 반영해, 아카이브된 풀이 컨트랙트 직접 호출로도 입금을 받지 못하게 한다 | 없음 |
| Lifecycle | 건드리지 않음 | 건드리지 않음 |
| 배지 | lifecycle 칩을 가리고 Archived로 표시 | 운영자에게 Hidden 표시로 보이며 풀의 상태로 표시되지 않음 |
✅ 양쪽 다 2026-08-04에 반영됐다. 아카이브 가드는 예전에 lifecycle_status = 'ACTIVE'일 때만 열린 포지션을 확인했다. 그래서 투자자 포지션을 들고 있는 CLOSED / MATURED / IMPAIRED / WIND_DOWN 풀도 아카이브될 수 있었다. 사람들 돈이 들어 있는 풀을, 하필 풀이 나가는 길에 지나는 상태들에서 숨긴 것이다. 이제 DRAFT를 뺀 모든 lifecycle에서 검사가 돌고, 판단은 순수 모듈 lib/shared/business/pool-archive.ts에 있으며 테스트가 enum을 열거한다. 그래서 나중에 lifecycle 값이 추가되면 가드를 조용히 건너뛰는 대신 테스트가 실패한다.
아카이브는 이제 컨트랙트도 pause한다. 예전에는 deleted_at만 쓰고 끝이라, 콘솔에서는 안 보이는 풀이 주소를 아는 사람에게는 deposit()이 열려 있었다. 복원은 의도적으로 unpause하지 않는다(v3-78). 풀은 pause된 채로 돌아오고 다시 여는 것은 별도의 행위다.
is_hidden은 존재하지만(0123) 어드민 목록 전용이다. 투자자 목록과 PDP까지 포함한다는 결정문보다 좁다. 홀더는 상환하고, 수익을 청구하고, 상각 안내를 읽으려면 그 풀에 접근할 수 있어야 한다.
기능 매트릭스
| 상태 | 입금 | 출금·상환 | 수익 청구 | 되돌릴 수 있나 | 뜻 |
|---|---|---|---|---|---|
ACTIVE | 가능 | 가능 | 가능 | — | 정상 |
ACTIVE + 상각 | 가능 | 가능 | 가능 | — | nav_per_token < 1.0. 자산은 상각됐지만 풀은 정상 작동 |
Paused(is_paused) | 불가(재투자도 막힌다. ⚠️ MVP에서는 재투자를 어느 화면에도 노출하지 않는다, 2026-08-27, 아직 반영 안 됨, v3-151. 온체인 함수는 남는다) | 가능 | 가능 | 어드민 즉시 토글 | 통상적·운영상의 이유로 자본 유입을 멈춘 상태다. 수동 전용이고 풀을 자동으로 pause하는 것은 아무것도 없다(v3-94) |
IMPAIRED | 불가 | 가능(락업과 패널티 면제) | 가능 | 회복 가능(cancelImpairment로 → ACTIVE) | 자산 부실을 검토 중이다. 원금이 상각될 수 있다 |
WIND_DOWN | 불가 | ⚠️ 풀이 보유한 것의 pro-rata 지분(nav_per_token = distributable / (totalSupply − settledUnclaimedLp), v3-100 + R10)을 requestRedemption → claim으로 | — | 종착이고 비가역 | 청산 |
Frozen(is_emergency_frozen) | 불가(전체 동결) | 첫 72시간 불가, 이후 가능 | 첫 72시간 불가, 이후 가능 | 기간이 정해진 긴급 조치 | 전부 멈추되 비대칭이다. 아래 참조 |
MATURED | 불가 | 가능(패널티 없음) | 가능 | 종착 | 기간 완료 |
CLOSED | 불가 | 가능(기존 홀더) | 가능 | — | 더 이상 모집하지 않음 |
UPCOMING | 불가 | — (포지션이 아직 없음) | — | → ACTIVE | 아직 열리지 않음 |
| 만석 | 불가(capacity 도달) | 가능 | 가능 | — | ACTIVE이지만 tvl >= capacity |
Archived(deleted_at) | 불가(온체인 pause() 반영) | — | — | 복원(사유 필수, 감사 대상) | 풀을 화면에서 정리했다. 모든 포지션이 이미 0일 때만 도달할 수 있으므로 상환하거나 청구할 사람이 남아 있지 않다. 그래서 숨겨도 안전하다(v3-110 A) |
Hidden(is_hidden) | 가능 | 가능 | 가능 | 자유 토글 | 투자자 목록과 PDP에서 빠지지만 포지션을 가진 사람에게는 완전히 정상 작동한다. 노출만 바뀌고 기능 변화는 전혀 없다 |
아카이브와 숨김: 투자자가 겪는 차이
위 두 행은 비슷해 보이지만 다르다. 아카이브는 영향받을 투자자가 없다는 조건에서만 가능하다. 포지션이 0이어야 하므로 불가·— 표시는 아무도 보유하지 않은 풀을 설명한다. 숨김은 노출만 바꾼다. 기존 홀더는 전과 똑같이 입금하고 상환하고 청구한다. 다만 둘러보다가 그 풀을 찾을 수 없을 뿐이다. 숨겨진 풀에 대한 알림은 계속 나간다. 홀더의 포지션이 살아 있으므로 침묵이 오히려 잘못된 신호가 되기 때문이다.
아카이브된 풀의 홀더는 구조상 존재하지 않으므로 "아카이브된 투자자에게도 알림이 가나?"는 주어가 없는 질문이다. 살아 있는 포지션이 있는 풀을 화면에서 내려야 한다면 그건 숨김이고, 아카이브가 조용히 둘 다 하지 못하도록 가드가 그 선택을 강제한다.
입금 게이팅(프론트엔드와 온체인이 일치해야 한다)
입금은 다음이 모두 성립할 때만 허용된다. lifecycle_status = 'ACTIVE'이고 is_paused = false이고 is_emergency_frozen = false이고 tvl + amount <= capacity. (온체인에서 deposit()은 whenActive · whenNotPaused · whenNotFrozen · duringSubscription 모디파이어를 갖는다. 08a 컨트랙트 레퍼런스 참조.)
- 차단: Upcoming · Impaired · Wind-down · Frozen · Paused · Matured · Closed · 만석
- 허용(여전히
ACTIVE): Active · Active + 상각 · Impairment 제안됨 · NAV 변경 제안됨 · Wind-down 제안됨 · 회차 정산
여러 조건이 겹칠 때의 우선순위
is_emergency_frozen > WIND_DOWN > IMPAIRED > is_paused > 상각. (상각 배너는 위의 어느 것도 해당되지 않을 때만 단독으로 표시된다.)
자동 해제(플래그 배타성, v3-78)
플래그가 lifecycle 위에 겹쳐지므로, 어드민 조작은 같은 쓰기 안에서 더 낮은 플래그를 자동으로 해제한다. 그래서 유효한 상태는 항상 하나뿐이다. 이는 백엔드 핸들러에서 atomic하게 처리한다. 어드민은 엔드포인트를 하나만 호출한다.
| 조작 | 설정 | 자동 해제 |
|---|---|---|
| Pause | is_paused = true | — (가장 낮다) |
| Impairment(execute) | lifecycle = IMPAIRED | is_paused(IMPAIRED가 이미 입금을 막는다) + 온체인 unpause() |
| Freeze | is_emergency_frozen = true | is_paused + 온체인 unpause() |
| Wind-down(execute) | lifecycle = WIND_DOWN | is_paused + 온체인 unpause(). 그리고 is_emergency_frozen을 설정하지 않는다(아래 참조) |
Close(POST /pools/{id}/close) | lifecycle = CLOSED | is_paused + 온체인 unpause()(v3-110 D) |
만기 · 자동 마감(pools.scheduler.lifecycle, 각각 end_date와 subscription_end_date 기준) | lifecycle = MATURED 또는 CLOSED | is_paused + 온체인 unpause()(v3-110 D) |
마지막 두 행이 v3-110에서 추가된 것이고, 규칙을 에스컬레이션에서 lifecycle 진행으로 확장한다. 풀이 만기가 되거나 완전히 닫히는 순간 "일시 중단이고 곧 돌아온다"는 말은 참이 아니게 되는데, 둘을 다 켜 두면 MATURED + PAUSED가 나오고 어드민 목록에는 그 조합을 담을 칩이 하나도 없었다. ⚠️ 스케줄러가 이를 무인으로 실행하므로 아래 v3-92 규칙은 권고가 아니라 필수다. 무인 상태에서 DB만 해제하면 아무도 보고 있지 않은 풀이 어긋난 채로 남는다.
더 높은 상태를 되돌린다고 낮은 플래그가 자동으로 복원되지는 않는다(unfreeze해도 is_paused = false로 남으므로 다시 pause하려면 수동으로 해야 한다). pools.post.pause도 풀이 ACTIVE이고 동결되지 않은 경우가 아니면 pause를 거부한다.
is_paused를 끄는 것은 온체인 pause도 함께 꺼야 한다 (v3-92)
is_paused의 기준은 온체인이고(08 §A. DB만 거는 pause는 보안 구멍이다), OpenZeppelin Pausable은 독립적인 컨트랙트 플래그다. emergencyFreeze()도 lifecycle 변경도 그것을 건드리지 않는다(PlatformPool.emergencyFreeze는 isEmergencyFrozen만 설정한다). 그래서 DB만 자동 해제하면 어긋난다. 컨트랙트는 paused()로 남는데 DB는 "pause 아님"으로 읽힌다.
deposit()이 계속whenNotPaused로 조용히 revert된다(어드민과 투자자 UI는 풀이 열려 있다고 보여 준다),- 나중에
pause()를 호출하면 **EnforcedPause()**로 revert돼 엔드포인트가 502를 내고 UI에는 복구 경로가 없다(DB가is_paused = false라고 하니 토글이 unpause가 아니라 pause를 제공한다), - 스스로 낫는 것도 없다. 온체인 인덱서는
paused()를 DB로 미러링하지 않는다.
그래서 에스컬레이션 핸들러들(pools.post.freeze · .impairment execute · .wind-down execute), 그리고 v3-110 이후로는 pools.post.close와 lifecycle 스케줄러까지 공용 헬퍼 clearOnChainPause(poolAddress, chainId)를 호출한다. 이 헬퍼는 라이브 paused()를 읽고 켜져 있으면 unpause()를 보낸다. 규칙은 이렇다.
- 순서는 에스컬레이션 tx가 먼저, 그다음
unpause()다. unpause를 먼저 하면 에스컬레이션이 실패했을 때 컨트랙트가 입금을 받는 상태로 남는다. 08 §A가 말하는 바로 그 구멍이다.unpause()에는whenNotFrozen이나whenActive가드가 없어서 에스컬레이션이 처리된 뒤에도 동작한다(가드가 걸린 것은pause()뿐이다). - DB는 의도가 아니라 체인을 미러링한다.
is_paused = false는 컨트랙트가 실제로 unpause된 경우에만 쓴다. unpause 트랜잭션이 실패하면 컬럼은true로 남는다. 두 플래그 다 입금을 막고 더 높은 상태가 표시 우선순위를 가져가므로, 진실한 이중 플래그가 거짓말하는 단일 플래그보다 낫다. 실패는pause_clear_failed: true로 감사되고, 성공한 해제는unpause_tx_hash를 기록한다. - DB 미러가 아니라 체인을 읽는다. 그래서 이미 어긋난 풀도 다음 에스컬레이션에서 스스로 복구된다.
Wind-down은 동결하지 않는다(v3-78, 안 A). 예전에는 executeWindDown이 is_emergency_frozen = true도 설정했다. 그런데 is_emergency_frozen이 우선순위에서 WIND_DOWN보다 위라, UI가 "동결됨(전부 중단)"으로 읽고 pro-rata 상환 경로를 가려 버렸다. wind-down의 목적과 정반대다. 온체인에서 requestRedemption에는 whenNotFrozen 가드가 없고 RedemptionLib은 wind-down 중 상환을 허용하므로 상환은 늘 동작했다. 틀린 것은 DB 플래그와 프론트엔드 표시뿐이었다. 이제 wind-down은 is_emergency_frozen을 false로 둔다. 입금은 이미 WIND_DOWN lifecycle이 막으므로(whenActive가 실패한다) 동결은 불필요했다.
부실 관련 상태들을 합치지 않는 이유
Paused, Impaired, Wind-down은 다 입금을 막지만 출금 방식, 되돌릴 수 있는지, 뜻이 다르다. 그래서 "내 돈을 뺄 수 있나, 얼마에?"에 대한 답이 다르고 합치면 오해를 부른다.
- Paused: 통상적이고 즉시 되돌릴 수 있으며 출금이 완전히 정상이다.
IMPAIRED: 회복 가능한 부실이고, 출금은 락업과 패널티가 면제된 채 허용되며, 원금이 상각될 수 있다.WIND_DOWN: 종착 청산이고 정상 상환이 없다. 낮아진 NAV로 풀의 분배 가능 유동성에서 pro-rata 지급만 있다.- Frozen: 긴급 상황이고 출금까지 막히며 기간이 정해져 있다.
동결은 비대칭이고 스스로 만료된다 (v3-28)
위 매트릭스의 그 행은 평면적인 "전부 중단"이 아니다. 동결에는 시계가 둘 있고 둘 다 UI가 무엇을 주장해도 되는지에 영향을 준다.
| 차단 기간 | 메커니즘 | |
|---|---|---|
| 입금(자본 유입) | 동결 전체 | deposit()의 whenNotFrozen |
| 출금과 수익 청구(가치 유출) | 첫 72시간만(freeze_started_at부터 FREEZE_EXIT_WINDOW) | PoolCommonLib.checkExitNotBlocked |
| 동결 자체 | 7일 뒤 트랜잭션 없이 만료된다(FREEZE_MAX_DURATION). _frozenActive가 그냥 false를 반환한다 | 그래서 풀이 영구히 망가질 수 없다 |
unfreeze(PAUSER)로 일찍 해제할 수 있고, 7일을 넘겨 연장하려면 거버넌스 타임락이 필요하다. 문구에 대한 결과: 동결 배너는 절대 기한이 없는 것처럼 읽히면 안 된다("나중에 다시 확인해 주세요"). 중단에는 언제나 명시된 끝이 있기 때문이다. freeze_exit_window_ends_at과 freeze_auto_expires_at을 풀 읽기 모델에 노출한 이유가 정확히 이것이다.
Impairment(검토 중인 상각)와 wind-down(청산)은 계약상으로도 구분된다(SSA 손실 흡수·NPL 조항). 그래서 한 상태로 합칠 수 없다.