Skip to content

상태 머신

작업 진행 중

이 페이지는 v3.0 상태 흐름을 반영한다. 구현이 진행 중이다. FM_ACCEPTED는 제거됐고(v3-34), 회차 상환이 QUEUEDPARTIALLY_FILLED를 추가한다(v3-26).

시스템 전체의 상태 필드와 그 유효한 전이를 다룬다. v3.0은 많은 플로우를 통합했다. 설정과 무관하게 모든 풀이 같은 로직을 쓴다.

입금 상태 deposits.status

v3.0의 통합된 입금 플로우다. Aset이 항상 입금 시점에 LP를 민팅한다.

PENDINGPROCESSINGCOMPLETED

PROCESSINGFAILED(전송 또는 민팅 오류)

v3.0 통합 플로우

USDC가 Pool 컨트랙트로 들어가고 → 투자자에게 LP가 민팅되고(Aset의 PlatformLPToken) → reserve가 남고 → 파트너 잔여분이 fund_wallet으로 가고 → COMPLETED가 된다.

전부 한 트랜잭션이다. Receipt NFT도 없고 D+7 환불 메커니즘도 없다.

상태 정의

상태설명
PENDING입금 트랜잭션이 제출됐고 확정을 기다린다.
PROCESSING블록 확정 진행 중이다(드물다. 보통 PENDING과 atomic하다).
COMPLETEDLP가 민팅됐고 reserve가 남았으며 fund_wallet이 자기 몫을 받았다.
FAILED전송 또는 민팅 오류다. failure_typeerror_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_STARTEDIN_REVIEWAPPROVED

IN_REVIEWREJECTED

상태 정의

상태설명
NOT_STARTED기록된 검증이 없다. SDK를 열고 아무것도 제출하지 않은 홀더도 포함한다. 그 경우 kyc_level만 기록된다.
IN_REVIEW신청자가 제출됐고 판정을 기다린다.
APPROVEDKYC를 통과했고 SBT가 온체인에 민팅됐다.
REJECTED검증에 실패했다. reject_type(RETRY / FINAL)을 볼 것.

**IN_REVIEW의 라이터는 하나뿐이다. kyc/enter-review.tsmarkApplicantInReview**이고, SumSub의 applicantPending / applicantOnHold webhook이나 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).

REQUESTEDPROCESSINGCOMPLETED

REQUESTEDPENDING_RESERVEPROCESSINGCOMPLETED(파트너가 자금을 보내면)

REQUESTED / PENDING_RESERVEREJECTED · PROCESSINGFAILED

즉시 플로우

  1. 투자자가 requestRedemption을 호출하면 NAV 스냅샷과 패널티가 계산되고, 같은 트랜잭션이 총 지급액에 대해 reserve를 확인한다.
  2. reserve가 충분하면 투자자에게서 LP를 바로 소각하고 USDC를 지급하며 패널티를 fund_wallet으로 보내고 COMPLETED가 된다. 에스크로도 대기열도 없고 어드민이 할 일도 없다.
  3. 모자라면 LP가 에스크로되고 상태가 PENDING_RESERVE가 되며, 부족액과 함께 RedemptionPendingReserve가 발생한다. 파트너의 fundRedemption잔액이 충당되는 순간 그 요청을 자동으로 정산한다.
  4. 수동 경로가 둘 남아 있지만 어느 것도 게이트가 아니다. 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생성 시에만 호출 가능하고 기존 풀은 옛 구현체에서 복제됐다. 그래서 그 배포 이전에 만들어진 풀은 앵커를 설정할 수 없고 창 없는 레거시 지연 시계를 계속 쓴다.

REQUESTEDQUEUEDCOMPLETED(전량 체결 후 claim)

QUEUEDPARTIALLY_FILLED → … → COMPLETED(여러 회차에 걸친 부분 체결. 잔량은 전량 청구될 때까지 이월된다)

QUEUED / PARTIALLY_FILLED → (취소: LP 반환, epochTotalDemandLp 차감)

어드민의 holdRequest/releaseRequest로 이상한 요청(또는 회차)을 자동 정산에서 빼낼 수 있다. 보류된 요청은 이후 회차로 넘어가거나 어드민 결정을 기다린다(releaseRequest로 다시 포함하거나 REJECTED).

