컨트랙트 코드 레퍼런스
✅ 소스에서 생성됨
이 페이지는 apps/contract/src/ 아래 배포된 Solidity 컨트랙트의 코드 수준 레퍼런스다(2026-07-30 확인). 코드에 실제로 작성된 함수, role, 온체인 안전 메커니즘을 문서화한다. 개념과 결정의 서술(왜 이런 구조인가)은 스마트 컨트랙트 구조 참조.
온체인 계층은 컨트랙트 4개와 구현 라이브러리 7개다. money-path(PlatformPool, PlatformLPToken)는 불변 Clones이고, PlatformKYCSoulbound만 업그레이드 가능하다(UUPS + 7일 타임락). 이 페이지는 컨트랙트마다 (1) 구조 시각화, (2) 설명이 붙은 전체 함수 목록, (3) 보안 메커니즘과 기능을 다룬다.
| 컨트랙트 | 파일 | 줄 수 | 역할 |
|---|---|---|---|
| PlatformPool | src/PlatformPool.sol | 1,539 | 코어 풀이다. 입금, LP 민팅·소각, reserve, 수익, 상환, lifecycle을 담당한다(대부분의 로직은 아래 라이브러리로 위임된다) |
| PlatformLPToken | src/PlatformLPToken.sol | 207 | ERC-20 LP 지분 토큰(풀당 클론 하나) |
| PlatformKYCSoulbound | src/PlatformKYCSoulbound.sol | 620 | Soulbound ERC-721 KYC/KYB 확인 토큰(전역, UUPS 업그레이드 가능) |
| PlatformPoolFactory | src/PlatformPoolFactory.sol | 152 | 클론 팩토리와 온체인 레지스트리 |
| RedemptionLib | src/libraries/RedemptionLib.sol | 1,226 | delegatecall. 모든 상환 로직(즉시와 회차) |
| GovernanceLib | src/libraries/GovernanceLib.sol | 585 | delegatecall. 타임락 설정 변경(propose/execute/cancel)과 직접 어드민 setter |
| YieldLib | src/libraries/YieldLib.sol | 317 | delegatecall. 수익 입금·분배·청구·재투자와 수수료 인출(onLpTransfer는 핫패스라 인라인으로 남는다) |
| NavLib | src/libraries/NavLib.sol | 170 | delegatecall. NAV 갱신(즉시와 타임락)과 대기분 적용·취소 |
| PoolConfigLib | src/libraries/PoolConfigLib.sol | 87 | delegatecall. init 시점 설정 검증(pure) |
| StablecoinAdminLib | src/libraries/StablecoinAdminLib.sol | 118 | delegatecall. 허용 스테이블코인 추가·삭제 |
| PoolCommonLib | src/libraries/PoolCommonLib.sol | 153 | internal(인라인). 공용 게이트와 소수 자릿수 연산 |
| PlatformPoolStorage | src/PlatformPoolStorage.sol | 215 | struct Layout 타입이다(배포되는 컨트랙트가 아니다). 각 풀이 자기 Layout s에 담는 스토리지 레이아웃을 정의한다 |
| PlatformPoolInterfaces | src/PlatformPoolInterfaces.sol | 26 | 최소 인터페이스(import 순환을 끊는다) |
1. 구조
컨트랙트 토폴로지
EIP-170 라이브러리 위임(풀별 스토리지, 공유 라이브러리 코드)
PlatformPool이 EIP-170의 24,576바이트 한계를 넘어서, 로직을 배포 시점에 링크되는 라이브러리로 나눴다. 라이브러리는 한 번만 배포되고 모든 풀이 이를 delegatecall한다. 공유되는 것은 라이브러리 _코드_이지 스토리지가 아니다. 각 풀 클론은 독립적인 자기 스토리지를 갖고, delegatecall이 호출자의 스토리지 컨텍스트에서 실행되므로 라이브러리는 언제나 자기를 호출한 풀의 스토리지를 읽고 쓴다.
PlatformPoolStorage.sol은 struct Layout을 정의한다(배포되는 컨트랙트가 아니라 타입이다). 각 풀이 자기 스토리지에 Layout s 하나를 선언하고, 라이브러리는 Layout storage s를 참조로 받는다. struct 타입 하나를 공유하면 풀과 라이브러리 사이에서 슬롯 레이아웃이 절대 어긋날 수 없다. 풀들이 공용 스토리지를 쓴다는 뜻은 아니다.
풀은 서로 격리돼 있다. 풀 A의
reserveBalance와 풀 B의reserveBalance는 서로 다른 컨트랙트의 서로 다른 슬롯이다. 두 클론은 같은 라이브러리 바이트코드를 가리킬 뿐 서로의 상태를 건드리지 않는다.
오프체인에서 이것이 보이지 않는 이유
라이브러리에 선언된 오류와 이벤트가 동일한 selector와 topic0으로 풀 주소 아래에서 revert하고 발생하므로 ABI와 오프체인 디코딩이 그대로다. 분리 근거와 바이트코드 크기 결과는 스마트 컨트랙트 구조 → 구현 라이브러리(EIP-170)에 있다.
Role 부여 흐름(누가 각 role을 갖고 배포 시 어떻게 부여되는지)은 스마트 컨트랙트 구조 → Role 부여 흐름에 있다. 이 페이지는 코드 수준 접근 통제 표에 집중한다(§3.1 참조).
2. 함수 레퍼런스
2.1 PlatformPool
코어 컨트랙트다. 얇은 role·nonReentrant 래퍼가 상환과 스테이블코인 관리 로직을 라이브러리로 위임한다. 함수는 호출자별로 묶었다.
투자자 함수(KYC 게이팅, 유효한 SBT가 있으면 누구나 호출 가능)
| 함수 | 게이트 | 설명 |
|---|---|---|
deposit(address stablecoin, uint256 amount) | nonReentrant · whenNotPaused · whenNotFrozen · whenActive · duringSubscription · requiresKYC | 외부 리저브 구현에서는 입금의 reserveBps 몫을 reserveWallet으로 보내고 나머지를 fundWallet으로 보낸다. 풀 내부 리저브 잔고를 적립하지 않는다. LP is minted to the investor. |
requestRedemption(uint256 lpTokenAmount, address preferredStablecoin) | nonReentrant · requiresRedeemableKyc | LP를 잠그고 상환을 연다. 즉시 풀은 지금 NAV를 스냅샷하고 패널티를 계산하며, 회차 풀은 currentEpochId에 등록한다(QUEUED, 정산으로 미룬다). → RedemptionLib. |
claimRedemption(uint256 requestId) | nonReentrant | 회차 풀 전용이다. 정산된 몫을 가져간다(G/H 인덱스로 O(1)). 체결된 LP를 소각하고 내림한 USDC를 원래 요청자에게 지급한다. 잔량은 이월된다. |
claimRedemptionFallback(uint256 requestId) | — | 🔴 제거됐다. available이 reserveBalance + redemptionFundedAmount였는데, reserve가 reserve_wallet으로 라우팅되면서 첫 항이 구조적으로 0이 된다. 그래서 이 함수가 대상으로 삼던 누구도 풀어 줄 수 없었다. |
cancelRedemption(uint256 requestId) | nonReentrant | 호출자가 대기 중인 요청을 취소하고 LP를 푼다(회차는 epochTotalDemandLp도 차감하고, 회차 분기에는 같은 요청 창 게이트가 걸린다). ⚠️ 2026-08-04 배포 이전에 만들어진 풀에서는 ep.lpRemaining 전액을 반환한다. 그 배포부터는 ClaimBeforeCancel 게이트가 적용된다. 아래 참조. |
claimYield(address stablecoin, uint256 amount) | nonReentrant · requiresRedeemableKyc | 누적 수익 중 amount를 선택한 스테이블코인으로 지급한다. amount == 0이면 전액이고 초과 요청은 잔액으로 클램프된다(revert하지 않는다). 잔돈은 발생분으로 남는다. → YieldLib. |
reinvest(address stablecoin, uint256 yieldAmount) | nonReentrant · whenNotPaused · whenNotFrozen · whenActive · requiresKYC | 발생 수익을 새 LP로 바꾼다(reserve 분리가 적용되고 비reserve 몫은 stablecoin으로 fund_wallet에 릴리스된다). 부분 금액이 허용되고 초과 요청만 revert된다(InsufficientYield). minReinvestAmount 미만이면 BelowMinReinvest로 revert된다. ⚠️ 이 2인자 시그니처가 나오기 전에 배포된 풀은 레거시 1인자 reinvest(uint256)를 노출하므로, 호출자가 통화 파라미터가 있다고 가정하면 안 된다. → YieldLib. |
pendingYield(address investor) view | — | 투자자가 벌었고 아직 가져가지 않은 전부다. 펀딩 여부와 무관하게 claimableYield + unfundedYield이고, 스토리지에서 읽는 게 아니라 각각 현재 시각까지 시뮬레이션한다. ⚠️ 열려 있는 상환 요청에 붙은 수익은 포함하지 않는다. 그건 previewEscrowAccrual(requestId)이고 에스크로가 끝날 때 요청 단위로 크레딧된다(v3-131 (3)). 이 값만 보여 주는 UI는 상환 진행 중인 사람에게 과소 표시한다. |
🟢 회차 관련 변경 둘이 2026-08-04에 반영됐지만, 그 이후에 만들어진 풀만 갖고 있다 (v3-105)
poolImplementation이 팩토리에서 immutable이라, 새 구현체는 새 팩토리를 뜻하고 기존 클론은 절대 업그레이드되지 않는다. 배포 전에 만들어진 풀은 여전히 옛 코드를 돌리는데, 거기서는 settledUnclaimedLp()가 revert하고 아래 두 동작이 없다.
배포 내용. 신규 팩토리 0xE1E2E974…DA90와 신규 풀 구현체 0x27D9948F…f968(21,186바이트)이고 커밋 0abe168에서 나왔다. 그 구현체에서 selector로 확인했다. previewEpochClaim(9ccd0389), setEpochSchedule / setEpochFundingDate / setEpochSettleAfter, 그리고 settledUnclaimedLp(915d0aa1)다. 마지막 것이 이 배포를 0abe168에 고정하는데, 그 커밋은 R2·R3와 R10도 함께 담고 있다. (2026-08-06의 다음 라운드가 이 팩토리를 대체한다. apps/contract/sepolia.md 참조.)
1. cancelRedemption에 claim 우선 게이트가 생겼다. RedemptionLib.sol:626이다. 회차 분기가 _epochFillMath를 계산하고 filledLp > 0인 동안 **ClaimBeforeCancel(requestId)**로 revert한다. 옛 풀에서는 여전히 에스크로된 포지션 전체를 반환하고, 체결된 몫의 현금은 청구권자 없이 redemptionCommitted에 남는다. 투자자 순서는 claimRedemption(창 게이트 없음) 다음 cancelRedemption(창 안에서)이다.
2. 새 뷰 previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD). PlatformPool.sol:317 → RedemptionLib.sol:703이고 _epochFillMath의 view 래퍼다. 체결과 미체결 분해가 공개 상태에서 유도되지 않기 때문에 필요하다. epochRequests는 (epochId, principalLp, lpRemaining, yieldAccSnapshot)만 반환하고 epochH / epochCarryGen / generationCloseH에는 getter가 없다(epochG만 있다). 그래서 회차 상환 UI는 배포나 뷰 작성이 아니라 현재 팩토리의 풀을 확보하는 것에 막혀 있다.
📡 ABI 공백: EpochFundingDateSet은 발생하는데 PlatformPool.abi.json에 없다
EpochScheduleSet, EpochFundingDateSet, EpochSettleAfterSet은 GovernanceLib에 선언돼 있고 PlatformPool에 다시 선언되지 않았다(라이브러리 이벤트에 대한 이 코드베이스의 관례다. 다시 선언된 EpochSettled와 비교해 보라). 외부 라이브러리 호출은 DELEGATECALL이므로 로그는 풀 주소 아래에 나타난다. 다만 배포된 ABI로는 디코딩할 수 없을 뿐이다. 오프체인 디코더에 이벤트 조각을 추가하면 컨트랙트 변경 없이 이미 배포된 풀에 대해서도 소급해서 복구된다. 그리고 EpochFundingDateSet이 특히 중요한 이유는, 온체인에서 "운영자가 이 주기의 펀딩일을 확정했다"를 정확히 알려 주는 유일한 신호이기 때문이다(v3-105. 정산의 열린 실패 실체화는 같은 스토리지 슬롯을 조용히 쓴다).
파트너 함수 (YIELD_DEPOSITOR_ROLE = fund_wallet)
| 함수 | 게이트 | 설명 |
|---|---|---|
depositYield(address stablecoin, uint256 grossAmount) | nonReentrant · whenNotFrozen | 총액 수익을 입금한다. 서비스 키가 net을 분배하고 수수료를 가져갈 때까지 보유된다. |
fundRedemption(uint256 requestId, address stablecoin, uint256 amount) | nonReentrant | PENDING_RESERVE인 즉시 요청을 보충한다(회차 top-up도 가능). 충당되면 지급이 자동 실행된다. → RedemptionLib. |
서비스 함수 (ORACLE_ROLE = Aset 자동화 키. 제한적이고 비수탁이다)
이 중 어느 것도 임의의 목적지로 자금을 옮길 수 없다.
| 함수 | 게이트 | 설명 |
|---|---|---|
updateNAV(uint256 newNav, uint256 reserveConsumed) | onlyRole(ORACLE_ROLE) | NAV를 설정한다. 상승은 즉시 적용되고 하락은 24시간 대기한다. _checkNavBound(서킷 브레이커 + 편차 상한, v3-32)를 통과해야 한다. 🔴 reserveConsumed는 0이어야 한다(R8). reserve는 손실 흡수 계층이 아니다. claim NAV가 가격 매기는 청구권 안에 이미 들어 있는 투자자 돈이라, 손실에 대해 차감하면 이중 계상이 된다. v3-16이 그 차감을 NAV 쓰기와 atomic하게 만들었고 ABI는 그대로다. R8이 바꾼 것은 값이다. 백엔드가 모든 호출에서 0을 보낸다(propose와 approve·override 경로 모두 NO_RESERVE_CONSUMED를 쓰고 반영됐다). 인자가 옛 모델을 조용히 되살리지 못하도록 다음 배포에 reserveConsumed != 0 → revert 가드를 넣는 것이 여전히 권장된다. 그전까지 이것은 온체인 제약이 아니라 호출자 관습이다. |
approveRedemption(uint256 requestId) | nonReentrant · onlyRole(ORACLE_ROLE) | 즉시 풀 전용이다. 승인하고 지급을 자동 실행한다(또는 PENDING_RESERVE로 표시한다). 원래 요청자에게만 지급한다. → RedemptionLib. |
rejectRedemption(uint256 requestId, string reason) | nonReentrant · onlyRole(ORACLE_ROLE) | 거부하고 잠긴 LP를 돌려주며 수익 기반 패널티를 되돌린다. → RedemptionLib. |
executeEpoch() | nonReentrant | 회차 풀 전용이다. 현재 회차를 정산한다(O(1), 루프 없음). 이월 우선 체결 비율을 계산하고 G/H 래더를 갱신하고 미체결 잔량을 이월하고 회차를 전진시킨다. 돈이 실제로 움직인다(v3-100, 반영됨). _reserveFilledGross가 체결 총액을 epochFundTopUp[id] → reserveBalance 순으로 차감해 단일 redemptionCommitted 스칼라에 넣는다. v3-91이 원래 명시한 회차별 항아리가 아니다. 그건 O(1) 청구를 깨서 철회됐다. 게이트는 cutoff가 아니라 settlementAllowedAt(그 주기의 펀딩일이고 + recallLeadDays까지만 미룰 수 있다)이다. ORACLE_ROLE이거나 그 시각 이후 누구나 호출할 수 있다. → RedemptionLib. |
holdRequest(uint256) / releaseRequest(uint256) | onlyRole(ORACLE_ROLE) | 이상치 보류다. 요청을 자동 정산에서 빼거나 다시 넣는다(컴플라이언스, 대형 상환). |
setEpochFundingDate(uint256 epochId, uint256 fundingDate) | onlyRole(ORACLE_ROLE) | 한 주기의 펀딩일을 확정하거나 조정한다. 모든 경계가 이 값에서 유도된다. 이웃 사이에 들어가지 않는 날짜는 거부한다(InvalidSchedule. 같은 날짜도 포함이다. 한 날에 두 주기가 있으면 요청 창을 공유하게 된다. 앞 이웃은 유도값으로, 뒤 이웃은 스토리지 값으로 읽는다). 그 주기의 요청 창이 열리기 전까지만 자유롭고, 그 뒤에는 WindowAlreadyOpen(opensAt)으로 revert한다(그때의 조정은 투자자가 이미 보고 있는 기한을 다시 쓰거나, 닫힌 창을 다시 열어 발행자에게 이미 보낸 총액을 바꾸게 된다). EpochFundingDateSet을 발생시키는데, 온체인에서 유일하게 정확한 확정 신호다. ✅ 호출자가 있다. pools.post.epoch-schedule.ts다(v3-107이 이를 닫았다). ⚠️ 전 구간 동작은 여전히 컨트랙트 배포를 기다린다. 이 함수는 배포된 구현체에 없으므로 기존 풀에 호출하면 revert된다. |
setEpochSettleAfter(uint256 epochId, uint256 settleAfter) | onlyRole(ORACLE_ROLE) | "발행자가 늦고 있다"용 노브다. 한 주기의 정산을 미룬다. 미루는 것만 가능하고(settleAfter < fundingDate이면 InvalidSchedule) fundingDate + recallLeadDays로 강하게 제한된다(SettleAfterTooLate(cap)). 제한 없는 지연은 더 이상 취소할 수 없는 수요를 묶어 두고(C10) 기다리는 동안 아무것도 벌지 못하게 한다(C3). EpochSettleAfterSet을 발생시킨다. ✅ 호출자가 있다. pools.post.epoch-schedule.ts다(v3-107이 이를 닫았다). ⚠️ 전 구간 동작은 위와 마찬가지로 컨트랙트 배포를 기다린다. |
settleYield(address stablecoin, uint256 treasuryAmount, uint256 poolMgmtAmount) | nonReentrant · onlyRole(ORACLE_ROLE) | 한 기간을 한 트랜잭션에 정산한다. unclaimedYield − 수수료를 발생 채무에 적용하고 수수료 두 항목을 거버넌스로 잠긴 트레저리와 풀별 수수료 지갑에 지급한다(raw 단위이고 수령자는 파라미터가 아니다). 원금이나 reserve에 닿을 수 없다. distributeYield + withdrawFees를 대체한다(v3-102). ⚠️ netAmount는 v3-131 (3)에서 제거됐다. 홀더가 받을 몫은 시간이 결정하고, 그 인자의 유일한 가드가 상한선이었는데 스케일이 잘못된 값은 언제나 그것을 통과했다. 채무가 흡수하지 못하는 현금은 선납분으로 unclaimedYield에 남는다. |
Pauser 함수 (PAUSER_ROLE = 빠른 Safe. 멈추는 것만 가능하고 자금 이동이 없다)
| 함수 | 게이트 | 설명 |
|---|---|---|
pause() / unpause() | onlyRole(PAUSER_ROLE) | soft pause다. 입금, 수익 입금, 상환 요청을 막고 기존 상환은 계속된다. 즉시 적용된다. |
emergencyFreeze() / unfreeze() | onlyRole(PAUSER_ROLE) | hard freeze다. freezeStartedAt을 기록한다. 비대칭이다. 자본 유입은 동결 내내 막히고, 가치 유출은 72시간 뒤 자동으로 풀리며, 동결 전체가 7일에 자동 만료된다. |
tripCircuitBreaker() | onlyRole(PAUSER_ROLE) | 브레이커를 걸어 updateNAV와 executeEpoch를 막는다(v3-32). |
어드민·거버넌스 함수 (DEFAULT_ADMIN_ROLE = 거버넌스 멀티시그)
생성 시점 설정(LP totalSupply > 0이 되면 불변이고 ConfigImmutableAfterDeposit / EpochImmutableAfterDeposit 등으로 revert한다): setLockupDays, addStablecoin, setMaturityDate, setSubscriptionPeriod, setEpochDurationDays(MAX_EPOCH_DURATION_DAYS = 90 이하), setEpochSchedule, setEnforceJurisdiction, setJurisdictionAllowed, setRedemptionGating, setNavDeviationCap, setNavStaleness다. removeStablecoin과 setHardCap(올리는 것만)은 입금 이후에도 호출 가능하다. resetCircuitBreaker는 운영 토글이다. (v3-26의 hold-back 레버였던 setFundingRestricted가 0183에서 제거되기 전까지 이 옆에 있었다.)
🟢 setEpochSchedule(fundingAnchor, recallLeadDays, requestWindowDays): 배선됐고, 새 풀은 앵커되며 레거시 풀은 영영 앵커될 수 없다
Model B 스케줄을 설치하는 setter다. 예전에는 어디에도 호출자가 없었다. lib/shared/contract/create-pool.ts에도, 어떤 lambda에도, 어드민 앱에도 없었다. 그래서 배포된 모든 풀이 fundingAnchor == 0이었고, PoolCommonLib.hasAnchoredSchedule이 이를 "앵커된 스케줄 없음"으로 읽어 창 게이트를 건너뛰고 재설계 이전의 지연 시계를 돌렸다. 6c0a08f에서 배선됐다(v3-107 · v3-116). 세 스케줄 항목이 생성 시 필수가 됐고, create-pool.ts를 통해 전송되며, 배포 후 체인에서 되읽어 확인하고, 컨트랙트가 Model A로 흘러가는 대신 ScheduleNotConfigured로 revert한다. 2026-08-04 배포(커밋 0abe168, 신규 팩토리)이고 신규 풀만 해당된다. 그 배포 이전에 만들어진 풀은 옛 구현체의 클론이라 레거시 지연 경로에 머문다. 그래서 v3-100의 "Model B는 플랫폼 전체가 아니라 신규 풀의 속성이다"가 이제 문자 그대로 시스템의 상태다.
두 가지가 이를 가혹하게 만든다.
- 순서가 중요하다. 이 setter가
recallLeadDays + requestWindowDays < epochDurationDays를 검증하므로 배포 시퀀스에서setEpochDurationDays다음에 실행돼야 한다. 아니면InvalidSchedule로 revert한다. - 생성 시 전용이다. LP가 생기면
ConfigImmutableAfterDeposit로 revert하므로, 앵커 없이 첫 입금을 받은 풀은 영영 앵커될 수 없다. 레거시 풀 7개를 레거시 경로에 붙잡아 두는 것과 같은 영속성이다.
DB에는 스케줄 항목이 있는데 체인이 fundingAnchor == 0을 읽는 풀은 설정이 아니라 배포 결함이다(v3-107).
Lifecycle과 긴급 조치:
| 함수 | 타임락 | 설명 |
|---|---|---|
setLifecycleStatus(LifecycleStatus) | — | DRAFT/UPCOMING/ACTIVE/IMPAIRED/MATURED/CLOSED 전이다. |
proposeImpairment / executeImpairment / cancelImpairment | 7일 | 파트너 부실로 IMPAIRED로 간다(입금 중단, 상환 유지). |
proposeWindDown / cancelWindDown | 30일 | wind-down 제안과 취소다. |
executeWindDown()(무권한) | 30일 이후 | 현재 소스의 executeWindDown()은 navPerToken을 재계산하지 않는다. 오라클 NAV를 유지하며 실제 지급에는 별도 자금 투입과 상환 절차가 필요하다. |
타임락 설정 변경
GovernanceLib에 3종 세트(propose* / execute* / cancel*, GOVERNANCE_TIMELOCK = 7일)가 다섯 있다.
| 세트 | 비고 |
|---|---|
FundWalletChange | YIELD_DEPOSITOR_ROLE도 함께 옮긴다 |
ReserveBpsChange | 온체인 이름은 propose/execute/cancelReserveBpsChange다 |
JurisdictionChange | 국가별 허용·차단 |
EnforceJurisdictionChange | 화이트리스트를 강제할지 자체를 뒤집는다 |
RedemptionGatingChange | 회차별 체결 상한 |
⚠️ 예전 항목 셋은 타임락 3종 세트가 아니므로 그렇게 적으면 안 된다. KycLevelChange는 풀 단위 KYC 게이트와 함께 제거됐다(마이그레이션 0063. pool_governance_changes에서도 enum 값이 사라졌다). TreasuryChange는 즉시 어드민 setter인 setTreasuryWallet이다(v3-69. 수수료가 보류 없이 자동 분배되므로 타임락은 낡은 주소에 묶어 두기만 한다). FreezeExtend는 제거됐다. 이유는 08의 freeze-extend 행에 있다(7일 타임락이 7일 동결 수명과 같았다). pendingFreezeExtend* 스토리지만 남고 unfreeze 시 정리된다.
NAV 대기분: applyPendingNav()(24시간 후 누구나) · cancelPendingNav()(어드민, 대기 기간 중)다.
LP 콜백과 뷰
onLpTransfer(from, to, value)는 LP 토큰이 잔액이 바뀔 때마다 호출해 추적 잔액과 수익을 동기화한다. 그 외에 view getter가 약 40개 있다(navPerToken, reserveBalance, totalDeposited, lifecycleStatus, isEmergencyFrozen, getPosition, getInvestorRedemptionRequests, getAcceptedStablecoins, 회차 상태인 currentEpochId/epochG/epochSettleNav 등, 서킷 브레이커 관련 circuitBreakerTripped/lastNavUpdateAt 등).
2.2 PlatformLPToken (ERC-20)
풀당 클론 하나다. 소수 18자리다. mint와 burn은 MINTER_ROLE로 게이팅되고(풀만 보유한다), DEFAULT_ADMIN_ROLE이 MINTER_ROLE의 role-admin이다.
| 함수 | 게이트 | 설명 |
|---|---|---|
initialize(string name_, string symbol_, address admin) | initializer | 클론 전용 초기화다. admin(일시적으로 팩토리)에게 DEFAULT_ADMIN_ROLE과 MINTER_ROLE을 부여한다. |
mint(address to, uint256 amount) | onlyRole(MINTER_ROLE) | LP를 민팅한다(pause 게이팅이 없다. 입금 pause는 풀이 관장한다). |
burn(address from, uint256 amount) | onlyRole(MINTER_ROLE) | LP를 소각한다. |
setPool(address _pool) | onlyRole(DEFAULT_ADMIN_ROLE) | 풀을 가리키게 하고 MINTER_ROLE을 부여한다(옛 풀 것은 회수한다). |
pause() / unpause() | onlyRole(DEFAULT_ADMIN_ROLE) | 세컨더리(투자자 간) 전송만 멈춘다. 그 외에는 허가가 필요 없고 화이트리스트도 없다(v3-58). |
decimals / name / symbol view | — | 클론 안전 오버라이드다(name과 symbol을 ERC-20 생성자가 아니라 인스턴스별로 저장한다). |
_update 전송 훅이 세 가지를 강제한다. (1) 풀 자신이 아닌 누군가가 풀로 직접 전송하는 것을 막는다(DirectPoolTransferDisallowed. 상환 플로우 밖에서 LP가 묶이는 것을 방지한다). (2) 세컨더리 전송(양쪽이 0 주소가 아니고 어느 쪽도 풀이 아닐 때)에는 pause가 아닐 것을 요구한다. 그 외에는 허가가 필요 없고 화이트리스트도 없다(v3-58). (3) 모든 민팅·소각·전송에서 pool.onLpTransfer(from, to, value)를 호출해 수익이 실제 보유자를 따라가게 한다.
2.3 PlatformKYCSoulbound (Soulbound ERC-721, UUPS)
전역 컨트랙트 하나이고 풀은 프록시 주소로 이를 참조한다. 유일하게 업그레이드 가능한 컨트랙트다.
| 분류 | 함수 | 게이트 | 설명 |
|---|---|---|---|
| Lifecycle | mint(address to, KYCLevel level, uint256 expiresAt, string countryCode, bool isUSPerson) | onlyRole(DEFAULT_ADMIN_ROLE) · whenNotPaused | 주소당 SBT 하나를 민팅한다(만료일이 미래여야 한다). |
revoke(uint256 tokenId, string reason) | onlyRole(DEFAULT_ADMIN_ROLE) | 취소 표시를 한다(제재, 사기). 만료와는 독립이다. | |
renew(uint256 tokenId, uint256 newExpiresAt) | onlyRole(DEFAULT_ADMIN_ROLE) | 만료를 연장하고 취소 표시를 지운다. | |
burn(uint256 tokenId) | onlyRole(DEFAULT_ADMIN_ROLE) | 토큰을 없애고 매핑을 정리한다. | |
검증 (view) | kycStateOf → NONE/VALID/EXPIRED/REVOKED | — | 해석된 상태다(취소가 만료보다 우선한다). |
isValidKYC / canRedeem / isRevokedKYC | — | 진입 게이트(VALID) · 출구 게이트(VALID와 EXPIRED. REVOKED는 아니다) · 취소 확인이다. | |
isValidKYCNonUS / isInstitution | — | 비미국 확인 · 법인(KYB) 레벨이다. | |
jurisdictionHashOf → bytes32 | — | ISO 국가 코드의 keccak256이다(유효하지 않으면 0). 풀이 자기 화이트리스트와 비교한다. | |
getKYCData / tokenIdOf / totalSupply | — | 전체 튜플 · 토큰 id(없으면 0) · 민팅 수다. | |
| 업그레이드 (UUPS) | proposeUpgrade(address newImpl) | onlyRole(DEFAULT_ADMIN_ROLE) | 대기 중인 구현체를 기록한다. UPGRADE_TIMELOCK이 0이라(예전 7일, v3-71) executeUpgrade가 같은 블록에 이어질 수 있다. |
executeUpgrade() | onlyRole(DEFAULT_ADMIN_ROLE) | 대기 후 실행한다. 일시적 승인 플래그를 설정한다. | |
cancelUpgrade() | onlyRole(DEFAULT_ADMIN_ROLE) | 대기 중인 제안을 취소한다. | |
| 어드민 | pause / unpause / setBaseURI | onlyRole(DEFAULT_ADMIN_ROLE) | 민팅을 멈추고 메타데이터 base URI를 설정한다. |
토큰별 KYCData: level(NONE/INDIVIDUAL/INSTITUTION), issuedAt, expiresAt, countryCode(ISO-3166-1 alpha-3, 예: "KOR". v3-60), isUSPerson, isRevoked다.
2.4 PlatformPoolFactory
| 함수 | 게이트 | 설명 |
|---|---|---|
createPool(CreatePoolParams params) | onlyRole(DEFAULT_ADMIN_ROLE) | LP와 Pool을 클론하고 role을 배선하고 등록한다. poolId를 반환한다. |
getPoolContracts(uint256 poolId) view | — | 풀 id에 대한 {pool, lpToken}을 반환한다. |
createPool의 순서(atomic, 단일 트랜잭션): LP를 클론하고(팩토리가 일시적 admin) → 호출자를 admin으로 해서 Pool을 클론하고(인계 창이나 프런트런 창이 없다) → lp.setPool(pool)로 풀에 MINTER_ROLE을 부여하고 → LP의 DEFAULT_ADMIN_ROLE을 호출자에게 부여하고 → 팩토리가 자기 LP MINTER_ROLE과 DEFAULT_ADMIN_ROLE을 포기한다. 결과적으로 풀이 유일한 민터이고 호출자가 admin이며 팩토리에는 아무것도 남지 않는다. 상태는 poolCounter, poolRegistry, isRegisteredPool / isRegisteredLpToken(오프체인 소비자는 등록된 주소만 신뢰해야 한다), 그리고 불변 kycContract / poolImplementation / lpImplementation이다.
2.5 구현 라이브러리
| 라이브러리 | 모델 | 주요 내용 |
|---|---|---|
| RedemptionLib | external (delegatecall) | 즉시: requestRedemption, approveRedemption, fundRedemption, claimRedemptionFallback, rejectRedemption, cancelRedemption. 회차: executeEpoch(정산), claimRedemption(pull). 내부: _finalizeInstantRequest(NAV 스냅샷 + 패널티 4종), _epochFillMath / _epochFillRatio(O(1) G/H 계산), _settleEpochYieldAndPenalty, _payEpochClaim(내림 USDC), _drawDown(reserve → 보유 버킷). |
| GovernanceLib | external (delegatecall) | fund-wallet, reserve bps, jurisdiction, enforce-jurisdiction, redemption-gating에 대한 타임락 변경 3종 세트(propose/execute/cancel)다(다섯이고 KYC 레벨, treasury, freeze-extend는 아니다. 3종 세트 표 참조). 직접 어드민 setter(락업, 모집 기간, 만기, hard cap, 회차 기간, funding-restricted, treasury와 fund-fee 지갑, NAV 편차 상한·staleness, 서킷 브레이커)도 있다. executeFundWalletChange가 (old,new)를 반환해 풀 래퍼가 _grantRole/_revokeRole을 수행한다(OZ의 private _roles는 delegatecall 라이브러리에서 접근할 수 없다). 내부: _requireExecutableChange / _requirePendingChange. |
| YieldLib | external (delegatecall) | depositYield, settleYield(발생 채무와 수수료 두 항목을 atomic하게 처리), claimYield(부분·클램프, 펀딩된 것만), reinvest(펀딩된 수익 → LP), pendingYield(view). |
| FixedYieldEngine | internal(인라인) | FIXED 드라이버의 스토리지 쪽이다. accrue(인덱스와 채무를 굴린다), fund(파트너 현금을 잔존 비율 래더에 적용한다), settleHolder, preview다. 내부인 것은 의도적이다. onLpTransfer가 모든 LP 민팅·소각·전송에서 발동하는데 이동마다 delegatecall을 하면 핫패스에 세금이 붙고, YieldLib도 자기 claim·reinvest 진입점에서 같은 두 함수가 필요하다. ✅ 그래서 풀이 들고 있던 손으로 복사한 _settle 쌍둥이가 없어졌다. 인라인 라이브러리 하나면 가스는 같고 소스는 하나다. |
| YieldAccrualPolicy | internal, pure | 스토리지 없는 래더 산술이다. accrualDelta, foldAccrual, survive, settle이다. 자체적으로 퍼즈 테스트한다(test/YieldAccrualPolicy.t.sol). 여기서 돈 계산이 틀리면 아무것도 revert되지 않고 조용히 잘못된 가격이 매겨지기 때문이다. |
| NavLib | external (delegatecall) | updateNAV(즉시 또는 편차 상한·타임락), applyPendingNav, cancelPendingNav다. 내부: _checkNavBound($1 클램프 + 편차 상한 bps + reserve 소진 규칙). |
| PoolConfigLib | external · pure | validate(...)로 init 시점을 검사한다. 0 주소가 아닐 것, fundWallet != treasuryWallet(v3-26, 비수탁), reserveBps ≤ 10000, penaltyRateBps ≤ 10000, min ≤ max ≤ capacity, 유동성 창 배열의 길이와 순서다. |
| StablecoinAdminLib | external | add(...)(소수 6~18자리 검증, 캐시, 인덱싱) · remove(...)(LP가 있는데 마지막 코인을 지우거나 풀이 아직 보유 중인 코인을 지우는 것을 거부한다, L-6). swap-and-pop을 쓴다. |
| PoolCommonLib | internal(인라인) | frozenActive / checkExitNotBlocked(72시간 이탈 게이트, v3-28) · checkRedeemableKyc · isLockupActive / isBeforeMaturity · normalizeAmount / denormalizeAmount(1e18로 스케일링). |
회차 정산(G/H 전역 인덱스, O(1)): G[id] = G[id-1] × (1 − fillRatio[id])(미체결 잔여 비율)이고, H[id] = H[id-1] + G[id-1] × fillRatio[id] × settleNav / (1e18 × NAV_PRECISION)(누적 USD/원금)이다. 청구는 filled = principal × (G[v-1] − G[latest]) / G[v-1]과 payout = principal × (H[latest] − H[v-1]) / G[v-1]을 읽는다. 요청 순서와 무관하므로 회차 수가 몇이든 청구 시간이 상수다. 지급액은 raw 스테이블코인 단위로 내림하고 잔돈은 보유 버킷에 남는다.
3. 보안 메커니즘과 기능
3.1 접근 통제(OpenZeppelin AccessControl)
| Role | 보유자(메인넷 계획) | 권한 | 제약 |
|---|---|---|---|
DEFAULT_ADMIN_ROLE | 거버넌스 멀티시그(Safe) | 거버넌스·lifecycle·NAV 한계·설정. 아래 모든 role의 role-admin이다 | 자금에 닿는 변경 대부분에 타임락이 걸린다 |
ORACLE_ROLE | Aset 서비스 키(Lambda) | NAV, 상환 승인·거부, 수익 분배, 수수료 인출, 회차 정산 | 임의 주소로 자금을 보낼 수 없다. 지급 목적지는 잠긴 요청자이고 수수료는 잠긴 트레저리로 간다 |
PAUSER_ROLE | 빠른 Safe | pause / freeze / 브레이커 작동 | 멈추는 것만 가능하고 자금을 옮기지 않는다 |
YIELD_DEPOSITOR_ROLE | 파트너 fund_wallet | depositYield, fundRedemption | 자금을 넣거나 부족분을 메우는 것만 가능하다 |
initialize가 _setRoleAdmin(ORACLE/PAUSER/YIELD_DEPOSITOR, DEFAULT_ADMIN_ROLE)을 배선하고 _admin에게 DEFAULT_ADMIN_ROLE과 PAUSER_ROLE을, _fundWallet에게 YIELD_DEPOSITOR_ROLE을 부여한다. ORACLE_ROLE은 배포 후에 부여한다. DEFAULT_ADMIN_ROLE이 모든 role을 관리하고 자기 자신의 admin이기도 하므로, 이를 멀티시그로 바꾸는 데 컨트랙트 변경이 필요 없다. role을 Safe에 부여하고 부트스트랩 EOA가 포기하면 된다. → 스마트 컨트랙트 구조 → Role 참조.
3.2 KYC 모디파이어(비대칭이다. 가치 유입은 막고 가치 유출은 절대 가두지 않는다)
requiresKYC(진입: 입금·재투자): 유효한 SBT가 있고,!allowsUSPersons면 비미국이어야 하고,requiresInstitutional이면 법인이어야 하고,enforceJurisdiction이면 관할이 화이트리스트에 있어야 한다.KYCRequired/USPersonsNotAllowed/InstitutionalOnly/JurisdictionNotAllowed로 revert한다.requiresRedeemableKyc(출구: 상환·청구):canRedeem만 본다(REVOKED나NONE이 아니면 되고EXPIRED는 통과한다). 관할과 법인 여부를 의도적으로 건너뛴다. 낡은 지역·레벨 규칙 때문에 이탈이 막히지 않게 하려는 것이다.- 지급 시점의 두 번째 출구 검사(v3-99): 위 모디파이어는 요청이 생성될 때의 적격성만 증명한다. 즉시 정산 경로 셋(
approveRedemption,fundRedemption안의 파트너 펀딩 자동 정산,claimRedemptionFallback)이 모여드는 단일 진입점_executeRedemptionPayout이canRedeem을 다시 확인하고RedemptionBlockedByKyc로 revert한다. 그래서 요청이PENDING_RESERVE에서 대기하는 동안 취소된 홀더에게 지급될 수 없다. 회차 풀은claimRedemption에서 같은 검사를 받는다. 호출자별이 아니라 진입점에서 게이팅한 것은 의도적이다. 이 구멍이 생긴 이유가 검사 없이 호출자가 추가된 것이었기 때문이다.
3.3 Lifecycle과 상태 게이트
whenActive(lifecycle이 ACTIVE) · whenNotPaused(soft pause, 입금 쪽) · whenNotFrozen(hard freeze, 자본 유입) · duringSubscription(입금 창)이다. 출구 경로는 대신 checkExitNotBlocked를 돌리고, 72시간 동결 이탈 창 안에서만 막힌다.
3.4 타임락(코드 상수)
여기 있는 것은 코드 상수 이름과 각각이 무엇을 게이팅하는지다. 변경 → 기간 → 근거의 정본 표(다른 문서가 참조하는 단일 기준)는 스마트 컨트랙트 구조 → 타임락 설정에 있고, 아래 기간은 그것을 옮겨 적은 것이다.
| 상수 | 기간 | 게이팅 대상 |
|---|---|---|
NAV_TIMELOCK | 24시간 | NAV 하락(상승은 즉시) |
GOVERNANCE_TIMELOCK | 7일 | fund_wallet, reserve bps, 관할(+enforce), 상환 게이팅(⚠️ 폐기됐고 MVP에 포함되지 않는다, 2026-08-27, v3-150. 타임락 3종 세트는 여전히 온체인에 있다), impairment다. treasury는 아니고(즉시 setter, v3-69), KYC 레벨도 아니고(제거됨, 0063), freeze-extend도 아니다(제거됨) |
WIND_DOWN_TIMELOCK | 30일 | wind-down 실행 |
UPGRADE_TIMELOCK(KYC) | 0 ⚠️ | UUPS 구현체 업그레이드다. 예전에는 7일이었고 2026-08-14에 v3-71이 없앴다. |
FREEZE_EXIT_WINDOW | 72시간 | 동결이 이탈을 막을 수 있는 기간 |
FREEZE_MAX_DURATION | 7일 | 동결 자동 만료 |
FALLBACK_NOTICE_DAYS | 7일 | 즉시 풀의 무권한 폴백 이탈 |
MAX_EPOCH_DURATION_DAYS | 90 | 회차 길이 상한 |
3.5 비수탁 보장 (v3-28 / v3-31 / v3-32)
- 지급 목적지 고정.
requestRedemption이 LP를 잠그고 요청자를 기록한다.approveRedemption,claimRedemption, 폴백이 언제나 그 주소에 지급하고 목적지 파라미터가 없으므로 어떤 role도 지급을 돌릴 수 없다. - 운영자 부재 시 이탈.
claimRedemptionFallback(즉시)과 무권한executeEpoch+ 대리 청구(회차)가 Aset 키가 침묵해도 투자자가 나갈 수 있게 보장한다. - 기간이 정해진 동결. 상환을 막는 동결은 72시간에 자동으로 완화되고 7일에 완전히 만료되며, 온체인에서 연장할 방법이 없다.
FreezeExtend경로는 7일 타임락이 7일 동결 수명과 같아서 제거됐다. 대신pause나impairment로 올린다. - NAV 발생 지점의 한계.
_checkNavBound가 서킷 브레이커가 걸렸거나|newNav − navPerToken|이navDeviationCapBps를 넘으면updateNAV와executeEpoch를 revert시킨다. NAV staleness 창이 회차 정산을 지킨다. 탈취된 hot key의 피해 범위를 제한한다.
3.6 자금 무결성 가드
- 재진입 방지. 자금을 움직이는 모든 경로에
nonReentrant가 있다(입금, 재투자, depositYield, claimYield, 모든 상환 요청·승인·펀딩·청구·취소, executeEpoch, settleYield). - reserve는 상환 유동성이다. 입금 시
reserveBps를 남기고 상환 지급과 wind-down 배분에 쓴다. 손실 계층이 아니다(R8). v3-16의 온체인 한계는 그대로다(reserveConsumed ≤ reserveBalance이고 상승 시에는 0이어야 한다). 다만 백엔드가 무조건 0을 보내므로 어떤 상각도 reserve를 차감하지 않는다. - reserve 분리는 파트너 쪽으로 반올림되고 raw를 먼저 계산한다.
deposit과YieldLib.reinvest둘 다 raw 스테이블코인 금액에 대해reserveAmountRaw = (amount × reserveBps) / 10000을 계산한 뒤 파트너에게amount − reserveAmountRaw를 준다. 정수 나눗셈이 내림하므로 reserve가 내려가고, 남는 값(raw 1단위 이하, 소수 6자리 USDC 기준$0.000001)이 파트너에게 간다. raw를 먼저 계산하는 것은 의도적이다. 그래야reserveBalance가 컨트랙트가 물리적으로 보유한 reserve와 같아진다. 정규화된 값을 나눈 뒤 전송을 위해 역정규화하면 카운터와 잔액 사이에 입금마다 단위 미만 오차가 남는다.reserveBalance가 18자리 누적값이라 각 증분이10^(18−decimals)의 정수배이므로, 카운터 자체에는 raw 단위 미만 잔여가 쌓이지 않는다. 이는 LP 민팅의 내림(normalizedAmount × NAV_PRECISION / effectiveNav, 최대 LP 1 wei)과는 다른 내림이다. 하나는 스테이블코인의 6번째 소수 자리에서, 다른 하나는 LP 토큰의 18번째 자리에서 일어난다. 축은 08 → 금액 단위 참조. - wind-down 분모. ✅
executeWindDown이distributable / (totalSupply − settledUnclaimedLp)로 가격을 매기고, 상태 변수settledUnclaimedLp가 이를 뒷받침한다(체결분이redemptionCommitted에 예약될 때 증가하고 청구나 소각 시 감소한다). 정산됐지만 미청구인 LP만 해당되고, 대기 중이거나 이월된 LP는 분모에 남는다(R10). 커밋0abe168에 들어갔고 dev에는 2026-08-04 배포됐다. ⚠️ 신규 풀만 해당된다. 기존 클론은 여전히totalSupply로 나눈다. 같은 변경이 분자에서− redemptionCommitted도 제거했는데, 그것이 같은 돈을 두 번 세고 있었다. - LP 무결성.
mint와burn은MINTER_ROLE전용이고(풀이 보유한다),burn은 언제나from = address(this)다(풀이 먼저transferFrom으로 LP를 가져온다)._update훅이 풀로 직접 보내는 전송을 막고 세컨더리 전송을 pause할 수 있다(그 외에는 허가가 필요 없다. v3-58). - 소수 자릿수 안전성. 모든 내부 계산이 1e18로 정규화된다. 스테이블코인은 6~18자리를 보고해야 한다. 지급은 네이티브 단위로 내림하고 잔돈은 남긴다.
- 클론 안전성. 구현체가 생성자에서
_disableInitializers()를 호출한다. 클론은 정확히 한 번 초기화된다(initializer). - 설정 불변성. 공시된 투자자 조건(락업, 만기, 스테이블코인, 회차 기간, 관할, 게이팅)은 LP
totalSupply > 0이 되면 잠긴다. 더 바꾸려면 재배포하거나 타임락 거버넌스 경로를 써야 한다.
3.7 업그레이드 경계
PlatformKYCSoulbound만 업그레이드 가능하다(ERC-1967 UUPS). _authorizeUpgrade는 정확히 그 대기 중인 구현체에 대해 proposeUpgrade → executeUpgrade를 거친 업그레이드가 아니면 revert하므로, 어드민조차 직접 upgradeToAndCall을 할 수 없다. ⚠️ 그 둘 사이의 대기는 이제 UPGRADE_TIMELOCK = 0이다(v3-71). 업그레이드가 어떻게 일어나는지에 대한 게이트는 남고, 효력 발생 전 지연은 사라졌다. money-path(PlatformPool, PlatformLPToken)는 불변 Clones다. 프록시도 없고 업그레이드도 없다. soulbound의 전송 불가성은 _update 오버라이드와 approve / setApprovalForAll의 revert(SoulboundTokenNonTransferable)로 강제된다.
함께 보기: 스마트 컨트랙트 구조(근거와 결정) · 커스터디 & 비수탁 · 상환 · 어드민 / RBAC.