콘텐츠로 이동

사용자 흐름과 호출 상세

화면에서 무엇을 누르면 뒷단에서 무엇이 불리고, 돌아온 값이 어떤 의미인지 적습니다. 각 다이어그램의 번호는 아래 표의 번호와 1:1로 대응합니다.

기능 한눈에 보기

기능 화면 핵심 호출 흐름
지갑 로그인 S1 personal_sign → POST /v1/auth/verify Flow 1
수신함 3분류 S2 DojangScroll.isVerified + XMTP consent Flow 2
1:1 / 그룹 대화 S3 S4 createDmWithIdentifier / createGroupWithIdentifiers Flow 3
온체인 증빙 S3 S5 S6 POST /v1/evidence-requests → recordEvidence Flow 4
스팸 지정 S3 updateConsentState(Denied) Flow 5

등장 인물

표기 정체 위치
App React 화면과 useUmulApp 상태 기계 브라우저
Wallet EIP-1193 지갑 (GIWA Wallet / MetaMask / WalletConnect) 브라우저 확장 또는 모바일
XMTP XMTP Browser SDK + dev 네트워크 브라우저 WASM + 원격
API Fastify 백엔드 localhost:3000
PG PostgreSQL 17 백엔드 뒤
Dojang DojangScroll 프리디플로이 GIWA Sepolia
Registry ConversationEvidenceRegistry GIWA Sepolia

핵심 원칙 하나만 기억하면 됩니다. 메시지 원문은 브라우저 밖으로 나가지 않습니다. API와 Registry가 보는 것은 해시와 서명뿐입니다.


Flow 1. 지갑 로그인 (S1 → S2)

sequenceDiagram
    participant U as 사용자
    participant App
    participant Wallet
    participant API
    participant PG
    participant XMTP
    participant Dojang

    U->>App: 1. "GIWA Wallet로 연결" 탭
    App->>Wallet: 2. eth_requestAccounts
    Wallet-->>App: 3. [address]
    App->>Wallet: 4. eth_chainId
    Wallet-->>App: 5. 0x164ce (91342)
    App->>Wallet: 6. wallet_switchEthereumChain (필요 시)
    App->>API: 7. POST /v1/auth/nonce {address}
    API->>PG: 8. INSERT wallet_login_nonces (nonce_hash)
    API-->>App: 9. {message, expiresAt}
    App->>Wallet: 10. personal_sign(message)
    Wallet-->>App: 11. signature
    App->>API: 12. POST /v1/auth/verify {message, signature}
    API->>PG: 13. nonce 소비 + 세션 생성 (원자적)
    API-->>App: 14. 200 + Set-Cookie umul_session
    App->>XMTP: 15. Client.create(signer)
    XMTP-->>App: 16. inboxId, installationId
    App->>Dojang: 17. isVerified(myAddress, TESTNET_FAUCET)
    Dojang-->>App: 18. bool
    App->>API: 19. GET /v1/evidence-requests
    API-->>App: 20. {items: [...]}
    App->>XMTP: 21. conversations.syncAll()
    XMTP-->>App: 22. Conversation[]
    App->>Dojang: 23. isVerified(peer) × N
    Dojang-->>App: 24. bool × N
    App-->>U: 25. 채팅 목록 (S2)
# 호출 받는 것과 그 의미
1 ConnectWallet.execute("giwa") 유스케이스 진입. 지갑 종류만 인자로 받습니다
2-3 eth_requestAccounts 사용자가 승인한 계정 주소. 이 주소가 곧 신원입니다. 이메일·전화번호는 쓰지 않습니다
4-5 eth_chainId 현재 체인. 0x164ce = 91342 = GIWA Sepolia
6 wallet_switchEthereumChain 다른 체인이면 전환 요청. 거부되면 chain_switch_rejected 로 중단
7 POST /v1/auth/nonce 로그인 챌린지 요청
8 INSERT wallet_login_nonces nonce 원문이 아니라 SHA-256 해시를 저장합니다. DB가 털려도 서명 가능한 챌린지가 새지 않습니다
9 {message, expiresAt} EIP-4361(SIWE) 형식 문자열. domain, URI, chainId 91342, nonce, issuedAt, expirationTime 포함. 5분 후 만료
10-11 personal_sign 지갑이 메시지에 서명. 개인키는 앱에 절대 들어오지 않습니다
12 POST /v1/auth/verify 서명 검증 요청
13 nonce 소비 + 세션 생성 단일 CTE 로 원자 처리. 같은 nonce 로 동시 요청이 와도 한 번만 성공합니다
14 Set-Cookie umul_session HttpOnly; SameSite=Lax. 토큰 원문은 DB에 없고 SHA-256 해시만 있습니다. 유효기간 7일
15-16 Client.create(signer) XMTP 신원 생성 또는 복구. inbox 당 installation 10개 제한이 있어 저장소를 비우고 반복 로그인하면 슬롯이 소진됩니다
17-18 DojangScroll.isVerified 내 지갑의 Dojang 인증 여부. true면 설정 화면에 "Dojang 인증됨" 초록 pill
19-20 GET /v1/evidence-requests 내가 참여자인 증빙 요청 목록. 상대가 만든 제안은 이걸 읽어야 앱이 존재를 압니다. 실패해도 로그인은 막지 않습니다
21-22 conversations.syncAll() 네트워크의 대화를 로컬 DB로 당겨옵니다. 이걸 안 하면 상대가 먼저 연 대화가 목록에 안 뜬다
23-24 isVerified(peer) 대화 상대별 인증 상태. 결과는 주소별 1시간 캐시. RPC 오류는 unverified가 아니라 unknown으로 구분
25 (없음) 수신함 분류 완료. 아래 Flow 2

