Skip to content

스마트 컨트랙트 구조

📌 현황: 대부분 구현됐고 남은 것은 배포와 QA다 (2026-07~08)

이 페이지의 v3.0 아키텍처는 구현돼 있다. 컨트랙트 넷, 회차 엔진, 거버넌스 3종 세트, 비수탁 안전장치들이다. 아직 움직이는 것은 설계가 아니라 배포와 QA다.

⚠️ 풀은 생성 시 구현체에 고정되는 업그레이드 불가 Clones 프록시라, "반영됐다"와 "특정 풀에 대해 참이다"는 서로 다른 주장이다. 수정은 신규 풀에만 닿는다. 라운드별 주소는 apps/contract/sepolia.md에 있고 가장 최근 두 라운드는 2026-08-04와 2026-08-06이다. v2.x 아키텍처는 폐기됐다.

v3.0의 컨트랙트 관계, role, 자금 흐름, 구조적 세부사항을 다룬다.

📑 코드 수준 레퍼런스

실제 소스(apps/contract/src/)에서 생성한 컨트랙트별 함수 목록, 구조 다이어그램, 온체인 보안 메커니즘은 **컨트랙트 코드 레퍼런스**에 있다. 이 페이지는 근거와 결정을 다루고, 그 페이지는 작성된 코드를 다룬다.

현재 소스와 구형 배포 구분

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

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

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

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

컨트랙트 레지스트리

v3.0은 활성 컨트랙트 4개를 쓴다(v2.x의 6개에서 줄었다). PlatformReceiptNFTPlatformEscrow가 둘 다 제거됐다. Receipt NFT는 없앴고(v3-03, LP가 입금 증빙이다) Escrow는 PlatformPool로 흡수됐다(v3-11, 10/90 분리가 deposit()에 인라인됐다).

PlatformPool은 EIP-170 크기 제한 아래에 머물려고 여러 외부 delegatecall 라이브러리를 링크한다. 이들은 같은 컨트랙트 4개의 구현 세부사항이지 새로운 온체인 서비스가 아니다. 아래 구현 라이브러리(EIP-170) 참조.

PlatformPool: 코어 ACTIVE

주 풀 컨트랙트다. 투자자 자금을 (잠시) 보관하고, LP 토큰을 민팅하고, reserve를 관리하고, 수익 분배를 처리하고, 상환을 처리하고, wind-down을 지원한다.

주요 책임:

  • 투자자 입금을 받는다(USDC/USDT/DAI)
  • 입금 즉시 투자자에게 LP 토큰을 민팅한다
  • 각 입금의 reserve_bps를 남기고 나머지를 fund_wallet으로 보낸다
  • depositYield()로 수익을 받는다(파트너에게 role 제한)
  • settleYield()로 LP 홀더에게 pro-rata 분배하고 수수료 항목을 지급한다
  • NAV 스냅샷과 reserve 확인, PENDING_RESERVE 폴백으로 상환을 처리한다(상환 로직은 RedemptionLib에 있다. 구현 라이브러리 참조)
  • 긴급 wind-down을 지원한다(60+30일 타임락)

Role: DEFAULT_ADMIN_ROLE ORACLE_ROLE PAUSER_ROLE YIELD_DEPOSITOR_ROLE RESERVE_FUNDER_ROLE

주요 파라미터(풀별): reserve_bps, fund_wallet, treasury_wallet, reserve_wallet

PlatformLPToken: ERC-20 ACTIVE

투자자 포지션을 나타내는 LP 토큰이다. 풀당 컨트랙트 하나다.

주요 책임:

  • 소수 18자리, 허가 없는 세컨더리 전송(화이트리스트 없음, v3-58)
  • 민팅 role은 PlatformPool에만 부여된다
  • 긴급 상황을 위해 pause 가능하다(세컨더리 전송만 멈춘다)
  • 전송 시 수익 자동 정산(LP가 움직일 때 추적 잔액이 바뀌기 전에 Pool의 **onLpTransfer(from, to, value)**를 호출한다). LP 전송 시 수익 정산 참조

Role: DEFAULT_ADMIN_ROLE MINTER_ROLE

참고(v3-14): 각 풀은 언제나 정확히 하나의 LP 토큰을 갖는다. 트랜치 상품은 tranche_group_id로 연결된 별도 SINGLE 풀 2~3개로 모델링하고 각자 자기 LP를 갖는다. 풀 하나에 LP 둘을 두는 패턴은 없다.

PlatformKYCSoulbound: Soulbound ERC-721 (UUPS 프록시, v3-30) ACTIVE

KYC/KYB 검증 토큰이다. 풀별이 아니라 전역 컨트랙트 하나다.

주요 책임:

  • 지갑당 토큰 하나(전송 불가)
  • INDIVIDUAL(KYC)과 INSTITUTION(KYB) 레벨 지원
  • 미국인 확인, 만료, 취소
  • Pool 컨트랙트가 isValidKYC(address), isInstitution(address), isValidKYCNonUS(address)로 검증한다

Role: DEFAULT_ADMIN_ROLE

Pool 컨트랙트가 kyc_level_required에 따라 여기를 호출해 KYC를 확인한다. 풀은 KYC를 프록시 주소로 참조하므로 KYC 업그레이드에 풀 마이그레이션이 필요 없다.

업그레이드 가능하며 유일하게 그렇다(v3-30). propose/execute 게이트가 있는 ERC1967 UUPS 프록시로 구현했다. proposeUpgrade(newImpl)executeUpgrade()(또는 cancelUpgrade())다. ⚠️ 2026-08-14부터 UPGRADE_TIMELOCK = 0이다. 예전에는 7일이었는데 대기가 사라져서 두 호출이 같은 블록에 들어갈 수 있다(v3-71 채택). _authorizeUpgrade는 정확히 그 대기 중인 구현체에 대해 타임락을 거친 업그레이드만 받는다. 직접 UUPS 업그레이드는 거부된다. money-path(PlatformPool / PlatformLPToken)는 **불변 Clones**로 남고(v3-27) KYC·컴플라이언스 로직만 제자리에서 진화할 수 있다. 새 의존성은 openzeppelin-contracts-upgradeable이다.

KYC만 업그레이드 가능하게 한 이유

KYC와 컴플라이언스는 온체인 조각 중 실제로 시간이 지나며 변하는 유일한 부분이다. KYC만 업그레이드 가능하게 하면(프록시 + 타임락) 모든 풀을 재배포하거나 마이그레이션하지 않고도 컴플라이언스를 바꿀 수 있고, 비수탁 money-path는 불변으로 남는다. 불변식은 주변부(KYC)가 적격성만 게이팅하고 자금을 옮기거나 지급 목적지를 바꿀 수 없다는 것이다. v3-30 · 09a-custody 참조.

PlatformEscrow: 제거됨 DEPRECATED

상태: v3-11에 따라 v3.0에서 제거됐다. 10/90 입금 분리(reserve / fund_wallet)는 이제 PlatformPool.deposit()에 인라인돼 있다. 별도 에스크로 컨트랙트가 없다.

Receipt NFT가 제거되고 입금이 atomic이 되면서 Escrow에 남은 역할은 얇은 10/90 분배기뿐이었다. 그것을 유지하면 실질적인 관심사 분리 이득 없이 가스와 감사 표면과 추적할 escrow_address만 늘었다.

PlatformPoolFactory: 팩토리 + 레지스트리 ACTIVE

모든 풀을 위한 통합 팩토리이자 온체인 레지스트리다.

주요 책임:

  • createPool(config): Pool과 LPToken 하나를 배포한다(항상 하나다. v3-14에 따라 트랜치는 DB 그룹핑으로 한다)
  • 단일 poolCounter(증가하는 풀 ID)
  • poolRegistry 매핑(poolId → 컨트랙트 주소)
  • getPoolContracts(poolId): 풀에 배포된 컨트랙트를 조회한다
  • role 자동 부여(LP의 MINTER_ROLE을 Pool에)