회차 플로우

  1. 투자자가 요청하면 LP가 잠기고 currentEpochId에 등록되며 상태가 QUEUED가 된다(NAV 스냅샷 없음)
  2. 마감에 executeEpoch()fillRatio와 정산 NAV를 계산한다(O(1)이고 자금 이동 없음). 이상치가 보류되지 않는 한 100% 자동이다
  3. 투자자(또는 대리 청구하는 키퍼)가 claimRedemption()을 호출하면 체결분이 지급되고 잔량은 PARTIALLY_FILLED로 이월된다
  4. 잔량이 0이 되면 COMPLETED가 된다

상태 정의

상태설명
REQUESTED투자자가 요청을 제출했다. 즉시 풀은 nav_at_request가 고정되고, 회차 풀은 등록 중이다. LP 전송이 막힌다.
QUEUED(회차)회차 창에 등록됐고 정산을 기다린다. NAV는 정산으로 미뤄진다.
PARTIALLY_FILLED(회차)pro-rata 부분 체결분을 청구했고 잔량이 다음 회차로 이월됐다.
PENDING_RESERVE(즉시)reserve가 부족해 파트너의 fundRedemption 보충을 기다린다.
PROCESSING자금이 전송되는 중이다.
COMPLETED투자자가 USDC를 받았다. payout_tx_hash가 기록된다.
REJECTEDReturn position(ADMIN 전용)으로 요청이 닫혔거나 투자자가 취소했다. rejection_reason이 기록되고, 어느 쪽인지는 failure_type이 말해 준다(아래 참조).
FAILED전송 오류다. failure_typeerror_message가 기록된다. 어드민이 재시도할 수 있다.

REJECTED는 네 가지 뜻을 갖고 failure_type으로 구분된다(v3-99). DB enum에 CANCELLED가 없어서 투자자 본인의 취소도 같은 상태로 뭉개진다. 그래서 리포팅은 이 컬럼으로 갈라야 하고, 그러지 않으면 자발적 취소가 거부율을 부풀린다.

