Skip to content

홀더 검증 (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 신규 투자자표준 depositsportfolio_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_positionsAset 정합기 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에 등록한 적이 없는 지갑이다. 하위 경우가 셋이다.

  1. 레거시 홀더: Joob API 연동 이전부터 RPS를 보유하던 홀더(ShardLab, Hashed)
  2. 세컨더리 매수자: Aset KYC를 거치지 않고 Tokocrypto에서 LP를 매수한 경우
  3. 전송 수신자: 다른 지갑에서 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 applicantCreated webhook
  • D → B: PlatformKYCSoulbound.mint() 성공
  • D → E: reject_type = FINALkyc_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 소각(취소). 포지션은 남지만 동작이 막힌다

권한 매트릭스

동작ABCDE
공개 풀 열람가능가능가능가능가능
본인 포트폴리오 열람가능가능가능(읽기 전용)
입금가능가능불가(KYC 안내)불가불가(지갑 연결 + KYC 안내)
수익 청구가능불가(KYC 안내)
상환가능불가(KYC 안내)
재투자가능불가
LP 전송(보내기)가능(허가 불필요)가능(허가 불필요)
LP 전송(받기)가능가능가능(source=TRANSFER_IN으로 C가 됨)불가불가

LP 전송은 검증으로 막히지 않는다. LP를 보유한 지갑이면 온체인에서 개인 간 전송을 할 수 있다. 검증은 가치 경계(입금, 상환, 수익 청구에 유효한 KYC 필요)에서만 강제되고 전송 자체에는 걸리지 않는다. 긴급 상황에서는 어드민 pause()로 모든 세컨더리 전송을 멈출 수 있다. 따라서 State C 지갑은 LP를 옮길 수는 있지만, KYC를 마쳐 State A가 되기 전까지 상환이나 청구는 못 한다. v3-58 참조.

구현 지도

스키마 (portfolio_positions)

sql
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) 태깅한다.

잔액 동기화(주기 실행, 초기에는 매시간):

  1. 알려진 지갑마다 모든 LP/RPS 컨트랙트에 대해 온체인 balanceOf()를 조회
  2. portfolio_positions.tokens와 비교
  3. 온체인이 더 크면 행을 생성하거나 갱신하고 source를 설정(아래 참조)
  4. 온체인이 더 작으면 상환 이벤트가 있는지 확인(정상)하거나 경고(예상치 못한 감소)
  5. last_reconciled_at 갱신

source 판정. balanceOf()는 지갑이 토큰을 가지고 있다는 것만 말해 주고 어떻게 얻었는지는 말해 주지 않는다. source를 정하려면 LP/RPS 토큰의 Transfer(from, to, value) 로그를 읽고 from을 봐야 한다.

source조건(from 기준)
DEPOSITfrom = 0x0(민팅)이면서 대응하는 deposits 행이 존재
LEGACY_SEEDED레거시 외부 컨트랙트(Joob RPS / Kaia) 보유분이며 마이그레이션 스냅샷에서 태깅
SECONDARY_PURCHASEfrom이 알려진 마켓플레이스·정산 컨트랙트(예: Tokocrypto)
TRANSFER_INfrom이 임의의 EOA나 다른 지갑(민팅도 아니고 알려진 마켓플레이스도 아님)

source는 잔액 폴링이 아니라 Transfer 로그가 필요하다

아래 미해결 결정 중 "폴링 대 이벤트 기반" 항목은 이걸로 정리된다. balanceOf 폴링으로는 source를 절대 만들 수 없다(from이 없다). Transfer 이벤트 로그를 읽어야 하며, 주기적 eth_getLogs 스캔이든 실시간 구독이든 상관없다(둘 다 from을 준다. 구현 선택일 뿐 기능 차이가 아니다). 실용적인 방식은 모든 LP Transfer를 로컬 테이블에 인덱싱해 두고, 지갑별 취득 전송을 조회해 source를 태깅하는 것이다.

한계가 둘 있다.

  • SECONDARY_PURCHASETRANSFER_IN 구분은 마켓플레이스가 식별 가능한 컨트랙트 주소로 정산할 때만 가능하다(알려진 주소 레지스트리에 보관). 지갑 대 지갑 OTC 거래는 일반 전송과 구분되지 않아 TRANSFER_IN으로 떨어진다.
  • 판정에는 온체인 Transfer 로그와 Aset deposits 테이블, 알려진 주소 레지스트리, 마이그레이션 스냅샷을 교차 참조한다.

구현 현황 (2026-07-01)

TRANSFER_IN 태깅만 만들어져 있고(indexer/writers/lp-transfer.ts), 그마저도 이미 Aset에 등록된 지갑에 한한다. 아직 없는 것: LEGACY_SEEDED(Joob RPS 마이그레이션 스냅샷 라이터 없음), SECONDARY_PURCHASE(알려진 마켓플레이스 레지스트리 없음), 그리고 E→C 지갑 연결 정합기(잔액이 있는 지갑이 새로 연결돼도 포지션 행이 생기지 않는다). 이것들이 나오기 전까지 State C는 프론트엔드 판정기에는 존재하지만, 이 문서가 앞세운 레거시·세컨더리 시나리오에 대해서는 데이터가 채워지지 않는다.

프론트엔드 상태 판정기

ts
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로 수렴한다. 예외 코드 경로가 없다.

참고