홀더 검증 (5-State 모델)
일부 구현됨. State C 생산자 미구현
Joob API 연동 과정에서 설계한 통합 홀더 검증 모델이다. 세 시나리오를 겨냥한다. 읽기 전용 표시(Joob 레거시), 실제 투자(Aset 직접), 세컨더리 마켓(Tokocrypto)이다. 상태 판정기는 프론트엔드에 완성돼 있고 portfolio_positions.source 출처 스키마도 살아 있지만, State C 백엔드 생산자(LEGACY_SEEDED, SECONDARY_PURCHASE, 그리고 E→C 지갑 연결 정합기)는 아직 만들어지지 않았다(정합기 Lambda 참조). 그래서 Aset이 이미 아는 지갑에 대해서는 읽기 전용 경로가 동작하지만, 온보딩한 적 없는 레거시(Joob RPS)나 세컨더리 마켓 홀더에 대해서는 포지션이 생성되지 않는다.
문제
Joob 레거시 RPS 홀더를 온보딩하고 세컨더리 마켓(Tokocrypto)을 계획하면서 서로 다른 "홀더" 시나리오가 셋 나왔다. 통합 모델이 없으면 병렬 코드 경로를 계속 유지해야 한다.
| 시나리오 | 예시 | 문제 |
|---|---|---|
| 읽기 전용 표시 | Kaia 위의 Joob 레거시 RPS | 유저 지갑에 토큰은 있는데 Aset DB에 행이 없음 |
| 실제 입금 | Aset 직접 풀, Joob 신규 투자자 | 표준 deposits → portfolio_positions 플로우 |
| 세컨더리 마켓 | Tokocrypto에서 LP 토큰 재판매 | 매수자가 입금한 적 없이 LP를 보유 |
이 셋은 같은 투자자 경험(포트폴리오 페이지, 수익 청구, KYC 안내)을 필요로 하지만 진입·이탈 메커니즘이 다르다.
해법: 5-State 모델
상태 머신 하나, 상태 다섯 개다. 시나리오가 다르다는 것은 상태 전이가 다르다는 뜻이고, 모든 지갑은 어느 시점에나 정확히 하나의 상태에 있다.
| 상태 | 라벨 | 온체인 LP 잔액 | SBT 민팅 | Aset DB 행 | 수익 청구 | 상환 |
|---|---|---|---|---|---|---|
| A | 검증된 투자자 | 있음 | 있음 | 있음 | 가능 | 가능 |
| B | 검증됐으나 이력 없음 | 없음 | 있음 | 없음 | — | — |
| C | 미검증 홀더 | 있음 | 없음 | 선택적 | 불가 | 불가 |
| D | 신원만 확인 | 없음 | 대기 중 | 없음 | — | — |
| E | 미등록 방문자 | 없음 | 없음 | 없음 | — | — |
모델 읽는 법
"상태"는 지갑에 대한 세 가지 독립적인 사실의 조합이다. 지갑이 상태를 고르는 게 아니라, (L1 잔액, L2 SBT, L3 DB 행)에 따라 어떤 상태에 있는 것이다. 권한은 상태에서 따라 나온다.
3계층 구조
다섯 상태는 독립적인 세 계층에서 파생된다.
┌────────────────────────────────────────────────────────────┐
│ L3 권리 (DB) portfolio_positions 행 │
│ ↑ 결정하는 것: 수익 지분, 상환 권리, UI 라벨 │
├────────────────────────────────────────────────────────────┤
│ L2 신원 (컨트랙트) PlatformKYCSoulbound SBT │
│ ↑ 담는 것: 레벨, 관할(ISO alpha-3), 만료, 취소 여부 │
│ 미국 플래그는 없음. 미국인 여부는 국가 코드에서 파생 │
│ (03-kyc-identity 참조) │
├────────────────────────────────────────────────────────────┤
│ L1 소유 (컨트랙트) LP / RPS / 향후 트랜치 토큰 │
│ ↑ 기준: 지갑별 balanceOf() │
└────────────────────────────────────────────────────────────┘| 계층 | 출처 | 변경 주체 |
|---|---|---|
| L1 소유 | LP / RPS 컨트랙트의 balanceOf(wallet) | Pool 컨트랙트(민팅·소각)와 홀더(허가 없는 전송, pause만 게이트) |
| L2 신원 | PlatformKYCSoulbound 속성(레벨, 관할, 발급·만료, 취소). 미국인 여부는 별도 확인 플래그가 아니라 국가 코드에서 파생된다(03-kyc-identity) | SumSub webhook → Aset 오라클 |
| L3 권리 | portfolio_positions 행 | Aset 정합기 Lambda와 입금·상환 핸들러 |
상태 정의
State A: 검증된 투자자
전형적인 "투자자" 상태다. Aset 입금 이력이 있고 KYC를 마쳤으며 LP를 보유한다.
- L1:
balanceOf(wallet) > 0 - L2: SBT가
MINTED이고 만료되지 않음 - L3:
portfolio_positions.tokens > 0
허용 동작: 입금, 상환, 수익 청구, 재투자, LP 전송(허가 불필요. 수신자는 검증이 필요 없고 KYC를 마칠 때까지 State C에 머문다. v3-58).
State B: 검증됐으나 이력 없음
KYC를 마치고 SBT를 받았지만 현재 포지션이 없는 지갑이다. 전량 상환했거나 아직 입금한 적이 없다.
- L1:
balanceOf(wallet) == 0 - L2: SBT가
MINTED이고 만료되지 않음 - L3: 활성
portfolio_positions행 없음(또는tokens = 0)
허용 동작: 풀 둘러보기, 입금(입금하면 A가 된다).
State C: 미검증 홀더
온체인에 토큰은 있지만 Aset에 등록한 적이 없는 지갑이다. 하위 경우가 셋이다.
- 레거시 홀더: Joob API 연동 이전부터 RPS를 보유하던 홀더(ShardLab, Hashed)
- 세컨더리 매수자: Aset KYC를 거치지 않고 Tokocrypto에서 LP를 매수한 경우
- 전송 수신자: 다른 지갑에서 LP를 받은 경우(허가 없는 전송이라 화이트리스트 게이트가 없다. 다만 KYC를 마치기 전엔 상환·청구를 못 한다)
- L1:
balanceOf(wallet) > 0 - L2: SBT 없음
- L3:
source = LEGACY_SEEDED/TRANSFER_IN/SECONDARY_PURCHASE인 행이 선택적으로 존재(정합기가 추가)
허용 동작: 포트폴리오 열람(읽기 전용), 지갑 연결, KYC 완료 시 A로 전이.
State C에서는 수익과 상환이 막힌다
신원이 검증되지 않으면 Aset은 법적으로 수익을 분배하거나 상환을 처리할 수 없다. UI는 포지션을 보여주되 청구·상환 CTA를 막고 KYC 안내 모달을 띄운다.
State D: 신원만 확인
KYC 제출은 끝났고 SBT 민팅이 대기 중인 지갑이다(오라클 지연, 가스 문제, 재시도).
- L1:
balanceOf(wallet) == 0 - L2: SBT가
NOT_MINTED이면서sbt_mint_queued_at이 설정됨(민팅 큐 적재), 또는FAILED. (PENDING이라는 값은 없다.sbt_status ∈ {NOT_MINTED, MINTED, FAILED}이고, 큐 적재·민팅 중 상태는NOT_MINTED+sbt_mint_queued_at이 NULL이 아닌 것으로 표현한다.) - L3: 행 없음
허용 동작: 랜딩·마케팅 페이지 열람. 투자 CTA는 "검증 진행 중" 툴팁과 함께 비활성화된다. SBT가 민팅되면 자동으로 B가 된다.
State E: 미등록 방문자
신원도 토큰도 없다. 사이트에 막 들어온 상태다.
- L1: 0(또는 지갑 미연결)
- L2: SBT 없음
- L3: 행 없음
허용 동작: 풀 둘러보기, 문서 읽기. 투자 CTA를 누르면 지갑 연결 안내를 거쳐 KYC 플로우로 간다.
상태 전이
전이 트리거 요약
- E → D: SumSub
applicantCreatedwebhook - D → B:
PlatformKYCSoulbound.mint()성공 - D → E:
reject_type = FINAL인kyc_status = REJECTED - B → A:
Pool.deposit()과portfolio_positions행 생성 - A → B: 전량 상환으로
portfolio_positions.tokens = 0 - E → C / B → A: 지갑 연결. 정합기가
balanceOf()를 스캔해source태그가 붙은portfolio_positions행을 만든다 - C → A: KYC 완료 → SBT 민팅 → 기존
portfolio_positions행 활성화 - A → C: SBT 소각(취소). 포지션은 남지만 동작이 막힌다
권한 매트릭스
| 동작 | A | B | C | D | E |
|---|---|---|---|---|---|
| 공개 풀 열람 | 가능 | 가능 | 가능 | 가능 | 가능 |
| 본인 포트폴리오 열람 | 가능 | 가능 | 가능(읽기 전용) | — | — |
| 입금 | 가능 | 가능 | 불가(KYC 안내) | 불가 | 불가(지갑 연결 + KYC 안내) |
| 수익 청구 | 가능 | — | 불가(KYC 안내) | — | — |
| 상환 | 가능 | — | 불가(KYC 안내) | — | — |
| 재투자 | 가능 | — | 불가 | — | — |
| LP 전송(보내기) | 가능(허가 불필요) | — | 가능(허가 불필요) | — | — |
| LP 전송(받기) | 가능 | 가능 | 가능(source=TRANSFER_IN으로 C가 됨) | 불가 | 불가 |
LP 전송은 검증으로 막히지 않는다. LP를 보유한 지갑이면 온체인에서 개인 간 전송을 할 수 있다. 검증은 가치 경계(입금, 상환, 수익 청구에 유효한 KYC 필요)에서만 강제되고 전송 자체에는 걸리지 않는다. 긴급 상황에서는 어드민
pause()로 모든 세컨더리 전송을 멈출 수 있다. 따라서 State C 지갑은 LP를 옮길 수는 있지만, KYC를 마쳐 State A가 되기 전까지 상환이나 청구는 못 한다. v3-58 참조.
구현 지도
스키마 (portfolio_positions)
ALTER TABLE portfolio_positions ADD COLUMN source TEXT
CHECK (source IN ('DEPOSIT', 'TRANSFER_IN', 'SECONDARY_PURCHASE', 'LEGACY_SEEDED'));
ALTER TABLE portfolio_positions ADD COLUMN entry_price NUMERIC; -- NAV at acquisition
ALTER TABLE portfolio_positions ADD COLUMN entry_tx_hash TEXT;
ALTER TABLE portfolio_positions ADD COLUMN last_reconciled_at TIMESTAMPTZ;source 열거값으로 할 수 있는 것:
- Joob 마이그레이션 시점의 레거시 시드를 태깅(
LEGACY_SEEDED) - Aset이 관여하지 않은 개인 간 전송을 태깅(
TRANSFER_IN) - 세컨더리 마켓 매수를 태깅(
SECONDARY_PURCHASE) - 표준 입금은
DEPOSIT
정합기 Lambda
역할이 둘이다. (a) portfolio_positions.tokens를 온체인 잔액과 맞추고, (b) 각 보유분을 어떻게 얻었는지(source) 태깅한다.
잔액 동기화(주기 실행, 초기에는 매시간):
- 알려진 지갑마다 모든 LP/RPS 컨트랙트에 대해 온체인
balanceOf()를 조회 portfolio_positions.tokens와 비교- 온체인이 더 크면 행을 생성하거나 갱신하고
source를 설정(아래 참조) - 온체인이 더 작으면 상환 이벤트가 있는지 확인(정상)하거나 경고(예상치 못한 감소)
last_reconciled_at갱신
source 판정. balanceOf()는 지갑이 토큰을 가지고 있다는 것만 말해 주고 어떻게 얻었는지는 말해 주지 않는다. source를 정하려면 LP/RPS 토큰의 Transfer(from, to, value) 로그를 읽고 from을 봐야 한다.
source | 조건(from 기준) |
|---|---|
DEPOSIT | from = 0x0(민팅)이면서 대응하는 deposits 행이 존재 |
LEGACY_SEEDED | 레거시 외부 컨트랙트(Joob RPS / Kaia) 보유분이며 마이그레이션 스냅샷에서 태깅 |
SECONDARY_PURCHASE | from이 알려진 마켓플레이스·정산 컨트랙트(예: Tokocrypto) |
TRANSFER_IN | from이 임의의 EOA나 다른 지갑(민팅도 아니고 알려진 마켓플레이스도 아님) |
source는 잔액 폴링이 아니라 Transfer 로그가 필요하다
아래 미해결 결정 중 "폴링 대 이벤트 기반" 항목은 이걸로 정리된다. balanceOf 폴링으로는 source를 절대 만들 수 없다(from이 없다). Transfer 이벤트 로그를 읽어야 하며, 주기적 eth_getLogs 스캔이든 실시간 구독이든 상관없다(둘 다 from을 준다. 구현 선택일 뿐 기능 차이가 아니다). 실용적인 방식은 모든 LP Transfer를 로컬 테이블에 인덱싱해 두고, 지갑별 취득 전송을 조회해 source를 태깅하는 것이다.
한계가 둘 있다.
SECONDARY_PURCHASE와TRANSFER_IN구분은 마켓플레이스가 식별 가능한 컨트랙트 주소로 정산할 때만 가능하다(알려진 주소 레지스트리에 보관). 지갑 대 지갑 OTC 거래는 일반 전송과 구분되지 않아TRANSFER_IN으로 떨어진다.- 판정에는 온체인
Transfer로그와 Asetdeposits테이블, 알려진 주소 레지스트리, 마이그레이션 스냅샷을 교차 참조한다.
구현 현황 (2026-07-01)
TRANSFER_IN 태깅만 만들어져 있고(indexer/writers/lp-transfer.ts), 그마저도 이미 Aset에 등록된 지갑에 한한다. 아직 없는 것: LEGACY_SEEDED(Joob RPS 마이그레이션 스냅샷 라이터 없음), SECONDARY_PURCHASE(알려진 마켓플레이스 레지스트리 없음), 그리고 E→C 지갑 연결 정합기(잔액이 있는 지갑이 새로 연결돼도 포지션 행이 생기지 않는다). 이것들이 나오기 전까지 State C는 프론트엔드 판정기에는 존재하지만, 이 문서가 앞세운 레거시·세컨더리 시나리오에 대해서는 데이터가 채워지지 않는다.
프론트엔드 상태 판정기
function resolveHolderState(wallet, sbt, positions): HolderState {
const hasBalance = positions.some((p) => p.tokens > 0);
const hasSbt = sbt?.status === 'MINTED' && !sbt.expired;
// No 'PENDING' status exists — queued = NOT_MINTED with sbt_mint_queued_at set.
const sbtPending = (sbt?.status === 'NOT_MINTED' && sbt?.queuedAt != null) || sbt?.status === 'FAILED';
if (hasBalance && hasSbt) return 'A';
if (!hasBalance && hasSbt) return 'B';
if (hasBalance && !hasSbt) return 'C';
if (sbtPending) return 'D';
return 'E';
}UI 컴포넌트는 이 판정기를 참조해 버튼을 막거나 모달을 띄운다.
미해결 결정 (Phase 2.5, PM 승인 필요)
대기 중인 결정
- [x] 5-state 모델 채택. 판정기는 v3-51로
apps/web/app/shared/lib/holder-state.ts에 반영됐다(resolveHolderState,portfolio.tsx에서 사용). - [x]
portfolio_positions.source값 확정.DEPOSIT/TRANSFER_IN/SECONDARY_PURCHASE/LEGACY_SEEDED이고CHECK제약으로 실제 강제된다(마이그레이션 0014). 값 목록을 정한 것이 생산자를 만든 것은 아니다. 현재 쓰이는 것은TRANSFER_IN뿐이다. 정합기 Lambda 참조. - [ ] State B "동기화 대기" UI 문구와 정책(즉시 조회 대 지연 정합)
- [ ] State C KYC 안내 모달 문구와 보존 정책
- [ ] 정합기 주기: 매시간 폴링 대 이벤트 기반(LP 전송 이벤트)
왜 세 시나리오에 모델 하나인가
| 시나리오 | 5-State 없을 때 | 5-State 있을 때 |
|---|---|---|
| Joob 레거시 RPS 홀더 | "레거시" 전용 코드 경로 | source = LEGACY_SEEDED인 State C |
| Aset 직접 입금 | 표준 플로우 | E → D → B → A를 거친 State A |
| Joob 신규 투자자 | Aset 직접과 동일하게 취급 | 동일하게 State A |
| Tokocrypto 세컨더리 매수 | "외부 취득" 경로를 새로 만들어야 함 | source = SECONDARY_PURCHASE인 State C. KYC를 하면 A가 됨 |
| 지갑이 LP 전송을 받음 | 별도로 처리해야 하는 예외 상황 | source = TRANSFER_IN인 State C |
"지갑이 토큰을 갖고 있는데 Aset이 직접 판 게 아닌" 모든 경우가 적절한 source가 붙은 State C로 수렴한다. 예외 코드 경로가 없다.
참고
- KYC & 신원: SumSub 플로우, SBT 발급, RETRY와 FINAL
- 핵심 개념 → LP 발행: LP가 단일 기준인 이유
- 풀 모델: State A 권한과 교차하는 풀별 KYC 게이팅