주소별 상태 3값의 차이

값 뜻 수신함 증빙
verified Dojang attestation 있음 일반 가능
unverified 조회 성공, attestation 없음 요청 차단
unknown RPC 실패로 확인 못 함 요청 차단

unknown을 verified 쪽으로 붙이지 않습니다. 확인하지 못한 상대를 인증된 것처럼 보여주는 편이 훨씬 위험합니다.


Flow 2. 수신함 분류 (S2)

sequenceDiagram
    participant XMTP
    participant App
    participant Dojang
    participant U as 사용자

    XMTP-->>App: 1. Conversation[] (id, kind, members, consentState)
    App->>App: 2. members → peerAddresses (내 inboxId 제외)
    App->>Dojang: 3. isVerified(peers) (캐시 미스만)
    Dojang-->>App: 4. Map<address, bool>
    App->>App: 5. classifyConversation({kind, peers, spam})
    App-->>U: 6. 일반 / 요청 / 스팸 탭에 배치
# 호출 받는 것과 그 의미
1 listConversations() XMTP 대화 목록. consentState가 핵심 필드입니다
2 (없음) 그룹 멤버에서 내 inboxId를 뺀 상대 주소 목록. 이더리움 식별자만 고릅니다
3-4 GetVerificationStatus.execute(peers) 캐시에 없는 주소만 실제 조회합니다. TTL 1시간, 앱 새로고침 시 초기화
5 classifyConversation 아래 규칙
6 (없음) 탭별 배치

분류 규칙 (우선순위 순)

  1. XMTP consent가 denied → 스팸함. 사용자가 직접 지정한 것이라 Dojang보다 우선
  2. 자신을 제외한 참여자 전원 verified → 일반함
  3. 그 외 (unverified 또는 unknown 하나라도) → 요청함

그룹은 한 명이라도 미인증이면 요청함입니다. 증빙 가능 여부와 같은 기준이라 "일반함에 있으면 증빙할 수 있다"가 성립합니다.


Flow 3. 새 대화와 메시지 송수신 (S4 → S3)

sequenceDiagram
    participant U as 사용자
    participant App
    participant XMTP
    participant Peer as 상대 앱

    U->>App: 1. 주소 입력 후 "대화 시작"
    App->>XMTP: 2. canMessage([address])
    XMTP-->>App: 3. Map<address, bool>
    App->>XMTP: 4. fetchDmByIdentifier / createDmWithIdentifier
    XMTP-->>App: 5. Conversation {id}
    U->>App: 6. 대화방 진입
    App->>XMTP: 7. listMessages(conversationId)
    XMTP-->>App: 8. Message[] (과거 기록)
    U->>App: 9. 메시지 입력 후 전송
    App->>XMTP: 10. conversation.sendText(text)
    XMTP-->>App: 11. messageId
    XMTP-->>Peer: 12. MLS 암호문 전달
    Peer->>Peer: 13. streamAllMessages onValue
    XMTP-->>App: 14. 내 메시지 에코 (같은 messageId)
# 호출 받는 것과 그 의미
1 (없음) 주소 여러 개를 넣으면 그룹, 하나면 1:1
2-3 canMessage() 상대가 XMTP를 쓴 적 있는지. false면 UnreachablePeerError. Dojang 인증과 무관합니다. 미인증 상대와도 대화는 열립니다
4-5 fetchDmByIdentifier → 없으면 createDmWithIdentifier 기존 DM 재사용이 먼저입니다. 안 그러면 같은 상대와 대화가 중복 생성됩니다
7-8 listMessages() 스트림은 구독 이후 메시지만 줍니다. 과거 기록은 이걸로 로컬 DB에서 읽습니다. 새 installation은 가입 이전 메시지를 복호화할 수 없습니다
10-11 sendText() 반환값은 XMTP 메시지 ID. 증빙에서 이 ID로 대상 메시지를 지목합니다
12 (없음) MLS 그룹 암호화. 서버는 암호문만 봅니다
13 streamAllMessages 수신자 화면에 즉시 반영
14 (없음) 발신자에게도 같은 ID로 에코됩니다. 낙관적 추가와 ID로 중복 제거하지 않으면 메시지가 두 번 보입니다

