Skip to content

상환

투자자가 포지션에서 나가는 방식이다. 상환 모델이 둘이고 풀별로 epoch_duration_days가 고른다.

모델epoch_duration_days기본 적용 대상NAV 가격정산
즉시0FIXED_TERM요청 시점 NAV(스냅샷)요청 시점에 reserve 확인 후 즉시 정산. 부족하면 PENDING_RESERVE가 됐다가 파트너의 fundRedemption자동 정산한다(v3-82)
회차> 0(v3-26)OPEN_ENDED정산 시점 NAV(forward pricing)회차 종료 시 일괄 pro-rata, 이월, pull claim

수익은 언제나 별도로 청구한다(상환 지급액에 포함되지 않는다).

NAV 메커니즘은 Writedown & NAV, 파트너 무응답 시의 긴급 wind-down은 풀 모델 → 긴급 Wind-Down 참조. 회차 모델은 회차 기반 상환으로 바로 가면 된다.

이 페이지 전체가 신규 풀에만 해당된다. 풀은 생성 시 구현체가 고정되는 EIP-1167 Clones라, 여기 적힌 컨트랙트 동작은 그 동작이 배포된 뒤에 만들어진 풀에만 적용된다. 그 이전에 만들어진 풀은 복제될 때의 구현체를 계속 쓰고, 어떤 재배포도 그것을 옮기지 않는다. 프론트엔드는 현재 팩토리에서 만든 풀이 있어야 그에 맞춰 개발할 수 있다. 항목별 배포 날짜는 17-changelog 참조.

외부 리저브의 출구 조건

외부 리저브 구현에서는 입금의 reserveBps 몫을 reserveWallet으로 보내고 나머지를 fundWallet으로 보낸다. 풀 내부 리저브 잔고를 적립하지 않는다.

현재 소스에는 claimRedemptionFallback이 없다. 7일 폴백을 통한 지급을 보장하지 않는다. 별도로 남아 있는 회차 정산과 대리 청구도 실제 투입 자금에 의존한다.

현재 소스의 executeWindDown()navPerToken을 재계산하지 않는다. 오라클 NAV를 유지하며 실제 지급에는 별도 자금 투입과 상환 절차가 필요하다.

이 절의 리저브 잔고 산식은 구형 구현의 이력이다. 외부 리저브 구현에는 적용하지 않는다.

지급 산식

토큰 가치        tokens_burned × nav_at_request
조기 패널티      penalty_type에 따라 계산(해당되면 → fund_wallet, v3-85)
──────────────────────────────────────────────
총 상환액        token_value - penalty

수익은 claimYield()로 따로 청구하고 상환에 포함되지 않는다. 수익은 NAV로 조정된 토큰 가치가 아니라 명목 입금액 기준으로 쌓인다.

어느 NAV인가. 즉시 풀은 nav_at_request로 가격을 매긴다. 요청 트랜잭션에서 찍은 스냅샷이고 다시 보지 않는다. 이후 하락해도 지급액이 줄지 않고 회복돼도 늘지 않는다. 회차 풀은 nav_at_request 자체가 없다(NULL이다). NAV와 패널티를 정산 시점에 계산한다(v3-18 개정). 회차 기반 상환 참조.

예외: NAV 하락 타임락 중에 한 즉시 요청은 예고된 NAV로 가격이 매겨진다(R2·R3). 대기 중인 하락은 24시간 동안 공개되므로, 그 창 안에 제출된 요청은 아직 유효한 pools.nav_per_token이 아니라 예고된 값을 쓴다. 그러지 않으면 내일 $0.98이 온다는 걸 알면서 $1.00을 확정하고 손실을 남은 홀더에게 떠넘길 수 있다. 예고 이전에 제출된 요청은 자기 스냅샷을 유지하고, 회차 풀은 영향이 없다(정산 시점에 가격을 매긴다). Writedown & NAV → R2·R3 참조.

출구 게이트: 누가 출금을 막을 수 있나

상환을 막을 수 있는 것이 셋이고 혼동하기 쉽다. 그중 사람이 버튼을 누르는 것은 하나뿐이다.

게이트주체적용 대상
락업·만기자동아래 3-State Lockup Model 참조
검증(AML)자동, 온체인두 풀 유형 모두
Return position(운영자 조작)ADMIN · SUPER_ADMIN만. FM 제외, 아래 참조즉시 풀만

검증: 취소는 출구를 막고, 만료는 막지 않는다

게이트는 PlatformKYCSoulbound.canRedeem이고 이 비대칭은 의도적이다(v3-31).

  • AML / REVOKED면 차단한다. 제재 대상이거나 취소된 홀더에게 가치가 나가면 안 된다.
  • SBT가 EXPIRED여도 통과한다. 만료로 막으면 서류가 지났다는 이유로 투자자 본인 자본을 가두게 되고, 그것이 v3-28이 막으려는 실패 양상이다. 만료는 행정 상태이지 자금을 붙잡을 이유가 아니다.

홀더의 상태가 그 사이에 바뀔 수 있으므로 두 시점에서 확인한다.

시점즉시회차
요청 시(requestRedemption)requiresRedeemableKyc같은 모디파이어
지급 시_executeRedemptionPayout. 모든 정산 경로claimRedemption

회차도 정확히 같은 방식으로 게이팅된다. 회차 풀에는 요청별 운영자 결정이 아예 없고(정산이 일괄 pro-rata다) AML을 위해 그것이 필요하지도 않다. 위의 두 시점이 모두 온체인에서 강제된다.

두 번째 검사는 _executeRedemptionPayout 안에 있다. 즉시 정산 경로가 모여드는 단일 진입점이다(fundRedemption 안의 파트너 펀딩 자동 정산). approveRedemptionclaimRedemptionFallback이 그것들이 끌어 쓰던 reserve와 함께 제거되기 전까지는 세 경로를 지켰다. 더 이상 통과하지 못하는 홀더는 RedemptionBlockedByKyc로 revert된다. 호출자마다가 아니라 진입점을 게이팅한 것은 의도적이다. 호출자를 하나씩 손보면 다음에 추가되는 호출자가 반드시 그것을 빠뜨린다. 그리고 세 호출자 중 둘을 제거하면서도 여기서 바꿀 게 없었다는 것이 방금 그것을 보여 줬다.

알아 둘 결과가 둘 있다.

  • revert가 파트너 자금을 묶어 두지는 않는다. 전송과 지급이 같은 트랜잭션이라 revert가 전송도 되돌린다. 요청은 자동으로 닫히지 않는다. 그렇게 하면 파트너의 트랜잭션이 Aset의 컴플라이언스 결정을 실행하게 되고 감사 기록에 파트너가 행위자로 남는다. 요청을 닫는 것은 명시적인 어드민 조작으로 남는다.
  • revert는 Aset 쪽에서 조용하다. 그래서 PENDING_RESERVE 요청에 대해 매시간 도는 sweep이 홀더가 더 이상 통과하지 못하면 어드민에게 알린다. sweep은 상태를 바꾸지 않는다.

(fundRedemptioncheckExitNotBlocked를 호출하지만 그건 동결 게이트이지 검증 게이트가 아니다.) 배경은 v3-99 참조.

Return position: 운영자 조작이 실제로 하는 일

"reject"가 아니라 Return position이라 부르는 이유는 투자자에게서 아무것도 빼앗지 않기 때문이다. 요청이 닫히고 에스크로된 LP가 그들에게 돌아간다. 지급받는 대신 다시 자기 포지션을 보유하게 된다. 이미 들어온 파트너 자금은 fund_wallet으로 돌아간다. 그 밖에 풀어야 할 것은 없다. 요청 시점에 차감되는 수익도 없고 패널티는 정산 시점에만 지급된다(v3-84).

  • 누가: ADMIN / SUPER_ADMIN만이다. FM을 의도적으로 제외한다. 이 조작의 주된 용도가 펀드가 끝내 채우지 않은 부족분을 닫는 것인데, FM이 바로 그 펀드다. 그들에게 이 권한을 남기면 돈을 갚아야 할 쪽이 투자자의 이탈 요청을 끝낼 수 있게 된다. LP는 돌아오지만 출구는 닫힌 채이고, 그게 반복되면 사실상 거부다. 이는 FM에게 그런 게이트가 없다고 적은 Notion FM 패널 PRD 쪽으로 3자 충돌을 정리한 결과이기도 하다.
  • 사유는 필수이고 미리 채워진다. 다이얼로그가 열릴 때 분류가 이미 선택돼 있고 자유 입력 메모도 있으며 둘 다 필수다. 분류는 UNFUNDED(멈춘 부족분), COMPLIANCE(홀더가 더 이상 검증을 통과하지 못함), OTHER다. 분류는 수정 가능하다. 플래그가 오탐일 수 있고 운영자가 다른 이유로 닫는 것일 수 있기 때문이다. NAV 승인·덮어쓰기와 같은 제안 후 확인 형태다.
    • 미리 채우는 근거: 요청 상태(PENDING_RESERVEUNFUNDED)와 실시간 출구 게이트 조회(GET /redemption-requests/{id}/exit-gate, ADMIN 전용)다. 후자가 홀더에게 더 이상 지급할 수 없음을 확인하면 COMPLIANCE로 올린다. 매시간 sweep이 아니라 결정 시점에 읽는다. sweep은 한 시간 낡았을 수 있고 그 요청에 아직 도달하지 않았을 수도 있다. 열린 채로 실패하므로(체인을 읽지 못하거나 회차 풀이면 상태 기반 기본값을 그대로 둔다) 운영자가 손댄 분류는 절대 덮어쓰지 않는다.
  • 허용 상태: REQUESTED 또는 PENDING_RESERVE.
  • 즉시 풀 전용인 이유: 회차 요청은 QUEUED로 생성되므로 이 조작이 받는 상태에 들어가지 않는다. 필요하지도 않다. 체결되지 않은 회차 잔량은 자동으로 이월되고, 투자자는 요청 창 안에서 취소할 수 있다.
  • 주 용도: 끝내 채워지지 않을 부족분을 닫아, 투자자 LP를 에스크로에 붙잡아 두지 않는 것이다.
  • REJECTED의 출처는 둘이다. 이 조작과, 투자자의 취소다(DB enum에 CANCELLED가 없어서 REJECTED + failure_type = 'INVESTOR_CANCELLED'로 뭉쳐진다). 리포팅은 이 둘을 갈라야 하고 그러지 않으면 자발적 취소가 거부율을 부풀린다.

3-State Lockup Model

락업이 어느 기준점을 쓰는지는 풀 구현체의 속성이라, 두 답이 동시에 살아 있다(v3-144). 2026-08-21 라운드까지의 락업은 포지션별이고 입금 시점부터 잰다. lockup_end = deposit_time + pool.lockup_days다. pool-wide 라운드부터는 모두에게 모집 마감일부터 잰다. lockup_end = subscription_end_date + pool.lockup_days다. 풀은 생성 시 구현체에 고정되는 Clones 프록시라, 이건 마이그레이션이 아니고 "이 날짜부터는 이 답이 맞다"는 전환일도 없다. 두 규칙의 풀이 영구히 공존한다.

@aset/typeslockupAnchorMs가 이를 판단하는 유일한 곳이다. 호출자마다 계산하지 않고 공유하는 이유는 같은 락업을 세 주체가 판단하기 때문이다. 투자자 앱은 어떤 버튼을 보여 줄지 고르고, redemption-requests.post.create는 요청을 받을지 결정하며, 컨트랙트는 그것을 허용할지 결정한다. 이들이 어긋나면 표시 차이가 아니라 revert되는 트랜잭션이 된다. 모르는 경우는 pool-wide로 읽는다. subscription_end_date + lockup ≥ invested_at + lockup이 항상 성립하기 때문이다. pool-wide로 잘못 짚으면 해제일이 늦게 보일 뿐이지만, per-investor로 잘못 짚으면 컨트랙트가 거부하는 상환을 제안하게 된다. 구현체가 기록되지 않은 풀은 per-investor다. 마이그레이션 0205보다 앞서고, 따라서 이 모든 것보다 앞서기 때문이다.