failure_type기록 주체
UNFUNDEDReturn position(어드민)파트너 자금이 끝내 오지 않았다. 투자자 잘못이 아니다. 에스크로된 LP가 돌아간다
COMPLIANCEReturn position(어드민)홀더가 더 이상 검증을 통과하지 못한다(SBT 취소, AML)
OTHERReturn position(어드민)그 외 전부다. 필수 자유 입력 메모가 상세를 담는다
INVESTOR_CANCELLEDPOST /{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_pausedsoft pause다. 신규 입금이 막히고 상환은 정상 처리된다. 어드민이 즉시 토글한다.
is_emergency_frozenhard freeze다. 모든 활동이 막힌다(입금, 상환, 수익 청구). 투자자가 자금에 접근하지 못하므로 아껴서 쓴다.
nav_per_tokenNAV 값이다(기본 $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

ACTIVEINACTIVE

상태 정의

상태설명
ACTIVE펀드가 운영 중이고 풀이 보이며 투자를 받는다.
INACTIVE펀드가 중단됐다. 기존 포지션은 남는다. 어드민이 되돌릴 수 있다.
NAV 갱신 상태 nav_history.status

PENDINGAPPLIED

PENDINGCANCELLED(어드민이 타임락 중에 취소)

상태설명
PENDINGNAV 하락이 제출됐다. 24시간 타임락이 걸려 있고 그동안 어드민이 취소할 수 있다.
APPLIED타임락이 끝났거나(하락) 즉시 적용됐다(상승). 새 NAV가 풀에 반영됐다.
CANCELLED타임락이 끝나기 전에 어드민이 대기 중인 갱신을 취소했다.
수익 분배 상태 yield_distributions.status

수익 분배 플로우다.

PENDINGPROCESSINGDISTRIBUTED

PROCESSINGSETTLED_NO_HOLDERS

PROCESSINGFAILED

v3.0 플로우

  1. 파트너가 Pool.depositYield(gross)를 호출하면 상태가 PENDING이 된다
  2. Aset Lambda가 이벤트를 받아 오프체인에서 net을 계산한다
  3. Lambda가 Pool.settleYield(stablecoin, net, treasury_fee, pool_mgmt_fee)를 호출하면 상태가 PROCESSING이 된다
  4. LP 홀더가 청구할 수 있게 되고 수수료는 이미 지급됐으므로 상태가 DISTRIBUTED가 된다

⚠️ 3단계는 v3-102 이전에는 호출 둘이었다. 분배가 이미 성공한 뒤 수수료 호출이 실패할 수 있었는데, 그래도 행은 DISTRIBUTED로 마무리됐다.

상태 정의

상태설명
PENDING마이그레이션 0113 이후 yield_distributions에서 금지된다(v3-104). CHECK (status <> 'PENDING')이고 컬럼 기본값이 PROCESSING으로 옮겨 갔다. 이 값은 제거된 레거시 서버키 생성 경로에서만 나왔고, insert와 settleYield 사이에서 크래시가 나면 어떤 정합기도 고칠 수 없는 고아가 남았다. ⚠️ enum 값 자체는 살아 있다. yield_distribution_investors.status에서 "아직 청구되지 않은 배분"을 뜻한다.
PROCESSINGFM의 depositYield가 온체인에 올라갔고(deposit_tx_hash가 기록됨) Aset의 settleYield가 남아 있다. PENDING이 아니라 이것이 "기록됐지만 아직 분배되지 않음" 상태다. 24시간이 넘으면 정체다. 돈은 풀에 있는데 홀더가 지급받지 못했다. 복구는 POST /{id}/distribute이고, dashboard_alert_counts.stalled_yield와 Yield 화면의 Stalled 목록으로 노출된다.
DISTRIBUTEDLP 홀더가 청구할 수 있다.
SETTLED_NO_HOLDERS정산은 됐는데 아무에게도 크레딧되지 않았다. 자격 있는 LP가 없었기 때문이다(빈 풀이거나, 모든 지분이 상환을 위해 에스크로된 풀). 실패가 아니다. 수수료는 지급됐고 net 수익은 unclaimedYield에 남아 홀더가 생기면 다음 분배에서 청구할 수 있다. 이 행에는 종착 상태다.
FAILED트랜잭션 오류다. failure_typeerror_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_MINTEDMINTED

NOT_MINTEDFAILED(민팅 tx가 revert됐거나 타임아웃)

FAILEDMINTED(재시도 성공)

MINTEDNOT_MINTED(로그인 정합화에서 온체인에 토큰이 없음을 확인. 아래 참조)

상태 정의

상태설명
NOT_MINTEDKYC가 승인되지 않았거나, 민팅이 큐에 있고 아직 온체인에서 확정되지 않았다.
MINTED온체인에서 확정됐고 sbt_tx_hash가 저장됐다.
FAILED큐 재시도를 거친 뒤에도 민팅 tx가 revert됐거나 타임아웃됐다. sbt_error가 기록된다. sweep이나 어드민 재시도로 다시 큐에 들어간다.

비동기 민팅: 민팅이 FIFO sbt-mint 큐에서 돌기 때문에 sbt_statuskyc_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다.

MINTEDNOT_MINTED 전이는 파이프라인 단계가 아니라 정합화다. POST /auth/verify가 행을 kycStateOf와 비교하고, 확정적인 NONE(토큰 없음)일 때만 role = GUEST와 함께 이 값을 지운다. EXPIREDREVOKED는 지우지 않고 토큰과 id를 유지한다. 온체인 읽기가 실패하면 아무것도 쓰지 않는다. 전체 표와 여기서 boolean isValidKYC를 읽는 것이 왜 버그였는지는 KYC & 신원 → 로그인 시점 정합화에 있다.

풀 Lifecycle 상태 pools.lifecycle_status

DRAFTUPCOMINGACTIVECLOSED(모집 종료) → MATURED(FIXED_TERM)

DRAFTUPCOMINGACTIVEMATURED(FIXED_TERM, 별도 모집 마감 없음)

ACTIVEIMPAIRED(파트너 부실) → WIND_DOWN(종착). v3-12

ACTIVE 또는 UPCOMINGCLOSED → 다시 ACTIVE(어드민 POST /pools/{id}/close, 되돌릴 수 있다)

🔴 CLOSED는 이제 MATURED의 형제가 아니라 그리로 가는 도중의 정거장이다(0204 / v3-141). subscription_end_date가 있는 풀은 그날 모집을 닫고 기간이 끝나면 만기가 되므로 그 순서로 둘 다 지난다. 0204 이전에는 두 날짜가 같은 컬럼이었고 만기 패스가 먼저 돌아서 자동 마감 패스가 아예 발동하지 않았다. 이제 만기 패스는 ACTIVECLOSED를 함께 고른다. 일찍 닫히고 만기가 되지 못하는 풀은 온체인에서는 패널티 없이 상환되는데도 알림, 배지, 부분·전량 규칙, 수익 도래 상한 등 모든 곳에서 만기가 안 온 것으로 읽히기 때문이다. 다만 subscription_end_date를 가진 CLOSED 풀만 고른다. 그래야 손으로 닫은 풀이 되돌아올 수 없는 상태로 쓸려 가지 않고 reopen 경로를 유지한다. ✅ 체인도 여기에 맞춰졌는데 한 번이 아니라 두 라운드에 걸쳐서다. LifecyclePolicyCLOSED를 종착으로 취급해서 온체인 호출이 revert됐고, 스위퍼는 컨트랙트가 공유하지 않는 상태를 쓰는 대신 행을 건너뛰었다(v3-92). b619d9aCLOSED → MATURED를 추가하고 MATURED → CLOSED를 제거했으며(2026-08-20 배포, 구현체 0xCfe751fd…), 3edf75bCLOSED → ACTIVE를 추가했다(2026-08-21 배포, 구현체 0x405E89Ec…, v3-142). reopen 결정이 첫 라운드가 이미 만들어진 뒤에 나와서 하루 차이로 그 배포를 놓쳤다. ⚠️ Clones 때문에 컷오프가 하나가 아니라 둘이다. 풀은 생성 시점 구현체의 규칙을 갖고 가고 poolImplementationimmutable이다. 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.tsmapPoolRowToView)에서 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부터 있었지만 LifecyclePolicyCLOSED → ACTIVE를 허용한 적이 없어서, 배포된 풀에 대한 모든 reopen이 502를 냈다. 이 행은 오프체인 절반만 주장하고 체인을 확인하지 않았다. 2026-08-21부터 참이고(v3-142) 그 구현체 이후에 만들어진 풀에만 해당된다.
MATUREDmaturity_days가 경과했다(FIXED_TERM 풀). 모든 상환에 패널티가 없다.
WIND_DOWN종착 상태다(파트너 60일 이상 무응답 또는 IMPAIRED에서 에스컬레이션). nav_per_tokendistributable / (totalSupply − settledUnclaimedLp)로 설정되고, 여기서 distributable은 reserve 더하기 회수한 자금(회차 top-up)이며 정산됐지만 미청구인 채무를 빼지 않는다. 그 채무는 대신 분모에서 빠진다(v3-100이 분자, R10이 분모. 06 → R10 참조). pro-rata 지분은 requestRedemption → claim으로 청구한다(온체인에 전용 redeem() 함수는 없다). claimWindDown은 없다(v3-12에서 제거).

ARCHIVED는 이 표에 의도적으로 없다. lifecycle 값이 아니다. 위 경고와 숨김 축 참조.

자동 전이(스케줄러)

전이트리거
DRAFTUPCOMING🔴 어드민이 "Publish"를 누르면 published_at이 설정된다. start_date가 이미 지났다면 같은 요청 안에서 바로 ACTIVE로 승격된다(pools.post.create / pools.post.lifecycle). 어드민 위저드가 확인 전에 어느 결과가 되는지 알려 준다
UPCOMINGACTIVE🟢 pools.scheduler.lifecycle(매시간). 시작일보다 먼저 발행된 풀에 대해 start_date에 도달하면 자동 전이한다
ACTIVE 또는 CLOSEDMATURED🟢 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가 없다) 컷오프가 물리는 것은 손으로 닫은 풀뿐이다
ACTIVEIMPAIRED🔴 어드민 proposeImpairment() → 7일 타임락 → executeImpairment()(v3-12). 이것이 유일한 경로다. setLifecycleStatusIMPAIRED를 목표로 받지 않고, 트랜치 상각도 이 경로를 써야 한다(v3-106)
IMPAIREDACTIVE🔴 어드민 cancelImpairment()(파트너가 회복되고 부실이 해소됨)
ACTIVE 또는 IMPAIREDWIND_DOWN🔴 60일 침묵 후 어드민 proposeWindDown() → 30일 타임락 → executeWindDown(). 참고: 온체인에서 강제되는 것은 30일 타임락(WIND_DOWN_TIMELOCK)뿐이다. 60일 파트너 침묵은 자동 추적기가 없는 오프체인 전제조건이고, proposeWindDown()은 수동 어드민 조작이다.
ACTIVECLOSED✅ 어드민 POST /pools/{id}/close 또는 **subscription_end_date**가 지나면 pools.scheduler.lifecycle이 처리한다(0204 / v3-141. v3-110 B는 만기인 end_date를 읽었다). 입금만 막히고 상환과 청구는 계속된다. 온체인 setLifecycleStatus가 먼저이고 DB가 나중이다. ⚠️ 스케줄러 패스는 여전히 만기 패스 뒤에 돌기 때문에, 두 날짜를 다 지난 풀은 MATURED로 남고 강등되지 않는다. 강등하면 이미 자유 이탈 자격을 얻은 홀더에게 조기 이탈 패널티를 되살리게 된다. subscription_end_date가 없는 풀은 여기서 아예 스윕되지 않는다
CLOSEDACTIVE✅ 어드민 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.impairmentpropose는 409를 내고(제안 타임스탬프는 설정됐는데 상태가 더 이상 ACTIVE가 아니다), execute는 DB 쪽 타임락 검사를 통과한 뒤 온체인에서 NoImpairmentProposal로 revert해 502가 되며, setLifecycleStatus는 그 목표를 거부한다. 이를 되돌리는 제품 경로가 없다.