메시지 전송 실패는 삼키지 않습니다. delivery: "failed"로 남기고 재시도 버튼을 띄웁니다.


Flow 4. 온체인 증빙 (S3 → S5 → S6)

가장 복잡한 흐름입니다. 제안자(A)와 서명자(B)가 서로 다른 앱 인스턴스다.

sequenceDiagram
    participant A as A 앱 (제안자)
    participant Dojang
    participant API
    participant PG
    participant XMTP
    participant B as B 앱 (서명자)
    participant Registry

    A->>Dojang: 1. isVerified(참여자 전원) (캐시 우회)
    Dojang-->>A: 2. bool × N
    A->>A: 3. buildEvidenceManifest → contentHash
    A->>API: 4. POST /v1/evidence-requests + Idempotency-Key
    API->>PG: 5. INSERT evidence_requests + participants
    API-->>A: 6. {id, status: PROPOSED, ...}
    A->>XMTP: 7. sendText("gasok:evidence:v1:<base64url>")
    XMTP-->>B: 8. 제어 메시지 수신
    A->>A: 9. toTypedData(request)
    A->>API: 10. POST /:id/signatures {signature}
    API->>PG: 11. SELECT FOR UPDATE → addSignature
    API-->>A: 12. {status: SIGNING}
    B->>API: 13. GET /v1/evidence-requests/:id (제어 메시지 수신 즉시 자동)
    API-->>B: 14. {contentHash, participantsHash, ...}
    B->>B: 15. messageRefs 로 로컬 메시지 선별 → 해시 재계산
    B->>API: 16. POST /:id/signatures {signature}
    API->>PG: 17. 전원 서명 → status READY
    API-->>B: 18. {status: READY}
    B->>Registry: 19. simulateContract(recordEvidence)
    Registry->>Dojang: 20. isVerified(참여자 전원) 재확인
    B->>Registry: 21. writeContract(recordEvidence)
    Registry-->>B: 22. txHash → receipt
    B->>API: 23. POST /:id/submission {txHash, outcome, blockNumber}
    API->>PG: 24. status CONFIRMED
# 호출 받는 것과 그 의미
1-2 GetVerificationStatus.execute(peers, {bypassCache: true}) 캐시를 반드시 우회합니다. 1시간 전 캐시로 증빙을 열면 그 사이 철회된 인증을 못 잡습니다. 한 명이라도 verified가 아니면 여기서 중단하고 API도 XMTP도 건드리지 않습니다
3 buildEvidenceManifest 메시지를 (sentAtNs, messageId) 순으로 정렬하고 원문은 각각 keccak256 합니다. contentHash는 버전·대화해시·참여자해시·메시지해시들·개수·nonce를 ABI 인코딩해 해싱한 값. 원문은 manifest에 남지 않는다
4 POST /v1/evidence-requests Idempotency-Key 헤더 필수. 같은 키 재요청은 새로 만들지 않고 기존 요청을 돌려줍니다
5 INSERT 해시·주소·서명만 저장. 메시지 원문 컬럼 자체가 없습니다
6 {id, status: PROPOSED} 아직 아무도 서명하지 않은 상태
7-8 gasok:evidence:v1:<base64url-json> 버전 붙은 텍스트 봉투. payload에 requestId, contentHash, nonce, messageRefs(대상 메시지 ID 목록)가 들어갑니다. 일반 채팅 UI는 이 prefix를 숨기고 증빙 카드로 바꿔 보여줍니다
9 toTypedData(request) EIP-712 struct. deadline은 요청의 expiresAt에서 파생합니다. 서명자마다 다른 값을 고르면 digest가 갈려 컨트랙트가 전 서명을 거부한다
10-12 POST /:id/signatures 서명자는 요청 본문이 아니라 세션 주소로 정합니다. 남의 서명을 대신 올릴 수 없습니다
13-14 GET /v1/evidence-requests/:id 스트림 핸들러가 제어 메시지를 디코드하는 즉시 자동 호출합니다. 제어 메시지에는 해시만 있고 서명 대상 원본은 백엔드에 있어서입니다. 참여자가 아니면 존재 여부도 숨기고 404. 로그인 시점에는 GET /v1/evidence-requests로 기존 요청을 한 번에 받아옵니다
15 messageRefs로 선별 후 재해싱 핵심. 자기 로컬에 쌓인 메시지 전체를 해싱하면 A와 집합이 달라 EVIDENCE_CONTENT_HASH_MISMATCH가 납니다. 제안이 지목한 메시지만 골라 해싱하고, 값이 다르면 지갑 서명을 요청조차 하지 않는다
16-18 전원 서명 SELECT FOR UPDATE로 행을 잠급니다. 두 참여자가 동시에 서명해도 최종 상태는 READY 하나
19-20 simulateContract 실제 전송 전 시뮬레이션. 컨트랙트 커스텀 에러(ParticipantNotVerified 등)를 가스 낭비 없이 그대로 받습니다. 컨트랙트가 Dojang을 다시 조회합니다. 서명 후 인증이 철회되면 여기서 막힙니다
21-22 writeContract → waitForTransactionReceipt 참여자와 서명을 주소 오름차순으로 넘깁니다. 컨트랙트가 정렬·중복·참여자해시 일치를 모두 검사합니다
23-24 POST /:id/submission tx 해시와 블록 번호 기록. outcome: "failed"면 서명은 유지한 채 FAILED가 되어 재제출할 수 있습니다