⚠️ subscription_end_date가 없는 pool-wide 풀은 기준점이 없고 락업이 아무것도 막지 않는다. lockupAnchorMs는 입금일로 폴백하지 않고 null을 답한다. 폴백하면 컨트랙트가 허용하는 상환을 거부하게 된다. ✅ 이제 그런 상태를 만들 수 없다(v3-146). FIXED_TERM 풀은 마감일 없이 발행할 수 없고, 모집 창이 선택값인 유일한 형태인 OPEN_ENDED 풀에는 락업이 아예 없다. null 분기가 남아 있는 것은 컨트랙트가 기준점이 설정되지 않았을 때 그렇게 동작하기 때문이고, 그 규칙 이전에 쓰인 행을 여전히 읽을 수 있어야 하기 때문이다.

🔴 그리고 마감일이 락업의 기준점이 되는 순간 수정할 수 없게 된다(v3-146). lockup_days > 0인 pool-wide 풀에서는 컨트랙트가 subscription_end_date의 자기 사본을 들고 있고, 수정을 거기 되쓰는 것이 아무것도 없으며, 풀이 LP를 보유하면 컨트랙트가 수정을 거부한다. 그래서 DB만 고치면 한 경계의 절반만 영구히, revert되는 방향으로 옮기게 된다. 어드민 폼과 PATCH /pools/{id} 둘 다 offeringCloseLockedByLockupReason으로 이를 거부한다. 락업이 없는 풀과 모든 per-investor 풀은 이 필드를 유지한다. 거기서는 날짜가 어떤 컨트랙트에도 닿지 않고, 모집을 줄이는 것은 정당한 실시간 수정이다.

만기는 풀 단위다. 절대 시각인 pools.maturity_date이고 컨트랙트의 maturityDate를 미러링하며(배포 시 subscription_end_date + maturity_days로 기록된다, v3-145), 언제 입금했든 모든 홀더에게 같다(마이그레이션 0161). 컨트랙트는 lockupDays를 기간으로, maturityDate를 날짜로 저장한다. 홀더별로 기준점을 잡고 싶어도 스토리지에 maturityDays가 없다.

늦은 입금자 결함은 만기가 아니라 기간을 옮겨서 해결됐다(v3-145, 2026-08-24). 예전에는 이렇게 적혀 있었다. 늦게 들어온 사람이 일찍 들어온 사람과 같은 날 만기가 되고, 짧게 보유하고도 온전한 기간의 쿠폰을 받는다. 만기가 deploy_time + maturity_days이던 동안에는 사실이었다. 풀이 모집을 끝내기도 전에 기간이 시작됐기 때문이다. 이제 기간의 양 끝을 모집 마감일부터 재고 입금은 그 전에만 받으므로, 누구도 온전한 기간보다 짧게 투자돼 있을 수 없다. 만기가 포지션별이 된 게 아니라 일찍 시작하기를 멈춘 것이다.

그것을 보완하던 장치(짧은 모집 기간, 모집 마감 ≤ 만기 − 수익 1회분으로 강제)는 따라서 폐기됐다. 이제 모집을 길게 열면 기간을 갉아먹는 게 아니라 만기가 뒤로 밀린다.

per-investor 풀에서 포지션별이란 (풀, 투자자)당 기준점 하나이지 입금당 하나가 아니다. deposit_timemoney_positions.invested_at이고 컨트랙트의 positions[investor].investedAt을 미러링하며, 락업은 투자자당 boolean 하나다(PoolCommonLib.isLockupActive). LP별이나 입금별 락업 회계는 없고, LP는 대체 가능하므로 붙일 대상 자체가 없다.

어느 입금이 이를 설정하는가(v3-130). 아래 표는 invested_at에 대한 것이므로 per-investor 기준점에만 해당한다. pool-wide 풀에서는 이 행들 중 어느 것도 락업을 움직이지 않는다. 기준점이 입금이 아니기 때문이다. invested_at은 여전히 기록되고(Deposited 이벤트의 파라미터다) 포지션 존재 여부를 여전히 결정한다.

자본 유입직전 LP 잔액invested_at
첫 입금0지금으로 설정
추가 입금> 0그대로. 나중 입금이 락업을 다시 시작시키지 않는다
전량 이탈 후 재진입0지금으로 설정. 돌아온 투자자는 다시 잠긴다
재투자입금과 같은 두 규칙동일

⚠️ 추가 입금 행의 결과: 락업 후반에 넣은 돈은 남은 기간만큼만 묶인다. 실수가 아니라 모델이고, LP가 대체 가능하다는 데서 따라 나온다. 다른 방식은 스키마에 없는 입금별 회계를 필요로 한다.

invested_at은 소각이나 전송에서 절대 지워지지 않는다. 재진입 행이 "기준점이 null인가"가 아니라 잔액을 기준으로 하는 이유가 그것이다. 전량 이탈이 남긴 만료된 기준점을 그대로 물려받으면 락업을 통째로 건너뛰게 된다.

상태조건동작
🔒 LOCKEDnow < lockup_end버튼이 비활성이고 상환할 수 없다. lockup_days > 0일 때만 존재한다. v3-83: 락업은 penalty_type과 독립이다. NO_EARLY 풀도 락업을 가질 수 있고(그동안 LOCKED, 이후 무패널티 이탈), 그렇게 동작한다.
⚠️ EARLY REDEMPTIONlockup_end ≤ now < maturity. FIXED_TERM만 해당된다(조기 이탈 구간이 있으려면 만기가 있어야 한다)상환할 수 있고 penalty_type이 적용된다(NO_EARLY는 0이다).
FREEnow ≥ maturity(FIXED_TERM) 또는 now ≥ lockup_end(OPEN_ENDED. 만기가 없으므로 EARLY 구간도 없다)패널티가 없다. 표준 상환이다.

예외 상황:

  • lockup_days = 0이면 LOCKED 상태를 건너뛴다.
  • pool-wide 풀에서는 lockup_end가 모든 홀더에게 같은 날짜다. 그래서 lockup_days > 0인 동안 reopen이 거부된다(D-2). 옛 마감일을 유지하면 새 투자자는 이미 지나간 락업을 갖게 되고, 기준점을 새 마감일로 옮기면 이미 락업을 넘긴 홀더가 다시 잠긴다. 이 거부는 "reopen과 락업은 함께 쓸 수 없다"가 아니라 기준점의 속성으로 적어 둔다. 만기가 투자자별이 되고 락업이 그것을 따라 입금 시점으로 돌아가면 이 제약은 사라지기 때문이다.
  • maturity_model = OPEN_ENDED이면 EARLY 구간이 없다. 락업이 끝나면 바로 FREE라서 조기 이탈 패널티가 적용될 일이 없다(그것을 가둘 만기가 없다). 개방형 풀의 생성·수정 화면이 패널티 절을 숨기는 이유다.
  • FIXED_TERM인데 maturity_days = NULL이면 FREE 상태에 도달하지 못한다(설정 오류다).

패널티 4종

풀의 penalty_type 차원으로 설정한다. 풀 모델 → 투자자 조건 참조.

유형산식패널티 목적지
NO_EARLY패널티 없음(0)
FLAT_FEEpenalty = penalty_fee_amount(고정 금액)fund_wallet(v3-85)
PRINCIPAL_BASED (기본 조기 이탈 기준, v3-79)penalty = grossAmount × penalty_rate_bps / 10000. 여기서 grossAmount요청 시점 NAV로 가격 매긴, 상환되는 몫이다. 그래서 부분 이탈은 나가는 부분에만 패널티가 붙는다fund_wallet(v3-85)
YIELD_BASED (v3-84에서 재도입. 락업 풀 전용)penalty = accrued_yield × penalty_rate_bps / 10000fund_wallet(v3-85)

YIELD_BASED는 가드 둘을 통과해야만 선택할 수 있다(v3-84). "발생 수익의 %" 패널티는 아직 수익이 쌓이지 않았으면 0이기 때문이다. 풀은 lockup_days > 0이어야 하고, 락업은 그 풀의 첫 수익 분배 이전에 풀릴 수 없다. 둘을 합치면 EARLY 구간에 도달한 투자자에게는 반드시 패널티를 뗄 수익이 있다. PRINCIPAL_BASED가 여전히 기본이다. Joob 풀(SSA §4.2의 배당 50% 몰수)은 문자 그대로 YIELD_BASED 5000 bps다. 20-joob-pool-config 참조.

모든 패널티 유형이 원금에서 차감돼 fund_wallet으로 전송된다(v3-85). 즉시와 회차 모두 그렇고, reserveBalance를 채우는 일은 절대 없다. YIELD_BASED는 패널티의 크기만 발생 수익 기준으로 정하고, 돈은 여전히 원금에서 나온다. 이미 분배된 수익은 회수할 수 없기 때문이다. 어드민이 면제할 수 없다.

이 목적지가 바꾸는 것과 바꾸지 않는 것: 평시 navPerToken은 오라클이 설정하며 reserveBalance에서 파생되지 않는다(reserve가 NAV에 들어가는 것은 WIND_DOWN뿐이다). 그래서 남은 LP 홀더의 일상 NAV는 영향받지 않는다. 줄어드는 것은 풀의 상환 유동성 버퍼wind-down pro-rata 회수액이다. 패널티가 FM에게 가기 때문이다.

부분 상환과 전량 상환 (v3-79)

부분 상환이 기본이다. 투자자는 잔액 안에서 어떤 금액이든 상환할 수 있고, 패널티는 상환되는 몫에만 비례해 붙는다(건드리지 않은 잔액은 조건을 유지한다). 종착 상태 둘은 전량만 허용한다. 그 상태의 포지션은 정리되거나 청산되는 중이고 부분 청구가 잔여 회계를 복잡하게 만들기 때문이다.

Lifecycle부분비고
ACTIVE(EARLY / FREE)허용상환 금액에 pro-rata로 패널티
IMPAIRED허용상각된 NAV로. 패널티 면제
MATURED전량만포지션이 닫힌다. 패널티 면제
WIND_DOWN전량만가용 유동성의 pro-rata 지분. 패널티 면제

즉시 풀과 회차 풀에 같은 규칙이 적용된다. 상환되는 몫 산식(grossAmount × rate)이 동일하므로 이 모델은 상환 방식에 따라 분기하지 않는다. 종착 상태의 전량 전용은 현재 오프체인에서 강제한다(백엔드 요청 핸들러가 amount != 전체 잔액을 거부하고, 프론트엔드가 금액을 전액으로 고정한다). 온체인 FullRedemptionRequired 가드는 다음 재배포 배치로 미뤘다(거기서 부분 상환은 안전 문제가 아니라 UX·회계 선호다. NAV pro-rata는 어느 쪽이든 안전하다). 14-decisions v3-79 참조.

투자자에게 보이는 상태 라벨

내부 상태투자자가 보는 것
REQUESTEDProcessing
QUEUED(회차)Queued for next settlement (D-N)
PARTIALLY_FILLED(회차)Partially filled — remainder rolled over
PENDING_RESERVE(즉시)Awaiting partner funds
PROCESSINGSettling
COMPLETEDCompleted
FAILEDFailed (사유 포함)

상환 시나리오(NAV 기준)

아래 모든 행은 FREE 상태(패널티 없음)에서 $10,000 입금분을 전액 상환하는 경우다. 변수는 양끝의 NAV뿐이다. 지급액은 언제나 tokens × nav_at_redemption이고 tokens = 10,000 / nav_at_investment다.

진입 NAV이탈 NAV토큰지급액무엇을 보여 주나
A1 정상$1.00$1.0010,000$10,000.00원금 전액 회수
A2 상각$1.00$0.8510,000$8,500.00토큰 가치의 15% NAV 손실을 투자자가 진다
B1 회복$0.80$0.9512,500$11,875.00할인된 진입 뒤 NAV가 회복된 경우. "공정한 진입"의 상방
B2 보합$0.85$0.8511,765$10,000.25원금 회수. 이익은 별도로 청구하는 수익이다
B3 추가 하락$0.85$0.7011,765$8,235.50할인된 진입이 안전을 보장하지 않는다