Role: DEFAULT_ADMIN_ROLE

팩토리는 풀 설정(차원)을 파라미터로 받아 배포 시점에 검증한다(검증 규칙 참조).

구현 라이브러리 (EIP-170)

이유: PlatformPool에 로직이 쌓이면서(회차 상환 v3-26, 비수탁 강화 v3-28/30/31/32) EIP-170의 24,576바이트 컨트랙트 크기 제한을 넘어 배포할 수 없게 됐다. 배포 가능하게 유지하려고 동작을 외부 delegatecall 라이브러리로 나누고 배포 시점에 링크한다. 라이브러리 주소가 바이트코드에 박히므로(setter도 diamondCut도 없다) 업그레이드 경로가 생기지 않는다. money-path는 불변 Clones로 남는다(v3-27). 1차로 RedemptionLib(그리고 PoolConfigLib/StablecoinAdminLib)을 뺐고, 기능이 조금씩 늘며 풀이 다시 한계에 닿자(claimYield 부분 청구 변경으로 기본 프로파일에 +2바이트만 남았다) 2차(2026-07-06)로 남은 큰 덩어리들을 뺐다. 타임락 설정(GovernanceLib), 수익 회계(YieldLib), NAV(NavLib)다. onLpTransfer는 의도적으로 인라인으로 남겼다(LP 전송 핫패스라, 이동마다 delegatecall을 하면 모든 민팅·소각·전송에 세금이 붙는다).

결과(2026-07-30 측정): PlatformPool은 21,232바이트(prod, 여유 +3,344바이트) / 20,691바이트(default, +3,885)다. 24,576바이트 제한 아래이지만 회차 엔진과 출구 게이트가 들어오면서 여유가 줄고 있다(2026-07-06 분리 시점에는 20,689바이트였다). 가장 큰 라이브러리는 12,016바이트의 RedemptionLib이다. 이 분리는 동작을 보존한다. forge 전체 스위트(보존·정밀도, 퍼즈 뒷받침 불변식, 비수탁 감사를 포함한 테스트 366건)가 계속 통과한다. 라이브러리에 선언된 오류와 이벤트는 동일한 selector와 topic0으로 풀 주소 아래에서 revert하고 발생하므로, ABI와 오프체인 디코딩이 그대로다(프론트엔드와 백엔드는 코드 변경이 필요 없고 컨트랙트 패키지에서 재생성된 ABI를 가져오면 된다. 풀 구현체만 재배포하고 팩토리를 다시 가리키면 된다).

라이브러리별 함수 목록, 공유 Layout(PlatformPoolStorage) 스토리지 모델, 위임 다이어그램은 컨트랙트 코드 레퍼런스 → §2.5 구현 라이브러리EIP-170 라이브러리 위임에 있다.

폐기된 컨트랙트 (v2.x → v3.0)

PlatformReceiptNFT: 제거됨 DEPRECATED

v3.0에서 제거됐다. 이제 LP 토큰 자체가 입금 증빙이다. Receipt NFT의 주된 용도였던 D+7 환불 메커니즘도 함께 제거됐다. 풀 모델의 마이그레이션 노트 참조.

Receipt NFT를 쓰던 기존 FUND_POOL 배포는 계속 동작하지만 새 풀에는 쓰지 않는다.

온체인과 DB의 경계 (v3-18)

Aset은 순수 DeFi가 아니라 허가형 RWA 플랫폼이다. 그래서 온체인 설정을 최소화한다. 정산 상태, KYC 컴플라이언스 필드, 조기 이탈 패널티만 PlatformPool에 저장한다. 나머지(트랜치 정보, lifecycle 메타데이터, 마케팅)는 DB에 있고 Aset Lambda가 강제한다.

⚠️ v3-18 정정(패널티의 기준): 초안은 패널티 파라미터를 DB 전용으로 분류했지만(Lambda가 지급액을 계산한다고 봤다), RedemptionConfig 컨트랙트가 패널티를 저장하고 계산하고 적용하므로 패널티 계산의 기준은 온체인이다(확인됨). DB의 penalty_*는 표시·리포팅 미러다. (관련: YIELD_BASED가 bps를 무시하고 전액을 몰수하던 버그가 있었고 요율을 적용하도록 수정됐다. SECURITY_REVIEW P-1.)

🟢 온체인 (PlatformPool 상태)

정산에 필수적인 것과 규제 준수 항목이다.

필드용도온체인인 이유
navPerTokenLP 토큰 가격정산에 쓴다. payout = LP × NAV
reserveBalance구형 구현만 해당외부 리저브 구현에서는 입금의 reserveBps 몫을 reserveWallet으로 보내고 나머지를 fundWallet으로 보낸다. 풀 내부 리저브 잔고를 적립하지 않는다.
lockupDays락업 기간requestRedemption()이 락업을 확인한다
maturityDate풀 만기만기 후에는 requestRedemption()이 락업을 건너뛴다
pausedsoft pause모디파이어가 deposit()만 막는다
isEmergencyFrozenhard freeze모디파이어가 모든 활동을 막는다
lifecycleStatusDRAFT / UPCOMING / ACTIVE / IMPAIRED / MATURED / CLOSED / WIND_DOWN상태별 정산 게이팅
reserveBpsdeposit()이 10/90 분리에 쓴다(basis point, ≤ 10000)컨트랙트가 atomic하게 나누려면 알아야 한다
fundWallet파트너 잔여분 릴리스 대상컨트랙트가 이 주소로 보낸다
kycContractPlatformKYCSoulbound 참조컨트랙트가 SBT를 확인한다
kycLevelRequiredKYC와 KYB 중 무엇이 필요한지규제 강제. 컨트랙트 직접 호출로 우회하는 것을 막는다
jurisdictionWhitelist허용되는 ISO 3166-1 alpha-3 국가 코드(v3-60)규제 강제. 지역 제한
penaltyRateBps, penaltyType, penaltyFeeAmount조기 이탈 패널티(RedemptionConfig)컨트랙트가 requestRedemption에서 패널티를 계산하고 적용한다(v3-18 정정)

🟡 DB 전용 (Aset Lambda가 강제)

변경 가능하거나 파생되거나 오프체인에서 계산할 수 있는 것이다.

필드DB 전용인 이유
description, apy_disclosure긴 텍스트라 온체인 저장이 비싸다
collateral_description자유 텍스트이고 자주 바뀐다
apy_rate(목표)마케팅 목표치다. 실제 수익은 NAV에 반영된다
min_investment, capacity소프트 한도다. 앱과 Lambda가 강제한다
penalty_type, penalty_rate_bps, penalty_fee_amountDB 미러일 뿐이다. 패널티의 기준은 온체인 RedemptionConfig이고(위 온체인 표 참조) DB 사본은 표시·리포팅용이다
tranche_group_id, tranche_role(v3-14)Lambda가 NAV 계산 시 손실 워터폴을 적용한다. 온체인에서는 풀이 표준 SINGLE로 남는다
maturity_modelLambda가 FIXED_TERM과 OPEN_ENDED의 상태 전이를 강제한다
external_provider, external_fund_id, partner_id연동 메타데이터이고 정산 역할이 없다
investor_count, total_yield_distributed온체인 이벤트에서 파생된다
start_date, end_date배포·만기 타임스탬프이고 이벤트에서 복원 가능하다
accepted_currencies(표시)온체인 화이트리스트가 유효한 스테이블코인을 추적하고, DB는 UI 표시 순서를 추적한다

🔴 절대 온체인에 두지 않는 것 (PII / 프라이버시)