tranche.post.writedown이 소진된 Junior에 대해 v3-106 전까지 정확히 이렇게 했다(2026-08-03 수정). 이제는 proposeImpairmentOnChain을 호출하고 제안됨만 저장하며 executeImpairment가 라벨을 바꾸게 둔다. 타임락이 끝나기 전에 입금을 멈춰야 한다면 그건 is_paused와 온체인 pause()(아래 자동 해제 표 참조). lifecycle 쓰기가 아니다.

폐기됨

  • DISTRESSED 상태: ACTIVE + writedown (NAV < 1.0) + is_paused 조합으로 대체됐다.
  • claimWindDown() 함수: v3-12에서 제거됐다. WIND_DOWN이 NAV를 distributable / (totalSupply − settledUnclaimedLp)로 설정한 뒤 requestRedemption → claim을 쓴다(v3-100 + R10). 온체인에 전용 redeem() 함수는 없다.
복합 풀 상태: 기능 매트릭스 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_atis_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하게 처리한다. 어드민은 엔드포인트를 하나만 호출한다.

조작설정자동 해제
Pauseis_paused = true— (가장 낮다)
Impairment(execute)lifecycle = IMPAIREDis_paused(IMPAIRED가 이미 입금을 막는다) + 온체인 unpause()
Freezeis_emergency_frozen = trueis_paused + 온체인 unpause()
Wind-down(execute)lifecycle = WIND_DOWNis_paused + 온체인 unpause(). 그리고 is_emergency_frozen을 설정하지 않는다(아래 참조)
Close(POST /pools/{id}/close)lifecycle = CLOSEDis_paused + 온체인 unpause()(v3-110 D)
만기 · 자동 마감(pools.scheduler.lifecycle, 각각 end_datesubscription_end_date 기준)lifecycle = MATURED 또는 CLOSEDis_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.emergencyFreezeisEmergencyFrozen만 설정한다). 그래서 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()를 보낸다. 규칙은 이렇다.

  1. 순서는 에스컬레이션 tx가 먼저, 그다음 unpause()다. unpause를 먼저 하면 에스컬레이션이 실패했을 때 컨트랙트가 입금을 받는 상태로 남는다. 08 §A가 말하는 바로 그 구멍이다. unpause()에는 whenNotFrozen이나 whenActive 가드가 없어서 에스컬레이션이 처리된 뒤에도 동작한다(가드가 걸린 것은 pause()뿐이다).
  2. DB는 의도가 아니라 체인을 미러링한다. is_paused = false는 컨트랙트가 실제로 unpause된 경우에만 쓴다. unpause 트랜잭션이 실패하면 컬럼은 true로 남는다. 두 플래그 다 입금을 막고 더 높은 상태가 표시 우선순위를 가져가므로, 진실한 이중 플래그가 거짓말하는 단일 플래그보다 낫다. 실패는 pause_clear_failed: true로 감사되고, 성공한 해제는 unpause_tx_hash를 기록한다.
  3. DB 미러가 아니라 체인을 읽는다. 그래서 이미 어긋난 풀도 다음 에스컬레이션에서 스스로 복구된다.