수익은 위 어느 것에도 영향받지 않는다. 모든 행에서 명목 $10,000에 대해 쌓이고, 상각 중에도 전액 지급되며, claimYield()로 따로 청구한다.

즉시 상환 플로우 (epoch_duration_days = 0)

아래 플로우는 즉시 풀(FIXED_TERM 기본값)에 적용된다. 모든 즉시 풀이 같은 플로우를 쓰고 AS_POOL/FUND_POOL 분리는 없다. 회차 풀(epoch_duration_days > 0)은 대신 회차 기반 상환 플로우를 쓴다.

역할: 🔵 투자자 · 🟠 파트너 · 🟢 시스템 · 🔴 어드민(선택적 수동 정산만)

🔑 reserve 확인은 어드민 승인이 아니라 요청 시점에 일어난다(v3-82). requestRedemption 트랜잭션 자체가 분기를 결정한다. reserve가 지급액을 감당하면 그 자리에서 정산되고, 아니면 요청이 PENDING_RESERVE에 머물다가 파트너의 이후 fundRedemption잔액이 지급액을 감당하는 순간 자동으로 정산한다. 정상 플로우에 어드민 승인 단계는 없다. approveRedemption은 reserve가 별도로 늘어난 경우를 위한 선택적 수동 정산으로만 남는다(④ 참조).

① 투자자가 상환을 요청한다 🔵 Investor

투자자가 Pool.requestRedemption(lp_amount)를 호출한다. 확인 모달에 토큰 수, NAV 가격, 토큰 가치, 발생 수익, (있다면) 패널티, 총 지급액이 표시된다.

② NAV 스냅샷 + reserve 확인(같은 tx) 🟢 System

requestRedemption 트랜잭션 안에서 컨트랙트가 이렇게 한다.

  • pools.nav_per_token에서 nav_at_request를 잡는다. NAV 하락 타임락 중에는 예외이고 위의 예외 항목 참조
  • 3-state 검사를 돌린다(LOCKED면 revert, EARLY면 penalty_type 수수료를 fund_wallet으로(v3-85), FREE면 없음)
  • gross = payout + penalty를 계산하고 reserve를 확인해 나중이 아니라 여기서 분기를 정한다. 패널티도 fund_wallet으로 나가므로, 투자자 순지급액만 확인하면 가용 유동성을 과대평가하게 된다.
    • **reserveBalance ≥ gross**면 즉시 정산(③)
    • **reserveBalance < gross**면 LP를 에스크로하고 PENDING_RESERVE에 머문다(④)

③ reserve 충분 → 즉시 정산 🟢 System

어드민 단계가 없다. 같은 요청 트랜잭션 안에서 Pool이 이렇게 한다.

  • 투자자의 LP를 직접 소각한다(에스크로 없음)
  • payout만큼 USDC를 투자자 지갑으로 전송한다
  • 조기 이탈 패널티가 있으면 fund_wallet으로 보낸다(v3-85. reserveBalance가 아니다). reserveBalancetotalDeposited를 차감한다
  • 상태가 COMPLETED가 된다(한 tx에서 RedemptionRequestedRedemptionCompleted를 발생시킨다)

④ reserve 부족 → 파트너 펀딩 → 자동 정산 🟠 Partner

상태가 PENDING_RESERVE가 되고 LP가 Pool에 에스크로되며 그 펀드의 파트너에게 알림이 간다(fm_shortfall).

  • 파트너가 fund_wallet에서 Pool.fundRedemption(stablecoin, amount)로 보충한다
  • fundRedemption풀 잔액이 지급액을 감당하는 순간 그 요청을 자동으로 정산한다. 같은 호출 안에서 LP를 소각하고 투자자에게 지급하고 COMPLETED로 만들며, 어드민 승인이 없다(v3-82)
  • 보충하고도 여전히 모자라면 PENDING_RESERVE로 남아 다음 펀딩을 기다린다
  • 🔴 이것이 유일한 정산 경로다. claimRedemptionFallback(v3-31, 예고 기간 후 무권한 실행)과 approveRedemption(서비스 키 수동 정산)은 둘 다 풀의 reserve를 끌어 썼는데, reserve가 입금 시점에 reserve_wallet으로 라우팅된다. 두 함수 다 이제 체인에 없다.
  • 🔴 v3-31의 이탈권도 함께 오프체인으로 옮겨 갔다. "파트너와 Aset이 없어도 누구나 오래된 요청을 정산할 수 있다"는 컨트랙트의 속성이었다. 이제는 reserve 지갑이 그 요청에 자금을 대는 것이고, 그건 운영상의 약속이다. 동결 이탈권(v3-28)은 영향받지 않고 여전히 온체인에 있다.

투자자는 그동안 "Awaiting partner funds"를 보다가 "USDC in wallet"을 본다.

실패 경로

전송이 실패하면 상태가 FAILED가 된다. failure_typeerror_message가 기록되고 어드민이 재시도할 수 있다.

구간 × 방식: 어떤 조합이 존재하나

투자자의 타임라인에는 구간이 셋 있고(LOCKED · EARLY · FREE/MATURED), 상환 가능한 각 구간은 두 방식(즉시 · 회차) 중 하나로 처리된다. 독립적인 설정 둘이 형태를 정한다.

구간상환 가능?비용처리 방식
🔒 LOCKED불가
⚠️ EARLYredemption_type이 정한다penalty_typeepoch_duration_days
✅ FREE / MATURED항상 가능없음epoch_duration_days. 같은 값

epoch_duration_days 하나가 두 구간을 모두 담당한다. 그래서 여섯 조합 중 둘은 안 만든 게 아니라 표현 자체가 불가능하다.

EARLYMATURED현재
닫힘즉시가능만기 일시금
닫힘회차가능만기 후 회차 상환. 체인은 늘 허용했고 이제 위저드가 이를 표현할 수 있다(v3-132)
즉시즉시가능Joob
회차회차가능런 리스크가 있는 풀
즉시회차불가표현할 수 없다. 온체인에 두 번째 주기 필드가 필요하다. 패널티를 물리는 조기 이탈 단계적 상환을 함께 허용하는 상품이 있을 때만 필요하다
회차즉시불가표현할 수 없고 수요도 없다. 운영 중에는 이탈을 조이다가 만기에 일시금으로 주는 것은 거의 자기모순이다

⑤와 ⑥은 위저드의 제한이 아니다. 이 둘을 분리하려면 온체인에 두 번째 주기 필드가 필요한데 PlatformPool은 업그레이드할 수 없고 각 풀이 생성 시 구현체에 고정된다. 그래서 새 필드는 신규 풀에만 닿는다. 이 고정은 가상의 이야기가 아니다. 배선되지 않은 LIQUIDITY_WINDOWS 값으로 만들어진 풀 넷은 고칠 수 없어서 이름을 바꾸는 대신 퇴역시켰다(마이그레이션 0106. v3-88 D5가 그것을 만든 드롭다운을 닫았다). ②는 성격이 다르다. 컨트랙트가 이미 갖고 있는 동작에 대한 위저드 가드일 뿐이다.

회차 기반 상환

epoch_duration_days > 0인 모든 풀에 적용된다(v3-26). OPEN_ENDED 풀의 기본값이고, 동시 이탈이나 런 리스크가 있는 풀이라면 FIXED_TERM을 포함해 어느 풀이든 선택할 수 있다(v3-46. 왜 즉시가 아니라 회차인가 참조). 이탈은 요청 순서대로 처리되지 않고 창으로 묶여 일괄 정산된다. 입금은 그대로 즉시·atomic이다. 회차는 한 방향에만 적용된다.

아래 절들은 현행 상태다. 엔진 재설계(v3-91, v3-93으로 개정되고 v3-100 / v3-105에서 마무리)가 컨트랙트에 들어가 있고, 각 항목이 어떻게 반영됐는지는(원래 설계와 다르게 반영된 둘을 포함해) 그 카드들에 기록돼 있으며 17-changelog에 날짜가 있다. 다른 곳에서 고정 N일 창이나 지연 시작을 설명하는 문장은 재설계 이전 것이다.

epoch_duration_days는 풀 생성 시 고정된다(v3-38). 유형에 따라 미리 채워지고(OPEN_ENDED → 7 / FIXED_TERM → 0) v1에서는 풀이 살아 있는 동안 불변이다. 투자자는 상환 모델을 알고 입금하고, 그것이 발밑에서 바뀌지 않는다. 온체인 setEpochDurationDays는 풀에 투자자가 생기면(totalLPSupply > 0) revert하고 제품에 노출되지 않는다. 살아 있는 풀의 유동성을 조이는 것은 공시되지 않은 상환 게이트이고, 값에 상한이 없으면 v3-31의 이탈 보장을 깨는 자금 가둠 백도어가 된다. 7일 타임락 거버넌스로 실시간 변경하는 것은 v2 항목이다(감사가 필요하다).

Model B: 요청 창 그다음 공백

엔진은 창 안에서만 요청을 받고 창 사이의 공백에서는 거부한다. 그것이 Model B이고 실제로 반영된 동작이다(v3-93). 이 페이지의 이전 초안이 설명하던 "항상 받고 마지막에 묶는" Model A가 아니다.

설정 축이 셋이고 전부 풀별이다.

온체인
epoch_duration_daysepochDurationDays스케줄이 전진하는 주기다. 고정 일수이고 MONTHLY → 28 / QUARTERLY → 84다. 주 단위 배수라 파생되는 모든 경계가 같은 요일을 유지한다. epoch_schedule_type은 온체인 필드가 없는 DB 라벨이고, 컨트랙트는 역법 연산을 전혀 하지 않는다. 그래서 한 주기는 역월이 아니고 역월과 어긋난다. "매월"이 아니라 "28일마다"라고 쓸 것(v3-124, 용어집 → 한 달)
request_window_daysrequestWindowDays마감 전까지 창이 열려 있는 기간
recall_lead_daysrecallLeadDays마감과 펀딩일 사이의 간격. 발행자가 확정된 총액을 회수해 송금할 시간이다

모든 것은 그 주기의 펀딩일(아래 참조)에서 거꾸로 유도되므로, 주기당 숫자 하나만 있고 저장된 경계 배열이 없다.

windowOpen(n) = cutoff(n) − requestWindowDays      ← 요청 개시
cutoff(n)     = fundingDate(n) − recallLeadDays    ← 요청 마감, 수요 고정
fundingDate(n)                                      ← 여기서부터 정산 가능, claim 개시

한 주기는 설정된 두 기간보다 길고, 남는 부분도 주기의 일부다. 월간 프리셋(주기 28, 창 7, 리드 10)을 실제 비율로 그리면 이렇다.

       창 (7)             회수 리드 (10)           나머지 (11)
 │◀───────────────▶│◀─────────────────────▶│◀────────────────────▶│
 ├─────────────────┼───────────────────────┼──────────────────────┤