증빙 상태 전이

PROPOSED → SIGNING → READY → SUBMITTED → CONFIRMED
    |         |        |         |
    +--→ REJECTED      +--→ FAILED (재제출 가능)
    +--→ EXPIRED

컨트랙트가 검사하는 순서 (recordEvidence)

  1. 참여자 2명 이상, 서명 개수 일치
  2. 해시가 0이 아니고 시간 범위 정상, deadline 미경과
  3. 참여자 주소 오름차순, 중복 없음
  4. participantsHash == keccak256(abi.encode(participants))
  5. evidenceId 미사용 (리플레이 차단)
  6. 참여자 전원 DojangScroll.isVerified
  7. 각 서명이 같은 typed data digest에서 해당 주소를 복구

evidenceId = keccak256(abi.encode(digest, nonce)). 같은 대화를 두 번 기록하려면 nonce가 달라야 합니다.


Flow 5. 스팸 지정 (S3)

sequenceDiagram
    participant U as 사용자
    participant App
    participant XMTP

    U->>App: 1. "스팸으로 지정"
    App->>XMTP: 2. conversation.updateConsentState(Denied)
    XMTP-->>App: 3. ok
    App->>XMTP: 4. listConversations()
    XMTP-->>App: 5. consentState: Denied
    App-->>U: 6. 스팸 탭으로 이동
# 호출 받는 것과 그 의미
2 setConversationConsent(id, "denied") 별도 서버 저장이 없습니다. XMTP consent 자체가 저장소라 다른 기기에서도 동기화됩니다
3 (없음) 실패하면 CONVERSATION_CONSENT_UPDATE_FAILED로 올립니다. 조용히 성공한 척하지 않습니다
5-6 (없음) 스팸 지정은 Dojang 상태보다 우선합니다. 인증된 상대라도 스팸함으로 갑니다. 해제도 같은 경로

알려진 한계

확인된 것만 적습니다.

한계 영향 우회
새로고침하면 온보딩으로 돌아갑니다 세션 쿠키는 유효하지만 앱 상태가 메모리에만 있습니다 다시 지갑 연결. 세션 복원은 P1
상대 서명을 5초 폴링으로 확인합니다 증빙 화면에서 상대 서명 반영이 최대 5초 늦습니다 서명 사실을 XMTP 제어 메시지로 알리면 없앨 수 있습니다
증빙 거부 UI가 없습니다 백엔드와 도메인은 있으나 화면 버튼이 없습니다 거부는 만료(1시간)로 대체
XMTP installation 10개 제한 저장소를 비우고 반복 로그인하면 소진돼 Client.create가 멈춥니다 e2e/revoke-installations.spec.ts 로 폐기
페이지와 API 호스트가 다르면 세션 쿠키 미전송 SameSite=Lax라 localhost ↔ 127.0.0.1 조합에서 증빙 API가 전부 401 같은 호스트로 맞춥니다
새 installation은 가입 이전 메시지를 못 읽습니다 다른 기기로 로그인하면 과거 대화가 비어 보입니다 MLS 구조상 정상. 대화 기록

저장 위치 요약

데이터 어디에 원문 여부
메시지 원문 브라우저 XMTP 로컬 DB (OPFS) 원문
메시지 암호문 XMTP 네트워크 복호화 불가
세션 토큰 브라우저 쿠키 / DB에는 SHA-256 해시만
로그인 nonce DB에 SHA-256 해시만
증빙 해시·서명 PostgreSQL + GIWA Sepolia 해시만
Dojang 인증 상태 GIWA Sepolia (조회 전용) 공개

관련 문서