필드제외 이유
투자자 이름 / 이메일 / 국가 / 생년월일불변 원장에서는 GDPR 제17조(삭제권)를 지킬 수 없다
KYC 서류와 사진컴플라이언스와 프라이버시 법규
차주 단위 대출 PII(Joob eNote의 차주 이름 등)파트너 NDA와 개인정보 보호
지갑에서 신원으로의 직접 매핑가명 모델이 깨진다
파트너 내부 조건과 수수료 구조상업적 민감성

이들은 다음에 남는다.

  • SumSub: KYC 서류, 제재 스크리닝 데이터
  • Aset DB: 지갑과 유저의 매핑, 표시 이름, 이메일
  • 파트너 시스템: 차주 단위 데이터(우리는 PII를 받지 않는다)

온체인 PlatformKYCSoulbound SBT는 확인 사실만 담는다(레벨, 관할, 만료, 취소 여부). 그 뒤의 PII는 절대 담지 않는다.

필드 동기화와 변경 가능성: DB와 온체인 (v3-29)

어드민이 풀 설정을 PATCH할 때 어떤 필드가 온체인으로 전파되거나 잠겨야 하는지다. 현재 pools.patch.update.tsDB만 갱신하므로 아래 규칙대로 강제해야 한다. (변경 가능성 자체는 04 → ACTIVE 이후 수정 가능성 참조.)

요약: 배포 전(DRAFT)에는 모든 필드를 자유롭게 수정할 수 있다. 배포된 뒤(ACTIVE 등)에는 필드의 분류에 따라 수정 가능 여부가 달라진다.

  • A는 수정 가능하되 전용 엔드포인트로만 가능하고 수정 폼(PATCH)으로는 안 된다.
  • B수정 불가다. 온체인에 잠겨 있고 바꾸려면 완전히 새 풀을 배포해야 한다.
  • C는 평범한 수정 폼(PATCH)으로 언제든 자유롭게 수정 가능하다.
  • C-lock부분적으로 잠겨 있다. min_investment는 고정이고 capacity는 올리는 것만 된다.
분류와 의미필드배포 후 수정 가능?
A: 온체인 setter가 있다is_paused/is_emergency_frozen, reserve_bps, fund_wallet, kyc_level_required, jurisdiction_whitelist, nav_per_token, lifecycle_status, allow_rollover가능하되 PATCH가 아니라 전용 엔드포인트로만. pause·freeze는 즉시, reserve·fund_wallet·KYC는 7일 타임락, NAV는 /nav-changes, lifecycle은 /lifecycle, rollover는 setAllowRollover(즉시, 타임락 없음). ⚠️ 다만 PATCH는 여전히 DB만 쓴다. 온체인 쌍이 동기화되지 않는다
B: 온체인이고 불변이다(setter 없음)penalty_type/penalty_rate_bps/penalty_fee_amount, lockup_days, maturity_days, accepted_currencies, redemption_type/notice_period, epoch_duration_days(epochDurationDays)불가. PATCH가 거부된다. 바꾸려면 새 풀을 배포해야 한다
C: 오프체인이고 온체인 쌍이 없다name, description, apy_rate와 공시, yield_frequency, net_yield_fee_config, collateral_type/collateral_ratio/collateral_description언제든 자유롭게 가능(평범한 PATCH. 온체인 대응물이 없다)
C-lock: 오프체인이지만 공시된 투자자 조건이다(v3-43)min_investment, capacity⚠️ 부분적으로 가능(Lambda가 강제하고 재배포는 없다). min_investment잠김, capacity올리는 것만(새 값이 현재 값 이상일 때만 받는다)

DRAFT(배포 전): 위 모든 분류가 자유롭게 수정 가능하다(DB만). "배포 후 수정 가능?" 열이 풀이 라이브가 되면 달라지는 부분이다.

규칙:

  • **A의 is_paused**는 전용 POST /pools/{id}/pause 엔드포인트로 처리한다(/freeze와 같은 구조다). 배포된 풀에는 온체인 pause()/unpause()를 호출하고(둘 다 즉시, 타임락 없음) DRAFT에는 DB만 쓴다. is_pausedPATCH 화이트리스트에서 제거됐다. ⚠️ DB만 거는 pause는 투자자가 deposit()을 직접 호출해 우회할 수 있게 한다(그 함수는 whenNotPaused로 게이팅된다) → 보안 구멍 → 반드시 온체인이어야 한다. ⚠️ 반대 방향도 마찬가지다(v3-92). is_paused끄는 모든 쓰기는 컨트랙트도 함께 꺼야 한다. OZ PausableemergencyFreeze()와도 lifecycle enum과도 독립이므로, v3-78의 자동 해제(freeze / impairment / wind-down)는 clearOnChainPause로 온체인 unpause()를 보내고 그다음에야 is_paused = false를 쓴다. DB만 끄면 풀이 온체인에서 paused()로 남는다(입금이 조용히 revert되고 나중에 pause()EnforcedPause()로 revert해 502가 된다).
  • B는 배포된 풀(lifecycle이 DRAFT가 아님)에서 pools.patch.update.ts가 거부한다. DRAFT에서만 가능하다. (EIP-170 바이트코드 여유 문제와, 이들이 ACTIVE 이후 잠기는 투자자 대상 조건이라는 점 때문이다. 04의 매트릭스와 일치하고 컨트랙트 변경은 없다.)
  • **C의 apy_rate**는 ⚠️ 현재는 맞지만 2026-08-27 결정이 이 필드를 발생 모드별로 나눈다(아직 반영 안 됨. 마이그레이션 없음, v3-154). FIXED 풀은 accrual_rate_bps만 갖고(Class B, 온체인이고 생성 시 전용) apy_rate는 비우며, TARGET 풀은 그 반대다. 따라서 FIXED 풀에서 실제 요율은 온체인 값이고, 의미 있는 필드에 대해서는 "온체인 쌍 없음" 분류가 성립하지 않게 된다. 현재 스키마는 여전히 apy_rate NOT NULL이다.
  • C-lock(v3-43): min_investmentcapacity오프체인이다(온체인 대응물이 없다. 위의 온체인 상태 표에 없고 Lambda가 입금 게이팅에서 둘 다 강제한다). ACTIVE 이후 min_investment잠긴다(PATCH 거부. 공시된 투자자 조건이라 바꾸려면 새 풀이다). capacity올리는 것만 된다(new_capacity >= current_capacity일 때만 PATCH를 받는다. 올리면 기존 홀더에게 해 없이 투자자를 더 받고, 내리는 것은 거부된다). 재배포는 없다. 이전 v3-29 초안을 정정한 것이다. 그 초안은 capacitymin_investment를 Class B "온체인 불변"으로 잘못 표시했는데, 컨트랙트가 아니라 정책으로 잠긴 오프체인 값이다.
  • investment_blocked는 폐기됐다(v3-29). 이제 배선된 온체인 is_paused와 중복이다. 투자 가능 판정에서 제거됐고(이제 ACTIVE && !is_paused뿐이다) "Fully Subscribed"는 tvl >= capacity에서 파생된다. DB 컬럼도 삭제됐다(마이그레이션 0018). 입금 중단은 is_paused를 쓴다.
  • 이미 전용 엔드포인트로 온체인화된 것: reserve%/fund_wallet/KYC는 거버넌스(7일 타임락), NAV는 /nav-changes, freeze는 /freeze, lifecycle은 /lifecycle이다.