개시              마감                   펀딩일               다음 개시
 │  요청 +        │         닫힘          │        닫힘          │
 │  취소          │    아무것도 안 받음   │   아무것도 안 받음   │
 └────────────────┴───────────────────────┴──────────────────────┘
                                   executeEpoch + claim
 │◀────────── 한 주기 = epoch_duration_days (28) ─────────────────▶│
  • 창 밖에서는 requestRedemptioncancelRedemption이 둘 다 revert된다(RequestWindowClosed. 두 진입점이 공유하는 _requireRequestWindow). 공백이 있는 이유는 발행자에게 알려 준 총액이 그 뒤에 바뀔 수 없게 하기 위해서다.
  • 0으로 유도되는 창도 revert된다(ScheduleNotConfigured). 열린 것으로 읽히지 않는다. 접수 중인 주기에 유도 가능한 펀딩일이 없으면 epochWindowOpenAt이 0을 반환하는데, block.timestamp < 0은 거짓이므로 예전에는 게이트가 통과하고 그 주기가 영원히 접수 중으로 읽혔다. 실제로 그 상태에 도달한 적은 없다. 정산이 전진하면서 다음 주기의 날짜를 쓰기 때문이다. 그건 가드가 아니라 정산 경로의 속성이고, 주기를 건너뛰거나 커서를 옮기는 변경이 생기면 사라진다. ⚠️ 소스에는 있고 아직 배포되지 않았다. Clones이므로 기존 풀은 옛 동작을 유지한다.
  • 창은 대기 시간을 제한할 뿐 그 대기가 보상받는지는 더 이상 정하지 않는다. v3-91 규칙 1에서는 마감 직후에 넣은 요청이 duration + recallLeadDays만큼 아무것도 못 벌면서 기다렸고, 그래서 창이 중요했다. v3-131 (3)이 반영된 이후로는 그 대기 동안에도 정상적으로 수익이 쌓인다. 그래서 창이 여전히 사 주는 것은 몰수로부터의 보호가 아니라 예측 가능하고 제한된 대기열이다.
  • 부실 상황이 이를 무시한다. WIND_DOWNIMPAIRED는 창 게이트를 통째로 건너뛴다. 그래서 부실 풀이 닫힌 창 뒤에 이탈을 가두는 일이 없다.
  • 나머지는 설정값이 아니라 유도값이다. epoch_duration_days − request_window_days − recall_lead_days다. 아무도 입력하지 않고, 한 주기의 펀딩일과 다음 주기의 창 개시 사이 구간이다(windowOpen(n+1) = windowOpen(n) + epoch_duration_days). 지급이 요청을 다시 열지 않는다. 컨트랙트는 그 합이 주기보다 엄격히 작을 것을 요구하므로(InvalidSchedule) 나머지는 항상 최소 1일이다.
  • 창을 기간이 아니라 주기 대비 비율로 읽을 것. 월간 프리셋은 28일 중 7일 동안 요청을 받는다. 마감부터 다음 개시까지 닫혀 있는 기간은 회수 리드 + 나머지 = 연속 21일이다. 운영자가 창이나 리드를 수정하면 두 숫자가 함께 움직인다. 그래서 어드민 위저드가 펀딩일에서 멈추지 않고 주기 전체를 그리며 나머지에 이름을 붙인다(v3-124).
  • 프리셋: 월간은 창 7 / 회수 10, 분기는 창 14 / 회수 21이다. 미리 채워지는 기본값이고 풀별로 수정할 수 있다.
  • 레거시 풀(fundingAnchor == 0)은 창이 아예 없고 옛 지연 동작을 유지한다. setEpochSchedule이 생성 시 전용이라 그 경로에 영구히 머문다(v3-100). 다만 풀은 거기 합류할 수 없다. 세 스케줄 항목이 생성 시 필수이고, 배포가 체인에서 fundingAnchor를 되읽어 확인하며, 컨트랙트가 지연 엔진으로 흘러가는 대신 ScheduleNotConfigured로 revert한다(v3-107).

펀딩일: 확정과 예정

앵커된 주기의 모든 경계는 숫자 하나, 곧 펀딩일에서 유도된다(v3-93). cutoff = fundingDate − recallLeadDays, windowOpen = cutoff − requestWindowDays다. 그 숫자는 주기마다 발행자가 운영자를 통해 주는 것이 원칙이고, C2에 따라 열린 채로 실패한다. 아무도 확정하지 않은 주기는 스스로 직전 펀딩일 + 주기로 전진하므로, 운영자가 침묵해도 아무것도 멈추지 않는다.

투자자 쪽에서 보면 표시된 날짜가 두 가지를 뜻할 수 있다는 뜻이고, v3-105가 이를 구분하는 방법을 정했다.

출처
확정운영자가 이 주기의 날짜를 입력했고 이제 잠겼다(창이 열리면 setEpochFundingDateWindowAlreadyOpen으로 revert한다)확정 시점에 pools.next_funding_date와 함께 기록된 DB 출처 정보
예정아무도 확정하지 않았고 직전 + 주기이며 창이 열릴 때까지 아직 움직일 수 있다그 출처 정보의 부재

배지는 체인이 아니라 데이터베이스를 읽는다. 체인은 구조상 이 질문에 답할 수 없다.

체인이 답할 수 없는 이유와 출처 정보가 있는 곳

epochFundingDate(id)는 확정된 값이든 유도된 값이든 같은 숫자를 반환하고, raw 스토리지 getter를 둬도 소용없다. 정산이 열린 실패 값을 epochFundingDate[id + 1]실체화하고(조용히, 이벤트 없이) 주기 1은 fundingAnchor에서 백필되므로, 아무도 확정한 적 없는 날짜에 대해서도 stored != 0이 참이다. 신뢰할 수 있는 온체인 답을 만들려면 전용 플래그가 필요하고 그러려면 새 스토리지와 새 뷰가 필요한데, 풀은 구현체가 immutable인 EIP-1167 클론이므로 그건 신규 풀에만 적용된다.

출처 정보가 있는 곳(v3-107): pools.next_funding_date_confirmed_atnext_funding_date_set_by다(마이그레이션 0111). setEpochFundingDate 트랜잭션이 확정된 뒤에만 기록되고, 정산이 주기를 전진시킬 때 null이 된다. 새 주기의 날짜는 예정 상태로 시작하므로 출처 정보가 이전 주기의 확정을 물려받지 않고 주기마다 다시 무장한다. 배지는 화면에 있는 주기에 대해 둘 다 존재할 때만 확정으로 표시한다.

운영자는 POST /pools/{id}/epoch-schedule로 setter에 도달하고, GET /pools/{id}가 스케줄 컬럼을 반환하므로 배지와 카운트다운이 입력을 갖는다. 감사와 정합 확인을 위해 EpochFundingDateSet온체인에서 유일하게 정확한 확정 신호다. 정산의 실체화는 아무것도 발생시키지 않는다. 그 ABI 조각과 인덱서 라이터가 준비돼 있어서 확정은 가정이 아니라 미러링된다(v3-92: DB는 우리 의도가 아니라 체인을 미러링한다).

왜 즉시가 아니라 회차인가

상환 모델은 maturity_model과 직교하는 게이팅 구조 선택이다(v3-46). epoch_duration_days는 풀별로 독립적으로 고르고, 유형에 따른 기본값(FIXED_TERM → 0, OPEN_ENDED → 회차)은 미리 채우는 값이지 제약이 아니다. 검증 결합이 없으므로 FIXED_TERM 풀도 > 0으로 만들 수 있다. 진짜 판단 축은 "동시 이탈 수요가 가용 유동성을 넘어 타이밍이나 순서가 불공정해질 수 있는가"(런 리스크)이지 "풀에 만기가 있는가"가 아니다. 여기서 직교하는 대상은 maturity_model이지 redemption_type이 아니다. 어드민 생성·수정 위저드는 redemption_type = FIXED_MATURITY일 때 회차 선택자를 숨긴다(v3-88). 만기가 된 풀은 한 번 지급하므로 주기가 의미 없다는 이유인데, 이는 위저드 가드일 뿐이고 위의 온체인 동작을 바꾸지 않는다.

  • OPEN_ENDED는 만기가 없어서 모든 이탈 물량이 예정 밖이고 그 조건에 계속 노출된다. 그래서 회차가 기본이다.
  • FIXED_TERM은 이탈 수요가 예정된 사건(만기. 대출이 만기가 되면서 파트너가 자본을 돌려주는 시점)에 몰린다. 만기 전 조기 이탈은 드물고 패널티로 걸러지므로 즉시 + PENDING_RESERVE로 충분하다. 다만 투자자가 집중돼 있거나 런에 취약한 FIXED_TERM 풀(또는 상각 위험이 큰 풀)은 같은 공정성 보장을 위해 생성 시 회차를 선택할 수 있다.

그 조건에서 즉시 FIFO 모델은 무너진다. ① 선착순 불공정과 뱅크런, ② 요청 시점 NAV가 차익을 고정(먼저 나가는 사람이 손실을 남는 사람에게 떠넘긴다), ③ 무기한 PENDING_RESERVE 대기다. 회차 모델은 회차 묶음 + pro-rata + forward pricing + pull claim으로 이를 해결한다(Maple / Goldfinch / Centrifuge의 표준이다).

회차는 런 차단이 아니다. 회차는 스트레스 상황에서 이탈을 공정하고 질서 있게 만들 뿐 런을 멈추지 않는다. 유동성이 부족하면 회차는 여전히 pro-rata 부분 체결로 정산하고 미체결 잔량이 이월된다. 이탈을 아예 멈추는 것은 is_emergency_frozen이고(72시간 이탈 차단 창이 지나면 이탈권이 열린다), 창마다 빠져나가는 속도에 상한을 두는 것은 redemption_gating_bps다(현재는 회차 전용이다). is_pausedIMPAIRED는 신규 입금만 멈추고 상환은 열어 둔다는 점도 기억할 것(IMPAIRED는 락업과 패널티까지 면제한다). 상환 모델은 공정성과 순서를 위해 고르고, 런을 멈추는 데는 동결과 게이팅 레버를 쓴다.

항목즉시회차
NAV 가격요청 시점 NAV(스냅샷)정산 시점 NAV(forward pricing, SEC Rule 22c-1 방식)
처리 순서FIFOpro-rata(요청 순서 우선권 없음). 창 안의 모든 요청에 공정하다
유동성 부족 시PENDING_RESERVE(무기한)pro-rata 부분 체결 + 잔량 이월
정산 트리거requestRedemption 트랜잭션 자체. reserve가 충분하면 그 자리에서 정산하고, 모자라면 PENDING_RESERVE가 됐다가 파트너의 fundRedemption이 자동 정산한다(v3-82)시간 기반. 창 종료 시 executeEpoch()(O(1), 루프 없음)
지급 방식push. 정산 트랜잭션 안에서pull. 투자자가 claimRedemption()을 호출한다
승인 게이트없다. approveRedemption은 reserve가 나중에 늘어난 PENDING_RESERVE 요청에 대한 선택적 수동 정산으로만 남고 REQUESTED 요청에는 revert한다100% 자동. 어드민은 이상치만 보류한다

흐름: 요청 → 정산 → 청구

역할: 🔵 투자자 · 🟢 시스템(스케줄러 / 무권한) · 🟠 파트너 · 🔴 어드민

① 요청(요청 창 안에서) 🔵 Investor

투자자가 Pool.requestRedemption(lp_amount)를 호출한다. [windowOpen(n), cutoff(n)) 밖에서는 RequestWindowClosed로 revert된다. Model B 참조. 창 안에서는 LP가 잠기고(Pool로 전송되며 소각되지 않는다) 접수 중인 회차에 등록돼 상태가 QUEUED가 되며, epochNewDemandLp[n] += lp_amountepochTotalDemandLp[n] += lp_amount가 된다(신규 수요와 이월 수요를 따로 추적한다. 이월 우선 체결 참조). NAV 스냅샷도 요청별 패널티 계산도 없다. 둘 다 정산으로 미뤄지고, 요청은 자기 래더 기준값 (gBase, hBase, generation)을 저장하며 YIELD_BASED 풀이라면 잠금 시점에 적립된 수익도 저장한다.

여기서 수익이 멈추지 않는다(v3-131 (3), 2026-08-20 반영. v3-91 규칙 1을 뒤집는다). LP를 잠그는 것은 보관 이동이므로 포지션은 계속 쌓인다. 멈추는 지점은 자금이 실제로 확정되는 정산이다.

⚠️ 채무가 어디에 기록되는지는 그것이 존재하는지와 별개의 문제다. accrualBasisLptotalLpSupply − poolHeldLp라서, 에스크로된 LP는 거기 있는 동안 인식된 채무가 아니다. 그 몫은 요청 단위로 계산되고, 에스크로가 끝날 때 채무와 홀더의 크레딧으로 같은 순간에 함께 기록된다. 채무를 먼저 인식하고 크레딧을 나중에 기록한 것이 첫 시도였고, 그 때문에 한 채무가 두 번 지급될 수 있었다. 현재 형태를 뒷받침하는 속성 테스트는 test/EscrowAccrualRecognition.t.sol에 있다. previewEscrowAccrual(requestId)가 에스크로가 열려 있는 동안의 차이이고, 그것이 "에스크로는 총 청구액을 절대 줄이지 않는다"를 주장이 아니라 검증 가능한 사실로 만든다.

