데이터베이스¶
PostgreSQL 17을 사용합니다. 마이그레이션은 backend/migrations에 있고 서버
기동 시 migrate()가 버전 순서대로 적용합니다. 적용 이력은
umul_internal.schema_migrations에 버전, 이름, SHA-256 체크섬으로 남습니다.
이미 적용한 파일의 내용이 바뀌면 MIGRATION_CHECKSUM_MISMATCH로 중단합니다.
저장하지 않는 값¶
- 일반 메시지 원문
- XMTP 복호화 키와 지갑 개인키
- 세션 토큰과 로그인 nonce의 원문 (SHA-256 해시만 저장)
- 증빙 아카이브의 평문과 콘텐츠 키 (클라이언트가 잠근 암호문과 래핑된 키만 보관하며, 서버는 어느 쪽도 열 수 없다)
001_auth.sql¶
wallet_login_nonces¶
| 열 | 설명 |
|---|---|
id |
uuid 기본키 |
address |
소문자 지갑 주소. CHECK (address = lower(address)) |
nonce_hash |
nonce의 SHA-256. UNIQUE |
expires_at |
발급 5분 후 |
consumed_at |
사용 시각. 일회성 보장의 근거 |
sessions¶
| 열 | 설명 |
|---|---|
id |
uuid 기본키 |
address |
소문자 지갑 주소 |
token_hash |
세션 토큰의 SHA-256. UNIQUE |
expires_at |
발급 7일 후 |
revoked_at |
로그아웃 시각 |
nonce 소비와 세션 생성은 하나의 CTE 문에서 원자적으로 처리합니다. 동시 요청이 와도 한 번만 소비됩니다.
002_evidence.sql¶
evidence_requests¶
| 열 | 설명 |
|---|---|
id |
uuid 기본키 |
idempotency_key |
UNIQUE. 중복 생성 차단 |
evidence_id |
32바이트. UNIQUE |
requester_address |
소문자 주소 |
content_hash, conversation_id_hash, participants_hash |
각 32바이트 |
manifest_version |
해시 규칙 버전 |
message_count |
1 이상 |
conversation_started_at, conversation_ended_at |
종료 시각이 시작 이상 |
status |
8개 상태만 허용하는 CHECK |
transaction_hash |
32바이트, UNIQUE, NULL 허용 |
block_number |
0 이상, NULL 허용 |
rejected_at |
거절 시각, NULL 허용 |
expires_at |
생성 1시간 후. CHECK (expires_at > created_at) |
created_at |
생성 시각 |
evidence_participants¶
(request_id, address) 복합 기본키입니다. signature는 65바이트이고,
signature와 signed_at은 함께 NULL이거나 함께 값이 있어야 한다는 CHECK가
있습니다. 주소 조회용 인덱스를 둡니다.
003_evidence_metadata.sql¶
증빙명과 증빙 대상 메시지 ID를 더합니다. 둘 다 온체인에 올라가지 않는 오프체인
메타데이터입니다. 컨트랙트는 contentHash만 받으므로 이름이 바뀌어도 온체인
기록과 서명은 영향을 받지 않습니다.
| 열 | 테이블 | 설명 |
|---|---|---|
name |
evidence_requests |
증빙명. NULL 허용. CHECK (length BETWEEN 1 AND 100) |
message_refs |
evidence_requests |
XMTP 메시지 ID 배열(text[]). 본문은 담지 않는다 |
saved_at |
evidence_participants |
저장하기(즐겨찾기). 참여자별 상태라 한 사람이 저장해도 다른 참여자 목록에는 영향이 없습니다 |
message_refs는 증빙 제안 제어 메시지로 참여자에게 전달되는 값과 같습니다. 상세
화면이 이 ID로 각자의 로컬 MLS DB에서 본문을 찾습니다.
004_evidence_metadata_integrity.sql¶
003이 남긴 정합성 구멍을 막습니다. message_count는 컨트랙트에 기록되고 서명
대상 digest에도 들어가는 값이고 message_refs는 화면이 본문을 되찾는 데
씁니다. 둘이 어긋나면 화면은 "메시지 12개"라고 하면서 3건만 보여주게 됩니다.
| 제약 / 인덱스 | 내용 |
|---|---|
evidence_requests_message_refs_match_count |
cardinality(message_refs) = message_count |
evidence_requests_message_refs_not_empty |
cardinality(message_refs) > 0 |
evidence_requests_created_at_idx |
(created_at DESC). 모아보기가 "저장한 것 먼저, 그다음 최신순"으로 읽습니다 |
003 이전 행은 message_refs가 NULL이라 두 CHECK 모두에서 빠집니다.
005_evidence_archive.sql¶
evidence_archives¶
증빙 원문의 클라이언트 측 암호문(AES-256-GCM, iv 12 + 본문 + 태그 16)입니다.
request_id가 기본키라 증빙당 한 건이고, 최초 1회만 씁니다. 길이는
29바이트에서 2MB 사이라는 CHECK가 있습니다.
evidence_archive_keys¶
서명 참여자별 래핑된 콘텐츠 키입니다. (request_id, address) 기본키에
evidence_participants로 복합 외래키를 걸어 참여자가 아닌 주소의 행을
막습니다. wrapped_key는 정확히 60바이트(iv 12 + 키 32 + 태그 16)라는
CHECK가 있습니다. 서명하지 않은 참여자의 행은 애플리케이션이 만들지 않습니다.
006_evidence_content.sql¶
아직 어느 코드도 이 마이그레이션이 만든 객체를 쓰지 않습니다. 스키마에는 존재하므로 적어 두지만, 실제 증빙 원문 보관은 005의
evidence_archives/evidence_archive_keys를 지납니다. 005를 대체할 설계인지 별개 용도인지는 아직 정해지지 않았습니다.
evidence_contents¶
| 열 | 설명 |
|---|---|
request_id |
evidence_requests 참조 기본키. ON DELETE CASCADE |
ciphertext |
클라이언트가 AES-256-GCM으로 잠근 결과. 1바이트 이상 1MB 이하 |
iv |
정확히 12바이트 |
algorithm |
'AES-GCM-256'만 허용 |
1MB를 넘으면 대화 하나를 통째로 담은 것이므로 증빙 범위를 좁혀야 한다는 뜻의 상한입니다.
evidence_participants 확장¶
참여자가 서명할 때 자기 몫의 열쇠를 같이 올립니다. 서명하지 않은 참여자는 열쇠가 없어 본문을 열 수 없습니다.
| 열 | 설명 |
|---|---|
wrapped_key |
정확히 48바이트 (CEK 32 + GCM 태그 16) |
wrapped_key_iv |
정확히 12바이트 |
둘은 함께 NULL이거나 함께 값이 있어야 한다는 CHECK가 있습니다. 005의
evidence_archive_keys.wrapped_key가 60바이트(iv를 포함)인 것과 달리 여기서는
iv를 별도 열로 뺐습니다.
키 유도는 EIP-712 멀티시그 서명(evidence_participants.signature)을 쓰지
않습니다. 그 값은 여기 저장되고 온체인 calldata로도 공개되므로, 그걸로 키를
만들면 서버가 복호화할 수 있게 됩니다. 전송되지 않는 별도 문구로 유도합니다.
동시성¶
상태 전이는 모두 PgEvidenceRepository.updateLocked를 지납니다. 트랜잭션 안에서
SELECT ... FOR UPDATE로 행을 잠그고, aggregate가 전이를 결정한 뒤 저장합니다.
aggregate가 전이를 거부하면 트랜잭션 전체를 롤백해 부분 반영이 남지 않습니다.
두 참여자가 동시에 서명하면 둘 다 성공하고 최종 상태는 READY가 됩니다. 같은
참여자가 동시에 두 번 서명하면 하나만 성공하고 다른 하나는
EVIDENCE_SIGNATURE_ALREADY_SUBMITTED로 실패합니다.
테스트 격리¶
통합 테스트는 스키마를 초기화하므로 umul_test 또는 umul_test_ 접두사
데이터베이스만 허용합니다. 다른 이름이면 UNSAFE_TEST_DATABASE로 중단하며,
중단하더라도 열린 커넥션 풀은 모두 닫습니다. 개발용 umul과 테스트용
umul_test는 서로 다른 컨테이너(5432 / 5433)에서 실행합니다.