구현 스펙은 ONCHAIN_VALUE_SYNC_DECISION.md에 있다. 회차 필드(v3-38): epoch_duration_days(온체인 epochDurationDays)는 Class B이고 풀 생성 시 설정된 뒤 운영 중에는 불변이다(배포 멀티콜이 설정하고, 풀에 투자자가 생기면(totalLPSupply > 0) 컨트랙트가 setEpochDurationDays를 revert한다. 배포 시에만 설정 가능하고 제품에 노출되지 않는다. 이미 들어온 투자자에게 상환 조건을 바꾸는 것은 공시되지 않은 게이트이자 자금 가둠 위험이다). 강화 조치는 ✅ 구현되고 테스트됐다(메인넷 배포와 감사 대기). MAX_EPOCH_DURATION_DAYS = 90 상한(EpochDurationTooLong), EpochDurationChanged 이벤트, 생성 시 전용 revert(totalLPSupply > 0이면 EpochImmutableAfterDeposit)다. PlatformPoolEpoch.t.sol이 23/23이다. Sepolia(6/22)는 강화 이전 버전이라 재배포가 필요하다. 7일 타임락 거버넌스를 통한 실시간 변경은 v2로 미뤘다. 런타임 회차 상태(currentEpochId/currentEpochEndsAt/체결 비율)는 읽어서 미러링할 뿐 오프체인에서 설정하지 않는다.

이 경계를 정한 이유

이유내용
허가형 모델투자자는 Aset을 운영 주체로 신뢰한다. 완전한 무신뢰 설정이 필요 없다(Centrifuge 방식)
감사와 배포 속도컨트랙트 표면이 작을수록 보안 감사가 빠르고 배포 가스가 싸다
규제상 물러설 수 없는 선KYC 레벨과 관할은 반드시 온체인이어야 한다. 그러지 않으면 숙련된 사용자가 컨트랙트를 직접 호출해 우회할 수 있고 VASP 위반이 된다
신뢰 가정Lambda는 NAV(v3-15)와 워터폴(v3-14)에 대해 이미 신뢰받고 있다. 설정을 더 오프체인에 둬도 추가되는 신뢰가 없다. 패널티만 예외로 온체인에서 계산한다(기준이 온체인이다)

Lambda 오류에 대한 완화 장치

Lambda가 로직의 상당 부분을 통제하므로 안전장치를 둔다.

  • NAV 하락에 24시간 타임락(그 기간에 어드민이 취소할 수 있다)
  • fund_wallet / reserve / KYC 설정 변경에 7일 타임락
  • wind-down 실행에 30일 타임락
  • 중요 함수에 멀티시그 어드민 role
  • 모든 NAV 갱신이 오프체인 감사를 위해 이벤트를 발생시킨다

전체 근거는 결정 → v3-18에, 관련 신뢰 구도는 v3-15(NAV는 항상 Aset이 처리한다)에 있다.

구조 다이어그램

컨트랙트 구조 (v3.0)

범례: 투자자 행위 · 파트너가 수익 입금 · Aset 어드민이 통치(멀티시그) · Aset 서비스 키(ORACLE_ROLE)가 NAV를 올리고 상환·수익을 정산

풀별 배포

자금 흐름

모든 풀에 통합된 플로우다(AS/FUND_POOL 분리가 더 이상 없다). 차이는 컨트랙트 로직이 아니라 풀 설정(차원)에서 나온다.

입금 플로우