✅ 반영됨: 멈추는 지점은 요청이 아니라 정산이다

규칙 1은 반올림 문제가 아니라 재분배였다. 발행자는 미상환 원금에 대해 온전한 쿠폰을 지급한다. 에스크로된 LP를 분모에서 빼도 그 쿠폰이 줄지 않고, 요청자의 몫이 남은 홀더에게 넘어갈 뿐이었다. 더 나쁘게는 모든 홀더가 요청하고 나면(만기 후 스케줄이 그 상황을 강제한다) 분모가 0이 되고 쿠폰이 아무에게도 귀속되지 않았다.

발생은 executeEpoch에서 끝난다. 요청 시점도 청구 시점도 아니다. 청구 시점으로 두면 자기 청구를 미룬 대가로 돈을 주게 된다. 미체결 잔량은 계속 쌓인다. 그 원금은 여전히 운용되고 있고 발행자도 그에 대해 계속 지급하기 때문이다.

컨트랙트가 이를 O(1)로 하는 방법. 걸림돌은 executeEpoch가 티어별 비율만 확정하고 홀더별 체결분을 실체화하지 않는다는 점이었다(그건 청구 시점에 유도된다). 그래서 "이 홀더를 정산 시점에 멈춘다"에 걸 고리가 없었다. 그래서 회차 래더가 epochG(잔존 비율)와 epochH(지급 현금) 옆에 세 번째 누적값을 둔다. 같은 잔존 비율을 발생 인덱스에 대해 적분한 epochGy다. 그러면 요청의 에스크로 기간 수익은 두 구간이 되는데, 체결이 갈라놓는 바로 그 두 구간이다. 자기 회차가 정산될 때까지 전체 원금에 대한 평탄 구간, 그다음 미체결 잔량에 대한 래더 구간이다. 이것이 에스크로가 끝날 때 요청 단위로 creditAccrued에 크레딧된다. 세대 재시작은 epochH와 마찬가지로 generationCloseGy가 반영한다.

제외 구간 셋이 모두 닫혔다. 세 번째, 즉 정산과 청구 사이에 떨어지는 분배는 반대 방향으로 닫혔다. 의도적으로 아무것도 벌지 않는다. 그 시점에는 지급액의 가격이 이미 매겨졌기 때문이다. 취소나 거부는 대신 지금까지 크레딧한다. 그것들은 아무 가격도 매기지 않기 때문이다. 그래서 한 회차 안에서 열렸다 취소된 요청도 이자를 잃지 않는다.

v3-91이 기대던 방어 둘은 그대로이고, 그것들이 이 방식을 안전하게 만든다. 가격은 forward이고(정산 NAV라 요청이 어떤 가격도 고정하지 않는다) 마감 이후에는 취소가 불가능하다. 스펙은 Notion "풀 상환 모델 — 구간 × 모드 (기획서)" §3.2, 컨트랙트 쪽 유도는 루트 04-contract-round.md §4와 06-yield-accrual-redesign.md §14-2에 있다.

:::

② 파트너 보충(회수 창 동안) 🟠 Partner

펀딩일이 기한이고, 마감과 펀딩일 사이의 recallLeadDays 구간이 그 창이다. 수요가 고정돼 있으므로 발행자에게 송금하라고 요청한 총액이 그 뒤에 움직일 수 없다. 파트너가 Pool.fundRedemption()으로 자금을 보내면 epochFundTopUp[id]에 쌓여 그 회차의 가용 유동성이 된다(즉시 방식과 달리 요청별로 지급되지 않는다).

⚠️ 온체인 EpochFundingNeeded 이벤트는 정산 전이 아니라 정산 시점에 발생한다. 이 이벤트는 채우지 못한 것을 보고한다(_settleTiers에서 demandUSD > filledGrossUSD일 때). 기한 이전 신호는 오프체인이다. readEpochShortfallepochTotalDemandLp / navPerToken / reserveBalance / epochFundTopUp를 읽어 D-2와 D-12h 리마인더를 구동한다. 아래 파트너 펀딩(3계층) 참조.

③ 정산: `executeEpoch()` (O(1)) 🟢 System

settlementAllowedAt(id), 즉 그 주기의 펀딩일을 게이트로 삼는다. 운영자의 settleAfter[id] 노브로 미룰 수 있지만 fundingDate + recallLeadDays를 절대 넘길 수 없다(이탈 보장 상한이다). 앵커되지 않은 레거시 풀은 여전히 currentEpochEndsAt을 게이트로 쓴다. 서킷 브레이커, 오래된 NAV(navStalenessSeconds)에도 막히고, navPerToken == 0이면 InvalidNav로 revert한다(오라클 손상 대비 가드이고 체결 계산보다 앞서야 한다. 그러지 않으면 NAV 0이 전량 체결로 계산된다).

투자자별 루프가 없고 LP가 소각되지 않는다. 다만 돈은 실제로 움직인다. 한 번에 가용액을 계산하고, 신규보다 이월 수요를 먼저 채우고, 미체결 잔량을 다음 주기로 넘기고, 체결된 현금을 약속만 하는 게 아니라 redemptionCommitted예약한다. 그다음 currentEpochId++를 하고 EpochSettled(id, aggFillRatio, settleNav, filledGrossUSD, settledLp)를 발생시키며, 부족하면 EpochFundingNeeded도 발생시킨다.

보통은 Aset 스케줄러가 실행하지만 허용된 뒤에는 누구나 호출할 수 있다(v3-31). Aset이 침묵해도 정산이 일어난다. ⚠️ 이건 executeEpoch이고 영향받지 않았다. v3-31이 잃은 것은 즉시 풀의 claimRedemptionFallback이다. O(1)이고 결정론적이라 열어 둬도 안전하다. 메커니즘은 정산 내부 동작 참조.

④ 청구(pull): `claimRedemption(requestId)` 🟢 System / 🔵 Investor

투자자(또는 아래에서 설명하는 누구든)가 정산된 금액을 가져간다. **요청당 O(1)**이고 _epochFillMath가 정확히 두 조각으로 계산한다. 요청의 최초 회차(거기서 신규 수요였다)와 그 뒤의 모든 회차(거기서 잔량이 이월 수요였다)다. 그래서 각 주기의 epochSettleNav가 요청의 전 생애에 걸쳐 자동으로 적용된다.

수행하는 일: filledLp를 소각하고, _payCommitted로 net을 투자자에게 패널티를 fund_wallet으로 보내며(둘 다 정산 예약분에서 나오고 살아 있는 reserve에서는 절대 나가지 않는다), 요청을 순수 이월 형태로 다시 앵커한다. 잔량이 남으면 상태가 PARTIALLY_FILLED, 아니면 COMPLETED가 된다. RedemptionClaimed(requestId, investor, filledLp, payout, penalty)를 발생시킨다(해당되면 RedemptionRolledOver도). 두 자금 항목이 모두 이벤트에 있는 이유는 둘 다 정산 예약분에서 나오기 때문이다. 원장이 redemptionCommitted에서 payout + penalty를 해제하는데, net만 보고하면 그 값이 영구히 과대 계상된다.

⚠️ lpRemaining은 여기서만 줄어든다. 미청구 체결분이 있는 상태의 취소가 먼저 claim해야 하는 이유가 그것이다. 취소 전 청구 참조. 정확한 계산은 정산 내부 동작에 있다.

pull 청구와 대리 청구. claimRedemption허가가 필요 없다(v3-31). Aset 키퍼를 포함해 누구나 호출할 수 있지만 언제나 원래 요청자에게 지급한다(목적지가 고정이고 불변이다. approveRedemption이나 settleYield 안의 수수료 항목과 같다). 덕분에 Aset이 미청구 지급액을 정당한 소유자에게 쓸어 보내면서도 비수탁을 유지한다. 컨트랙트가 "권리자에게만 지급한다"를 강제하고, 자금은 청구되기 전까지 Aset이라는 회사가 아니라 PlatformPool 컨트랙트에 머문다. 미청구 accruedYield와 같은 부류다. 폴백 청구도 AML과 KYC REVOKED 게이트를 존중하므로 제재 대상 지갑이 보류를 버텨 내지 못한다. ⚖️ "확정됐으나 미청구"인 자금의 보관과 무주물 귀속 정책은 [법무 검토 필요] 항목이다.

청구에는 만료가 없다(C7, v3-107에 기록). 정산된 몫은 무기한 청구 가능하다. 기한도 없고, 풀로 쓸어 담지도 않으며, 남은 홀더에게 귀속시키지도 않는다. 시간이 지났다는 이유로 투자자 돈을 몰수하는 것은 규제 대상 RWA 맥락에서 지지될 수 없고, 그 돈이 누구에게도 방해가 되지 않는다. 예약분은 redemptionCommitted 안에 격리돼 있어서 미청구 지급액이 다른 주기의 체결이나 수익에 이중으로 쓰일 수 없다. 대가는 redemptionCommitted가 저절로 0으로 돌아가지 않아 hold-back 해제 클램프가 조여진다는 것이고, 그 돈이 이름이 정해진 청구권자의 것이므로 의도한 교환이다. 위의 무주물 귀속 문제에 먼저 답하지 않고 만료를 추가하지 말 것.

부분 체결과 이월

유동성이 모자라면 회차는 한 티어 안에서 순서 우선권 없이 pro-rata로 채운다. 다만 티어가 둘이고 이월분을 먼저 채운다(C8).

  1. 이월 티어: 이전 주기에서 이미 넘어온 잔량이다. 먼저 채우므로 이미 한 주기를 기다린 요청이 새 수요에 추월당하지 않는다.
  2. 신규 티어: 이번 주기의 새 요청이다. 이월 티어가 남긴 유동성으로 채운다.

두 티어 어느 쪽도 채우지 못한 것은 다음 주기의 이월 수요가 된다. 각 주기의 settleNav가 그 주기가 채운 몫에 적용되므로, 세 회차에 걸친 요청은 세 가격으로 지급된다. 그러면서도 정산은 **O(1)**이고 청구도 O(1)이다. 몇 주기를 살아남든 마찬가지다.

계산 예시(3회차, NAV $1.00 / $0.90 / $0.95, 부분 체결) → 총 지급액 $94.75. 컨트랙트 단위 테스트의 기준값으로 쓴다.

정산 내부 동작

아래는 전부 온체인 메커니즘이다. 컨트랙트와 인덱서 작업을 위해 적어 둔 것이고, 투자자가 보는 것(위의 2티어 보장)은 아무것도 바꾸지 않는다.

체결 계산, 예약 버킷, 청구의 두 조각

executeEpoch가 계산하는 것.

  • available = reserveBalance + epochFundTopUp[id]이고, 게이팅이 설정돼 있으면 redemption_gating_bps × totalDeposited / 10000으로 제한된다(TVL이 아니라 예치 자본 누계다, v3-67). ⚠️ redemption_gating_bps는 폐기됐고 MVP에 포함되지 않는다(2026-08-27, 아직 반영 안 됨, v3-150). MVP에서 상한이 항상 0이라 이 항은 작동하지 않는다. 컨트랙트가 여전히 이를 갖고 있으므로 산식은 그대로 둔다.
  • 이월 우선 2티어 체결: 이월 티어(epochCarryDemandLp[id])를 ratioCarry로 먼저 채우고, 신규 수요(epochNewDemandLp[id])를 남은 것에서 ratioNew로 채운다. 둘 다 저장하고, EpochSettled가 보고하고 오프체인 미러가 읽는 집계값 epochAggFillRatio[id] = filledGross / demand도 저장한다.
  • 래더 전진: epochGepochH이월 티어에 대해서만 움직이고, 전량 체결 시 세대 단위로 재시작한다(아래 🔴F 수정). epochCarryGen[id]가 전진 후의 세대를 기록한다.
  • 이월: 두 티어 어느 쪽도 채우지 못한 것이 다음 주기의 이월 수요가 된다(epochCarryDemandLp[id+1], epochTotalDemandLp[id+1]). 닫힌 세대는 아무것도 넘기지 않는다. 그 wei 미만 잔량은 어떤 요청도 청구할 수 없는 수요이기 때문이다.
  • 현금은 약속되는 게 아니라 예약된다(🔴B / 🔴G): _reserveFilledGross가 체결된 총액을 epochFundTopUp[id] → reserveBalance 순서로 차감해 단일 스칼라 **redemptionCommitted**에 더한다. 청구는 그 스칼라에서만 끌어가므로 같은 현금이 두 회차를 뒷받침할 수 없고, 상환이 unclaimedYield를 뒷받침하는 잔액에 닿을 수도 없다. 회차별 epochClaimable 항아리는 없다. 그 설계는 철회됐다(v3-100). 청구가 여러 회차에 걸치기 때문에 회차별 항아리는 O(V)가 된다.
  • currentEpochId++. 앵커된 풀에서는 다음 주기의 열린 실패 펀딩일이 실체화된다(epochFundingDate[id+1] = fundingDate(id) + 주기. 어드민이 이미 확정한 값이 있으면 건드리지 않는다). 레거시 풀은 currentEpochEndsAt += 주기를 한다.

