사용자 흐름과 호출 상세¶
화면에서 무엇을 누르면 뒷단에서 무엇이 불리고, 돌아온 값이 어떤 의미인지 적습니다. 각 다이어그램의 번호는 아래 표의 번호와 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 | (없음) | 탭별 배치 |
분류 규칙 (우선순위 순)
- XMTP consent가
denied→ 스팸함. 사용자가 직접 지정한 것이라 Dojang보다 우선 - 자신을 제외한 참여자 전원
verified→ 일반함 - 그 외 (
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)
- 참여자 2명 이상, 서명 개수 일치
- 해시가 0이 아니고 시간 범위 정상,
deadline미경과 - 참여자 주소 오름차순, 중복 없음
participantsHash == keccak256(abi.encode(participants))evidenceId미사용 (리플레이 차단)- 참여자 전원
DojangScroll.isVerified - 각 서명이 같은 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 (조회 전용) | 공개 |