1. 투자자 → Pool.deposit(stablecoin, amount)
2. Pool:
   - safeTransferFrom(투자자, Pool, amount)
   - PlatformKYCSoulbound로 KYC 확인
   - 금액 정규화(예: USDC 6자리 → 18자리)
   - reserve_amount = amount × reserve_bps / 10000 계산
   - partner_amount = amount - reserve_amount 계산
   - 투자자에게 LP 민팅: tokens = (amount × NAV_PRECISION) / nav_per_token — 전액 기준
     ([v3-56](/backend/14-decisions#v3-56). reserve 분리가 청구권을 희석하지 않는다)
   - reserve_amount를 reserveBalance에 보유
   - safeTransfer(fund_wallet, partner_amount)      ← 잔여분을 파트너에게 보내고 ReleasedToPartner 발생
   - emit Deposited(investor, stablecoin, amount, lp_amount)
3. Aset 인덱서가 이벤트를 받아 오프체인에 미러링한다(deposits 행, 포지션, TVL)

⚠️ 입금 시 파트너에게 밀어 주는 것이 없다. 이 단계는 예전에 "webhook으로 파트너에게 통지"로 끝났다. 그런 통지는 존재하지 않는다. apps/ 전체에서 aset-lp-mintpartner_endpointgrep해도 아무것도 없고, 코드베이스의 webhook은 전부 들어오는 것뿐이다(SumSub KYC, SES 반송). 파트너는 체인이나 리포트에서 입금을 알게 된다. 아래 파트너 통지와 같은 발견이고 플로우 하나 앞선 것이다. → v3-02

custody_mode = 'MIRROR'인 풀에는 입금 플로우가 없다. Aset은 파트너가 보고한 것을 미러링할 뿐이다. (is_showcase = true인 풀도 입금을 받지 않지만, 이유가 다르다. 애초에 컨트랙트를 배포하지 않는다.)

수익 플로우

1. 파트너 → Pool.depositYield(gross_amount)
   (role 제한: 호출자는 YIELD_DEPOSITOR_ROLE(파트너의 `fund_wallet`)이나
    RESERVE_FUNDER_ROLE(풀의 `reserve_wallet`)을 가져야 한다. 둘 다 펀딩 가능, D4-a)
2. Pool이 gross_amount를 unclaimedYield에 보유
3. Pool이 YieldDeposited(gross_amount) 발생
4. Aset Lambda가 이벤트를 받아:
   - DB에서 pool.net_yield_fee_config를 읽고
   - net_amount와 fee_amount를 오프체인에서 계산
5. Aset Lambda → Pool.settleYield(stablecoin, treasury_fee, pool_mgmt_fee)   // 트랜잭션 하나
   - net = unclaimedYield − 수수료. 명시하는 게 아니라 유도한다(v3-131 (3), 2026-08-20)
   - net을 발생 채무에 적용하고, 흡수하지 못한 것은 선납분으로 남는다
   - treasury_fee를 Aset 트레저리로, pool_mgmt_fee를 fund_fee_wallet으로 전송
   - 수수료 항목 하나라도 실패하면 그 기간 전체가 revert된다
7. 투자자 → Pool.claimYield(stablecoin, amount)   // amount 0이면 전액 청구, 초과 요청은 클램프
   - 파트너가 실제로 펀딩한 만큼에서 지급한다. 발생했으나 미펀딩이면 YieldAccruedButUnfunded로 revert
   - 투자자 지갑으로 전송

상환 플로우

표준:

1. 투자자 → Pool.requestRedemption(lp_amount)
   - LP를 잠근다(전송 불가)
   - nav_at_request를 기록한다(스냅샷)
   - payout = (lp × nav_at_request) − penalty 를 계산한다

모든 패널티 타입이 원금에서 차감되고 패널티는 reserve가 아니라 **fund_wallet**으로 간다(v3-84 + v3-85, 배포된 RedemptionLib에 반영돼 있다. RedemptionLib.sol:524payout = grossAmount − request.penaltyAmount이고 패널티는 s.fundWallet으로 지급된다). isYieldPenalty는 ABI 안정성을 위해 요청 struct에 남지만 항상 false다. 총액 지급 분기는 남아 있지 않다.

2. Aset 서비스 키(ORACLE_ROLE)가 reserve 충분 여부를 확인한다
3. Aset(ORACLE_ROLE) → Pool.approveRedemption(requestId)
   - reserveBalance >= payout이면 즉시 실행
   - 아니면 상태를 PENDING_RESERVE로
4. PENDING_RESERVE이면 파트너 → Pool.fundRedemption(amount)
   - 파트너가 Pool에 USDC를 추가로 보낸다
   - 합계가 payout 이상이 되면 실행
5. 실행: Pool이 LP를 소각하고 투자자에게 USDC를 전송한다

패널티 계산을 포함한 전체 메커니즘은 상환 참조.

Wind-Down 플로우

T+0       파트너가 응답하지 않게 됨
T+60일    어드민(멀티시그) → Pool.proposeWindDown()
          - windDownProposedAt = block.timestamp 설정
          - WindDownProposed 이벤트 발생
T+60~90일 30일 타임락 기간
          - 파트너가 응답하면 어드민이 cancelWindDown() 호출
T+90일    누구나 → Pool.executeWindDown()
          - block.timestamp >= windDownProposedAt + 30일 이어야 함
          - navPerToken = distributable / (totalSupply − settledUnclaimedLp) 로 강제(24시간 타임락 없음)
            distributable = reserveBalance + totalEpochTopUp   (v3-100 분자, R10 분모)
            → `− redemptionCommitted`는 이중 계상으로 제거됨(0abe168). 신규 풀만 해당
          - lifecycle_status = WIND_DOWN 설정
이후      LP 홀더 → Pool.requestRedemption(lpAmount)  // 표준 상환 경로
          - 락업과 패널티 면제(lifecycle = WIND_DOWN)
          - payout = lpAmount × navPerToken  (= 분배 가능 유동성의 pro-rata 지분:
            reserve + 회차 top-up)
          - 즉시 풀: fundRedemption 안의 파트너 펀딩 자동 정산으로 처리
          - 회차 풀: executeEpoch → claimRedemption으로 처리
          - LP를 소각하고 USDC를 전송

claimWindDown() 산식과 수학적으로 같은 이유

(자기 LP / claimingSupply) × distributable자기 LP × navPerToken과 같다. executeWindDown이 가격을 정확히 그렇게 설정하기 때문이다.

navPerToken    = distributable / claimingSupply
distributable  = reserveBalance + totalEpochTopUp
claimingSupply = totalSupply − settledUnclaimedLp

정산됐지만 미청구인 채무는 분자가 아니라 분모에서 빠진다(R10). redemptionCommitted는 유동성 카운터 셋이 이미 차감한 값이 반영된 별개 버킷이라, 그것까지 빼면 이중 계상이 된다(v3-100이 분자를 넓혔고 R10이 양쪽을 바로잡았다). 전체 유도는 06 → R10 참조.

표준 상환 경로로 통합하면 중복 함수가 사라지고 wind-down 중 부분 상환이 가능해진다. (온체인에 전용 redeem() 함수는 없다. WIND_DOWN 이탈도 다른 상환과 똑같이 requestRedemption → claim을 탄다.) v3-12 참조.

맥락과 근거는 풀 모델 → 긴급 Wind-Down 참조.

온체인 이벤트(DB 미러)

함수 레퍼런스는 08a에 있다

컨트랙트별 함수 목록(시그니처, role 게이트, 투자자·파트너·서비스·pauser·어드민 함수 설명)은 **컨트랙트 코드 레퍼런스 → §2 함수 레퍼런스**에서 소스로부터 생성해 관리한다. 이 페이지는 아래 이벤트만 담는다. 이벤트는 코드 레퍼런스가 다루지 않는 인덱서·연동 관심사(온체인에서 DB로의 미러링)다.

회차 상환 이벤트 (v3-26)

회차 풀이 발생시키고 Aset 인덱서가 redemption_epochsredemption_requests로 미러링한다.

이벤트발생 함수담는 것미러 대상
EpochSettledexecuteEpochepochId, fillRatio, settleNav, filledGrossUSD, settledLpredemption_epochs(fill_ratio, settled_nav, settled_at)와 money_events(EPOCH_SETTLED)
RedemptionClaimedclaimRedemption요청자, filledLp, payout, penalty요청의 lp_filled / settled_nav / payout / 상태와 money_events(REDEMPTION_CLAIMED)
RedemptionRolledOverclaimRedemptionrequestId, nextEpochId, lpRemaining(인덱서가 위치 기반으로 디코딩)요청을 PARTIALLY_FILLED로, 수요 이월
EpochFundingNeededexecuteEpoch(정산)epochId, shortfall(기한 필드 없음)파트너 펀딩 알림(notification_eventsnotification_deliveries, D-2/D-12h)

비수탁 안전장치 이벤트 (v3-28 / v3-31 / v3-32 / v3-30)

동결, 폴백, NAV 한계, KYC 업그레이드 경로가 발생시키고 Aset 인덱서가 감사와 풀 응답의 동결 필드를 위해 미러링한다.

이벤트발생 함수담는 것비고
EmergencyFrozenemergencyFreezetimestamp(= freezeStartedAt)72시간 이탈 창과 7일 자동 만료 시계를 시작한다(v3-28).
EmergencyUnfrozenunfreezetimestampPAUSER 복구다. 동결을 일찍 해제한다.
FreezeExtendProposed / FreezeExtended / FreezeExtendCancelled제거됨동결 연장 경로가 없어졌다. 7일 거버넌스 타임락이 7일 동결 수명과 같아서, 제안이 실행 가능해질 때쯤이면 동결이 이미 끝나 있었다. 그때 실행하면 아무 일도 안 하거나, 출금 권리를 되찾은 투자자에게 소급해서 풀을 다시 잠그고 72시간 이탈 차단을 재시작했다. pendingFreezeExtend* 스토리지만 남고(unfreeze 시 정리된다) POST /pools/{id}/freeze는 연장 액션에 410을 반환한다. 대신 pauseimpairment로 올린다.
RedemptionFallbackClaimedclaimRedemptionFallback🔴 제거됨. 그 함수는 풀의 reserve를 끌어 썼는데 reserve가 입금 시점에 reserve_wallet으로 라우팅된다. v3-31의 이탈권은 이제 컨트랙트가 아니라 운영상의 약속이다.
NavDeviationCapSetsetNavDeviationCapbps0이면 갱신당 편차 한계를 끈다(v3-32).
CircuitBreakerTripped / CircuitBreakerResettripCircuitBreaker / resetCircuitBreakerby브레이커가 걸리면 updateNAVexecuteEpoch가 revert된다(v3-32).
UpgradeProposed / UpgradeExecuted / UpgradeCancelledPlatformKYCSoulbound 업그레이드 경로새 구현체, effectiveAt7일 KYC UUPS 업그레이드 타임락(v3-30). KYC 컨트랙트에만 해당되고 money-path는 불변이다.

Role과 접근 통제

모든 컨트랙트가 OpenZeppelin AccessControl을 쓴다. role은 컨트랙트 인스턴스별로 부여된다.

role별 권한 표는 08a에 있다

컨트랙트별(PlatformPool, LPToken, KYCSoulbound, Factory) 어느 role이 무엇을 호출할 수 있는지는 **컨트랙트 코드 레퍼런스 → §3.1 접근 통제**에 있다. 이 페이지는 부여 흐름(누가 각 role을 갖고 배포 시 어떻게 부여되는지)과 멀티시그·타임락 근거를 다룬다.

멀티시그 어드민

DEFAULT_ADMIN_ROLE은 보통 멀티시그 지갑(예: Gnosis Safe)이 보유하고 다른 모든 role의 role-admin이다. 중요한 변경(fund_wallet, wind-down)에는 투자자 보호를 위해 온체인 타임락이 추가로 걸린다. 배포 시 _adminPAUSER_ROLE도 부트스트랩으로 가져가고(메인넷에서는 빠른 별도 Safe에 넘긴다), ORACLE_ROLE은 배포 후에 부여한다.

Role 부여 흐름

서명자 신원(KMS)

플랫폼 자신의 서명 신원이다. 정본은 apps/infra/lib/config/signer-roles.ts다.

2026-08-12까지는 ADMIN_PRIVATE_KEY 하나가 플랫폼 쪽 모든 role을 동시에 갖고 있었고, 178개 함수에 평문 Lambda 환경변수로 주입됐다. 그래서 lambda:GetFunctionConfiguration(AWS 관리형 ReadOnlyAccess 정책 안의 액션이다)이 SBT를 민팅하고 풀을 만들고 NAV를 움직이고 lifecycle을 바꾸는 권한과 같아졌다. 이를 스테이지당 AWS KMS ECC_SECG_P256K1 서명 키 넷으로 대체했다. role마다 하나이고 각자 주소가 다르다. 키 자료는 KMS를 떠나지 않는다. 백엔드가 KMS에 트랜잭션 해시 서명을 요청하므로 함수 설정에서 읽어 낼 것이 없고, 모든 트랜잭션이 CloudTrail에 이름 붙은 키에 대한 kms:Sign 기록을 하나씩 남긴다.

신원KMS 별칭필요한 온체인 부여서명 대상(예시)
adminalias/aset-signer-admin-<stage>PlatformPoolFactory.DEFAULT_ADMIN_ROLE, PlatformPool.DEFAULT_ADMIN_ROLE, PlatformKYCSoulbound.DEFAULT_ADMIN_ROLE, PlatformLPToken.DEFAULT_ADMIN_ROLEcreatePool, setLifecycleStatus, 거버넌스 propose/execute, setEpochSchedule, KYC revoke/burn
minteralias/aset-signer-minter-<stage>PlatformKYCSoulbound.MINTER_ROLEmint(취소나 소각은 못 한다)
oraclealias/aset-signer-oracle-<stage>PlatformPool.ORACLE_ROLEupdateNAV, approveRedemption, rejectRedemption, holdRequest, releaseRequest, settleYield, setEpochFundingDate
pauseralias/aset-signer-pauser-<stage>PlatformPool.PAUSER_ROLEpause/unpause, emergencyFreeze/unfreeze, tripCircuitBreaker

devprod는 스택이 따로이므로 키도 따로이고 주소도 다르다. 그래서 배포가 잘못된 환경을 향해도 dev 서명자가 prod 컨트랙트에 조치할 수 없다.

파생 주소: apps/infra/lib/config/signer-addresses.ts(생성된 파일)다. KMS 키는 존재하기 전까지 주소가 없으므로, 주소는 키 배포의 결과물이지 입력이 아니다. 현재 두 스테이지 다 첫 배포 전이라 기록돼 있지 않다.

이 분리는 대칭이 아니다

DEFAULT_ADMIN_ROLE이 다른 모든 role의 role-admin이므로, admin 키는 언제든 자기에게 oracle이나 pauser를 부여할 수 있다. 이 분리가 사 주는 것은 좁은 키 셋의 피해 범위를 제한하는 것이다. oracle 키가 유출되면 NAV는 움직일 수 있지만 SBT를 민팅하거나 풀을 만들거나 스스로에게 더 큰 권한을 줄 수는 없다. admin은 유출되면 전부를 잃는 키로 다루고, 자동화된 DEFAULT_ADMIN_ROLE 호출 지점(lifecycle 스케줄러, 거버넌스 실행)이 감당할 수 있게 되면 위에서 말한 멀티시그 뒤로 옮기는 편이 좋다.

YIELD_DEPOSITOR_ROLE에는 KMS 신원이 없고 만들어서도 안 된다

2026-08-13 서명자 감사에서 정리됐다. 컨트랙트가 이 role을 initialize 시점에 fund_wallet에 부여하고(PlatformPool.sol:744) fund-wallet이 바뀔 때마다 다시 부여한다(:1429). 파트너의 신원이고 플랫폼은 상시 보유할 필요가 없다.

  • depositYield는 플랫폼이 서명하지 않는다. yield-distributions.post.create.ts가 이제 클라이언트가 서명한 deposit_tx_hash를 요구하고 없으면 거부하므로 레거시 서버키 경로가 사라졌다. depositYieldOnChain은 호출자가 없는 죽은 코드로 남아 있다.
  • fundRedemptionfund_wallet이 곧 플랫폼 키인 경우에만 플랫폼이 서명한다. initialize가 role을 fund_wallet에 부여하고(PlatformPool.sol:739) 그 외에는 아무것도 부여하지 않는다. create-pool.ts의 배포 시점 자가 부여, 즉 v3-53 개발용 임시 조치는 2026-08-13에 제거됐다. 그래서 실제 파트너 지갑을 가진 풀에는 더 이상 플랫폼 사본이 없다. executeFundWalletChange가 지갑을 따라 role을 옮기므로(옛 것을 회수하고 새 것에 부여, :1413-1414) 펀드별이나 FM별 부여 단계가 필요 없다. 두 핸들러 모두 파트너가 서명하는 대안(record-funding / fund_tx_hash. 검증하고 기록할 뿐 아무것도 보내지 않는다)을 갖고 있고 admin-web이 이미 그것을 쓴다. ⚠️ 2026-08-13 이전에 배포된 풀은 여전히 그 추가 부여를 갖고 있고 풀마다 회수해야 한다.

조건부 경로를 어디서나 동작하게 하려고 다섯 번째 신원을 추가하지 말 것. 그러면 파트너의 능력이 플랫폼으로 넘어오고, 09a-custody의 비수탁 판정이 바로 그 일이 일어나지 않는다는 데 기대고 있다.

작업 순서

순서가 중요하다. 부여받지 못한 키는 revert되는 트랜잭션에 서명하고, 아무도 서명할 수 없는 주소에 부여하는 것은 더 나쁘다. 온체인에서는 정상으로 보이기 때문이다.

  1. 키를 배포한다. pnpm --filter @aset/infra deploy:<stage>:kms. 독립 CDK 앱(bin/kms.ts)이라 AWS 자격 증명 외에는 아무것도 필요 없다. 인증서도 시크릿도 없다. cdk deploy --all에 포함하지 않은 것은 의도적이다. 서명 키 스택이 일상적인 배포에 휩쓸려 들어가면 안 된다.
  2. 주소를 기록한다. AWS_PROFILE=aset pnpm --filter @aset/infra signer:addresses --stage <stage> --write. --write 없이는 읽기 전용이다. 생성된 파일을 커밋한다.
  3. 위 표에 따라 기록된 각 주소에 온체인 role을 부여한다. 풀은 Clones이므로 풀 범위 role (DEFAULT_ADMIN_ROLE, ORACLE_ROLE, PAUSER_ROLE)은 풀마다 부여해야 하고 다시 가리킬 레지스트리가 없다.
  4. 런타임을 전환한다(lib/shared/contract/client.ts). KMS로 서명하게 하고, aset-signer-<role>-<stage>-sign 관리형 정책을 그 role이 필요한 함수에만 붙인다.
  5. 옛 단일 키 주소를 회수하고 ADMIN_PRIVATE_KEY를 삭제한다. 이 순서로 한다.

키는 RETAIN이고 destroy 스크립트가 없으며 교체는 마이그레이션이다

서명 키는 다시 만들 수 없다. 컨트랙트가 role을 부여한 대상이 그 키의 주소이고, 풀 클론마다 한 번씩 부여됐다. 키를 지우면 그 모든 부여가 영구히 고아가 된다. 어떤 새 키도 그 주소를 만들어 낼 수 없기 때문이다. 그래서 RemovalPolicy.RETAIN이고 KMS 대기 기간이 30일이다.

별칭도 의도적으로 유지한다. Keyalias prop은 Key.addAlias를 거치는데 그 함수는 removal policy를 적용하지 않는다. 그래서 키가 Retain인 동안 별칭은 기본값 Delete가 된다. 그러면 스택을 지웠을 때 아무도 주소로 삼을 수 없는 유지된 키가 남고, 다음 배포가 주소를 가진 키를 만드는데 온체인 부여는 전부 옛 주소를 가리키고 있다. 이 실패는 조용하다. 스택은 정상으로 배포되고 트랜잭션이 나중에 revert된다. 그래서 별칭을 RETAIN으로 명시해 생성하고, 재배포는 기존 별칭 이름에서 요란하게 실패하게 한다.

다른 스택과 달리 destroy:<stage>:kms 스크립트가 없다. 이 스택을 내리는 데는 명령 하나로 만들어 둘 만큼 안전한 결과가 없다. 두 리소스가 다 유지되므로 내려도 관리에서 빠지는 것 말고는 얻는 게 없고, 복구는 어차피 수동이다. 서명자를 지우는 것은 온체인 role을 옮긴 뒤 KMS를 직접 상대하는 의도적인 작업이다.

같은 이유로 enableKeyRotation도 없다(대칭 암호화 키에만 적용되고, 서명자를 교체한다는 것은 새 주소와 모든 풀에 대한 재부여를 뜻한다. 백그라운드 작업이 아니라 런북이다).

멀티시그 + 타임락 패턴

중요한 변경은 투자자 보호를 위해 멀티시그 어드민과 온체인 타임락을 함께 쓴다.

2단계 절차

  1. 제안: 어드민(멀티시그 서명자들)이 변경을 제안한다
  2. 대기: 타임락 기간이다(이벤트로 투자자에게 보인다)
  3. 실행: 타임락이 지나면 누구나 실행할 수 있다(또는 그 기간에 멀티시그가 취소한다)

타임락 설정(정본)

모든 타임락 값의 단일 기준이다. 다른 문서는 이 값들을 중복해 적지 말고 여기를 참조해야 한다(어긋남을 막는다). 커스터디 맥락은 09a-custody 참조.

변경타임락이유
fund_wallet 변경7일중대한 변경이라 투자자가 보고 대응할 수 있어야 한다
reserve_bps 변경7일투자자 보호 수준에 영향을 준다
nav_data_source 변경v3-15에서 차원이 제거됐다. NAV는 항상 Aset이 처리한다.
kyc_level_required 변경7일누가 투자할 수 있는지에 영향을 준다
jurisdiction_whitelist 변경7일누가 투자할 수 있는지에 영향을 준다(지역 제한, v3-10/18)
Impairment 실행7일파트너 부실로 입금을 멈춘다. 검토·취소 창이다(v3-12)
NAV 하락24시간기존 투자자가 상각 전에 대응할 시간을 준다
Wind-down 실행30일(60일 무응답 이후)회복·취소 창이다
컨트랙트 업그레이드(KYC만) · 긴급 동결v3-27 / v3-28 / v3-30코어 money-path는 불변이다(업그레이드 불가 Clones). PlatformKYCSoulbound가 유일하게 업그레이드 가능한 컨트랙트이고 propose/execute 게이트 뒤의 UUPS 프록시이며 UPGRADE_TIMELOCK = 0이다(예전에는 7일이었다. v3-71). 업그레이드 가능한 "오라클·수수료 모듈" 같은 것은 없고 그것들은 오프체인 Lambda다(09a-custody). 동결은 즉시 적용할 수 있지만, 상환을 막는 전면 동결은 최대 72시간 뒤 자동으로 해제된다(이탈권). → 09a-custody

파트너 통지

어드민이 변경을 제안하면 온체인 이벤트가 곧 통지다. FundWalletChangeProposed(oldWallet, newWallet, effectiveAt)와 그 형제들(ReserveBpsChangeProposed, JurisdictionChangeProposed, EnforceJurisdictionChangeProposed, RedemptionGatingChangeProposed, WindDownProposed, ImpairmentProposed)이 GovernanceLib에서 발생하고, 파트너가 자기 풀의 것을 직접 감시한다.

🔴 2026-08-05 정정: 이 절의 오프체인 절반은 만들어진 적이 없다. 예전에는 두 번째 채널을 설명하면서 "Aset Lambda가 즉시 POST {partner_endpoint}/aset-governance-action을 보낸다"고 적었고, can_object_until 필드가 포함된 전체 예시 페이로드까지 있었다. 계약상 이의 제기 창이 있다는 뜻으로 읽힌다. 그중 아무것도 존재하지 않는다. apps/infra, apps/web, apps/admin-web에서 partner_endpointaset-governance-actiongrep하면 0건이고, pools.post.governance.ts어떤 통지도 보내지 않는다. 파트너에게도, 인앱으로도, 이메일로도 아니다. 조용히 지우지 않고 지어낸 요소를 명시하는 이유는 파트너가 그 페이로드에 맞춰 개발했을 수 있어서다. 엔드포인트도, 페이로드 형태도, can_object_until 이의 제기 권리도 전부 만들어 낸 것이다. LP 민팅 webhook과 같은 부류의 결함이고 v3-115에 기록됐으며 v3-02에서 정정됐다.

미해결: 여기에 통지가 필요한가? 7일 타임락은 그것이 시작됐다고 누군가에게 알려야만 검토 창이 된다. 만들지 말지는 제품 판단이다. 지금 정직한 서술은 "이벤트를 지켜보라"이다. → 넘겨졌고 결정되지 않았다.

금액 단위: Raw와 Normalized

모든 온체인 금액 인자의 정본이다. 2026-07-31에 apps/contract/src와 대조해 확인했고, 코드에서는 apps/infra/lib/shared/contract/units.tsscripts/check-onchain-units.mjs가 강제한다.

PlatformPool이 받는 모든 금액의 스케일은 규칙 하나로 정해진다.

이 트랜잭션에서 움직이는 토큰을 가리키는 인자는 Raw다. 그 스테이블코인 자체의 소수 자릿수를 쓴다. 풀의 원장에 있는 수치를 가리키는 인자는 Normalized다. 언제나 18이다.

진입점인자토큰이 움직이나
depositamountsafeTransferFromRaw
depositYieldgrossAmountsafeTransferFromRaw
fundRedemptionamountsafeTransferFromRaw
settleYieldtreasuryAmount, poolMgmtAmountsafeTransfer ×2Raw
claimYieldamount내부에서 역정규화Normalized (18)
reinvestyieldAmount내부에서 역정규화Normalized (18)
updateNAVreserveConsumed아니다. reserveBalance를 차감한다Normalized (18). R8에 따라 항상 0이다(NO_RESERVE_CONSUMED, 반영됨)
setHardCapnewHardCap아니다. config.capacity를 대체한다Normalized (18)
createPoolminInvestment, maxInvestment, capacity, penaltyFeeAmount아니다. 입금 시점 비교에 쓴다Normalized (18)

원장(unclaimedYield, accruedYield, reserveBalance, redemptionCommitted, totalDeposited, 설정 상한)은 전부 18자리다. PoolCommonLib.normalizeAmount가 들어오는 길에 각 스테이블코인을 올리고, denormalizeAmount가 전송 순간에 다시 내린다(단위 미만 잔돈은 잘리는데, 그래서 컨트랙트가 그 잔돈을 지급하지 않고 발생분으로 남긴다).

축이 둘 더 있고 앞의 것들과 혼용할 수 없다. navPerTokennavAtRequestNAV_PRECISION = 1e6을 쓰고($1.00 == 1e6), bps 필드는 0..10000 정수다. LP 토큰 금액은 평범한 18자리 ERC-20 단위이고 수치상 Normalized와 일치한다.

문서가 아니라 타입으로 강제하는 이유

여기서 축이 틀려도 revert되지 않는다. 이 인자들에 대한 온체인 검사가 전부 상한선이었다(예전의 netAmount > unclaimedYieldreserveConsumed > reserveBalance). 그래서 1e12만큼 작은 값도 통과하고 트랜잭션이 성공하며, 오프체인 기록은 따로 계산되므로 그럴듯한 상태로 남는다. ✅ 그 두 인자는 이제 둘 다 없어졌다. reserveConsumedR8이 0으로 고정했고, settleYieldv3-131 (3)에서 netAmount를 잃었다. 홀더가 받을 몫은 호출자가 명시하는 게 아니라 시간이 결정하기 때문이다. 검증할 수 없는 인자는 문서를 더 잘 쓰는 게 아니라 없애는 게 낫다. 그런 실수 둘이 동시에 라이브였다(v3-101).

  • distributeYield(netAmount)(나중의 settleYield이고 이제 인자가 아예 제거됐다)가 6자리를 받아 발생 수익이 1e12만큼 모자랐고, 모든 투자자의 claimYieldNoYieldToClaim으로 revert됐을 것이다. 첫 실제 분배 전에 잡혔다.
  • updateNAV(reserveConsumed)가 6자리를 받아 상각이 reserve에서 사실상 아무것도 차감하지 않으면서 성공을 보고했다. (R8이 그 뒤 반영돼 updateNAV가 0이 아닌 reserveConsumedrevert한다. 그래서 이 인자는 이제 쓰이지 않는 정도가 아니라 표현 불가능하고, 위의 상한선 설명이 더 이상 적용되지 않는다. 다만 이것이 보여 주는 버그의 부류는 사라지지 않는다.)

둘 다 이미 주석에 적혀 있었다(컨트랙트의 @param netAmount ... (normalized to 18 decimals)와 백엔드의 YIELD_NORMALIZED_DECIMALS = 18 상수). 주석은 지켜지지 않았다. 그래서 축을 브랜드 TypeScript 타입으로 만들었다. RawAmount / NormalizedAmount / NavPrice이고 축이 틀리면 컴파일 오류다. parseUnits는 빌드 가드가 contract/units.ts 밖에서 금지하고, 브랜드 캐스팅은 lib/shared/contract/에 갇혀 있다.

범위: 가드는 인자 생성(parseUnits)을 덮고, v3-102 이후로는 읽기 방향(formatUnits)도 덮는다. 첫날부터 예외가 필요했을 읽기 지점 12곳은 타입 디코더(fromRaw / fromNormalized / fromNavPrice)로 옮기거나, LP 금액과 비율에 대해서는 normalizeAmount(value, <NAMED_DECIMALS>)로 옮겼다. 그래서 이 규칙이 예외 0건으로 성립한다. 읽기 쪽의 잘못된 스케일은 여전히 표시·기록 오류일 뿐이다. 위험이 달라져서가 아니라 예외 목록이 0이 됐기 때문에 강제한다.

스테이블코인 관리

PlatformPool은 풀당 여러 스테이블코인을 지원한다. 어드민이 addStablecoin(address)removeStablecoin(address)(DEFAULT_ADMIN_ROLE)로 허용 스테이블코인을 관리한다.

각 풀의 accepted_currencies(DB)가 온체인 스테이블코인 화이트리스트에 대응한다. 스테이블코인의 컨트랙트 주소는 체인마다 다르다. Base의 USDC는 Ethereum이나 Kaia의 USDC와 주소가 다르다. (함수 시그니처는 08a → §2.1 어드민addStablecoin/removeStablecoin, 온체인 검증 규칙(소수 6~18자리, 마지막 코인과 보유 잔액 가드)은 08a → StablecoinAdminLib 참조.)

멀티체인 스테이블코인 주소

스테이블코인 컨트랙트 주소는 체인마다 다르다. 발행사가 직접 배포한 네이티브 스테이블코인만 지원하고, 브리지 버전(예: USDC.e)은 디페그 위험 때문에 제외한다. 주소 조회는 STABLECOIN_ADDRESSES[chainId][symbol]이다.

체인별 주소는 풀 모델 → 스테이블코인 참조.

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

제거된 컨트랙트

컨트랙트제거 시점이유
PlatformReceiptNFTv3.0Receipt NFT는 D+7 환불 메커니즘에 쓰였다. 환불 메커니즘이 제거됐고 LP 토큰 자체가 입금을 증명한다.
PlatformEscrowFactoryv2.0 → v3.0 전환통합 PlatformPoolFactory로 대체됐다.

제거된 함수와 동작

기능제거 시점대체
D+7 자동 환불v3.0환불 메커니즘 없음. 투자 플랫폼은 보통 환불을 제공하지 않는다. 파트너 지급불능은 wind-down으로 처리한다.
FUND_ISSUED LP 민팅(FM이 외부에서 민팅)v3.0LP는 항상 Aset이 PlatformLPToken으로 민팅한다. 파트너에게는 API로 알린다.
멀티시그 상환 변형(cosign, execute-transfer)v3.0PENDING_RESERVE 폴백이 있는 어드민 단일 승인.
escrow_model enumv3.0에스크로 컨트랙트가 없다. 분리가 PlatformPool.deposit()에 인라인됐다(v3-11).
lp_issuance_model enumv3.0항상 PLATFORM_ISSUED다.

제거된 스펙(v2.x 문서에 있었으나 구현되지 않음)

개념비고
AS_POOL / FUND_POOL 바이너리 pool_type차원 모델로 대체됐다. 풀 모델 참조.
2단계 상환 승인(Operator → Admin)v3.0에서는 단일 단계다.

구조 요약

v3.0 한눈에 보기

  • 활성 컨트랙트 4개(6개에서 줄었다): PlatformPool, PlatformLPToken, PlatformKYCSoulbound, PlatformPoolFactory
  • 단일 자금 흐름(AS/FUND_POOL 분리 없음): Pool이 100%를 받아 reserve를 남기고 잔여분을 fund_wallet으로 릴리스한다
  • LP는 항상 Aset이 민팅한다: 투자자 포지션의 단일 기준
  • 중요한 변경(fund_wallet, wind-down)에 멀티시그 + 타임락
  • role 분리: Admin(멀티시그), Oracle, Yield Depositor(파트너)
  • 파트너 실패에 대비한 wind-down 메커니즘(60+30일 절차)
컨트랙트상태설명
PlatformPoolACTIVE외부 리저브 구현에서는 입금의 reserveBps 몫을 reserveWallet으로 보내고 나머지를 fundWallet으로 보낸다. 풀 내부 리저브 잔고를 적립하지 않는다. 현재 소스에는 claimRedemptionFallback이 없다. 7일 폴백을 통한 지급을 보장하지 않는다. 별도로 남아 있는 회차 정산과 대리 청구도 실제 투입 자금에 의존한다. 현재 소스의 executeWindDown()navPerToken을 재계산하지 않는다. 오라클 NAV를 유지하며 실제 지급에는 별도 자금 투입과 상환 절차가 필요하다.
PlatformLPTokenACTIVEERC-20 LP 토큰이다. 허가 없는 세컨더리 전송(화이트리스트 게이트 제거, v3-58. 검증은 가치 경계에서 강제한다)과 수익 정산을 지원한다. 풀당 LP는 언제나 하나이고, 트랜치 그룹핑은 DB 메타데이터로 한다(v3-14). 풀 하나에 LP 둘은 없다.
PlatformKYCSoulboundACTIVESoulbound KYC/KYB 토큰이다. 풀별이 아니라 전역이다. 유일하게 업그레이드 가능한 컨트랙트이고 ERC1967 UUPS 프록시에 propose/execute 게이트가 있으며 UPGRADE_TIMELOCK = 0이다(v3-30. 타임락은 v3-71에서 없앴다). 풀은 프록시 주소를 참조한다.
PlatformPoolFactoryACTIVE통합 팩토리이자 레지스트리다. poolCounter 하나를 쓰고 배포 시 풀 설정을 검증한다.
PlatformEscrowDEPRECATEDv3.0에서 제거됐다(v3-11). 10/90 분리가 PlatformPool.deposit()에 인라인됐다.
PlatformReceiptNFTDEPRECATEDv3.0에서 제거됐다(v3-03). LP가 입금 증빙이다.

컨트랙트 수: 4개(v2.x에서는 6개였다).