청구의 두 조각(_epochFillMath):

  1. 최초 회차. 거기서 포지션은 신규 수요였다 → principalLp × epochNewFillRatio[vintage]이고 epochSettleNav[vintage]로 가격을 매긴다
  2. 그 뒤의 모든 회차. 거기서 잔량은 이월 수요다 → (epochG[vintage], epochH[vintage], epochCarryGen[vintage])에서 마지막 정산 경계까지의 누적 래더 구간이다

지급액은 스테이블코인 소수 자릿수로 내림한다(반올림은 금지다. 초과 인출이 된다). 내림 잔돈은 redemptionCommitted 안에 남고 현재는 의도적으로 쓸어 담지 않는다. 다시 앵커할 때 epochId ← 0으로 두고(신규 티어 조각은 이미 계산됐으므로 두 번 세일 수 없다) principalLplpRemainingremainingLp로 설정하며, 기준값을 마지막 정산 경계에 고정한다.

래더 세대: 전량 체결이 왜 붕괴가 아니라 재시작인가 (🔴F 수정)

단순한 잔존 비율 곱은 자기 성공을 견디지 못한다. G[id] = G[id−1] × (1 − fillRatio)첫 전량 체결에서 0이 되는데, fillRatio = 1은 예외 상황이 아니라 운영 목표다. 그러면 유동성 게이팅이 첫 정상 주기에서 죽고, 그 뒤로는 종착 청구 분기가 유동성 확인 없이 남은 잔액을 지급한다. 요청에 (gBase, hBase)를 스냅샷하는 것은 필요하지만 충분하지 않다. 요청이 들어올 때 G[vintage−1]이 이미 0이었다면 스냅샷도 0이다.

이를 닫는 것은 이 사실이다. 전량 체결은 그 세대의 미결 수요를 전부 해소하므로 그 안의 모든 요청이 끝난 것이다. 그렇다면 래더를 붕괴시키는 대신 그 지점에서 재시작할 수 있다.

  • 전량 체결 시: generationCloseH[gen] = H를 기록하고 gen을 증가시키며 epochG[id] = 1e18, epochH[id] = 0으로 리셋한다.
  • ep.generation이 현재 세대보다 오래된 청구는 "내 세대는 전량 체결로 닫혔다"로 읽어 전액 체결로 처리하고 generationCloseH[내 세대]에서 가격을 매긴다. 저장된 그 값이 재시작을 가로질러 미청구 낙오자를 온전하게 지켜 준다.
  • demand == 0은 전량 체결이 아니다. 래더가 그대로 이어지고 세대도 전진하지 않는다. (v3-93의 demand == 0 미해결 항목도 여기서 정리됐다.)
  • 같은 재시작이 정밀도 소진도 흡수한다. 잔존 비율이 1e18 스케일에서 0~2 wei로 반올림되면 revert하지 않고 의도적으로 전량 체결로 취급한다. 정산을 막으면 자금이 갇히는데, 그건 지급액 내림이 어차피 버리는 wei 미만 잔돈을 버리는 것보다 나쁘다(v3-100).

청구도 정산도 O(1)로 남는다. 상세는 v3-100 참조.

상태

상태
QUEUED회차에 등록됐고 정산을 기다린다
PARTIALLY_FILLEDpro-rata 부분 체결분을 청구했고 잔량이 다음 회차로 이월됐다
COMPLETED전량 체결되고 청구됐다

취소

투자자는 QUEUEDPARTIALLY_FILLED인 동안(회차) cancelRedemption을 할 수 있고, 잠긴 LP가 반환되며 epochTotalDemandLp가 차감된다. 취소된 요청은 REJECTED로 남는다(별도 CANCELLED 상태가 없고 failure_type = INVESTOR_CANCELLED가 이를 구분한다).

🔴 즉시 상환의 취소는 MVP 범위 밖이다(2026-08-27 결정, 아직 반영 안 됨, v3-149). 투자자 앱에 즉시 취소 버튼이 없고 회차 플로우에만 있다. RedemptionLib의 즉시 분기는 여전히 온체인에 있다. MVP 범위 밖일 뿐 제거된 게 아니다. 회차 취소는 그대로다. 요청 창 안에서만 가능하고, 취소해도 파트너 자금은 돌아가지 않는다.

취소 창 = 요청 창이다(v3-93). cancelRedemptionrequestRedemption과 같은 게이트를 갖고 둘이 함께 열리고 닫힌다(두 진입점이 공유하는 _requireRequestWindow). 마감 이후의 취소는 이미 발행자에게 보낸 확정 총액을 거짓으로 만들어 그들이 과다 회수하게 만든다. 정산 이후의 취소는 Σ claims ≤ redemptionCommitted 불변식을 깬다. 이월된 수요가 갇히는 것은 아니다. 부분 체결된 투자자는 다음 요청 창에서 취소한다. 그러므로 이 제약은 요청이 아니라 회차에 걸린다. ⚠️ 이에 대한 옛 UI 문구는 이제 틀렸다. 에스크로 기간 수익이 이미 남은 홀더에게 갔기 때문에 환급되지 않는다고 안내했다. v3-131 (3)이 반영된 이후로는 취소가 대기 전체를 크레딧한다. 마지막 정산까지가 아니라 지금까지다. 취소는 아무 가격도 매기지 않기 때문이다.

여전히 미해결인 것: 어드민 거부에도 같은 마감 게이트가 걸리는지, 그리고 규제상 강제 취소(KYC 취소, 제재 등재)에 예외 경로를 두는지다.

취소 전 청구

창 안의 취소가 이야기의 전부는 아니다. 정산이 요청의 일부를 이미 채웠다면 다르다. 정산은 체결된 총액을 redemptionCommitted에 예약하지만 LP를 소각하지는 않는다. 그래서 ep.lpRemaining을 돌려주면 포지션 전체를 반환하면서 그 현금에 청구권자가 없게 되고, 남은 홀더 전원이 덜 받게 된다. 그래서 cancelRedemptionfilledLp > 0인 동안 **ClaimBeforeCancel(requestId)**로 revert하고(v3-105) 투자자의 순서는 이렇게 된다.

  1. claimRedemption: 체결된 몫을 지급하고 그 LP를 소각하며 잔량을 순수 이월 형태로 다시 앵커한다. 창 게이트가 없으므로 언제든 할 수 있다.
  2. cancelRedemption: 요청 창 안에서 잔량을 돌려주고 이월 수요를 차감한다.

이미 청구한 요청은 다음 정산 전까지 filledLp == 0으로 읽히므로 평범한 취소는 영향받지 않는다. 이 게이트는 미청구 체결분이 있을 때만 발동한다.

프론트엔드 요건. 이 2단계를 그대로 드러내야 한다. 설명 없이 revert하는 취소 버튼은 비활성화된 버튼보다 나쁘고, 문구는 체결된 몫을 명시하면서 그것이 몰수가 아니라 지급된다고 말해야 한다. previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD)가 그 분해를 제공하는데, 이는 공개 상태에서 유도할 수 없다. epochRequests(epochId, principalLp, lpRemaining, yieldAccSnapshot)만 노출하고 epochH / epochCarryGen / generationCloseH에는 getter가 없다. 시그니처는 08a에 있다. 둘 다 배포돼 있으므로 새로 만들 것은 없다. 프론트엔드는 현재 팩토리에서 만든 풀이 있어야 그에 맞춰 개발할 수 있다(이 페이지 맨 위의 클론 주의 참조).

🔴 정리해야 할 상호작용: KYC가 취소된 회차 투자자는 청구가 막히고(canRedeem) 이제 취소도 막히는데, rejectRedemptionQUEUEDPARTIALLY_FILLED를 받은 적이 없다. 그래서 어느 쪽도 그 요청을 닫을 수 없다. 어드민의 수동 해소나 게이트의 예외 조항이 필요하다. P1.

파트너 펀딩(3계층)

reserve가 그 회차의 수요를 감당하지 못하면 파트너가 기한 전에 fund_wallet에서 보충해야 한다. 통지와 강제는 계층으로 나뉜다.

  1. 온체인(기준이자 하드 기한): 그 주기의 펀딩일이 기한이고, EpochFundingNeeded정산 시점에 채우지 못한 금액과 함께 발생한다. 그래서 파트너 키퍼가 Aset 없이도 무신뢰로 반응할 수 있다.
  2. 오프체인(알림, 편의): Aset Lambda가 라이브 풀 상태에서 부족액을 읽어(readEpochShortfall) 파트너 대시보드와 D-2, D-12h 이메일 리마인더를 보낸다(notification_eventsnotification_deliveries. notification_logs는 마이그레이션 0110에서 삭제됐다). 이것이 유일한 기한 이전 신호다. 신뢰 경계는 아니다.
  3. 강제 레버(Centrifuge 방식, 개발 결정 ①): ⚠️ 제거됐다. 그 레버는 잔여분 hold-back이었다. setFundingRestricted(true)를 하면 각 입금의 fund_wallet 릴리스가 상환 유동성으로 풀에 남았고, "상환이 대기 중인 동안 새 자금 조달은 없다"는 원칙이었다. 어떤 제품 화면에도 연결된 적이 없고 0183에서 삭제됐다(v3-112). 만성적인 부족에 남은 수단은 IMPAIRED로의 에스컬레이션이다(v3-12).

hold-back 버킷은 없어졌다(0183)

heldFundReleasesPlatformPool 안에서 reserveBalance 옆에 있었고 상환도 지급할 수 있었다. 그래서 둘이 늘 혼동됐고, 이 페이지에도 둘을 구분하는 표가 있었다. 그 버킷은 존재했던 모든 풀에서 0으로 읽혔으므로 제거해도 숫자는 달라지지 않는다. 제거되는 것은 다섯 개 자금 산식의 항 하나다. 남아 있는 버킷 의미는 23-money-path → Reserve 참조.

이제 reserveBalance가 풀의 유일한 유동성 버킷이다. 모든 입금과 재투자의 reserve_bps 보유분에서 오고, 상환 유동성으로 보관되는 투자자 돈이며, 그 회차 자체의 top-up 다음에 쓰이고(epochFundTopUp[id] → reserveBalance), 파트너에게 릴리스되지 않으며, NAV 손실을 흡수하지 않는다(R8).

이상치 보류(하이브리드)

회차는 이상치가 감지되지 않는 한 100% 자동으로 정산된다. 감지되면 어드민이 holdRequest(id)(정산에서 제외)와 releaseRequest(id)(다시 포함)를 쓸 수 있다. 단위는 컴플라이언스나 단일 대형 건은 요청별, NAV나 총 물량이나 낮은 체결률은 회차별이다. 트리거는 전부 설정값이고 대부분 기존 신호를 재사용한다.