Wind-down은 동결하지 않는다(v3-78, 안 A). 예전에는 executeWindDownis_emergency_frozen = true도 설정했다. 그런데 is_emergency_frozen이 우선순위에서 WIND_DOWN보다 위라, UI가 "동결됨(전부 중단)"으로 읽고 pro-rata 상환 경로를 가려 버렸다. wind-down의 목적과 정반대다. 온체인에서 requestRedemption에는 whenNotFrozen 가드가 없고 RedemptionLib은 wind-down 중 상환을 허용하므로 상환은 늘 동작했다. 틀린 것은 DB 플래그와 프론트엔드 표시뿐이었다. 이제 wind-down은 is_emergency_frozenfalse로 둔다. 입금은 이미 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_atfreeze_auto_expires_at을 풀 읽기 모델에 노출한 이유가 정확히 이것이다.

Impairment(검토 중인 상각)와 wind-down(청산)은 계약상으로도 구분된다(SSA 손실 흡수·NPL 조항). 그래서 한 상태로 합칠 수 없다.

상태 배너 문구: 정본 v3-95

위의 기능 매트릭스는 상태가 무엇을 하는지를 말한다. 이 절은 그것에 대해 우리가 무엇을 말해도 되는지를 고정한다. 이런 절이 존재하는 이유는 두 앱 모두에서 상태 문구가 동작에서 표류했고 코드가 하지 않는 주장을 내보냈기 때문이다. 어드민 배너가 존재하지 않는 자동 pause 기능을 안내했고, 투자자 드로어가 pause 날짜와 검토 기한과 주간 업데이트 주기와 원금 보장을 지어냈다(v3-95).

