Money Path: 자본과 수수료 흐름
돈이 어디로 움직이는지를 처음부터 끝까지 담은 정본 지도다. 투자 자본 유입, 수익·수수료 유출, 상환 유출을 다룬다. 이 페이지가 지갑·은행계좌 네이밍과 온체인 대 오프플랫폼 경계의 단일 기준이다. 주제별 세부 동작은 04-pool-models(입금·수익), 07-redemption, 09a-custody(불변성)에 있고 이 페이지가 그것들을 통합한다.
네이밍은 v3-70, 파트너 잔여분 표현은 v3-112를 따른다. 2026-08-05에
apps/contract/src와 인덱서 라이터에 대조해 검증했고, 같은 날 위 성격에 맞게 재구성하면서 구현 깊이는 08a / 11-db-schema / 06-writedown-nav로 넘겼다.§9가 기록 모델이다. append-only 원장과 거기서 fold한 프로젝션을 다룬다(v3-122, 2026-08-11). 3~7절은 돈이 어디로 가는지를 그리고, §9는 그에 대해 무엇이 저장되고 어떤 수치가 파생되는지를 말한다.
1. 정본 네이밍
모든 지갑과 은행계좌는 세 스트림 중 하나에 속한다. 투자자 자본, 펀드매니저 수수료, Aset 수수료다. 이름은 하나의 축으로 통일했다. 온체인 지갑과 그것의 fiat 오프램프가 접두사를 공유한다.
| 스트림 | 온체인 지갑 | fiat 은행계좌 | 통제 주체 |
|---|---|---|---|
투자 자본(입금 시 릴리스되는 1 − reserve%) | fund_wallet | fund_bank_account | 파트너 / 펀드 SPV |
| 펀드매니저 수수료(Pool-management fee) | fund_fee_wallet | fund_fee_bank_account | 파트너 / 펀드 SPV |
| Aset 수수료(platform + SPC + performance) | treasury_wallet | — (Aset이 별도로 오프램프한다) | Aset |
| Reserve | reserve_wallet (reserveWallet) | 온체인 밖의 지갑 | 지갑 서명자, 풀 컨트랙트의 출금 제약 밖 |
네이밍 규칙 (v3-70)
- **
treasury_wallet**이 Aset 수수료 멀티시그의 정본 이름이다. 다른 곳에서 그냥 "treasury"라고 쓴 것도 이것을 뜻한다. (DB 컬럼도 이미pools.treasury_wallet이다.) fund_wallet≠fund_fee_wallet.fund_wallet은 입금 money-path(파트너 잔여분,amount − reserve)이고,fund_fee_wallet은 Pool-management fee만 받으며 반드시 별도 주소여야 한다.fund_fee_wallet은 구현 완료다(pools.fund_fee_wallet, 마이그레이션 0067, 온체인fundFeeWallet). 목적지와 요율은 §4 참조.- "90%"라고 쓰지 말고 "파트너 잔여분"이라고 쓸 것. 분리는
reserveAmountRaw = amount × reserveBps / 10000이고 나머지가 파트너 몫이다. 90%는 기본값reserve_bps = 1000에서의 수치일 뿐이고reserve_bps는 풀별 설정 필드다. reserve를 0으로 두고 런칭한다는 전제에서는 잔여분이 90%가 아니라 **100%**이고,reserve_bps = 2000인 풀은 80%를 보낸다. "90%"라고 쓰면 설정값이 상수로 굳는다. pool_wallet은 폐기됐다. 죽은 레거시 AS_POOL 컬럼이니 쓰지 말 것. 투자 자본 지갑은fund_wallet이다.- 현재 소스의 외부 리저브 풀은 입금의
reserve_bps몫을reserve_wallet으로 보내고 나머지를fund_wallet으로 보낸다. 풀 내부 리저브 잔고와 getter는 제거됐다. 리저브 비율은 보유 비율이 아니라 외부 지갑으로 보내는 비율이다. 외부 잔액이 풀의 지급 재원으로 자동 보충되지는 않는다. - 이 네이밍을 쓰는 데 DB 마이그레이션도 온체인 변경도 필요 없다. 라이브 식별자가 이미 일치한다. 맞추는 것은 fund-data 시트와 문서 산문뿐이다.
2. 온체인과 오프플랫폼의 경계
Aset 시스템은 처음부터 끝까지 **스테이블코인(USDC)**이다. 온체인 money path는 fund_wallet과 Pool 컨트랙트에서 시작해 거기서 끝난다. 스테이블코인과 fiat 사이의 전환은 OTC 파트너를 거쳐 펀드의 은행계좌로 가는데, 이는 Aset 플랫폼 바깥의 펀드·파트너 쪽에서 일어난다.
왜 중요한가
Aset이 증명하고 강제할 수 있는 것(불변 지급 목적지, reserve, 비수탁)은 전부 이 경계의 왼쪽에 있다. fiat 구간(OTC, fund_bank_account, fund_fee_bank_account)은 파트너 운영 영역이다. Aset은 정합 확인을 위해 계좌 정보를 기록하지만 fiat를 움직이지 않는다.
구형 자금 흐름 예시
기존 클론은 배포 당시 구현체를 유지한다. 아래에 남긴 풀 내부 리저브·즉시상환 산식은 구형 구현 기록이며 새 풀의 동작으로 사용하지 않는다. 노션 2026-09-08 결정은 기존 7개 풀을 테스트 풀로 처리하고 투자자 화면에서 제외하는 것이다. 이 문서 변경만으로 그 제외 작업이나 배포가 완료되는 것은 아니다. 현재 구조는 §6를 따른다.
3. 스트림 ①: 자본 유입(입금)
입금 시점에는 수수료를 떼지 않는다. 유일한 분리는 reserve와 릴리스되는 자본이고, 그것도 입금 트랜잭션 안에서 일어난다. 투자자 자본은 지갑을 떠난 같은 블록에 파트너에게 도착한다. 그 블록 이후는 전부 오프플랫폼이고 수동이다.
3a. 온체인 구간: 트랜잭션 하나
deposit(address stablecoin, uint256 amount)는 투자자가 서명한다. Aset 키는 관여하지 않는다. 순서는 이렇다.
- 게이트:
whenNotPaused·whenNotFrozen·whenActive(lifecycle이ACTIVE여야 한다) ·requiresKYC(msg.sender)· 허용 스테이블코인 ·minInvestment· 투자자별 누적maxInvestment·totalDeposited기준capacity. revert 이름과 정확한 조건은 08a-contract-reference 참조. - 가져오기:
safeTransferFrom(투자자 → 풀, amount)를 raw 스테이블코인 단위로 수행한다. - 분리(역시 raw 단위):
reserveAmountRaw = amount × reserveBps / 10000,partnerAmountRaw = amount − reserveAmountRaw.reserveBalance는 정규화된 reserve 값을 받는다(§3c). - 민팅: 전액 입금액에 대해
effectiveNav가격으로 LP를 민팅한다. reserve 보유는 유동성 배분이고 청구권을 희석하지 않는다(§3b). - 릴리스: 파트너 잔여분이
fund_wallet으로 전송된다(ReleasedToPartner이벤트). 이 단계에는 갈래가 하나뿐이다. 예전에는 둘이었다. hold-back 레버가 잔여분을 컨트랙트 안에 붙잡아 둘 수 있었는데, 0183에서 그 버킷과 함께 제거됐다(§6).
releaseToPartner()라는 함수는 없다
ReleasedToPartner는 이벤트다. 전송은 deposit() 끝부분에 인라인된 safeTransfer다. grep releaseToPartner apps/contract/src는 아무것도 반환하지 않는다. 운영자가 붙잡거나 재시도하거나 승인할 수 있는 별도 단계였던 적이 없다. (04 / 08 / 08a가 2026-08-05까지 이를 함수 호출처럼 적었고 지금은 이벤트로 부른다. 다른 데서 호출 형태를 발견하면 그건 낡은 문서다.)
3b. 투자자가 받는 것: LP 수량과 가격
LP는 전액 입금액에 대해 effectiveNav 가격으로 민팅된다. reserve 보유는 유동성 배분이라 투자자의 청구권을 희석하지 않는다.
- 새 풀의 첫 입금은 1:1이다. $10,000이면 10,000 LP다. 특별 케이스가 아니라 NAV가 아직 1.0이기 때문이다.
- NAV 하락 타임락 안에서는 예고된, 더 낮은 NAV로 가격이 매겨진다(R2·R3). 그래서 입금자는 큐에 있던 상각이 적용될 때 즉시 손실을 보는 대신 LP를 더 많이 받는다. 상승은 즉시 적용되므로 예고 가격이 적용되는 일이 없다.
- 반올림은 언제나 풀 쪽으로 한다(최대 LP 1 wei). 스케일과 산식은 08 → Amount Units와 08a 참조.
⚠️ 재투자는 코드에 있지만 배포되지 않았다
v3-111이 재투자도 effectiveNav로 옮겨서, 같은 블록의 같은 금액이면 재투자와 입금이 같은 LP를 민팅한다. YieldLib.reinvest가 이제 그 값을 읽는다. 다만 이 변경은 배포된 어떤 구현체에도 없고, 풀은 만들어질 때의 구현체에 고정되므로, 지금 존재하는 모든 풀에서는 큐에 있는 하락 중의 재투자가 여전히 낡은 더 높은 NAV로 민팅돼 LP를 덜 받는다.
3c. reserve 분리
raw 금액에 대해 reserveAmountRaw = amount × reserveBps / 10000을 적용하고 나머지가 파트너 몫이다. reserve는 내림하므로 잔돈(raw 1단위 이하, USDC 기준 $0.000001)은 파트너에게 간다. reserveBalance는 인덱서가 실행할 때마다(약 2분) pools.reserve_balance로 미러링된다. 반올림 근거와 서로 다른 두 하한은 08a → 자금 무결성 가드 참조.
3d. reserve_bps: 어디서 오고 언제 읽히나
- 기준값은
pools.reserve_bps다.0..10000정수 bps이고 풀 생성 시 필수이며,initialize()에서PoolConfig.reserveBps로 들어간다. - 평범하게 수정할 수 없다.
PATCH /pools/{id}에서 제외돼 있다. 바꾸려면POST /pools/{id}/governance(RESERVE_BPS)로 온체인 propose/execute를 거쳐 7일 타임락을 지나야 하고, execute 시 DB에 미러링된다. - 입금마다 실시간으로 읽고 스냅샷하지 않는다.
deposit()이 실행 시점에s.config.reserveBps를 읽으므로, 거버넌스로 바꾼 값은 이후의 모든 입금에 적용되고 이전 입금에는 적용되지 않는다.deposits테이블에 reserve 컬럼이 없어서 과거 입금이 어떤 요율을 적용받았는지는 체인에서만 복원할 수 있다.
3e. fiat 구간: 플랫폼이 돈을 보지 못하게 되는 지점
| # | 홉 | 돈을 들고 있는 주체 | 동기적인가 | 기록 위치 |
|---|---|---|---|---|
| 1 | 투자자 지갑 → Pool | Pool 컨트랙트 | 예. 온체인 atomic | Deposited → deposits 행(§3f) |
| 2 | Pool → fund_wallet | 파트너 | 예. 같은 트랜잭션 | ReleasedToPartner. ✅ 원장에 있다(money_events, kind RELEASED_TO_PARTNER) |
| 3 | fund_wallet USDC → OTC 상대방 | OTC 상대방 | 아니오. 수동, 오프플랫폼 | 🔴 어디에도 없음 |
| 4 | OTC → fund_bank_account fiat | 파트너 / 펀드 SPV | 아니오. 수동, 오프플랫폼 | 🔴 어디에도 없음 |
| 5 | fiat가 기초자산에 투입됨 | 차주 | 아니오. 파트너 운영 | 12시간 주기 fund-data 스냅샷에 집계로만 |
경계는 홉 2와 3 사이다(§2). 코드베이스에서 fund_wallet 잔액을 읽는 곳이 없고, 릴리스된 USDC와 파트너가 받았다고 보고하는 금액을 비교하는 코드도 없다. 파트너 쪽에서 들어오는 숫자는 12시간 주기 fund-data 스냅샷 필드뿐이고, 그것은 NAV 손실 비율과 읽기 전용 뷰에 쓰인다. 그래서 일주일 동안 전환되지 않고 남아 있는 fund_wallet 잔액은 Aset의 어느 화면에서도 그날 오후에 전환된 것과 구분되지 않는다. 입금별 정산 기록도, 기대 기한도, 알림도 없다.
상환의 오프플랫폼 펀딩은 정확히 거울상이고 그쪽은 계측돼 있다(컨트랙트 상태 + 스케줄러 + POST /redemption-requests/{id}/record-funding). 다만 거기서도 기록되는 것은 온체인 top-up 트랜잭션뿐이고 전환 자체는 아니다. fiat 구간은 양방향 모두 기록되지 않는다.
미해결 항목(코드로 답할 수 없고, 답이 나오기 전까지 사실처럼 적어서는 안 된다):
- OTC 상대방이 누구이고, 펀드별인지 관할별인지
- 홉 3~4의 기대 정산 기한과 무엇을 지연으로 볼지
- 릴리스된 USDC와 수령한 fiat를 누가, 얼마나 자주, 어떤 자료를 기준으로 대조하는지
- 전환되지 않은
fund_wallet잔액을 플랫폼 밖 어딘가에서 감시하는지 fund_bank_account/fund_fee_bank_account는 이름일 뿐이다.pools에도funds에도 그런 DB 컬럼이 없다. 컬럼으로 만들지 여부는 미정이다.
3f. 기록
입금은 두 번 기록된다. POST /deposits가 일반 경로이고, 인덱서의 writeDeposit이 놓친 것을 잡는 약 2분 주기 정합기다. 어느 쪽도 오프체인에서 LP를 크레딧하지 않는다. 둘 다 금액을 Deposited 이벤트에서 가져오고, API 경로는 검증된 이벤트가 없으면 422를 반환한다. 라이터 차이(출처, 멱등성, 그리고 둘 사이의 평가액 불일치)는 11-db-schema → deposits에 있다.
자금 지도에서 중요한 것은 기록되지 않는 것이다.
- 분리는 원장에 있다(§9).
Deposited와ReleasedToPartner가 원장 kind라서,reserve_balance는 체인에서 다시 읽는 게 아니라 이들에서 fold된다. 입금이 reserve를 크레딧하고 파트너 구간이 잔여분을 차감하며, 둘 다 이벤트다. (FundReleasesHeld/FundReleasesReleased도 kind였는데 0183이 hold-back을 없애면서 함께 사라졌다.) - 재투자도 같은 경로로 기록된다.
Reinvested가 원장 kind라서,POST /yield/reinvest호출이 도달하지 못한 재투자도 입금을 잡는 sweep이 주워 간다. 다른 자본 유입 이벤트처럼 워크플로 행을 갖고, 입금 목록은 이벤트 kind에서is_reinvestment를 파생한다.
3g. 실패와 진행 중 상태
- 온체인 구간은 전부 아니면 전무다.
nonReentrant트랜잭션 하나이고, revert되면 스테이블코인도 안 움직이고 LP도 안 민팅되며reserveBalance와totalDeposited도 그대로다. 부분 입금도 없고 대기 중 입금 상태도 없다. - 틈이 있는 곳은 돈이 아니라 기록이다. 온체인은 성공했는데
POST /deposits가 실패하면, 자본은 이동하고 LP는 민팅됐는데 플랫폼은 그것을 모른다. 인덱서가 기록할 때까지(입금) 또는 무기한(재투자, §3f) 그렇다. - fiat 구간에는 실패 상태 자체가 없다. 어떤 코드도 그것을 관측하거나 표현할 수 없다는 의미에서다.
- 자본 유입을 막는 게이트(
is_paused, 활성 동결,capacity,is_showcase와custody_mode = 'MIRROR'. 뒤 둘은 백엔드 전용이다)는 입금이 일어나는지를 바꿀 뿐 돈이 어디로 가는지를 바꾸지 않는다. 조건과 revert 이름은 08a 참조. ⚠️capacity는 두 번 강제된다. 온체인에서는totalDeposited기준, 오프체인에서는pools.tvl기준인데 두 카운터를 맞추는 것이 아무것도 없다.
3h. 계산 예시
reserve_bps = 1000, navPerToken = 1.02, 대기 중인 NAV 변경이 없는 풀에 $10,000을 넣는 경우:
온체인, 한 tx
reserve $1,000 → reserveBalance로 남음
릴리스 $9,000 → fund_wallet (ReleasedToPartner 발생)
LP 민팅 10,000 / 1.02 = 9,803.921568… (Deposited 발생)
오프체인, 수 초 뒤
deposits +1 COMPLETED · pools.tvl +10,000 · 포지션 +9,803.92 LP
오프플랫폼, 기한 없음, 기록 없음
fund_wallet $9,000 USDC ──OTC──► fund_bank_account (현지 통화)
▲ Aset이 증명할 수 있는 마지막 수치 ▲ 기록도 확인도 기한도 없음$9,000은 reserve_bps = 1000일 때 이 풀의 잔여분이지 상수가 아니다. §1의 네이밍 규칙 참조.
여기서는 reserve 잔돈이 생기지 않는다. 10,000 × 1000 / 10000이 정확히 나눠떨어진다. $10,000.000001이었다면 reserve는 같은 $1,000으로 내림되고 나머지 $0.000001이 파트너에게 갔을 것이다.
재투자도 같은 money-path를 탄다 (E1)
reinvest(stablecoin, amount)는 발생 수익을 LP로 바꾸고 똑같이 분리한다. reserve_bps가 남고 잔여분이 릴리스되므로, 재투자된 원금도 풀에 쌓이지 않고 신규 자본과 정확히 같은 방식으로 파트너가 운용한다. stablecoin은 릴리스 통화를 고르는 것이고, 풀은 depositYield로 이미 그 통화를 들고 있다.
차이는 위에서 다룬 둘뿐이다. 가격(§3b, v3-111 대기)과 정합기 부재(§3f)다. 재투자 자체의 게이트(allowRollover, minReinvestAmount, 지갑이 아니라 정산된 accruedYield를 쓰므로 ERC-20 승인이 필요 없음)는 05-investment-lifecycle 참조.
4. 스트림 ②: 수익과 수수료
수익은 파트너에게서 온다. 모든 수수료는 여기서 뗀다(입금 시점에는 절대 떼지 않는다). 수수료 금액은 net_yield_fee_config를 바탕으로 Aset Lambda가 오프체인에서 계산한 뒤, 투자자 크레딧과 수수료 지급을 함께 처리하는 단일 settleYield 호출로 온체인에 적용한다(v3-102).
수수료 분류: 목적지 셋
수수료 목적지의 정본은 여기다. §1은 이름만 담는다.
| 수수료 | net_yield_fee_config 키 | 부과 기준 | 목적지 | 상태 |
|---|---|---|---|---|
| Aset 플랫폼 | platform_yield_take_bps | 총 수익 | treasury_wallet | ✅ 운영 중 |
| Aset SPC mgmt | spc_mgmt_bps | AUM × 일수/365 | treasury_wallet | ✅ 운영 중 |
| Aset 성과 | perf_fee_bps / perf_hurdle_bps | hurdle를 넘는 총 수익 | treasury_wallet | ⏸️ 휴면. 요율을 설정하는 어드민 UI가 없어 항상 0으로 계산된다 |
| FM Pool-mgmt | pool_mgmt_bps | AUM × 일수/365 | fund_fee_wallet | ✅ 운영 중 |
목적지는 둘이고, 둘 다 net 크레딧과 같은 트랜잭션의 단일 settleYield 호출 안에서 지급된다(목적지는 v3-69, atomic 처리는 v3-102). 키는 마이그레이션 0075 이후 정수 bps다. 그 전의 admin_fee_pct / perf_fee_pct 같은 percent 필드는 더 이상 존재하지 않는다.
계산 예시(Joob의 {platform_yield_take_bps: 100, perf_fee_bps: 2000, perf_hurdle_bps: 1500}, 총 수익 $1,000):
platform_take = 1,000 × 100 / 10000 = $10
perf_fee = $0 ← 휴면. 요율이 설정된다면 $200(2000 bps)이 될 것
net = 1,000 − 10 = $990
Pool.settleYield(usdc, 990, 10, 0) # 990 → LP 홀더, 10 → treasury_wallet (한 tx)[오프플랫폼] FM이 fund_fee_wallet의 USDC를 OTC로 fiat로 바꿔 fund_fee_bank_account로 보낸다. 입금과 같은, 기록되지 않는 구간이다(§3e).
5. 스트림 ③: 자본 유출(상환)
상환에는 고정 주소 로직이 없다. Pool 안에서 reserve가 먼저 지급하고, 부족분은 파트너가 채운다. 지급은 언제나 원래 요청자(request.investor)에게 간다. 운영자가 설정할 수 있는 지급 주소는 없다.
- reserve가 가능하면 즉시 지급한다. 아니면
transfer_source = FUND가 되고 파트너가fundRedemption()으로 보충한다. fundRedemption은YIELD_DEPOSITOR_ROLE로 role 제한돼 있다(그 role은fund_wallet이 갖고, 7일 타임락으로만 바꿀 수 있다). 하드코딩된 주소가 아니다.- 파트너의 펀딩 구간은 입금과 대칭이고 똑같이 기록되지 않는다.
fund_bank_account→ OTC → USDC →fundRedemption이 오프플랫폼이다(§3e). - 즉시 방식과 회차 방식의 메커니즘은 07-redemption 참조.
6. 외부 리저브와 상환 재원
현재 소스의 외부 리저브 풀은 입금의 reserve_bps 몫을 reserve_wallet으로 보내고 나머지를 fund_wallet으로 보낸다. 풀 내부 리저브 잔고와 getter는 제거됐다. 리저브 비율은 보유 비율이 아니라 외부 지갑으로 보내는 비율이다. 외부 잔액이 풀의 지급 재원으로 자동 보충되지는 않는다.
7일 무권한 청구 폴백 claimRedemptionFallback은 제거됐다. 회차 정산과 청구는 실제 투입된 자금에 의존한다. 운영 SLA 전환은 승인됐지만 응답 기한이나 지급 보장을 이 문서에서 만들지 않는다. 이는 별도로 남아 있는 회차 정산·대리 청구 기능을 제거한다는 뜻이 아니다.
7. 청산 가격과 지급 자금
현재 소스의 executeWindDown은 오라클이 설정한 NAV를 유지한다. 외부 지갑 잔액으로 NAV를 재계산하지 않으며, NAV가 존재한다는 사실이 지급 자금 확보를 뜻하지 않는다.
근거: PoolLedgerLib.releaseReserveShare, GovernanceLib.executeWindDown, RedemptionLib 및 노션 §1-4·§7-9·§8-2·§8-3. 수탁에 관한 대체 법무 문구는 별도 검토 대상이다.
8. 불변성 요약
| 경로 | 변경 가능성 |
|---|---|
상환 지급(request.investor) | 🔒 불변 |
수익 청구(msg.sender, 보유량에서 파생) | 🔒 불변 |
| Reserve path | reserveWallet 변경에는 7일 거버넌스 타임락을 적용한다. 외부 지갑 잔액은 풀이 보유하지 않는다. |
파트너 잔여분 목적지(fund_wallet) | 🔒 경로는 불변, 🟡 지갑은 변경 가능(7일 타임락) |
수수료 수령(treasury_wallet) | 🟡 변경 가능. 7일 타임락(Aset 자신의 돈) |
fund_wallet(파트너 잔여분 목적지) | 🟡 변경 가능. 7일 타임락(파트너 변경) |
Aset은 제한된 hot key(ORACLE_ROLE)를 보유하는데, 목적지가 고정된 함수만 호출할 수 있어 자금 방향을 바꿀 수 없다. 전체 모델은 09a-custody 참조.
9. 이동은 어떻게 기록되나: 원장과 그 프로젝션
3~7절은 돈이 어디로 가는지를 그린다. 이 절은 기록 모델이다. 플랫폼이 그 이동에 대해 무엇을 저장하는지, 어떤 수치가 사실이고 어떤 것이 파생인지를 다룬다.
9a. 구조
저장소가 세 종류이고, 그 차이가 곧 모델 전체다.
| 담는 것 | 쓰는 주체 | |
|---|---|---|
money_events | 돈을 움직인 컨트랙트 로그 한 건당 행 하나 | ingest. append-only라 행이 갱신되거나 삭제되지 않는다 |
| 프로젝션 | 모든 잔액 | fold. 이벤트에서 다시 만든다 |
| 워크플로 테이블 | 우리 프로세스에 대한 사실 | 핸들러와 usecase |
잔액을 결정하는 것은 원장뿐이다. 프로젝션은 그 앞의 이벤트들에 대한 순수 함수다. 같은 이벤트를 두 번 적용해도 같은 답이 나오고, 어떤 수치든 추론하는 대신 즉석에서 다시 계산할 수 있다. scripts/money/replay.ts가 바로 그 질문을 소리 내어 던지는 것이다. 다시 fold해서 차이를 보고하고, --write를 주면 고친다.
워크플로 테이블은 어떤 로그에도 없는 것을 담는다. 운영자의 승인과 그 주체, 거부 사유, 파트너의 펀딩 기한, 투자자의 리스크 확인, 실패한 제출 같은 것들이다. 이들은 이동한 돈이 아니라 우리 프로세스를 서술하고, 따로 두는 덕분에 원장이 순수하게 사실만 담는다.
파생 목록은 둘을 함께 보는 뷰다. money_deposit_list, money_redemption_list, yield_claim_list가 원장과 워크플로 행을 조인해 화면이 읽는 형태로 제공한다. 상태는 어떤 이벤트가 존재하는지에서 파생되므로, 상환이 COMPLETED인 이유는 원장이 그 완료를 담고 있기 때문이다.
9b. 원장으로 들어오는 두 경로
이벤트는 두 가지 방식으로 money_events에 도달하고, 이는 의도적인 중복이다. 같은 append를 서로 다른 실패 양상 아래 두 번 시도하는 것이다.
Fastpath(lib/money/ingest/fastpath.ts). 트랜잭션 해시를 아는 핸들러가 그 영수증 하나를 가져와 로그를 디코딩하고 즉시 append한다. 그래야 투자자가 자기 입금을 보려고 sweep을 기다리지 않는다. 설계상 실패해도 치명적이지 않다. 실패해도 돈은 온체인에 있고 sweep이 주워 가며, 여기서 요청을 실패시키면 실제로 일어난 트랜잭션을 두고 일어나지 않았다고 말하는 셈이 된다. 호출자가 이벤트를 만들지 못한 시도를 기록할 수 있도록 found(채굴됐는지)와 reverted(채굴됐는데 실패했는지)를 반환한다.
Sweep(lib/shared/indexer/engine.ts). 체인마다 커서를 읽고, head − confirmations까지 청크 단위로 폴링하고, 각 로그를 순서대로 적용하고, 청크마다 커서를 전진시켜 중간까지의 진행이 크래시에도 살아남게 한다. eth_getLogs 창은 적응형이라 제공자의 범위 제한 오류가 나면 줄어들고 체인별로 추적된다. 실행당 블록 예산이 있어서 오래 멈춰 있던 체인이 호출 하나를 독점하지 못한다.
두 경로는 서로가 뭘 했는지 알 필요가 없다. UNIQUE (chain_id, tx_hash, log_index)가 무엇이 새 것인지 결정하고, 정말로 새로운 행만 알림과 워크플로 쓰기를 유발하므로 같은 블록을 다시 읽어도 부작용이 없다.
커서를 head에서 시작하는 이유
커서가 없는 체인에서는 sweep이 제네시스부터 다시 스캔하지 않고 현재의 안전한 head에서 시작한다. 그래서 새 체인은 추가된 시점부터 앞으로만 인덱싱되고 그 이전은 읽지 않는다. 앞선 구간을 복구하는 것은 의도적인 별도 작업이다(커서를 옮겨 두고 sweep을 돌린다). 첫 실행이 알아서 하는 일이 아니다.
9c. 인덱서가 무엇을 감시하나
eth_getLogs는 고정된 목록으로 필터링한다. 그래서 목록에 없는 이벤트는 가져오지도, 디코딩하지도, 원장에 도달하지도 않는다. 조용히 그렇게 된다. 그래서 목록을 각 이벤트의 용도로 나눠 두고, 모든 원장 kind가 목록에 있는지 테스트가 확인한다.
| 이벤트 | 효과 | |
|---|---|---|
| 자금 | Deposited · ReleasedToPartner · YieldDeposited · YieldDistributed · FeesWithdrawn · YieldClaimed · Reinvested · RedemptionRequested · RedemptionFunded · RedemptionCompleted · EpochSettled · RedemptionClaimed · RedemptionFallbackClaimed · NavUpdated · NavUpdateApplied · LP Transfer | money_events에 append한 뒤 fold |
| Lifecycle과 거버넌스 | EmergencyFrozen · EmergencyUnfrozen · ImpairmentExecuted · WindDownExecuted · LifecycleStatusChanged · FundingRestrictedSet | pools에 미러링. 돈이 아니라 상태다 |
| 스케줄 | EpochScheduleSet · EpochFundingDateSet · EpochSettleAfterSet · EpochFundingNeeded | redemption_epochs / pools에 미러링 |
| 금액이 없는 종료 결과 | RedemptionCancelled · RedemptionRejected · RedemptionPendingReserve | money_redemption_workflow에 기록 |
마지막 행이 이 분리를 설명한다. 이 이벤트들은 금액을 담지 않고, 각자가 돌려주는 LP는 자기 safeTransfer가 내는 Transfer로 이미 원장에 있다. 이것으로 원장 행을 만들면 갖고 있지 않은 숫자를 주장하거나 같은 이동을 두 번 세게 된다. 이들이 담는 것은 이유이고, 그건 워크플로 사실이다.
lifecycle 미러링은 편의가 아니라 필수다. executeWindDown은 허가가 필요 없고 멀티시그가 executeImpairment를 직접 호출할 수 있어서, 백엔드 핸들러가 유일한 라이터가 아니다. 이 미러링이 없으면 체인은 WIND_DOWN인데 미러는 여전히 입금을 광고할 수 있다.
9d. append 이후에 도는 것
ingestLogs가 append한 뒤, 정말로 새로운 행에 대해서만 usecase를 돌리고 해당 풀의 프로젝션을 다시 만든다.
- usecase(
lib/money/usecases/)가 원장 행 id를 키로 워크플로 행을 만들고 알림을 보낸다. 따라서 알림은 그 뒤의 체인 사실 없이 존재할 수 없고, 같은 블록을 다시 읽어도 두 번 발송되지 않는다. 우리 핸들러가 보지 못한 이벤트에 대해서도 행을 만든다. 컨트랙트를 직접 호출해 시작한 상환도 워크플로 기록을 갖는데, 결정이 없었으므로 결정 항목은 비어 있다. - **
rebuildPoolProjections**가 그 풀을 이벤트에서 다시 fold해money_pool_state,money_positions, 미러 컬럼을 쓴다. 풀의 스테이블코인 소수 자릿수와 회차 풀 여부를 받는데, 둘 다 로그에서 읽을 수 없고 둘 다 계산을 바꾸기 때문이다.
9e. 불변식
| 규칙 | 성립하는 이유 |
|---|---|
UNIQUE (chain_id, tx_hash, log_index)가 유일한 멱등성 키다 | 모든 라이터가 하나의 키에 동의한다. 두 번째 관례를 두면 "이걸 본 적 있나"에 대한 답이 둘이 된다 |
재생 순서는 (chain_id, block_number, log_index)이고 절대 id가 아니다 | id는 삽입 순서다. fastpath가 특정 트랜잭션을 먼저 넣고 sweep이 나중에 그 주변을 채우므로, 두 순서는 일상적으로 어긋난다 |
| 모든 행이 자기 금액의 축을 기록한다. raw / normalized(18) / LP(18) / NAV(1e6) | 같은 종류의 두 금액은 같은 스케일을 써야 하고, 가격과 토큰 수는 그럴 수 없다. 로그는 스테이블코인 소수 자릿수를 담지 않으므로 축을 디코딩 시점에 기록하고 프로젝션이 변환한다 |
| 이벤트는 프로젝션이 필요로 하는 모든 숫자를 담아야 한다 | fold는 로그가 빠뜨린 것을 복원할 수 없다. EpochSettled는 예약한 총액과 정산한 LP를 보고하고, RedemptionClaimed와 RedemptionCompleted는 지급액과 함께 패널티 항목도 보고한다 |
| fold 말고는 아무것도 프로젝션 컬럼에 쓰지 않는다 | 한 수치의 라이터가 둘이면 조용히 어긋난다. 둘 다 그럴듯한 숫자를 만들어 내기 때문이다 |
9f. 무엇이 파생이고 무엇이 아닌가
| 수치 | 출처 |
|---|---|
pools.tvl · reserve_balance · lp_total_supply | 원장에서 fold |
portfolio_positions의 토큰, 원금, 취득원가, 락업 기준점, 출처 | 원장에서 fold |
accrued_yield · claimable_yield | fold. 컨트랙트의 누적 계정을 항 단위로 재현한다(§9g) |
pools.nav_per_token | fold가 아니다. NAV는 오라클이 시작한다. 백엔드가 계산해 updateNAV를 호출하고, DB 쓰기는 확정된 온체인 결과를 따른다. 인덱서는 비교해서 어긋남을 로그로 남길 뿐 쓰지 않는다 |
money_redemption_workflow.epoch_id | 요청이 도착할 때 epochRequests()에서 읽는다. RedemptionRequested가 이를 담지 않고, acceptingEpochAt은 마감 전후로 다른 회차를 반환하므로 타임스탬프로는 복원할 수 없다 |
승인, 거부 사유, 펀딩 기한, risk_ack, 실패 | 워크플로 테이블. 어떤 로그에도 없다 |
원장이 가격을 매기지 않은 풀에는 가격이 없다
money_pool_state.nav_per_token은 nullable이고 null은 par가 아니다. NAV는 오라클이 정하므로, 첫 원장 이벤트가 생기기 전에 상각된 풀은 원장이 기록하지 않은 가격을 갖고 있다. null을 $1.00으로 읽으면 그 홀더들을 풀의 실제 가치보다 높게 가격 매기게 된다.
가격의 기준은 pools.nav_per_token이다.
9g. 수익은 누적 인덱스로 발생한다
⚠️ v3-131 (3)에 맞춰 다시 썼다. 2026-08-20 반영. 지분당 누적 방식(accumulatedYieldPerShare + yieldDebt)은 없어졌다. 그 방식에는 시간에 대한 기억이 없어서 "이 시점부터는 수익 없음"을 잔액을 옮기는 것으로만 표현할 수 있었다. 그래서 LP를 에스크로하면 그 홀더가 벌지 못하게 됐고, 에스크로된 몫은 아무에게도 속하지 않게 됐다.
컨트랙트는 여전히 홀더별로 수익을 배분하지 않는다. 대신 인덱스를 누적한다. yieldIndex가 풀이 약속한 요율로 시간에 따라 오르고, 풀의 채무도 그에 따라 totalLpSupply − poolHeldLp에 대해 오른다. 인식된 채무만 해당하고, 열려 있는 에스크로의 몫은 경계에 도달할 때까지 요청 단위로 보류된다(previewEscrowAccrual). 홀더의 구간은 손이 닿을 때마다 그 인덱스에 대해 가격이 매겨지고(FixedYieldEngine.settleHolder, onLpTransfer가 구동한다), 그래서 떠나는 것이 해가 되지 않는다. 적립된 청구권은 금액이므로 잔액이 0이어도 남는다.
현금과 권리는 별개이고, 그 분리가 요점이다. settleYield는 파트너 현금을 잔존 비율 래더(G, H)에 적용해, 부분 지급이 먼저 요청한 사람에게 몰리는 대신 모든 미결 청구권을 같은 비율로 줄인다. 그래서 홀더마다 버킷이 둘이다. claimableYield(펀딩됐고 인출 가능)와 creditAccrued(발생했고 파트너를 기다린다)다.
프로젝션도 같은 방식을 fold한다.
YieldDistributed가 자신이 기록한 지분당 값을 발생시키므로, fold는 추측해야 할 공급량으로 나누는 대신 그 상승분을 읽는다. 컨트랙트의 분모는totalSupply − escrowedLP인데, 이탈 대기 중인 LP는 벌지 않기 때문이다- 모든 LP 전송이 잔액이 움직이기 전에 양쪽 끝을 정산한다. 그래서 기간 중간에 나간 홀더가 자기가 있던 기간의 수익을 유지한다
claimable_yield는pendingYield, 즉 적립분과 미정산분의 합이고 그것이 청구가 지급하는 값이다
yield_distribution_investors의 투자자별 배분은 각 홀더의 잔액에 그 발생한 yieldPerShare를 곱한다. 계산에 분모가 없으므로 지급액과 어긋날 수 없다. 귀속된 합계가 크레딧된 합계에 못 미치면 그 차이는 소유자를 찾지 못한 지갑이 보유한 LP이고, 그 간극은 로그로 남는다.
9h. 원장이 보지 못하는 것
완전해 보이는데 실제로는 그렇지 않은 집계는 집계가 없는 것보다 나쁘므로 경계를 명시한다.
- revert된 트랜잭션. revert는 상태와 함께 로그도 되돌리므로
eth_getLogs가 아무것도 반환하지 않는다. 실패는 트랜잭션이 온다고 알려 준 그 요청에서만 관측할 수 있고, 워크플로 행에TX_REVERTED로 기록된다. 어드민 집계 이름이 **"Failed Submissions"**인 이유가 그것이다. 플랫폼이 통보받은 시도만 세고, 지갑에서 직접 서명한 뒤 탭을 닫은 사람은 아무 흔적도 남기지 않는다. - fiat 구간(양방향 모두, §3e).
- 리셋 이전 이력. 원장은 체인이 아직 로그를 갖고 있는 것, 즉 입금과 상환을 다룬다. NAV, 회차 스케줄, 수익 분배는 재생할 로그를 남기지 않으므로 각자를 소유한 워크플로 테이블에 있다. 리셋 이전 스냅샷을 폐기할 때 복사해 온 것이지 파생한 게 아니다.
RedemptionRequested가 넓어지기 전에 발생한 상환은 현재 ABI로 아예 디코딩되지 않는다. Clones에 고정된 동작. 풀은 생성 시점의 구현체로 돌기 때문에, 프로젝션이 맞는데도 체인이 옛 규칙으로 지급할 수 있다. 그래서 종착이고 되돌릴 수 없는 wind-down NAV는 한 번 더 유도해 비교한다. 미러는 어느 쪽이든 컨트랙트의 숫자를 기록한다. 지급하는 것은 컨트랙트이기 때문이다.
9i. 정합 확인
상시 검사가 셋이고, 각각 하나를 믿는 대신 독립적으로 유도한 값을 비교한다.
| 검사 | 비교 대상 |
|---|---|
scripts/money/replay.ts | 프로젝션과 원장을 새로 fold한 결과 |
| 인덱서(실행할 때마다) | reserve_balance와 lp_total_supply를 컨트랙트 직접 조회와 비교하고 LEDGER DRIFT를 로그로 남긴다 |
yield.scheduler.reconcile(매시간) | claimable_yield와 온체인 pendingYield |
| wind-down NAV | 실행 시점에 컨트랙트의 값과 원장의 버킷 |
체인에서 원장을 다시 만드는 방법은 sweep과 scripts/money/replay.ts --force다. sweep이 블록을 다시 읽어 appendToLedger로 append하고, replay가 그때 원장이 담고 있는 것에서 모든 프로젝션을 다시 만든다. 둘 다 현재 ABI로 동작한다.
scripts/ledger-recovery/가 리셋 이전 풀들에 대해 이 일을 했고 지금은 없어졌다(0176). 재배포 이전 ABI 스냅샷으로 디코딩했는데, 2026-08-11 재배포가 이벤트 넷을 넓혔다. 그 ABI들은 이제 필드에 닿기 전에 topic0에서 실패하고, 그것을 쓰던 풀은 전부 DB에서 제거됐다.
9j. 더 읽을 것
결정 기록은 v3-122에, 테이블·뷰·컬럼은 11-db-schema에 있다.
관련 문서
- 04-pool-models: 입금·수익 메커니즘, reserve 분리, 트랜치
- 07-redemption: 즉시 상환과 회차 상환
- 08-smart-contracts · 08a-contract-reference: 컨트랙트 함수와 역할
- 09a-custody: money-path 불변성과 비수탁
- 11-db-schema:
fund_wallet,treasury_wallet,reserve_balance,net_yield_fee_config - 14-decisions: v3-70(네이밍 통일), v3-69(수수료 분리), v3-122(원장)