분류트리거(기본 임계값)단위
대형 상환단일 요청이나 회차 총액이 TVL의 25% 초과(소프트 5%) 또는 절대 금액 하한요청 / 회차
NAV 이상 · 오라클정산 NAV가 직전 대비 ±5% 초과로 벗어남 · NAV가 오래됨 · 서킷 브레이커 작동(v3-32)회차
컴플라이언스요청자 KYC가 REVOKED · AML 조사 · 제재 일치요청
파트너 부실풀이 IMPAIRED/WIND_DOWN에 근접 · reserve 소진 · yield_overdue회차
만성 이월 · 낮은 체결률같은 요청이 N회(3회) 이월됨 · 회차 fillRatio10% 미만요청 / 회차

⚠️ 의존성: NAV 편차 상한, 서킷 브레이커, staleness 검사는 v3-32(코어의 NAV 한계)를 필요로 한다. v3-32 참조. 그것이 없으면 NAV 계열 이상치 감지가 작동할 수 없다.

만기 후 회차 상환

🔨 구현됐으나 미배포. 컬럼 넷 중 둘은 dev에 적용되지 않았다

구간 × 방식 매트릭스의 조합 ②다. 만기 전까지 이탈을 거부하고, 그 뒤로 여러 회차에 걸쳐 원금을 상환한다. 이를 돌리는 엔진은 위에 문서화된 회차 엔진 그대로다.

코드에 들어간 것: 위저드(v3-132), 계획 컬럼(0189), 모든 주기의 날짜를 쓰는 배포와 그 뒤의 관측 날짜 컬럼(v3-133, 0190), 주기별 확정 엔드포인트와 그 순서 가드(v3-139)다. 컨트랙트 변경은 없다. 모든 부분이 체인이 이미 갖고 있던 설정과 호출 지점을 타는데, 그것이 v3-131의 전제였고 여전히 유효하다.

⚠️ 아무것도 라이브가 아니다. 마이그레이션 0189와 0190이 dev에 적용되지 않았고 API도 수동 배포다. 아래 (3)번, 즉 수익 발생이 정산에서 끝나는 것은 ✅ 반영됐고 온체인에 있다(신규 구현체 0x7C21153E… + 팩토리 0xE8453DAc…, 2026-08-21 Base Sepolia에서 확인). ⚠️ 신규 풀만 해당되고 poolCounter()가 0이다. 그 팩토리에서 만들어진 것이 없으므로 지금 존재하는 모든 풀은 여전히 옛 구현체에 고정돼 있고 이 상품에서 여전히 엉뚱한 사람에게 지급한다.

전체 계획, 결정 기록, 미해결 항목: Notion "풀 상환 모델 — 구간 × 모드 (기획서)".

무엇인가

만기 전까지 상환을 거부하고(redemption_type = FIXED_MATURITY) 만기 이후로는 일시금 대신 주기(epoch_duration_days > 0)에 맞춰 이탈을 정산하는 풀이다. 발행자가 원금을 한 번에가 아니라 여러 주기에 걸쳐 갚고, 아직 미상환인 부분에 대해서는 계속 쿠폰을 지급한다.

만기 전상환 요청이 revert된다
만기 후표준 Model B 창에 따라 epoch_duration_days마다 정산된다
주기별 금액설정하지 않는다. 한 주기는 reserveBalance + epochFundTopUp[이번 주기]를 대상으로 그 주기 요청들에 pro-rata로 정산한다. 실질적으로는 그 주기에 대한 발행자의 펀딩과 풀이 들고 있는 상시 reserve다
스케줄 길이풀별로 pools.redemption_term_epochs, 곧 주기 수다(0189). 스펙에서 redemption_window_epochs라 부르던 컬럼이고 더 짧은 이름으로 나갔으며 옛 문서에는 둘 다 등장한다
스케줄 동안의 쿠폰미상환 원금에 대해 계속된다

온체인의 새 풀 유형이 아니고 어떤 enum에도 값이 추가되지 않는다. 이미 존재하는 설정 둘의 조합이고, 그것이 이 기능이 감당 가능한 이유 전부다. redemptionConfiginitialize에서만 설정되고 setter가 없어서, 새 redemption_type 값은 신규 풀에만 닿는다. 그리고 마지막 세 번째 값이었던, 배선되지 않은 LIQUIDITY_WINDOWS는 풀 넷을 영구히 상환 불가 상태로 남겼다(마이그레이션 0106).

이 중 딜에 특화된 것은 없다. "4개월"도 "매번 원금의 4분의 1"도 플랫폼의 가정이 아니다. 스케줄 길이는 설정하는 값이고, 균등하지 않은 분할도 아무 설정 없이 이미 동작한다. 주기별 금액이 그때 들어온 만큼이기 때문이다.

무엇이 바뀌어야 하나

#변경계층상태
1위저드가 FIXED_MATURITY + epoch_duration_days > 0을 만들 수 있게 한다. 주기 함께 다녀야 하는 앵커 스케줄 항목까지 포함하고, 스케줄 블록 이름을 **"만기 후 상환"**으로 바꾼다FE/BE✅ 구현됨(v3-132), 미배포
2스케줄 길이, 곧 "4회차 중 2회차" 카운터FE/BE + 스키마redemption_term_epochs로 구현됨(0189). 처음 명세와 달리 선택값이 아니라 이 조합에 필수
3수익 발생이 요청이 아니라 정산에서 끝난다컨트랙트✅ 2026-08-20 반영. 신규 풀만
4만기를 투자자별로 앵커컨트랙트⬜ 범위 밖. 대신 짧은 모집 기간
5마지막 주기 이후에도 끝내 요청하지 않는 홀더운영⬜ 구현 대상 아님

(3)이 마지막 MVP 항목이었고, 이 상품에서 엔진이 실제로 틀린 결과를 만들어 내던 유일한 지점이었다. 매 주기마다 요청자의 쿠폰을 남아 있는 홀더에게 넘겼다. ✅ 2026-08-20 반영: 에스크로된 LP가 그 홀더를 위해 계속 벌고, 그 몫은 요청 단위로 회차 래더에 대해 쌓이며 에스크로가 끝날 때 채무와 크레딧이 한 순간에 함께 인식된다. previewEscrowAccrual(requestId)가 열려 있는 동안의 시차다. ⚠️ 신규 풀만 해당된다. 구현체는 온체인에 있지만(Base Sepolia, 2026-08-21 확인) poolCounter()가 0이다. 그 팩토리에서 만들어진 풀이 없고, 기존 풀은 전부 옛 규칙을 돌리는 옛 구현체에 고정돼 있다.

(2)는 선택값에서 필수로 바뀌었다. 처음에는 운영이 텀시트에서 가져올 수 있는 표시용 카운터로 명세됐다. 그런데 배포가 유한한 날짜 목록을 써야 하게 되면서(v3-133) 그 개수를 말해 주는 것이 이 값이 됐다. 리마인더와 엔드포인트와 확인 카드가 전부 이를 읽고, 어느 것도 텀시트로는 동작할 수 없다. 여전히 생성 시 전용이므로, 늦게 정할 수 있는 대신 사후에 끼워 넣을 수 없다.

(1)은 가드가 하나가 아니었고 그 부분들이 서로 얽혀 있었다. pool-create/payload.tsredemption_type !== FIXED_MATURITY를 조건으로 셋을 막고 있었다. 주기 자체, 앵커 스케줄 블록 전체(epoch_schedule_type · funding_anchor_date · request_window_days · recall_lead_days · epoch_cycle_mode), 그리고 redemption_gating_bps다. 마지막 것만 계속 막힌다. 결정에 따라 이 조합에서는 숨긴다. ⚠️ epoch_cycle_mode는 이후 모든 조합에 대해 위저드에서 아예 빠졌다(v3-140). 스케줄 블록 없이 주기만 푸는 것은 생성 엔드포인트가 거부하는 풀을 만들어 낸다. 회차 풀의 체인 항목들을 한 세트로 요구하기 때문이다. 그리고 setEpochSchedule이 생성 시 전용이라, 앵커 없이 빠져나간 풀은 영영 고칠 수 없다. 이름을 바꾸는 것은 그중 작은 부분이었다.

(3)과 (4)는 움직인다면 함께 움직인다. 둘 다 컨트랙트 변경이라 (4)를 따로 내보내면 커버리지는 안 늘고 배포 라운드만 하나 더 든다. 어느 쪽이든 기존 풀에는 닿지 않는다. 풀은 생성 시 구현체에 고정되므로 둘 다 새로 만든 풀에만 적용되고 저울질할 마이그레이션이 없다. 게다가 플랫폼은 아직 메인넷 이전이다.

날짜가 체인에 도달하는 방법

🔴 쓰이지 않은 주기는 온체인에서 공백이 아니다. 확신에 찬 틀린 날짜이고 정산할 때마다 굳는다

PoolCommonLib.epochFundingDateAt저장이 아니라 유도다. 주기에 저장된 값이 있으면 그것을, 없으면 직전 + epochDurationDays를 반환하며 딱 한 단계만 되돌아본다. 그래서 아무도 쓰지 않은 주기도 날짜를 답하고, 매월 15일 역법 계획에서는 그 답이 밀린다.

위저드가 승인한 것         3/15 · 4/15 · 5/15 · 6/15
체인이 만들어 낼 것        3/15 · 4/12 · 5/10 · 6/7

2회차에 사흘, 4회차에 여드레 어긋나는데 어디서도 revert하지 않는다. 복구 가능한 상태로 남지도 않는다. 정산할 때마다 다음 주기의 유도값을 스토리지에 쓰므로(RedemptionLib.sol:894) 매 정산이 틀린 날짜를 하나씩 영구히 고정하고 오차가 앞으로 굴러간다.

배포가 2번부터 N번 주기를 쓴다. 만기가 실제 타임스탬프가 되는 순간이고, 그 계획을 만기에 맞춰 다시 만들 수 있는 첫 순간이자 풀이 불완전한 스케줄로 라이브가 되기 전 마지막 순간이다(v3-133). 1번 주기는 setEpochSchedule의 앵커로 이미 온체인에 있고, 나머지는 만기에서 다시 유도하는 게 아니라 그 앵커에서 앞으로 걸어 나간다. 그래야 목록 전체가 하나의 스케줄에 속한다.

실행을 제한하는 것Lambda 시계다(300초 예산, 60초 예비). 쓰기 전마다 다시 읽는다. 트랜잭션 수가 아니다. 그건 아무도 측정하지 않은 트랜잭션당 실제 시간에서 추측해야 하고, 체인이 느린 날 그 추측이 무너진다
중간에 끊긴 실행배포 실패가 아니다. 풀은 온체인에 있고 설정도 맞다. 빠진 날짜들은 창이 몇 달 뒤에 열리는 주기의 것이고 각각 나중에 쓸 수 있다. 라이브 풀에 DEPLOY_FAILED를 남기는 것은 거짓 기록이다
끊긴 실행을 드러내는 것쓰이지 않은 주기를 조건으로 하는 펀딩일 리마인더다(v3-135). ⚠️ 현재는 그것뿐이다. 배포 후 확인 카드가 아직 없고, 이 풀 형태에 대한 어드민 뷰 계층도 작업 중이다
쓰인 날짜가 기록되는 곳EpochFundingDateSetredemption_epochs.funding_date(0190)다. 관측된 사실만 담는다. NULL아무도 이것을 설정하지 않았다는 뜻이고, 그것이 설정된 날짜와 유도된 날짜를 구분하는 유일한 방법이다. ⚠️ 2번 주기부터 센다. 1번은 setEpochSchedule에서 오고 그건 EpochScheduleSet을 발생시키므로 구조상 행이 없다
나중에 목록을 마저 채우는 방법epoch_id를 담은 POST /pools/{id}/epoch-schedule(v3-139)

🔴 라이터가 둘이고 순서가 반대다. 합치면 그중 하나가 조용히 깨진다 (v3-134)

배포는 오름차순(2 → N)이다. 실행이 중간에 끊길 수 있으므로 쓰이지 않고 남는 것이 가장 먼 주기여야 한다. 배포가 이미 만기 이전에 창이 열리는 앵커를 거부하기 때문에 안전하다.