여기서 문구는 장식이 아니다. 투자자가 자기 돈을 뺄 수 있는지에 대해 갖는 유일한 설명이다. 잘못된 상태 문구를 잘못된 nav_per_token과 같은 등급의 결함으로 다룰 것.

문구는 어디에 있고 무엇이 그것을 강제하나 (v3-96)

아래 규칙은 양심에 맡기는 것이 아니다. 무시하기 어려운 순서로 세 가지 장치가 뒷받침한다.

장치위치잡는 것
문구 모듈: 상태 문구를 컴포넌트에 인라인으로 쓰지 않는다apps/web/app/shared/copy/status.ts, apps/admin-web/app/shared/copy/status.ts, apps/infra/lib/shared/notifications/copy.ts표면 전체를 한 파일에서 감사할 수 있게 하고, 문구 변경이 JSX 안에 묻히는 대신 문구 diff로 읽히게 한다. 각 문자열은 자기 주장을 강제하는 코드 경로를 함께 담는다.
copy-guard: 빌드를 막는 스크립트scripts/check-copy.mjs. 세 앱의 build에 연결돼 있다(--app web / admin-web / infra)단정적인 보장 표현, 지어낸 기한과 요일 주기, 문구 모듈 안의 시계 읽기(Date.now(), new Date())를 잡는다. 부정형 고지("수익이 보장되지 않습니다")는 설계상 통과한다.
eslint no-restricted-syntax두 프론트엔드의 eslint.config.js, app/shared/copy/** 범위같은 실수를 타이핑하는 동안 잡는다.

백엔드 문구도 대상이다. apps/infra는 2026-07-30까지 가드 밖에 있었다. 알림 레지스트리가 제품에서 사용자에게 노출되는 가장 큰 문구 표면인데도 그랬다. 모든 이메일과 인앱 카드가 거기서 렌더되고, 이메일은 누가 검토할 때쯤이면 이미 발송돼 있다. 문구 모듈의 시계 규칙은 정확한 파일명 copy.ts를 기준으로 하므로, 시계를 정당하게 읽는 형제 파일(중복 조회 창을 잡는 notify.ts, format-date.ts)은 걸리지 않는다.

가드를 lint 규칙이 아니라 빌드 단계로 둔 것은 의도적이다. CI는 lint가 아니라 build를 돌리고(deploy-web.yml / deploy-admin-web.ymlpnpm --filter … build:dev를 실행한다), 두 프론트엔드 모두 기존 오류가 쌓여 있어 pnpm lint가 실패하는 상태다. eslint만으로는 아무것도 막지 못했을 것이다. 오탐이면 해당 줄이나 그 위에 copy-guard-allow: <이유> 주석을 단다. 이유는 필수다. 목표는 우회를 어렵게 만드는 게 아니라 기록되게 하는 것이기 때문이다.

⚠️ infra의 커버리지 공백: apps/infra에는 CI 워크플로가 없다(deploy-web / deploy-admin-web / deploy-docs뿐이다). 그래서 infra 게이트는 자기 buildtest 스크립트에서만 돌고, 로컬과 배포 직전에만 실행되며 push 시에는 돌지 않는다. 게다가 CDK 배포는 핸들러를 직접 번들링하고 build를 돌리지 않으므로, pnpm buildpnpm test를 건너뛴 배포는 게이트도 건너뛴다. 제대로 막으려면 infra CI 잡이 필요하다.

어떤 상태가 무엇을 하는지 확인할 수 없으면 그럴듯한 문장을 쓰지 말 것. TODO(copy)를 남기고 물어본다. 이것이 CLAUDE.md 2번 규칙의 문구 절반(2-b)이다. 배선되지 않은 버튼을 가짜로 만들지 말고 disabled로 두라는 것과 같은 원칙이다.

다섯 규칙

  1. 스케줄러가 실제로 쓰지 않는 한 어떤 상태도 자동이라고 서술하지 말 것. 자동 전이는 위의 *자동 전이(스케줄러)*에 열거돼 있고, 그 목록에 없는 것은 운영자가 조작하는 것이다. 특히 풀을 자동으로 pause하는 것은 아무것도 없다(v3-94).
  2. 보호·보장·회수 표현 금지. "당신의 투자는 보호됩니다"도, "원금이 확보됩니다"도, "원금 보호"도 안 된다. wind-down은 명시적으로 pro-rata이고 원금에 한참 못 미칠 수 있다. 담보는 UNSECURED일 수 있다. 문구 오류 중 법적 노출이 있는 유일한 부류이기도 하다.
  3. 날짜나 주기를 지어내지 말 것. 표시하는 모든 날짜는 컬럼에서 와야 한다(freeze_exit_window_ends_at, next_yield_due, end_date 등). 렌더 시점에 Date.now()로 만든 날짜는 기본값이 아니라 조작이다. 우리가 운영하지 않는 약속("매주 금요일 업데이트")도 마찬가지다.
  4. 동사만 쓰지 말고 축을 밝힐 것. 자본 유입(입금 + 재투자)이 영향받는지 가치 유출(상환 + 출금 + 청구)이 영향받는지를 말한다. "Paused"만 쓰면 "내 돈이 묶였다"로 읽히는데, is_paused가 하는 일은 그 반대다.
  5. 배너나 드로어의 모든 요소는 실제로 동작해야 한다. 죽은 버튼 금지(CLAUDE.md 2번 규칙). 열 문서가 없으면 "Read notice" 버튼도 없다.

투자자 문구 (apps/web, shared/ui/PoolStatusAlert.tsx)

변형제목문구실제로 막는 것
pausedTemporarily Paused신규 입금과 재투자가 중단됐습니다. 기존 포지션, 상환, 출금, 수익 청구는 영향받지 않습니다.자본 유입만
writedownPrincipal Writedown원금 가치가 하락했습니다(NAV $x). 입금과 출금은 정상 작동합니다.없음
impairedUnder Review신규 투자가 일시 중단됐습니다. 출금은 계속 가능합니다. 상황을 주시하고 있습니다.입금(이탈 시 락업과 패널티 면제)
winddownWinding Down…가용 유동성 범위 안에서 pro-rata로 지급됩니다. 회수액이 원금에 크게 못 미칠 수 있습니다…입금. 이탈은 pro-rata만
frozenTemporarily Unavailable입금, 출금, 수익 청구가 일시 중단됐습니다. 출금과 수익 청구는 {freeze_exit_window_ends_at}에 재개되고, 중단 자체는 {freeze_auto_expires_at}에 자동으로 해제됩니다.전부. 비대칭이고 위 v3-28 참조
maturedPool Matured{date}에 만기가 됐습니다. 패널티 없이 상환할 수 있습니다. 상환은 투자자가 시작합니다.입금
closedPool Closed더 이상 투자를 받지 않습니다. 기존 투자자는 수익을 청구하고 상환을 요청할 수 있습니다.입금
fullFully Subscribed신규 투자를 받지 않습니다. 기존 포지션은 영향받지 않습니다.입금(capacity 도달)
upcomingComing Soon{start_date}에 투자가 열립니다입금(아직 열리지 않음)

상세 드로어가 있는 변형은 paused뿐이다. 무엇이 멈췄고 무엇이 계속되는지, 그리고 pause에는 정해진 기간이 없으며 그 자체로 풀 자산에 대한 진술이 아니라는 것을 말한다. 그 이상은 없다.

어드민 문구 (apps/admin-web)

표면문구
풀 상세 상단 배너(is_paused)이 풀의 입금이 중단됐습니다. 상환, 출금, 수익 청구는 계속됩니다. (yield_overdue일 때는 "수익 분배도 연체 상태입니다. 재개하기 전에 분배하세요."가 덧붙는다)
Controls → Deposit Pause자본 유입, 즉 신규 입금과 수익 재투자를 막습니다. 상환, 출금, 수익 청구는 계속됩니다. 언제든 되돌릴 수 있고, 풀을 자동으로 pause하는 것은 없습니다.
Controls → Emergency Freeze입금, 출금, 수익 청구를 전부 중단합니다. 출금은 72시간 뒤 자동 재개되고, 동결 자체는 7일 뒤 자동 만료됩니다.
Controls → Impairment공개적인 부실 신호입니다. 입금이 중단되고 출금은 패널티가 면제된 채 열려 있습니다. 7일 타임락이 있고 되돌릴 수 있습니다.
Controls → Wind-down되돌릴 수 없습니다. 풀을 영구히 닫고 투자자는 남은 것에서 pro-rata로 상환받습니다. 실행 전 30일 타임락이 있습니다.

pause 배너와 pause 컨트롤에 "Resume Pool"이라고 쓰면 안 된다. pause는 풀을 멈춘 적이 없고 자본 유입만 멈췄다. 둘 다 Resume Deposits로 쓴다.

해소됨(2026-08-04): wind-down의 "reserve" 표현

두 앱 모두 wind-down 지급을 reserve에서 pro-rata로 나간다고 설명했다. 그 문구를 쓸 당시의 컨트랙트와는 맞았다(executeWindDownnavPerToken = reserveBalance / totalSupply였다). 근거는 reserve가 입금·재투자의 reserveBps 분리로만 채워지고 파트너가 회수 자본을 되넣을 경로가 없다는 것이었다. 그래서 reserve를 0으로 두고 런칭한다는 전제에서는 산식이 NAV 0을 내고, 문구가 0원 지급을 정직하게 설명했다. v3-100이 그 전제를 끝냈다. 분자가 reserveBalance + totalEpochTopUp이 됐고 totalEpochTopUp은 파트너의 fundRedemption에서 오므로, 회수된 자본이 실제로 거기 도달한다. (v3-100은 redemptionCommitted를 빼기도 했는데, 그건 0abe168에서 이중 계상으로 제거됐다. 같은 커밋이 분모도 totalSupply − settledUnclaimedLp로 고쳤다. R10, 2026-08-04 배포, 신규 풀만.)

결정(2026-08-04): 표현을 바꾼다. 이제 모든 표면이 가용 유동성이라고 쓰고, 그것이 곧 그 분자다. 투자자 풀 상태(web/shared/copy/status.ts), 어드민 wind-down 컨트롤(이미 이렇게 쓰고 있었다), pools.post.wind-down 피드 항목이 해당된다. reserve 전용 표현은 제품 문구 어디에도 살아남지 않았다. 남아 있던 것은 두 status.ts의 개발자용 주석이 대체된 산식을 적고 있던 것이고, 이제 현행 산식을 적는다.

이 변경이 주장하지 않는 것. 이는 기준의 변경이지 기대치의 변경이 아니다. wind-down에서는 정의상 파트너가 응답하지 않으므로, reserve가 0인 풀은 파트너가 실제로 자금을 넣지 않는 한 여전히 0이거나 0에 가깝게 가격이 매겨진다. 투자자 문구가 그 이유로 단서 문장을 유지한다("회수액이 원금에 크게 못 미칠 수 있습니다"). 그리고 어떤 표면도 산식 자체를 인용하지 않는다.