확인 카드는 내림차순(N → 2)이다. 여기서는 아무것도 끊기지 않고 위험이 뒤집힌다. setEpochFundingDate는 어떤 주기의 창이 열린 뒤에는 거부되는데, 그 창은 epochFundingDateAt에서 유도되고 그것은 앞 주기가 쓰이지 않은 동안 0을 반환한다. 0인 창은 "아직 안 열림"으로 읽히므로 가드가 잠들어 있다. K번 주기를 쓰는 것이 K+1번 주기의 가드를 무장시킨다. 그래서 여기서 오름차순으로 하면 아무도 고르지 않은 날짜를 근거로 어떤 주기를 잠가 버릴 수 있다.

🔴 걷기 원시함수와 계획 원시함수: 한 라운드에 같은 버그가 세 군데서 나왔다

generateFundingDates는 넘겨받은 첫 날짜에서 걸어 나간다. repaymentPlanDates는 만기와 roll day에서 첫 날짜를 스스로 찾는다.

앵커를 이미 들고 있으면서 repaymentPlanDates에 손을 대는 호출자는 CALENDAR에서 자기 firstFundingMs무시되고 1번 주기가 갖고 있지도 않을 수 있는 roll day에서 다시 유도된다. rollDay: 0이면 월말에 떨어져 05-30 · 06-30 · 07-30이 되는데, 1~28 규칙 밖이고 2월마다 3월로 넘어간다.

한 라운드에 세 번 나타났고 전부 조용했으며 전부 "규칙이 확신에 찬 틀린 날짜를 만들어 내는" 형태였다. 모든 날짜가 미리 정해져 있다고 주장하던 위저드 문구, 배포 자신의 라이터, 그리고 roll-15 계획에 *"05-30을 확정하세요"*를 제안하던 리마인더다. 해법은 planDatesFromAnchor이고 앵커를 가진 두 호출자가 이제 이를 쓴다. repaymentPlanDates에는 경고를 달았다. 앵커를 들고 있으면 거기서 걸어 나가고, 절대 다시 유도하지 말 것.

단조성은 전송 전에 양쪽 이웃에 대해 확인한다(v3-139). 그리고 양쪽을 다르게 읽는다. 앞쪽은 유도된 날짜까지 포함해서 읽고, 뒤쪽은 누군가 실제로 설정한 경우에만 읽는다. 유도된 후속 주기의 날짜는 epochFundingDate[이번 주기] + 주기이고, 이는 지금 교체하려는 슬롯의 함수다. 그래서 그것과 비교하면 정당한 이동이 거부됐다(5번 주기가 06-01로 저장돼 있고 6번이 06-29로 유도되는데, 5번을 07-10으로 옮기면 충돌로 보고됐다. 그러면 6번은 08-07로 유도되는데도 그랬다). written은 인덱서의 EpochFundingDateSet 미러에서 온다(redemption_epochs.funding_date, 0190). 앞 주기보다 이른 날짜는 그 주기의 요청 창을 과거에 두는데, 거기서는 _requireRequestWindow가 모든 요청을 거부하고 setEpochFundingDate가 모든 수정을 거부한다. 쓸 수도 고칠 수도 없는 주기가 된다. 0으로 읽히는 이웃은 1970년으로 취급하지 않고 건너뛴다. 같은 규칙이 이제 온체인에도 있다. GovernanceLib.setEpochFundingDate에서 0이 아님 가드와 창 가드 옆에 있어서, ORACLE 키를 가진 호출자도 더 이상 이를 우회하지 못한다. 온체인 검사는 앞 이웃을 유도값으로, 뒤 이웃을 저장값으로 비교한다. 유도된 후속값은 epochFundingDate[N] + 주기이고 덮어쓰려는 슬롯의 함수이므로 경계가 아니라 옛 값이다. ⚠️ 소스에는 있고 아직 배포되지 않았다. 풀은 업그레이드 경로가 없는 Clones라 그 배포 이전에 만들어진 모든 풀은 가드 없는 구현체를 유지한다. 그래서 엔드포인트 검사는 남고 중복이 아니다. 오프체인이라는 점이 바로 이미 라이브인 풀까지 덮을 수 있게 해 준다. 엔드포인트 검사는 어디서든 운영자 실수를 덮고, 컨트랙트 쪽 사본은 탈취된 키를 덮되 그것이 배포된 뒤 배포된 풀에만 해당된다.

함께 나온 결정들

질문결정
주기별 분할 금액저장하지 않는다. 그 주기에 발행자가 넣은 만큼을 pro-rata로 배분한다
요청 단위포지션 전체를 한 번에 요청하는 것이 기본이고, 주기별 요청도 계속 허용한다
redemption_gating_bps⚠️ 폐기됐고 MVP에 포함되지 않는다(2026-08-27, 아직 반영 안 됨, v3-150). 이 조합에서는 이미 숨겨져 있다. 주기당 정산 가능액에 상한을 두는데, 발행자가 이미 스케줄에 맞춰 자금을 대고 있으므로 두 번째 상한은 그 스케줄을 깨는 일밖에 못 한다
쿠폰 기준미상환 원금이다. 설정을 추가하지 않는다. 표준 기본값이고 분배 로직이 가진 유일한 동작이다
은행 휴일영업일 규약도, 휴일 달력도, 영업 중심지 목록도 없다. 은행이 관여하는 구간은 발행자가 자금을 회수하는 부분뿐이고 그건 recall_lead_days가 이미 덮는다. 투자자 청구와 발행자 펀딩은 온체인이고 은행 영업시간에 매이지 않는다. 마감에 걸친 휴일은 그 주기의 펀딩일을 옮기게 하는데, 그건 이미 있는 운영자 조작이다
끝내 요청하지 않는 홀더보유한 채로 두고 알린다. 순서가 중요하다. 알림, 그다음 약관의 종료 조항, 그리고 그 조항이 요구하는 만큼의 코드만. wind-down 경로는 절대 쓰지 않는다. 권한은 편해 보이지만 의미가 정반대이고, 투자자는 자기 풀이 청산 중이라고 표시된 것을 보게 된다

이 상품이 부딪히는 제약

  • 첫 주기는 만기에서 시작하지 않고, 거기 두면 풀이 망가진다. 창은 앵커 − recall_lead_days − request_window_days에 열리므로 펀딩 앵커는 앵커 ≥ maturityDate + recall_lead_days + request_window_days를 만족해야 한다. 그보다 이르면 1번 주기의 요청 창이 만기 전에 열리고, 거기서는 _validateRedemption이 모든 요청을 revert한다. 존재하지만 절대 쓸 수 없는 주기가 된다. ✅ 예전의 함정은 두 값을 다르게 쟀다는 것이었다. maturityDatedeploy_time + maturity_days라 DRAFT가 늦게 배포되면 움직였고, 앵커는 운영자가 입력하는 절대 날짜였다. 이제 둘 다 절대값이라(v3-145) 위저드 시점부터 관계가 안정적이다. 그래도 배포가 이를 다시 확인한다(pools.worker.deploy의 W2-4). 펀딩 앵커와 만기가 여전히 따로 입력되는 두 날짜이기 때문이다.
  • 주기는 자기 창을 담을 만큼 길어야 한다. setEpochSchedulerecall_lead_days + request_window_days < epoch_duration_days가 아니면(그리고 앵커 자체가 그 앞에 둘을 담을 여유가 없으면) InvalidSchedule로 revert한다. 그래서 28일 주기는 발행자의 회수 리드 투자자 요청 창을 합쳐 4주 미만 안에 담아야 한다. 긴 회수 리드가 필요한 스케줄은 요청 창을 줄일 게 아니라 더 긴 주기를 써야 한다.
  • 한 주기는 28일이나 84일이지 역월이 아니다(v3-124). 매월 15일에 송금하는 발행자는 주기와 어긋나고, 운영자가 주기마다 펀딩일을 옮겨 보정한다.
  • 주기 상한은 90일이다. 반기나 연 단위 상환 스케줄은 표현할 수 없다.
  • 주기와 스케줄 항목은 생성 시에만 정한다. 모집 계획과 함께 확정해야지 그 뒤에 정할 수 없다.
  • 🔴 한 주기의 남은 펀딩은 이월되지 않고 되돌려 받을 방법도 없다(v3-137). epochFundTopUp은 주기를 키로 하고, 정산은 reserveBalance + epochFundTopUp[currentEpochId]만 읽으며(RedemptionLib.sol:955), reserveBalance +=는 저장소 전체에서 정확히 한 번 나온다(PoolLedgerLib.sol:73, 입금 분리). 그래서 top-up을 어디로든 옮기는 경로가 없다. 없어지는 것은 아니다. wind-down 분자인 totalEpochTopUp에 계속 포함되므로 청산 시 홀더에게 도달한다. 정확한 운영 규칙은 그 주기에 묶인다이고, 발행자는 스케줄 전체를 미리 채우는 대신 주기마다 실제 수요만큼 자금을 댄다. 쓸어 담는 기능이 없는 것은 아무도 내리지 않은 컨트랙트 결정이지(어느 주기로 넣을 것인가?) 아무도 만들지 않은 기능이 아니다. ⚠️ v3-100 항목 1이 남은 금액이 "다음 회차로 이월된다"고 적었고 거기서 오류가 시작됐다. v3-100 카드에서 정정됐지만 over_funding_detected 알림은 여전히 펀드매니저에게 옛 버전을 말하고 있다(22-notifications).
  • ⚠️ 이월된 수요와 혼동하지 말 것. 미체결 수요는 실제로 다음 주기로 넘어간다(epochCarryDemandLp, 신규보다 먼저 처리된다). 펀딩은 넘어가지 않는다. 앞 문장을 뒤 문장에 재사용한 것이 위의 알림 결함이 생긴 경위다.
  • 상환을 시작할 수 있는 것은 투자자뿐이다. 무권한 경로(v3-31, claimRedemptionFallback)는 이미 자금이 확보된 요청의 지급을 예고 기간 후에 밀어 줄 뿐 요청을 만들지는 못한다. 그래서 끝내 요청하지 않는 홀더는 기존 어떤 메커니즘으로도 닿을 수 없고, (5)가 기능이 아니라 운영과 약관의 문제인 이유가 그것이다.

긴급 Wind-Down

파트너가 오랫동안(60일 이상) 응답하지 않으면 풀을 청산할 수 있다. 풀을 종료하고 회수 가능한 자금을 투자자에게 배분한다. 온체인으로 회수할 수 있는 것은 풀 안에 있는 것뿐이고, fund_wallet에 있는 잔여분은 파트너가 통제하므로 회수하려면 오프체인 법적 절차가 필요하다. 전체 절차는 풀 모델 → 긴급 Wind-Down에 있고, 타임라인은 제안(멀티시그) → 30일 타임락(그동안 파트너가 응답할 수 있다) → 누구나 호출할 수 있는 executeWindDown()이다.

상환 경로에 특화된 것은 이렇다.

  • 현재 소스의 executeWindDown()navPerToken을 재계산하지 않는다. 오라클 NAV를 유지하며 실제 지급에는 별도 자금 투입과 상환 절차가 필요하다.
  • 상환 경로에서 락업과 패널티가 면제된다.
  • 입금은 WIND_DOWN lifecycle이 막는다(whenActive 게이트). wind-down은 isEmergencyFrozen을 설정하지 않는다. 그렇게 하면 이탈이 72시간 막히는데, wind-down에 필요한 것과 정반대다.
  • 현재 소스에는 claimRedemptionFallback이 없다. 7일 폴백을 통한 지급을 보장하지 않는다. 별도로 남아 있는 회차 정산과 대리 청구도 실제 투입 자금에 의존한다.

폐기됨 (v2.x → v3.0)

AS_POOL/FUND_POOL 분리, 멀티시그 에스크로, FM_ACCEPTED 사전 확인 단계(엔드포인트 포함)가 전부 없어졌다. 풀은 이제 설정으로만 달라지고, 파트너 조율은 워크플로 단계가 아니라 상태다(v3-34). 현재 redemption_status enum은 REQUESTED, QUEUED, PARTIALLY_FILLED, PENDING_RESERVE, PROCESSING, COMPLETED, REJECTED, FAILED다.